Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions completer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
39 changes: 38 additions & 1 deletion console.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 ------------------------------------------------------------- //
Expand Down
23 changes: 13 additions & 10 deletions internal/completion/line.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:]
Expand Down
34 changes: 32 additions & 2 deletions internal/completion/line_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
54 changes: 44 additions & 10 deletions internal/line/line.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand Down Expand Up @@ -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)

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:]
Expand Down
Loading
Loading