From bd3eb4c267fc5a004518726f5791969cf16cb9c5 Mon Sep 17 00:00:00 2001 From: devangpratap <115096812+devangpratap@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:48:28 -0400 Subject: [PATCH] Completion: Do not emit multi line candidates for fish fish splits the output of the completion function on newlines to build the candidate list, so an option whose help text spans paragraphs added its continuation lines as candidates of their own. Build the candidate with the newline replacement the fish2 branch already used, so both paths agree. --- tools/cli/fish.go | 9 +++++++-- tools/cli/fish_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 tools/cli/fish_test.go diff --git a/tools/cli/fish.go b/tools/cli/fish.go index 442329458..5368445af 100644 --- a/tools/cli/fish.go +++ b/tools/cli/fish.go @@ -52,10 +52,15 @@ func fish_output_serializer(completions []*Completions, shell_state map[string]s n := completions[0].Delegate.NumToRemove fm := markup.New(false) // fish freaks out if there are escape codes in the description strings legacy_completion := shell_state["_legacy_completion"] + // fish splits the output of the completion function on newlines, so a description + // spanning multiple lines adds its continuation lines as spurious candidates. + candidate := func(m *Match) string { + return strings.ReplaceAll(m.Word+"\t"+fm.Prettify(m.Description), "\n", " ") + } if legacy_completion == "fish2" { for _, mg := range completions[0].Groups { for _, m := range mg.Matches { - f("%s", strings.ReplaceAll(m.Word+"\t"+fm.Prettify(m.Description), "\n", " ")) + f("%s", candidate(m)) } } } else if n > 0 { @@ -72,7 +77,7 @@ func fish_output_serializer(completions []*Completions, shell_state map[string]s } else { for _, mg := range completions[0].Groups { for _, m := range mg.Matches { - f("echo -- %s", utils.QuoteStringForFish(m.Word+"\t"+fm.Prettify(m.Description))) + f("echo -- %s", utils.QuoteStringForFish(candidate(m))) } } } diff --git a/tools/cli/fish_test.go b/tools/cli/fish_test.go new file mode 100644 index 000000000..afdba4ea0 --- /dev/null +++ b/tools/cli/fish_test.go @@ -0,0 +1,25 @@ +// License: GPLv3 Copyright: 2026, Kovid Goyal, + +package cli + +import ( + "strings" + "testing" +) + +func TestFishCompletionMultilineDescription(t *testing.T) { + completions := []*Completions{{Groups: []*MatchGroup{{Title: "Options", Matches: []*Match{ + {Word: "--config", Description: "Specify a path to the configuration file.\n\nIf it is not specified, config files are searched for."}, + }}}}} + for _, shell_state := range []map[string]string{{}, {"_legacy_completion": "fish2"}} { + data, err := fish_output_serializer(completions, shell_state) + if err != nil { + t.Fatal(err) + } + // each candidate must be a single line, since fish splits the output of the + // completion function on newlines to get the candidate list + if q := strings.TrimRight(string(data), "\n"); strings.Contains(q, "\n") { + t.Fatalf("A multi line description produced more than one candidate with shell state %#v:\n%s", shell_state, q) + } + } +}