Skip to content

Commit e436f91

Browse files
committed
Add fish shell operators and fix double-quote escape handling
Expand fish format to support all operators from fish's tokenizer: &&, ||, & (background), |&, &|, &>, &>>, &>?, >&, <>, >?, >>?, <?, <>&. Add EscapingQuoteEscapeChars interface method so fish can restrict double-quote backslash escapes to \", $, \, and \<newline> only — all other \X sequences are now kept literal. Fix fishQuoteWord to not treat backtick as a special character. Assisted-by: Crush:glm-5.2
1 parent 0e2893b commit e436f91

15 files changed

Lines changed: 340 additions & 13 deletions

cmd/carapace-shlex/cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ var rootCmd = &cobra.Command{
7979
for _, word := range tokens.Words() {
8080
words = append(words, word.Value)
8181
}
82-
fmt.Fprintln(cmd.OutOrStdout(), shlex.Join(words))
82+
fmt.Fprintln(cmd.OutOrStdout(), shlex.JoinWith(words, format))
8383
return nil
8484
default:
8585
encoder := json.NewEncoder(cmd.OutOrStdout())

format.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ type Format interface {
4444
// Only elvish needs this (\ is a bareword char in elvish).
4545
EscapeNotBareword() bool
4646

47+
// EscapingQuoteEscapeChars returns the set of characters that backslash
48+
// can escape inside the escaping quote (double quotes). If nil, backslash
49+
// escapes any character (the POSIX/bash default). Fish returns only
50+
// `"`, `$`, `\`, and newline.
51+
EscapingQuoteEscapeChars() map[rune]bool
52+
4753
// QuoteWord quotes a single word for safe insertion into a command line.
4854
// Used by JoinWith. The implementation should use the shell's preferred
4955
// quoting style: backslash-escaping for POSIX shells, double-quote

format_bash.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ func (bashFormat) NonEscapingQuoteEscapes() bool { return false }
3232

3333
func (bashFormat) NonEscapingQuoteBackslashEscapes() bool { return false }
3434
func (bashFormat) EscapeNotBareword() bool { return true }
35+
func (bashFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
3536
func (bashFormat) QuoteWord(s string) string { return posixQuoteWord(s) }

format_cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,5 @@ func (cmdFormat) KeywordOperators() map[string]WordbreakType { return nil }
6565
func (cmdFormat) NonEscapingQuoteEscapes() bool { return false }
6666
func (cmdFormat) NonEscapingQuoteBackslashEscapes() bool { return false }
6767
func (cmdFormat) EscapeNotBareword() bool { return true }
68+
func (cmdFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
6869
func (cmdFormat) QuoteWord(s string) string { return cmdQuoteWord(s) }

format_elvish.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func (elvishFormat) KeywordOperators() map[string]WordbreakType { return nil }
4343
func (elvishFormat) NonEscapingQuoteEscapes() bool { return true } // '' → '
4444
func (elvishFormat) NonEscapingQuoteBackslashEscapes() bool { return false }
4545
func (elvishFormat) EscapeNotBareword() bool { return false }
46+
func (elvishFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
4647
func (elvishFormat) QuoteWord(s string) string { return elvishQuoteWord(s) }
4748

4849
// braceState tracks the parser context inside braces.

format_fish.go

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,49 @@ package shlex
77
// - `not` is a prefix keyword but not a pipeline delimiter, so not in KeywordOperators
88
// - No word splitting on variable expansion (doesn't affect lexing)
99
// - Narrower escape set in double quotes (\" \$ \\ and \+newline only)
10+
// - Supports &&, ||, & (background), |&, &|, &>, &>>, &>?, &>>?, >&, >|, <>, >?, >>?, <?, <>&
1011
type fishFormat struct{}
1112

1213
// FishFormat returns the fish lexical format.
1314
func FishFormat() Format { return fishFormat{} }
1415

1516
func (fishFormat) Classifier() tokenClassifier {
1617
t := newBaseClassifier(escapeRunes)
17-
// Fish operators: |, ;, <, >, >>, >>?, >?, <>&
18-
// No &&, ||, & — fish uses keyword operators (and, or, not) instead
19-
t.addWordbreaks("|;<>")
18+
// Fish operators: |, ;, <, >, &, ?
19+
// & is included for &&, ||, &, |&, &|, &>, &>>, &>?, >&, <>&
20+
// ? is part of redirect operators (>? >>? <?) — deprecated glob char
21+
t.addWordbreaks("|;<>&?")
2022
return t
2123
}
2224

2325
func (fishFormat) ClassifyOperator(raw string) WordbreakType {
2426
switch raw {
2527
case "|":
2628
return WORDBREAK_PIPE
29+
case "|&", "&|":
30+
return WORDBREAK_PIPE_WITH_STDERR
31+
case ">|":
32+
return WORDBREAK_PIPE // pipe with explicit fd (e.g. 2>|)
2733
case ";":
2834
return WORDBREAK_LIST_SEQUENTIAL
35+
case "&&":
36+
return WORDBREAK_LIST_AND
37+
case "||":
38+
return WORDBREAK_LIST_OR
39+
case "&":
40+
return WORDBREAK_LIST_ASYNC
2941
case ">", ">>", ">>?", ">?":
3042
return WORDBREAK_REDIRECT_OUTPUT
31-
case "<", "<>&":
43+
case "&>", "&>?":
44+
return WORDBREAK_REDIRECT_OUTPUT_BOTH
45+
case "&>>", "&>>?":
46+
return WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND
47+
case "<", "<>&", "<?":
3248
return WORDBREAK_REDIRECT_INPUT
49+
case "<>":
50+
return WORDBREAK_REDIRECT_INPUT_OUTPUT
51+
case ">&":
52+
return WORDBREAK_REDIRECT_INPUT_DUPLICATE // fd redirection (e.g. >&2)
3353
default:
3454
return WORDBREAK_UNKNOWN
3555
}
@@ -45,4 +65,19 @@ func (fishFormat) KeywordOperators() map[string]WordbreakType {
4565
func (fishFormat) NonEscapingQuoteEscapes() bool { return true } // ' and \\ inside single quotes
4666
func (fishFormat) NonEscapingQuoteBackslashEscapes() bool { return true }
4767
func (fishFormat) EscapeNotBareword() bool { return true }
48-
func (fishFormat) QuoteWord(s string) string { return fishQuoteWord(s) }
68+
69+
// EscapingQuoteEscapeChars returns the limited set of characters that
70+
// backslash can escape inside fish double quotes: ", $, \, and newline.
71+
// All other \X sequences are literal (both characters emitted).
72+
func (fishFormat) EscapingQuoteEscapeChars() map[rune]bool {
73+
return fishDoubleQuoteEscapes
74+
}
75+
76+
var fishDoubleQuoteEscapes = map[rune]bool{
77+
'"': true,
78+
'$': true,
79+
'\\': true,
80+
'\n': true,
81+
}
82+
83+
func (fishFormat) QuoteWord(s string) string { return fishQuoteWord(s) }

format_fish_test.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,263 @@ func TestFishFormat_ParensNotWordbreak(t *testing.T) {
174174
t.Errorf("fish parens: Words = %v, want [echo (echo test)]", words)
175175
}
176176
}
177+
178+
func TestFishFormat_AndAnd(t *testing.T) {
179+
tokens, err := SplitWith("echo foo && echo bar", FishFormat())
180+
if err != nil {
181+
t.Fatal(err)
182+
}
183+
pipelines := tokens.Pipelines()
184+
if len(pipelines) != 2 {
185+
t.Errorf("fish &&: %d pipelines, want 2", len(pipelines))
186+
}
187+
last := tokens[len(tokens)-3]
188+
if last.Type != WORDBREAK_TOKEN || last.WordbreakType != WORDBREAK_LIST_AND {
189+
t.Errorf("fish &&: Type=%v WordbreakType=%v, want WORDBREAK_TOKEN/LIST_AND", last.Type, last.WordbreakType)
190+
}
191+
}
192+
193+
func TestFishFormat_OrOr(t *testing.T) {
194+
tokens, err := SplitWith("echo foo || echo bar", FishFormat())
195+
if err != nil {
196+
t.Fatal(err)
197+
}
198+
pipelines := tokens.Pipelines()
199+
if len(pipelines) != 2 {
200+
t.Errorf("fish ||: %d pipelines, want 2", len(pipelines))
201+
}
202+
}
203+
204+
func TestFishFormat_Background(t *testing.T) {
205+
tokens, err := SplitWith("echo foo & echo bar", FishFormat())
206+
if err != nil {
207+
t.Fatal(err)
208+
}
209+
pipelines := tokens.Pipelines()
210+
if len(pipelines) != 2 {
211+
t.Errorf("fish &: %d pipelines, want 2", len(pipelines))
212+
}
213+
}
214+
215+
func TestFishFormat_PipeWithStderrMerge(t *testing.T) {
216+
tokens, err := SplitWith("echo foo |& cat bar", FishFormat())
217+
if err != nil {
218+
t.Fatal(err)
219+
}
220+
pipelines := tokens.Pipelines()
221+
if len(pipelines) != 2 {
222+
t.Errorf("fish |&: %d pipelines, want 2", len(pipelines))
223+
}
224+
wb := tokens[len(tokens)-3]
225+
if wb.Type != WORDBREAK_TOKEN || wb.WordbreakType != WORDBREAK_PIPE_WITH_STDERR {
226+
t.Errorf("fish |&: Type=%v WordbreakType=%v, want WORDBREAK_TOKEN/PIPE_WITH_STDERR", wb.Type, wb.WordbreakType)
227+
}
228+
}
229+
230+
func TestFishFormat_AmpPipe(t *testing.T) {
231+
tokens, err := SplitWith("echo foo &| cat bar", FishFormat())
232+
if err != nil {
233+
t.Fatal(err)
234+
}
235+
pipelines := tokens.Pipelines()
236+
if len(pipelines) != 2 {
237+
t.Errorf("fish &|: %d pipelines, want 2", len(pipelines))
238+
}
239+
}
240+
241+
func TestFishFormat_ExplicitFdPipe(t *testing.T) {
242+
// >| is a pipe with explicit fd in fish (e.g. echo foo >| bar)
243+
tokens, err := SplitWith("echo foo >| cat bar", FishFormat())
244+
if err != nil {
245+
t.Fatal(err)
246+
}
247+
pipelines := tokens.Pipelines()
248+
if len(pipelines) != 2 {
249+
t.Errorf("fish >|: %d pipelines, want 2", len(pipelines))
250+
}
251+
wb := tokens[len(tokens)-3]
252+
if wb.Type != WORDBREAK_TOKEN || wb.WordbreakType != WORDBREAK_PIPE {
253+
t.Errorf("fish >|: Type=%v WordbreakType=%v, want WORDBREAK_TOKEN/PIPE", wb.Type, wb.WordbreakType)
254+
}
255+
}
256+
257+
func TestFishFormat_AmpRedirect(t *testing.T) {
258+
_, err := SplitWith("echo foo &> file.txt", FishFormat())
259+
if err != nil {
260+
t.Fatal(err)
261+
}
262+
ctx := SplitForCompletion("echo foo &> file.txt", FishFormat())
263+
if !ctx.IsRedirect {
264+
t.Errorf("fish &>: IsRedirect = false, want true")
265+
}
266+
}
267+
268+
func TestFishFormat_AmpRedirectAppend(t *testing.T) {
269+
_, err := SplitWith("echo foo &>> file.txt", FishFormat())
270+
if err != nil {
271+
t.Fatal(err)
272+
}
273+
ctx := SplitForCompletion("echo foo &>> file.txt", FishFormat())
274+
if !ctx.IsRedirect {
275+
t.Errorf("fish &>>: IsRedirect = false, want true")
276+
}
277+
}
278+
279+
func TestFishFormat_FdRedirect(t *testing.T) {
280+
// >&2 is a fd redirect: >& is the operator, 2 is the fd number.
281+
// After >&2, the cursor is at a new word (not a redirect target).
282+
// Test that >& is classified as a redirect operator.
283+
tokens, err := SplitWith("echo foo >&2", FishFormat())
284+
if err != nil {
285+
t.Fatal(err)
286+
}
287+
found := false
288+
for _, tok := range tokens {
289+
if tok.Type == WORDBREAK_TOKEN && tok.WordbreakType == WORDBREAK_REDIRECT_INPUT_DUPLICATE {
290+
found = true
291+
break
292+
}
293+
}
294+
if !found {
295+
t.Errorf("fish >&2: no WORDBREAK_REDIRECT_INPUT_DUPLICATE token found")
296+
}
297+
}
298+
299+
func TestFishFormat_InputOutputRedirect(t *testing.T) {
300+
ctx := SplitForCompletion("echo foo <> ", FishFormat())
301+
if !ctx.IsRedirect {
302+
t.Errorf("fish <>: IsRedirect = false, want true")
303+
}
304+
}
305+
306+
func TestFishFormat_NoclobberRedirect(t *testing.T) {
307+
ctx := SplitForCompletion("echo foo >? ", FishFormat())
308+
if !ctx.IsRedirect {
309+
t.Errorf("fish >?: IsRedirect = false, want true")
310+
}
311+
}
312+
313+
func TestFishFormat_NoclobberAppendRedirect(t *testing.T) {
314+
ctx := SplitForCompletion("echo foo >>? ", FishFormat())
315+
if !ctx.IsRedirect {
316+
t.Errorf("fish >>?: IsRedirect = false, want true")
317+
}
318+
}
319+
320+
func TestFishFormat_TryInputRedirect(t *testing.T) {
321+
ctx := SplitForCompletion("echo foo <? ", FishFormat())
322+
if !ctx.IsRedirect {
323+
t.Errorf("fish <?: IsRedirect = false, want true")
324+
}
325+
}
326+
327+
func TestFishFormat_DoubleQuoteEscapedQuote(t *testing.T) {
328+
tokens, err := SplitWith(`echo "say \"hello\""`, FishFormat())
329+
if err != nil {
330+
t.Fatal(err)
331+
}
332+
words := tokens.Words().Strings()
333+
if len(words) != 2 || words[1] != `say "hello"` {
334+
t.Errorf("fish \\\" in double: Words = %v, want [echo say \"hello\"]", words)
335+
}
336+
}
337+
338+
func TestFishFormat_DoubleQuoteEscapedDollar(t *testing.T) {
339+
tokens, err := SplitWith(`echo "cost: \$5"`, FishFormat())
340+
if err != nil {
341+
t.Fatal(err)
342+
}
343+
words := tokens.Words().Strings()
344+
if len(words) != 2 || words[1] != `cost: $5` {
345+
t.Errorf("fish \\$ in double: Words = %v, want [echo cost: $5]", words)
346+
}
347+
}
348+
349+
func TestFishFormat_DoubleQuoteEscapedBackslash(t *testing.T) {
350+
tokens, err := SplitWith(`echo "C:\\path"`, FishFormat())
351+
if err != nil {
352+
t.Fatal(err)
353+
}
354+
words := tokens.Words().Strings()
355+
if len(words) != 2 || words[1] != `C:\path` {
356+
t.Errorf("fish \\\\ in double: Words = %v, want [echo C:\\path]", words)
357+
}
358+
}
359+
360+
func TestFishFormat_DoubleQuoteNonEscapeBackslash(t *testing.T) {
361+
// \n inside fish double quotes is NOT an escape — both \ and n are literal
362+
tokens, err := SplitWith(`echo "hello\nworld"`, FishFormat())
363+
if err != nil {
364+
t.Fatal(err)
365+
}
366+
words := tokens.Words().Strings()
367+
if len(words) != 2 || words[1] != `hello\nworld` {
368+
t.Errorf("fish \\n in double: Words = %v, want [echo hello\\nworld]", words)
369+
}
370+
}
371+
372+
func TestFishFormat_DoubleQuoteNonEscapeBackslashOther(t *testing.T) {
373+
// \t inside fish double quotes is NOT an escape — both \ and t are literal
374+
tokens, err := SplitWith(`echo "a\tb"`, FishFormat())
375+
if err != nil {
376+
t.Fatal(err)
377+
}
378+
words := tokens.Words().Strings()
379+
if len(words) != 2 || words[1] != `a\tb` {
380+
t.Errorf("fish \\t in double: Words = %v, want [echo a\\tb]", words)
381+
}
382+
}
383+
384+
func TestFishFormat_DoubleQuoteEscapedNewline(t *testing.T) {
385+
// \<newline> is a line continuation escape inside fish double quotes
386+
tokens, err := SplitWith("echo \"hello\\\nworld\"", FishFormat())
387+
if err != nil {
388+
t.Fatal(err)
389+
}
390+
words := tokens.Words().Strings()
391+
if len(words) != 2 || words[1] != "hello\nworld" {
392+
t.Errorf("fish \\<newline> in double: Words = %v, want [echo hello\\nworld]", words)
393+
}
394+
}
395+
396+
func TestFishFormat_QuoteWordBacktick(t *testing.T) {
397+
// Backtick is a regular character in fish — should not trigger quoting
398+
q := fishQuoteWord("hello`world")
399+
if q != "hello`world" {
400+
t.Errorf("fishQuoteWord backtick: got %q, want %q", q, "hello`world")
401+
}
402+
}
403+
404+
func TestFishFormat_QuoteWordDollar(t *testing.T) {
405+
q := fishQuoteWord("hello$world")
406+
if q != `"hello\$world"` {
407+
t.Errorf("fishQuoteWord dollar: got %q, want %q", q, `"hello\$world"`)
408+
}
409+
}
410+
411+
func TestFishFormat_QuoteWordSafe(t *testing.T) {
412+
q := fishQuoteWord("hello-world")
413+
if q != "hello-world" {
414+
t.Errorf("fishQuoteWord safe: got %q, want %q", q, "hello-world")
415+
}
416+
}
417+
418+
func TestFishFormat_CompletionAndAnd(t *testing.T) {
419+
ctx := SplitForCompletion("echo foo && echo bar hel", FishFormat())
420+
if ctx.CurrentWord != "hel" {
421+
t.Errorf("fish && completion: CurrentWord = %q, want %q", ctx.CurrentWord, "hel")
422+
}
423+
if len(ctx.Words) != 3 {
424+
t.Errorf("fish && completion: Words = %v, want 3 (echo bar hel)", ctx.Words)
425+
}
426+
}
427+
428+
func TestFishFormat_CompletionBackground(t *testing.T) {
429+
ctx := SplitForCompletion("echo foo & echo bar hel", FishFormat())
430+
if ctx.CurrentWord != "hel" {
431+
t.Errorf("fish & completion: CurrentWord = %q, want %q", ctx.CurrentWord, "hel")
432+
}
433+
if len(ctx.Words) != 3 {
434+
t.Errorf("fish & completion: Words = %v, want 3 (echo bar hel)", ctx.Words)
435+
}
436+
}

format_nushell.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,5 @@ func (nushellFormat) KeywordOperators() map[string]WordbreakType { return nil }
5757
func (nushellFormat) NonEscapingQuoteEscapes() bool { return false }
5858
func (nushellFormat) NonEscapingQuoteBackslashEscapes() bool { return false }
5959
func (nushellFormat) EscapeNotBareword() bool { return true }
60+
func (nushellFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
6061
func (nushellFormat) QuoteWord(s string) string { return nushellQuoteWord(s) }

format_powershell.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,5 @@ func (powershellFormat) KeywordOperators() map[string]WordbreakType { return nil
6161
func (powershellFormat) NonEscapingQuoteEscapes() bool { return true } // '' → '
6262
func (powershellFormat) NonEscapingQuoteBackslashEscapes() bool { return false }
6363
func (powershellFormat) EscapeNotBareword() bool { return true }
64+
func (powershellFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
6465
func (powershellFormat) QuoteWord(s string) string { return powershellQuoteWord(s) }

format_tcsh.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ func (tcshFormat) KeywordOperators() map[string]WordbreakType { return nil }
2323
func (tcshFormat) NonEscapingQuoteEscapes() bool { return false }
2424
func (tcshFormat) NonEscapingQuoteBackslashEscapes() bool { return false }
2525
func (tcshFormat) EscapeNotBareword() bool { return true }
26+
func (tcshFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
2627
func (tcshFormat) QuoteWord(s string) string { return posixQuoteWord(s) }

0 commit comments

Comments
 (0)