From 397edc2e0d166f7217e80aec852d774d73cdbd56 Mon Sep 17 00:00:00 2001 From: maxlandon Date: Sat, 18 Jul 2026 14:29:40 +0200 Subject: [PATCH] feat(parsing): add EscapeLiteral mode to opt out of shell backslash escaping By default the console splits input with POSIX shell semantics, so an unquoted backslash escapes the next character. This mangles values that carry literal backslashes (e.g. Windows paths: `C:\Windows\Temp` becomes `C:WindowsTemp`) and makes a trailing backslash request a continuation line, which is surprising when the console is used as a general Cobra frontend rather than a shell. Introduce a console-wide EscapeMode with two values: - EscapeShell (default): unchanged POSIX escape behavior. - EscapeLiteral: backslashes are ordinary characters; quotes still group words, `C:\Windows\Temp` is passed through verbatim, and a trailing backslash no longer requests another line. Select it with Console.SetEscapeMode(console.EscapeLiteral). The mode is threaded through all three shell-word splitters so command execution, multiline-continuation detection, and completion/highlighting stay consistent. The EscapeShell path is byte-for-byte unchanged. Closes #88. Co-Authored-By: Claude Opus 4.8 --- completer.go | 4 +-- console.go | 39 +++++++++++++++++++++- internal/completion/line.go | 23 +++++++------ internal/completion/line_test.go | 34 ++++++++++++++++++-- internal/line/line.go | 54 +++++++++++++++++++++++++------ internal/line/line_test.go | 55 ++++++++++++++++++++++++++++++-- run.go | 18 ++++++++--- run_escape_test.go | 50 +++++++++++++++++++++++++++++ 8 files changed, 244 insertions(+), 33 deletions(-) create mode 100644 run_escape_test.go diff --git a/completer.go b/completer.go index 08d3352..72da79c 100644 --- a/completer.go +++ b/completer.go @@ -23,7 +23,7 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { // Split the line as shell words, only using // what the right buffer (up to the cursor) - args, prefixComp, prefixLine := completion.SplitArgs(input, pos) + args, prefixComp, prefixLine := completion.SplitArgs(input, pos, c.getEscapeMode()) command.ResetCompletionFlagState(menu.Command, args) // Prepare arguments for the carapace completer @@ -142,7 +142,7 @@ func (c *Console) highlightSyntax(input []rune) string { func (c *Console) computeHighlight(input []rune) string { // Split the line as shellwords - args, unprocessed, err := line.Split(string(input), true) + args, unprocessed, err := line.Split(string(input), true, c.getEscapeMode()) if err != nil { args = append(args, unprocessed) } diff --git a/console.go b/console.go index a3197b1..1da64d2 100644 --- a/console.go +++ b/console.go @@ -31,6 +31,7 @@ type Console struct { menus map[string]*Menu // Different command trees, prompt engines, etc. current *Menu // Cached pointer to the active menu (guarded by mutex). filters []string // Hide commands based on their attributes and current context. + escapeMode line.EscapeMode // How input lines are split into words (guarded by mutex). isExecuting atomic.Bool // Used by log functions, which need to adapt behavior (print the prompt, etc.) printed bool // Used to adjust asynchronous messages too. mutex *sync.RWMutex // Concurrency management. @@ -131,7 +132,9 @@ func New(app string) *Console { // Syntax highlighting, multiline callbacks, etc. console.cmdHighlight = line.GreenFG console.flagHighlight = line.BrightWhiteFG - console.shell.AcceptMultiline = line.AcceptMultiline + console.shell.AcceptMultiline = func(input []rune) bool { + return line.AcceptMultiline(input, console.getEscapeMode()) + } console.shell.SyntaxHighlighter = console.highlightSyntax // Completion @@ -151,6 +154,40 @@ func (c *Console) Shell() *readline.Shell { return c.shell } +// EscapeMode controls how the console splits an input line into command words. +// See EscapeShell (the default) and EscapeLiteral. +type EscapeMode = line.EscapeMode + +const ( + // EscapeShell is the default POSIX-shell behaviour: a backslash escapes the + // following character (so `C:\Windows` becomes `C:Windows`), and a trailing + // backslash marks the line as an incomplete continuation. + EscapeShell = line.EscapeShell + + // EscapeLiteral preserves backslashes as ordinary characters, so values such + // as Windows paths (`C:\Windows\Temp`) are passed to commands verbatim + // without quoting or doubling. Quotes still group words, and a trailing + // backslash no longer requests another line. Use this when the console is a + // general Cobra frontend rather than a shell. + EscapeLiteral = line.EscapeLiteral +) + +// SetEscapeMode selects how the console splits input lines into command words. +// It applies to command execution, multiline-continuation detection, and +// completion/highlighting alike. The default is EscapeShell. +func (c *Console) SetEscapeMode(mode EscapeMode) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.escapeMode = mode +} + +func (c *Console) getEscapeMode() line.EscapeMode { + c.mutex.RLock() + defer c.mutex.RUnlock() + + return c.escapeMode +} + // // Settings & Initialisation Functions ------------------------------------------------------------- // diff --git a/internal/completion/line.go b/internal/completion/line.go index 5da8f32..72230fb 100644 --- a/internal/completion/line.go +++ b/internal/completion/line.go @@ -14,21 +14,21 @@ import ( // SplitArgs splits the line in valid words, prepares them in various ways before calling // the completer with them, and also determines which parts of them should be used as // prefixes, in the completions and/or in the line. -func SplitArgs(line []rune, pos int) (args []string, prefixComp, prefixLine string) { - line = line[:pos] +func SplitArgs(input []rune, pos int, mode line.EscapeMode) (args []string, prefixComp, prefixLine string) { + input = input[:pos] // Remove all colors from the string - line = []rune(strip(string(line))) + input = []rune(strip(string(input))) // Split the line as shellwords, return them if all went fine. - args, remain, err := splitCompWords(string(line)) + args, remain, err := splitCompWords(string(input), mode) // We might have either no error and args, or no error and // the cursor ready to complete a new word (last character // in line is a space). // In some of those cases we append a single dummy argument // for the completer to understand we want a new word comp. - mustComplete, args, remain := mustComplete(line, args, remain, err) + mustComplete, args, remain := mustComplete(input, args, remain, err) if mustComplete { return sanitizeArgs(args), "", remain } @@ -103,7 +103,7 @@ func sanitizeArgs(args []string) (sanitized []string) { // split has been copied from go-shellquote and slightly modified so as to also // return the remainder when the parsing failed because of an unterminated quote. -func splitCompWords(input string) (words []string, remainder string, err error) { +func splitCompWords(input string, mode line.EscapeMode) (words []string, remainder string, err error) { var buf bytes.Buffer words = make([]string, 0) @@ -113,7 +113,7 @@ func splitCompWords(input string) (words []string, remainder string, err error) if strings.ContainsRune(line.SplitChars, char) { input = input[read:] continue - } else if char == line.EscapeChar { + } else if char == line.EscapeChar && mode == line.EscapeShell { // Look ahead for escaped newline so we can skip over it next := input[read:] if len(next) == 0 { @@ -132,7 +132,7 @@ func splitCompWords(input string) (words []string, remainder string, err error) var word string - word, input, err = splitCompWord(input, &buf) + word, input, err = splitCompWord(input, &buf, mode) if err != nil { return words, word + input, err } @@ -145,7 +145,7 @@ func splitCompWords(input string) (words []string, remainder string, err error) // splitWord has been modified to return the remainder of the input (the part that has not been // added to the buffer) even when an error is returned. -func splitCompWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { +func splitCompWord(input string, buf *bytes.Buffer, mode line.EscapeMode) (word string, remainder string, err error) { buf.Reset() raw: @@ -163,7 +163,7 @@ raw: buf.WriteString(input[0 : len(input)-len(cur)-read]) input = cur goto double - case char == line.EscapeChar: + case char == line.EscapeChar && mode == line.EscapeShell: buf.WriteString(input[0 : len(input)-len(cur)-read]) buf.WriteRune(char) input = cur @@ -218,6 +218,9 @@ double: input = cur goto raw case line.EscapeChar: + if mode != line.EscapeShell { + continue + } // bash only supports certain escapes in double-quoted strings char2, l2 := utf8.DecodeRuneInString(cur) cur = cur[l2:] diff --git a/internal/completion/line_test.go b/internal/completion/line_test.go index 090e4c1..d9b5a21 100644 --- a/internal/completion/line_test.go +++ b/internal/completion/line_test.go @@ -26,7 +26,7 @@ func TestSplitCompWords(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - words, remainder, err := splitCompWords(tc.input) + words, remainder, err := splitCompWords(tc.input, line.EscapeShell) if err != tc.wantErr { t.Fatalf("splitCompWords(%q) err = %v, want %v", tc.input, err, tc.wantErr) } @@ -40,6 +40,36 @@ func TestSplitCompWords(t *testing.T) { } } +func TestSplitCompWordsLiteral(t *testing.T) { + // In literal mode, backslashes are kept verbatim so completing a Windows + // path never collapses separators or triggers an unterminated-escape error. + tests := []struct { + name string + input string + wantWords []string + wantRemainder string + }{ + {"windows path", `cd C:\Windows`, []string{"cd", `C:\Windows`}, ""}, + {"trailing backslash", `cd C:\Windows\`, []string{"cd", `C:\Windows\`}, ""}, + {"quotes still group", `cd "a b"`, []string{"cd", "a b"}, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + words, remainder, err := splitCompWords(tc.input, line.EscapeLiteral) + if err != nil { + t.Fatalf("splitCompWords(%q, literal) err = %v, want nil", tc.input, err) + } + if !reflect.DeepEqual(words, tc.wantWords) { + t.Fatalf("splitCompWords(%q, literal) words = %q, want %q", tc.input, words, tc.wantWords) + } + if remainder != tc.wantRemainder { + t.Fatalf("splitCompWords(%q, literal) remainder = %q, want %q", tc.input, remainder, tc.wantRemainder) + } + }) + } +} + func TestAdjustQuotedPrefix(t *testing.T) { tests := []struct { name string @@ -94,7 +124,7 @@ func TestSplitArgs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { runes := []rune(tc.input) - args, prefixComp, prefixLine := SplitArgs(runes, len(runes)) + args, prefixComp, prefixLine := SplitArgs(runes, len(runes), line.EscapeShell) if !reflect.DeepEqual(args, tc.wantArgs) { t.Fatalf("SplitArgs(%q) args = %q, want %q", tc.input, args, tc.wantArgs) } diff --git a/internal/line/line.go b/internal/line/line.go index 3d0f840..47491aa 100644 --- a/internal/line/line.go +++ b/internal/line/line.go @@ -24,10 +24,30 @@ var ( ErrUnterminatedEscape = errors.New("unterminated backslash-escape") ) +// EscapeMode controls how the line parser treats backslashes when splitting an +// input line into words. +type EscapeMode int + +const ( + // EscapeShell is the default POSIX-shell behaviour: a backslash escapes the + // following character, so `C:\Windows` becomes `C:Windows`, and a trailing + // backslash marks the line as an incomplete continuation. + EscapeShell EscapeMode = iota + + // EscapeLiteral preserves backslashes as ordinary characters. Quotes still + // group words and are removed, but `C:\Windows\Temp` is passed through + // verbatim and a trailing backslash does not request another line. + EscapeLiteral +) + // Parse is in charge of removing all comments from the input line // before execution, and if successfully parsed, split into words. -func Parse(line string) (args []string, err error) { - lineReader := strings.NewReader(line) +// +// The mode governs how backslashes are treated when the (comment-stripped) +// line is split into words: EscapeShell applies POSIX escape rules, while +// EscapeLiteral preserves backslashes verbatim. +func Parse(input string, mode EscapeMode) (args []string, err error) { + lineReader := strings.NewReader(input) parser := syntax.NewParser(syntax.KeepComments(false)) // Parse the shell string a syntax, removing all comments. @@ -43,15 +63,26 @@ func Parse(line string) (args []string, err error) { return nil, err } + // In literal mode, split with our own splitter so that backslashes (e.g. in + // Windows paths) are preserved instead of being consumed as shell escapes. + if mode == EscapeLiteral { + args, _, err = Split(parsedLine.String(), false, EscapeLiteral) + + return args, err + } + // Split the line into shell words. return shellquote.Split(parsedLine.String()) } // acceptMultiline determines if the line just accepted is complete (in which case // we should execute it), or incomplete (in which case we must read in multiline). -func AcceptMultiline(line []rune) (accept bool) { +// +// The mode controls escape handling: in EscapeLiteral, a trailing backslash is an +// ordinary character and never requests another line (only unterminated quotes do). +func AcceptMultiline(line []rune, mode EscapeMode) (accept bool) { // Errors are either: unterminated quotes, or unterminated escapes. - _, _, err := Split(string(line), false) + _, _, err := Split(string(line), false, mode) if err == nil { return true } @@ -112,7 +143,10 @@ func TrimSpaces(remain []string) (trimmed []string) { // Split has been copied from go-shellquote and slightly modified so as to also // return the remainder when the parsing failed because of an unterminated quote. -func Split(input string, hl bool) (words []string, remainder string, err error) { +// +// In EscapeLiteral mode, backslashes are treated as ordinary characters: they +// are neither consumed as escapes nor able to mark a line continuation. +func Split(input string, hl bool, mode EscapeMode) (words []string, remainder string, err error) { var buf bytes.Buffer words = make([]string, 0) @@ -132,7 +166,7 @@ func Split(input string, hl bool) (words []string, remainder string, err error) input = input[l:] continue - } else if c == EscapeChar { + } else if c == EscapeChar && mode == EscapeShell { // Look ahead for escaped newline so we can skip over it next := input[l:] if len(next) == 0 { @@ -163,7 +197,7 @@ func Split(input string, hl bool) (words []string, remainder string, err error) var word string - word, input, err = splitWord(input, &buf, hl) + word, input, err = splitWord(input, &buf, hl, mode) if err != nil { remainder = input return words, remainder, err @@ -177,7 +211,7 @@ func Split(input string, hl bool) (words []string, remainder string, err error) // splitWord has been modified to return the remainder of the input (the part that has not been // added to the buffer) even when an error is returned. -func splitWord(input string, buf *bytes.Buffer, hl bool) (word string, remainder string, err error) { +func splitWord(input string, buf *bytes.Buffer, hl bool, mode EscapeMode) (word string, remainder string, err error) { buf.Reset() raw: @@ -194,7 +228,7 @@ raw: buf.WriteString(input[0 : len(input)-len(cur)-l]) input = cur goto double - } else if c == EscapeChar { + } else if c == EscapeChar && mode == EscapeShell { buf.WriteString(input[0 : len(input)-len(cur)-l]) if hl { buf.WriteRune(c) @@ -282,7 +316,7 @@ double: } input = cur goto raw - } else if c == EscapeChar && !hl { + } else if c == EscapeChar && !hl && mode == EscapeShell { // bash only supports certain escapes in double-quoted strings c2, l2 := utf8.DecodeRuneInString(cur) cur = cur[l2:] diff --git a/internal/line/line_test.go b/internal/line/line_test.go index 192e589..e856974 100644 --- a/internal/line/line_test.go +++ b/internal/line/line_test.go @@ -27,7 +27,7 @@ func TestParse(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, err := Parse(tc.input) + got, err := Parse(tc.input, EscapeShell) if tc.wantErr { if err == nil { t.Fatalf("Parse(%q): expected error, got nil (words=%q)", tc.input, got) @@ -66,7 +66,7 @@ func TestSplit(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - words, _, err := Split(tc.input, false) + words, _, err := Split(tc.input, false, EscapeShell) if !errors.Is(err, tc.wantErr) { t.Fatalf("Split(%q) err = %v, want %v", tc.input, err, tc.wantErr) } @@ -93,13 +93,62 @@ func TestAcceptMultiline(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if got := AcceptMultiline([]rune(tc.input)); got != tc.want { + if got := AcceptMultiline([]rune(tc.input), EscapeShell); got != tc.want { t.Fatalf("AcceptMultiline(%q) = %v, want %v", tc.input, got, tc.want) } }) } } +func TestParseLiteral(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"windows path", `ls C:\Windows\Temp`, []string{"ls", `C:\Windows\Temp`}}, + {"trailing backslash", `ls C:\Windows\Temp\`, []string{"ls", `C:\Windows\Temp\`}}, + {"escaped space kept literal", `echo a\ b`, []string{"echo", `a\`, "b"}}, + {"quotes still group", `echo "a b" c`, []string{"echo", "a b", "c"}}, + {"comment still stripped", `ls C:\Temp # note`, []string{"ls", `C:\Temp`}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Parse(tc.input, EscapeLiteral) + if err != nil { + t.Fatalf("Parse(%q, literal): unexpected error: %v", tc.input, err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Parse(%q, literal) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestAcceptMultilineLiteral(t *testing.T) { + // A trailing backslash must never request another line in literal mode, + // but unterminated quotes still do. + tests := []struct { + name string + input string + want bool + }{ + {"trailing backslash accepted", `ls C:\Temp\`, true}, + {"windows path accepted", `ls C:\Windows\Temp`, true}, + {"unterminated single still waits", "echo 'oops", false}, + {"unterminated double still waits", `echo "oops`, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := AcceptMultiline([]rune(tc.input), EscapeLiteral); got != tc.want { + t.Fatalf("AcceptMultiline(%q, literal) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + func TestIsEmpty(t *testing.T) { empty := []rune{' ', '\t'} diff --git a/run.go b/run.go index dc46510..1fd8263 100644 --- a/run.go +++ b/run.go @@ -76,7 +76,7 @@ func (c *Console) StartContext(ctx context.Context) error { menu = c.activeMenu() // Parse the line with bash-syntax, removing comments. - args, err := line.Parse(input) + args, err := line.Parse(input, c.getEscapeMode()) if err != nil { menu.ErrorHandler(ParseError{newError(err, "Parsing error")}) continue @@ -130,13 +130,21 @@ func (m *Menu) RunCommandArgs(ctx context.Context, args []string) (err error) { // RunCommandLine is the equivalent of menu.RunCommandArgs(), but accepts // an unsplit command line to execute. This line is split and processed in // *sh-compliant form, identically to how lines are in normal console usage. -func (m *Menu) RunCommandLine(ctx context.Context, line string) (err error) { - if len(line) == 0 { +func (m *Menu) RunCommandLine(ctx context.Context, input string) (err error) { + if len(input) == 0 { return } - // Split the line into shell words. - args, err := shellquote.Split(line) + // Split the line into shell words, honoring the console's escape mode so + // that this path stays consistent with normal interactive execution. + var args []string + + if m.console.getEscapeMode() == line.EscapeLiteral { + args, _, err = line.Split(input, false, line.EscapeLiteral) + } else { + args, err = shellquote.Split(input) + } + if err != nil { return fmt.Errorf("line error: %w", err) } diff --git a/run_escape_test.go b/run_escape_test.go new file mode 100644 index 0000000..554a95d --- /dev/null +++ b/run_escape_test.go @@ -0,0 +1,50 @@ +package console + +import ( + "context" + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +// TestRunCommandLineEscapeMode verifies that Console.SetEscapeMode flows all the +// way through to the argument vector a command actually receives. +func TestRunCommandLineEscapeMode(t *testing.T) { + tests := []struct { + name string + mode EscapeMode + line string + want []string + }{ + {"shell default eats backslashes", EscapeShell, `run C:\Windows\Temp`, []string{`C:WindowsTemp`}}, + {"literal preserves backslashes", EscapeLiteral, `run C:\Windows\Temp`, []string{`C:\Windows\Temp`}}, + {"literal preserves trailing backslash", EscapeLiteral, `run C:\Windows\Temp\`, []string{`C:\Windows\Temp\`}}, + {"literal still groups quotes", EscapeLiteral, `run "a b" C:\x`, []string{"a b", `C:\x`}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := New("test") + c.SetEscapeMode(tc.mode) + menu := c.ActiveMenu() + + var got []string + root := &cobra.Command{Use: "root"} + root.AddCommand(&cobra.Command{ + Use: "run", + Run: func(_ *cobra.Command, args []string) { + got = args + }, + }) + menu.Command = root + + if err := menu.RunCommandLine(context.Background(), tc.line); err != nil { + t.Fatalf("RunCommandLine(%q): %v", tc.line, err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("RunCommandLine(%q) args = %q, want %q", tc.line, got, tc.want) + } + }) + } +}