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) + } + }) + } +}