From 0cf0fac13c4c5d63eaf3361cef2051af3bcdfae8 Mon Sep 17 00:00:00 2001 From: greymoth <246701683+greymoth-jp@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:14:52 +0900 Subject: [PATCH] Escape backslashes in fish completion descriptions Inside a fish single-quoted string a literal backslash must be written as a doubled backslash, just as a literal single quote is written as backslash plus quote. escapeSingleQuotes only handled the quote, so a usage string containing a backslash corrupted the generated -d '...' description, and a trailing backslash left the single-quoted string unterminated. See https://fishshell.com/docs/current/language.html --- fish.go | 11 ++++++++++- fish_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/fish.go b/fish.go index 8002a33ca1..1fac34a205 100644 --- a/fish.go +++ b/fish.go @@ -216,6 +216,15 @@ func commandAncestry(command *Command) string { return strings.Join(ancestry, "; and ") } +// escapeSingleQuotes escapes a string for use inside a fish single-quoted +// string, such as a `-d '...'` description. Within single quotes fish only +// recognizes the escape sequences \\ and \', so the backslash must be escaped +// as well as the single quote. Escaping the quote without escaping the +// backslash corrupts any description that contains a backslash, and a trailing +// backslash even leaves the single-quoted string unterminated. +// See https://fishshell.com/docs/current/language.html func escapeSingleQuotes(input string) string { - return strings.ReplaceAll(input, `'`, `\'`) + return fishSingleQuoteReplacer.Replace(input) } + +var fishSingleQuoteReplacer = strings.NewReplacer(`\`, `\\`, `'`, `\'`) diff --git a/fish_test.go b/fish_test.go index 1b3908f1ba..c18a7dca26 100644 --- a/fish_test.go +++ b/fish_test.go @@ -41,6 +41,38 @@ func TestFishCompletion(t *testing.T) { expectFileContent(t, "testdata/expected-fish-full.fish", res) } +func TestFishCompletionBackslashEscaping(t *testing.T) { + // Inside fish single-quoted strings the only escape sequences are \\ and + // \', so a backslash in a description must be emitted as \\. An unescaped + // backslash silently corrupts the description, and a trailing one turns the + // closing quote into an escaped quote, leaving the string unterminated. + // Ref: https://fishshell.com/docs/current/language.html + cmd := &Command{ + Name: "greet", + Flags: []Flag{ + &StringFlag{ + Name: "path", + Usage: `match \d+ then C:\tmp\`, + }, + }, + Commands: []*Command{ + { + Name: "win", + Usage: `run under C:\sys\`, + }, + }, + } + cmd.setupCommandGraph() + + res, err := cmd.ToFishCompletion() + require.NoError(t, err) + + // Both the flag and the subcommand descriptions must have their backslashes + // doubled in the generated `-d '...'` tokens. + assert.Contains(t, res, `-d 'match \\d+ then C:\\tmp\\'`) + assert.Contains(t, res, `-d 'run under C:\\sys\\'`) +} + func TestFishCompletionShellComplete(t *testing.T) { cmd := buildExtendedTestCommand() cmd.ShellComplete = func(context.Context, *Command) {}