Skip to content

Commit 397edc2

Browse files
maxlandonclaude
andcommitted
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 <noreply@anthropic.com>
1 parent f44dc1d commit 397edc2

8 files changed

Lines changed: 244 additions & 33 deletions

File tree

completer.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func (c *Console) complete(input []rune, pos int) readline.Completions {
2323

2424
// Split the line as shell words, only using
2525
// what the right buffer (up to the cursor)
26-
args, prefixComp, prefixLine := completion.SplitArgs(input, pos)
26+
args, prefixComp, prefixLine := completion.SplitArgs(input, pos, c.getEscapeMode())
2727
command.ResetCompletionFlagState(menu.Command, args)
2828

2929
// Prepare arguments for the carapace completer
@@ -142,7 +142,7 @@ func (c *Console) highlightSyntax(input []rune) string {
142142

143143
func (c *Console) computeHighlight(input []rune) string {
144144
// Split the line as shellwords
145-
args, unprocessed, err := line.Split(string(input), true)
145+
args, unprocessed, err := line.Split(string(input), true, c.getEscapeMode())
146146
if err != nil {
147147
args = append(args, unprocessed)
148148
}

console.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type Console struct {
3131
menus map[string]*Menu // Different command trees, prompt engines, etc.
3232
current *Menu // Cached pointer to the active menu (guarded by mutex).
3333
filters []string // Hide commands based on their attributes and current context.
34+
escapeMode line.EscapeMode // How input lines are split into words (guarded by mutex).
3435
isExecuting atomic.Bool // Used by log functions, which need to adapt behavior (print the prompt, etc.)
3536
printed bool // Used to adjust asynchronous messages too.
3637
mutex *sync.RWMutex // Concurrency management.
@@ -131,7 +132,9 @@ func New(app string) *Console {
131132
// Syntax highlighting, multiline callbacks, etc.
132133
console.cmdHighlight = line.GreenFG
133134
console.flagHighlight = line.BrightWhiteFG
134-
console.shell.AcceptMultiline = line.AcceptMultiline
135+
console.shell.AcceptMultiline = func(input []rune) bool {
136+
return line.AcceptMultiline(input, console.getEscapeMode())
137+
}
135138
console.shell.SyntaxHighlighter = console.highlightSyntax
136139

137140
// Completion
@@ -151,6 +154,40 @@ func (c *Console) Shell() *readline.Shell {
151154
return c.shell
152155
}
153156

157+
// EscapeMode controls how the console splits an input line into command words.
158+
// See EscapeShell (the default) and EscapeLiteral.
159+
type EscapeMode = line.EscapeMode
160+
161+
const (
162+
// EscapeShell is the default POSIX-shell behaviour: a backslash escapes the
163+
// following character (so `C:\Windows` becomes `C:Windows`), and a trailing
164+
// backslash marks the line as an incomplete continuation.
165+
EscapeShell = line.EscapeShell
166+
167+
// EscapeLiteral preserves backslashes as ordinary characters, so values such
168+
// as Windows paths (`C:\Windows\Temp`) are passed to commands verbatim
169+
// without quoting or doubling. Quotes still group words, and a trailing
170+
// backslash no longer requests another line. Use this when the console is a
171+
// general Cobra frontend rather than a shell.
172+
EscapeLiteral = line.EscapeLiteral
173+
)
174+
175+
// SetEscapeMode selects how the console splits input lines into command words.
176+
// It applies to command execution, multiline-continuation detection, and
177+
// completion/highlighting alike. The default is EscapeShell.
178+
func (c *Console) SetEscapeMode(mode EscapeMode) {
179+
c.mutex.Lock()
180+
defer c.mutex.Unlock()
181+
c.escapeMode = mode
182+
}
183+
184+
func (c *Console) getEscapeMode() line.EscapeMode {
185+
c.mutex.RLock()
186+
defer c.mutex.RUnlock()
187+
188+
return c.escapeMode
189+
}
190+
154191

155192
//
156193
// Settings & Initialisation Functions ------------------------------------------------------------- //

internal/completion/line.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,21 @@ import (
1414
// SplitArgs splits the line in valid words, prepares them in various ways before calling
1515
// the completer with them, and also determines which parts of them should be used as
1616
// prefixes, in the completions and/or in the line.
17-
func SplitArgs(line []rune, pos int) (args []string, prefixComp, prefixLine string) {
18-
line = line[:pos]
17+
func SplitArgs(input []rune, pos int, mode line.EscapeMode) (args []string, prefixComp, prefixLine string) {
18+
input = input[:pos]
1919

2020
// Remove all colors from the string
21-
line = []rune(strip(string(line)))
21+
input = []rune(strip(string(input)))
2222

2323
// Split the line as shellwords, return them if all went fine.
24-
args, remain, err := splitCompWords(string(line))
24+
args, remain, err := splitCompWords(string(input), mode)
2525

2626
// We might have either no error and args, or no error and
2727
// the cursor ready to complete a new word (last character
2828
// in line is a space).
2929
// In some of those cases we append a single dummy argument
3030
// for the completer to understand we want a new word comp.
31-
mustComplete, args, remain := mustComplete(line, args, remain, err)
31+
mustComplete, args, remain := mustComplete(input, args, remain, err)
3232
if mustComplete {
3333
return sanitizeArgs(args), "", remain
3434
}
@@ -103,7 +103,7 @@ func sanitizeArgs(args []string) (sanitized []string) {
103103

104104
// split has been copied from go-shellquote and slightly modified so as to also
105105
// return the remainder when the parsing failed because of an unterminated quote.
106-
func splitCompWords(input string) (words []string, remainder string, err error) {
106+
func splitCompWords(input string, mode line.EscapeMode) (words []string, remainder string, err error) {
107107
var buf bytes.Buffer
108108
words = make([]string, 0)
109109

@@ -113,7 +113,7 @@ func splitCompWords(input string) (words []string, remainder string, err error)
113113
if strings.ContainsRune(line.SplitChars, char) {
114114
input = input[read:]
115115
continue
116-
} else if char == line.EscapeChar {
116+
} else if char == line.EscapeChar && mode == line.EscapeShell {
117117
// Look ahead for escaped newline so we can skip over it
118118
next := input[read:]
119119
if len(next) == 0 {
@@ -132,7 +132,7 @@ func splitCompWords(input string) (words []string, remainder string, err error)
132132

133133
var word string
134134

135-
word, input, err = splitCompWord(input, &buf)
135+
word, input, err = splitCompWord(input, &buf, mode)
136136
if err != nil {
137137
return words, word + input, err
138138
}
@@ -145,7 +145,7 @@ func splitCompWords(input string) (words []string, remainder string, err error)
145145

146146
// splitWord has been modified to return the remainder of the input (the part that has not been
147147
// added to the buffer) even when an error is returned.
148-
func splitCompWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) {
148+
func splitCompWord(input string, buf *bytes.Buffer, mode line.EscapeMode) (word string, remainder string, err error) {
149149
buf.Reset()
150150

151151
raw:
@@ -163,7 +163,7 @@ raw:
163163
buf.WriteString(input[0 : len(input)-len(cur)-read])
164164
input = cur
165165
goto double
166-
case char == line.EscapeChar:
166+
case char == line.EscapeChar && mode == line.EscapeShell:
167167
buf.WriteString(input[0 : len(input)-len(cur)-read])
168168
buf.WriteRune(char)
169169
input = cur
@@ -218,6 +218,9 @@ double:
218218
input = cur
219219
goto raw
220220
case line.EscapeChar:
221+
if mode != line.EscapeShell {
222+
continue
223+
}
221224
// bash only supports certain escapes in double-quoted strings
222225
char2, l2 := utf8.DecodeRuneInString(cur)
223226
cur = cur[l2:]

internal/completion/line_test.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func TestSplitCompWords(t *testing.T) {
2626

2727
for _, tc := range tests {
2828
t.Run(tc.name, func(t *testing.T) {
29-
words, remainder, err := splitCompWords(tc.input)
29+
words, remainder, err := splitCompWords(tc.input, line.EscapeShell)
3030
if err != tc.wantErr {
3131
t.Fatalf("splitCompWords(%q) err = %v, want %v", tc.input, err, tc.wantErr)
3232
}
@@ -40,6 +40,36 @@ func TestSplitCompWords(t *testing.T) {
4040
}
4141
}
4242

43+
func TestSplitCompWordsLiteral(t *testing.T) {
44+
// In literal mode, backslashes are kept verbatim so completing a Windows
45+
// path never collapses separators or triggers an unterminated-escape error.
46+
tests := []struct {
47+
name string
48+
input string
49+
wantWords []string
50+
wantRemainder string
51+
}{
52+
{"windows path", `cd C:\Windows`, []string{"cd", `C:\Windows`}, ""},
53+
{"trailing backslash", `cd C:\Windows\`, []string{"cd", `C:\Windows\`}, ""},
54+
{"quotes still group", `cd "a b"`, []string{"cd", "a b"}, ""},
55+
}
56+
57+
for _, tc := range tests {
58+
t.Run(tc.name, func(t *testing.T) {
59+
words, remainder, err := splitCompWords(tc.input, line.EscapeLiteral)
60+
if err != nil {
61+
t.Fatalf("splitCompWords(%q, literal) err = %v, want nil", tc.input, err)
62+
}
63+
if !reflect.DeepEqual(words, tc.wantWords) {
64+
t.Fatalf("splitCompWords(%q, literal) words = %q, want %q", tc.input, words, tc.wantWords)
65+
}
66+
if remainder != tc.wantRemainder {
67+
t.Fatalf("splitCompWords(%q, literal) remainder = %q, want %q", tc.input, remainder, tc.wantRemainder)
68+
}
69+
})
70+
}
71+
}
72+
4373
func TestAdjustQuotedPrefix(t *testing.T) {
4474
tests := []struct {
4575
name string
@@ -94,7 +124,7 @@ func TestSplitArgs(t *testing.T) {
94124
for _, tc := range tests {
95125
t.Run(tc.name, func(t *testing.T) {
96126
runes := []rune(tc.input)
97-
args, prefixComp, prefixLine := SplitArgs(runes, len(runes))
127+
args, prefixComp, prefixLine := SplitArgs(runes, len(runes), line.EscapeShell)
98128
if !reflect.DeepEqual(args, tc.wantArgs) {
99129
t.Fatalf("SplitArgs(%q) args = %q, want %q", tc.input, args, tc.wantArgs)
100130
}

internal/line/line.go

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,30 @@ var (
2424
ErrUnterminatedEscape = errors.New("unterminated backslash-escape")
2525
)
2626

27+
// EscapeMode controls how the line parser treats backslashes when splitting an
28+
// input line into words.
29+
type EscapeMode int
30+
31+
const (
32+
// EscapeShell is the default POSIX-shell behaviour: a backslash escapes the
33+
// following character, so `C:\Windows` becomes `C:Windows`, and a trailing
34+
// backslash marks the line as an incomplete continuation.
35+
EscapeShell EscapeMode = iota
36+
37+
// EscapeLiteral preserves backslashes as ordinary characters. Quotes still
38+
// group words and are removed, but `C:\Windows\Temp` is passed through
39+
// verbatim and a trailing backslash does not request another line.
40+
EscapeLiteral
41+
)
42+
2743
// Parse is in charge of removing all comments from the input line
2844
// before execution, and if successfully parsed, split into words.
29-
func Parse(line string) (args []string, err error) {
30-
lineReader := strings.NewReader(line)
45+
//
46+
// The mode governs how backslashes are treated when the (comment-stripped)
47+
// line is split into words: EscapeShell applies POSIX escape rules, while
48+
// EscapeLiteral preserves backslashes verbatim.
49+
func Parse(input string, mode EscapeMode) (args []string, err error) {
50+
lineReader := strings.NewReader(input)
3151
parser := syntax.NewParser(syntax.KeepComments(false))
3252

3353
// Parse the shell string a syntax, removing all comments.
@@ -43,15 +63,26 @@ func Parse(line string) (args []string, err error) {
4363
return nil, err
4464
}
4565

66+
// In literal mode, split with our own splitter so that backslashes (e.g. in
67+
// Windows paths) are preserved instead of being consumed as shell escapes.
68+
if mode == EscapeLiteral {
69+
args, _, err = Split(parsedLine.String(), false, EscapeLiteral)
70+
71+
return args, err
72+
}
73+
4674
// Split the line into shell words.
4775
return shellquote.Split(parsedLine.String())
4876
}
4977

5078
// acceptMultiline determines if the line just accepted is complete (in which case
5179
// we should execute it), or incomplete (in which case we must read in multiline).
52-
func AcceptMultiline(line []rune) (accept bool) {
80+
//
81+
// The mode controls escape handling: in EscapeLiteral, a trailing backslash is an
82+
// ordinary character and never requests another line (only unterminated quotes do).
83+
func AcceptMultiline(line []rune, mode EscapeMode) (accept bool) {
5384
// Errors are either: unterminated quotes, or unterminated escapes.
54-
_, _, err := Split(string(line), false)
85+
_, _, err := Split(string(line), false, mode)
5586
if err == nil {
5687
return true
5788
}
@@ -112,7 +143,10 @@ func TrimSpaces(remain []string) (trimmed []string) {
112143

113144
// Split has been copied from go-shellquote and slightly modified so as to also
114145
// return the remainder when the parsing failed because of an unterminated quote.
115-
func Split(input string, hl bool) (words []string, remainder string, err error) {
146+
//
147+
// In EscapeLiteral mode, backslashes are treated as ordinary characters: they
148+
// are neither consumed as escapes nor able to mark a line continuation.
149+
func Split(input string, hl bool, mode EscapeMode) (words []string, remainder string, err error) {
116150
var buf bytes.Buffer
117151
words = make([]string, 0)
118152

@@ -132,7 +166,7 @@ func Split(input string, hl bool) (words []string, remainder string, err error)
132166
input = input[l:]
133167

134168
continue
135-
} else if c == EscapeChar {
169+
} else if c == EscapeChar && mode == EscapeShell {
136170
// Look ahead for escaped newline so we can skip over it
137171
next := input[l:]
138172
if len(next) == 0 {
@@ -163,7 +197,7 @@ func Split(input string, hl bool) (words []string, remainder string, err error)
163197

164198
var word string
165199

166-
word, input, err = splitWord(input, &buf, hl)
200+
word, input, err = splitWord(input, &buf, hl, mode)
167201
if err != nil {
168202
remainder = input
169203
return words, remainder, err
@@ -177,7 +211,7 @@ func Split(input string, hl bool) (words []string, remainder string, err error)
177211

178212
// splitWord has been modified to return the remainder of the input (the part that has not been
179213
// added to the buffer) even when an error is returned.
180-
func splitWord(input string, buf *bytes.Buffer, hl bool) (word string, remainder string, err error) {
214+
func splitWord(input string, buf *bytes.Buffer, hl bool, mode EscapeMode) (word string, remainder string, err error) {
181215
buf.Reset()
182216

183217
raw:
@@ -194,7 +228,7 @@ raw:
194228
buf.WriteString(input[0 : len(input)-len(cur)-l])
195229
input = cur
196230
goto double
197-
} else if c == EscapeChar {
231+
} else if c == EscapeChar && mode == EscapeShell {
198232
buf.WriteString(input[0 : len(input)-len(cur)-l])
199233
if hl {
200234
buf.WriteRune(c)
@@ -282,7 +316,7 @@ double:
282316
}
283317
input = cur
284318
goto raw
285-
} else if c == EscapeChar && !hl {
319+
} else if c == EscapeChar && !hl && mode == EscapeShell {
286320
// bash only supports certain escapes in double-quoted strings
287321
c2, l2 := utf8.DecodeRuneInString(cur)
288322
cur = cur[l2:]

0 commit comments

Comments
 (0)