Skip to content

Commit ae1bac1

Browse files
committed
Add zsh-specific operators and missing bash-shared operators
Add operator classification for >|, >>|, ;;, ;&, ;|, &| found in zsh lex.c but previously returning WORDBREAK_UNKNOWN. This fixes redirect detection for >| and >>| and pipeline splitting for all new list operators. Also documents remaining zsh edge cases (RC_QUOTES inside $'...', line continuation, INTERACTIVECOMMENTS, CSHJUNKIEQUOTES). Assisted-by: Crush:glm-5.2
1 parent e730afa commit ae1bac1

7 files changed

Lines changed: 171 additions & 30 deletions

File tree

format_bash_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,34 @@ func TestBashFormat_Comment(t *testing.T) {
117117
t.Errorf("bash comment: Words = %v, want 2 words", words)
118118
}
119119
}
120+
121+
func TestBashFormat_ForceOutputRedirect(t *testing.T) {
122+
ctx := SplitForCompletion("echo foo >| bar", BashFormat())
123+
if !ctx.IsRedirect {
124+
t.Errorf("bash >|: IsRedirect = false, want true")
125+
}
126+
}
127+
128+
func TestBashFormat_CaseTerminator(t *testing.T) {
129+
tokens, err := SplitWith("echo foo ;; bar", BashFormat())
130+
if err != nil {
131+
t.Fatal(err)
132+
}
133+
pipelines := tokens.Pipelines()
134+
if len(pipelines) != 2 {
135+
t.Errorf("bash ;;: Pipelines = %d, want 2", len(pipelines))
136+
}
137+
var found *Token
138+
for i := range tokens {
139+
if tokens[i].Type == WORDBREAK_TOKEN {
140+
found = &tokens[i]
141+
break
142+
}
143+
}
144+
if found == nil {
145+
t.Fatal("bash ;;: no wordbreak token found")
146+
}
147+
if found.WordbreakType != WORDBREAK_LIST_SEQUENTIAL_DOUBLE {
148+
t.Errorf("bash ;;: WordbreakType = %v, want WORDBREAK_LIST_SEQUENTIAL_DOUBLE", found.WordbreakType)
149+
}
150+
}

format_zsh.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package shlex
22

33
// zshFormat implements Format for zsh lexing.
4-
// Extends bash with RC_QUOTES (” → ' inside single quotes).
4+
// Extends bash with RC_QUOTES, zsh-specific operators (>>|, ;&, ;|, &|),
5+
// and WORDCHARS/FIGNORE for word breaks.
56
type zshFormat struct{}
67

78
// ZshFormat returns the zsh lexical format.
@@ -13,7 +14,18 @@ func (zshFormat) Classifier() tokenClassifier {
1314
}
1415

1516
func (zshFormat) ClassifyOperator(raw string) WordbreakType {
16-
return bashWordbreakType(raw) // zsh uses the same operator grammar as bash
17+
switch raw {
18+
case ">>|":
19+
return WORDBREAK_REDIRECT_OUTPUT_APPEND_FORCE
20+
case ";&":
21+
return WORDBREAK_LIST_FALLTHROUGH
22+
case ";|":
23+
return WORDBREAK_LIST_FALLTHROUGH_RETRY
24+
case "&|":
25+
return WORDBREAK_LIST_ASYNC_ERRCHECK
26+
default:
27+
return bashWordbreakType(raw)
28+
}
1729
}
1830

1931
func (zshFormat) KeywordOperators() map[string]WordbreakType { return nil }

format_zsh_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,62 @@ func TestZshFormat_RCQuotesLonger(t *testing.T) {
6464
t.Errorf("zsh RC_QUOTES longer: Words = %v, want [echo it's a test]", words)
6565
}
6666
}
67+
68+
func TestZshFormat_Operators(t *testing.T) {
69+
tests := []struct {
70+
input string
71+
wantType WordbreakType
72+
wantRaw string
73+
}{
74+
{"echo foo >| bar", WORDBREAK_REDIRECT_OUTPUT_FORCE, ">|"},
75+
{"echo foo >>| bar", WORDBREAK_REDIRECT_OUTPUT_APPEND_FORCE, ">>|"},
76+
{"echo foo ;& bar", WORDBREAK_LIST_FALLTHROUGH, ";&"},
77+
{"echo foo ;| bar", WORDBREAK_LIST_FALLTHROUGH_RETRY, ";|"},
78+
{"echo foo &| bar", WORDBREAK_LIST_ASYNC_ERRCHECK, "&|"},
79+
{"echo foo ;; bar", WORDBREAK_LIST_SEQUENTIAL_DOUBLE, ";;"},
80+
}
81+
for _, tt := range tests {
82+
tokens, err := SplitWith(tt.input, ZshFormat())
83+
if err != nil {
84+
t.Fatalf("zsh operator %q: %v", tt.wantRaw, err)
85+
}
86+
var found *Token
87+
for i := range tokens {
88+
if tokens[i].Type == WORDBREAK_TOKEN && tokens[i].RawValue == tt.wantRaw {
89+
found = &tokens[i]
90+
break
91+
}
92+
}
93+
if found == nil {
94+
t.Fatalf("zsh operator %q: not found in tokens %v", tt.wantRaw, tokens)
95+
}
96+
if found.WordbreakType != tt.wantType {
97+
t.Errorf("zsh operator %q: WordbreakType = %v, want %v", tt.wantRaw, found.WordbreakType, tt.wantType)
98+
}
99+
}
100+
}
101+
102+
func TestZshFormat_ForceRedirectIsRedirect(t *testing.T) {
103+
ctx := SplitForCompletion("echo foo >| bar", ZshFormat())
104+
if !ctx.IsRedirect {
105+
t.Errorf("zsh >|: IsRedirect = false, want true")
106+
}
107+
}
108+
109+
func TestZshFormat_ForceAppendRedirectIsRedirect(t *testing.T) {
110+
ctx := SplitForCompletion("echo foo >>| bar", ZshFormat())
111+
if !ctx.IsRedirect {
112+
t.Errorf("zsh >>|: IsRedirect = false, want true")
113+
}
114+
}
115+
116+
func TestZshFormat_FallthroughIsPipelineDelimiter(t *testing.T) {
117+
tokens, err := SplitWith("echo foo ;& bar", ZshFormat())
118+
if err != nil {
119+
t.Fatal(err)
120+
}
121+
pipelines := tokens.Pipelines()
122+
if len(pipelines) != 2 {
123+
t.Errorf("zsh ;&: Pipelines = %d, want 2", len(pipelines))
124+
}
125+
}

skills/shlex/references/comparison.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ These are the characters that break a word and are classified as `WORDBREAK_TOKE
6464

6565
| Shell | Pipe | Redirect | Command sep | List operators | Other wordbreaks |
6666
|-------|------|----------|-------------|----------------|------------------|
67-
| **bash** | `\|` | `< > >> <<< <> <& &> &>>` | `;` `&` `&&` `\|\|` `\|&` | `&&` `\|\|` `&` `;` | `@ = ( :` + `COMP_WORDBREAKS` |
68-
| **zsh** | `\|` | `< > >> <<< <> <& &> &>>` `\|&` `=(...)` | `;` `&` `&&` `\|\|` | `&&` `\|\|` `&` `;` | `@ = ( :` + `WORDCHARS`/`FIGNORE` |
67+
| **bash** | `\|` | `< > >> >| <<< <> <& &> &>>` | `;` `&` `&&` `\|\|` `\|&` `;;` | `&&` `\|\|` `&` `;` `;;` | `@ = ( :` + `COMP_WORDBREAKS` |
68+
| **zsh** | `\|` | `< > >> >| <<< <> <& &> &>>` `\|&` `=(...)` | `;` `&` `&&` `\|\|` `;&` `;\|` `&\|` `;;` | `&&` `\|\|` `&` `;` `;&` `;\|` `&\|` `;;` | `@ = ( :` + `WORDCHARS`/`FIGNORE` |
6969
| **oil** | `\|` | `< > >> <<< <> <& &> &>>` | `;` `&` `&&` `\|\|` `\|&` | same | `@ = ( :` |
7070
| **tcsh** | `\|` | `< > >> << >& <&` | `;` `&` | `&&` `\|\|` `&` `;` | `( )` |
7171
| **fish** | `\|` | `< > >> >>? >? <>&` | `;` | `and` `or` `not` (keywords) | `( )` |

skills/shlex/references/format-bash.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ This is critical for completion: bash's `COMP_WORDBREAKS` determines where the w
9999
| `<` | `<` | `WORDBREAK_REDIRECT_INPUT` | redirect |
100100
| `>` | `>` | `WORDBREAK_REDIRECT_OUTPUT` | redirect |
101101
| `>>` | `>>` | `WORDBREAK_REDIRECT_OUTPUT_APPEND` | redirect |
102+
| `>\|` | `>\|` | `WORDBREAK_REDIRECT_OUTPUT_FORCE` | redirect (noclobber override) |
102103
| `&>` / `>&` | `&>` / `>&` | `WORDBREAK_REDIRECT_OUTPUT_BOTH` | redirect |
103104
| `&>>` | `&>>` | `WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND` | redirect |
104105
| `<<<` | `<<<` | `WORDBREAK_REDIRECT_INPUT_STRING` | redirect |
@@ -110,11 +111,12 @@ This is critical for completion: bash's `COMP_WORDBREAKS` determines where the w
110111
| `;` | `;` | `WORDBREAK_LIST_SEQUENTIAL` | list |
111112
| `&&` | `&&` | `WORDBREAK_LIST_AND` | list |
112113
| `\|\|` | `\|\|` | `WORDBREAK_LIST_OR` | list |
114+
| `;;` | `;;` | `WORDBREAK_LIST_SEQUENTIAL_DOUBLE` | list (case terminator) |
113115

114116
Multi-char operators are matched greedily in the `WORDBREAK_STATE` (consecutive wordbreak runes accumulate into one `WORDBREAK_TOKEN`).
115117

116-
`IsPipelineDelimiter()``|`, `|&`, `&`, `;`, `&&`, `||` — these split pipelines in `CurrentPipeline()`.
117-
`IsRedirect()` → the eight redirect operators — these are stripped in `FilterRedirects()`.
118+
`IsPipelineDelimiter()``|`, `|&`, `&`, `;`, `&&`, `||`, `;;` — these split pipelines in `CurrentPipeline()`.
119+
`IsRedirect()` → the redirect operators including `>|` — these are stripped in `FilterRedirects()`.
118120

119121
### File-descriptor prefixes
120122

skills/shlex/references/format-zsh.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,20 @@ Zsh has a 5-state model (vs bash's 2) because of the "full quoting" states where
8282

8383
## Operators
8484

85-
Same operator grammar as bash (`|`, `||`, `|&`, `&`, `;`, `&&`, `<`, `>`, `>>`, `<<<`, `<>`, `<&`, `&>`, `&>>`). See [format-bash.md](format-bash.md#operators-wordbreaktype) for the full table.
85+
Same operator grammar as bash (`|`, `||`, `|&`, `&`, `;`, `&&`, `<`, `>`, `>>`, `>|`, `<<<`, `<>`, `<&`, `&>`, `&>>`, `;;`). See [format-bash.md](format-bash.md#operators-wordbreaktype) for the full table.
8686

87-
Zsh additions (not in bash):
88-
- **`=(...)` process substitution**`=` as a wordbreak followed by `(...)`.
89-
- **Glob qualifiers** `(...)` after a path — these are word breaks but not operators in the pipeline sense.
87+
Zsh-specific operators (not in bash):
88+
89+
| Operator | RawValue | WordbreakType | Category |
90+
|----------|----------|---------------|----------|
91+
| `>>\|` | `>>\|` | `WORDBREAK_REDIRECT_OUTPUT_APPEND_FORCE` | redirect (noclobber override) |
92+
| `;&` | `;&` | `WORDBREAK_LIST_FALLTHROUGH` | list (case fall-through) |
93+
| `;\|` | `;\|` | `WORDBREAK_LIST_FALLTHROUGH_RETRY` | list (case fall-through with retry) |
94+
| `&\|` | `&\|` | `WORDBREAK_LIST_ASYNC_ERRCHECK` | list (background with error check) |
95+
96+
Three of the four are pipeline delimiters (`IsPipelineDelimiter() == true`): `;&`, `;|`, `&|`. The exception is `>>|`, which is a redirect (`IsRedirect() == true`) and therefore not a pipeline delimiter.
97+
98+
Zsh also shares `>|` (force output redirect) and `;;` (case terminator) with bash — both are classified in `bashWordbreakType`.
9099

91100
## Comments
92101

@@ -95,8 +104,14 @@ Zsh additions (not in bash):
95104
## Edge Cases
96105

97106
- **`RC_QUOTES` off (default)**: `''` closes then reopens single quotes (same as bash).
107+
- **`RC_QUOTES` inside `$'...'`**: zsh disables RC_QUOTES inside `$'...'` (ANSI-C quoting). The lexer cannot distinguish `$'...'` from `'...'`, so `$'it''s'` with `RC_QUOTES` would incorrectly produce `it's` instead of two segments. Unlikely in completion input.
98108
- **Named directories**: zsh's `hash -d` creates `~name` expansions. Carapace handles this via `NamedDirectories.Matches` in `quoteValue`, not in the lexer.
99109
- **`FULL_QUOTING_*_STATE` quirk**: when a word both starts and ends with the same quote, zsh places the trailing space *inside* the quote. Carapace forces nospace in these states.
110+
- **`=(...)` process substitution**: `=` is a wordbreak (in `BASH_WORDBREAKS`) but classified as `WORDBREAK_UNKNOWN`, not a redirect.
111+
- **Glob qualifiers** `(...)` after a path: these are word breaks but not operators in the pipeline sense.
112+
- **Backslash-newline line continuation**: zsh consumes `\` + newline entirely (no character added). The lexer's `ESCAPING_STATE` adds the newline as a literal. Rare in single-line completion input.
113+
- **`INTERACTIVECOMMENTS`**: `#` is only a comment in interactive mode when this option is set (on by default in modern zsh). The lexer always treats `#` as a comment.
114+
- **`CSHJUNKIEQUOTES`**: with this tcsh-compatibility option, newlines inside single quotes close the quote. Very niche, not handled.
100115

101116
## References
102117

wordbreak.go

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const (
1212
WORDBREAK_REDIRECT_INPUT
1313
WORDBREAK_REDIRECT_OUTPUT
1414
WORDBREAK_REDIRECT_OUTPUT_APPEND
15+
WORDBREAK_REDIRECT_OUTPUT_FORCE // >| (noclobber override)
16+
WORDBREAK_REDIRECT_OUTPUT_APPEND_FORCE // >>| (noclobber override for append)
1517
WORDBREAK_REDIRECT_OUTPUT_BOTH
1618
WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND
1719
WORDBREAK_REDIRECT_INPUT_STRING
@@ -27,6 +29,10 @@ const (
2729
WORDBREAK_LIST_SEQUENTIAL
2830
WORDBREAK_LIST_AND
2931
WORDBREAK_LIST_OR
32+
WORDBREAK_LIST_SEQUENTIAL_DOUBLE // ;; (case terminator)
33+
WORDBREAK_LIST_FALLTHROUGH // ;& (zsh case fall-through)
34+
WORDBREAK_LIST_FALLTHROUGH_RETRY // ;| (zsh case fall-through with retry)
35+
WORDBREAK_LIST_ASYNC_ERRCHECK // &| (zsh background with error check)
3036
// COMP_WORDBREAKS
3137
WORDBREAK_CUSTOM
3238
// Elvish-specific: output capture delimiters ( and )
@@ -36,25 +42,31 @@ const (
3642
)
3743

3844
var wordbreakTypes = map[WordbreakType]string{
39-
WORDBREAK_UNKNOWN: "WORDBREAK_UNKNOWN",
40-
WORDBREAK_REDIRECT_INPUT: "WORDBREAK_REDIRECT_INPUT",
41-
WORDBREAK_REDIRECT_OUTPUT: "WORDBREAK_REDIRECT_OUTPUT",
42-
WORDBREAK_REDIRECT_OUTPUT_APPEND: "WORDBREAK_REDIRECT_OUTPUT_APPEND",
43-
WORDBREAK_REDIRECT_OUTPUT_BOTH: "WORDBREAK_REDIRECT_OUTPUT_BOTH",
44-
WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND: "WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND",
45-
WORDBREAK_REDIRECT_INPUT_STRING: "WORDBREAK_REDIRECT_INPUT_STRING",
46-
WORDBREAK_REDIRECT_INPUT_DUPLICATE: "WORDBREAK_REDIRECT_INPUT_DUPLICATE",
47-
WORDBREAK_REDIRECT_INPUT_OUTPUT: "WORDBREAK_REDIRECT_INPUT_OUTPUT",
48-
WORDBREAK_PIPE: "WORDBREAK_PIPE",
49-
WORDBREAK_PIPE_WITH_STDERR: "WORDBREAK_PIPE_WITH_STDERR",
50-
WORDBREAK_LAMBDA_PIPE: "WORDBREAK_LAMBDA_PIPE",
51-
WORDBREAK_LIST_ASYNC: "WORDBREAK_LIST_ASYNC",
52-
WORDBREAK_LIST_SEQUENTIAL: "WORDBREAK_LIST_SEQUENTIAL",
53-
WORDBREAK_LIST_AND: "WORDBREAK_LIST_AND",
54-
WORDBREAK_LIST_OR: "WORDBREAK_LIST_OR",
55-
WORDBREAK_CUSTOM: "WORDBREAK_CUSTOM",
56-
WORDBREAK_OUTPUT_CAPTURE: "WORDBREAK_OUTPUT_CAPTURE",
57-
WORDBREAK_BRACKET: "WORDBREAK_BRACKET",
45+
WORDBREAK_UNKNOWN: "WORDBREAK_UNKNOWN",
46+
WORDBREAK_REDIRECT_INPUT: "WORDBREAK_REDIRECT_INPUT",
47+
WORDBREAK_REDIRECT_OUTPUT: "WORDBREAK_REDIRECT_OUTPUT",
48+
WORDBREAK_REDIRECT_OUTPUT_APPEND: "WORDBREAK_REDIRECT_OUTPUT_APPEND",
49+
WORDBREAK_REDIRECT_OUTPUT_FORCE: "WORDBREAK_REDIRECT_OUTPUT_FORCE",
50+
WORDBREAK_REDIRECT_OUTPUT_APPEND_FORCE: "WORDBREAK_REDIRECT_OUTPUT_APPEND_FORCE",
51+
WORDBREAK_REDIRECT_OUTPUT_BOTH: "WORDBREAK_REDIRECT_OUTPUT_BOTH",
52+
WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND: "WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND",
53+
WORDBREAK_REDIRECT_INPUT_STRING: "WORDBREAK_REDIRECT_INPUT_STRING",
54+
WORDBREAK_REDIRECT_INPUT_DUPLICATE: "WORDBREAK_REDIRECT_INPUT_DUPLICATE",
55+
WORDBREAK_REDIRECT_INPUT_OUTPUT: "WORDBREAK_REDIRECT_INPUT_OUTPUT",
56+
WORDBREAK_PIPE: "WORDBREAK_PIPE",
57+
WORDBREAK_PIPE_WITH_STDERR: "WORDBREAK_PIPE_WITH_STDERR",
58+
WORDBREAK_LAMBDA_PIPE: "WORDBREAK_LAMBDA_PIPE",
59+
WORDBREAK_LIST_ASYNC: "WORDBREAK_LIST_ASYNC",
60+
WORDBREAK_LIST_SEQUENTIAL: "WORDBREAK_LIST_SEQUENTIAL",
61+
WORDBREAK_LIST_AND: "WORDBREAK_LIST_AND",
62+
WORDBREAK_LIST_OR: "WORDBREAK_LIST_OR",
63+
WORDBREAK_LIST_SEQUENTIAL_DOUBLE: "WORDBREAK_LIST_SEQUENTIAL_DOUBLE",
64+
WORDBREAK_LIST_FALLTHROUGH: "WORDBREAK_LIST_FALLTHROUGH",
65+
WORDBREAK_LIST_FALLTHROUGH_RETRY: "WORDBREAK_LIST_FALLTHROUGH_RETRY",
66+
WORDBREAK_LIST_ASYNC_ERRCHECK: "WORDBREAK_LIST_ASYNC_ERRCHECK",
67+
WORDBREAK_CUSTOM: "WORDBREAK_CUSTOM",
68+
WORDBREAK_OUTPUT_CAPTURE: "WORDBREAK_OUTPUT_CAPTURE",
69+
WORDBREAK_BRACKET: "WORDBREAK_BRACKET",
5870
}
5971

6072
func (w WordbreakType) MarshalJSON() ([]byte, error) {
@@ -69,7 +81,11 @@ func (w WordbreakType) IsPipelineDelimiter() bool {
6981
WORDBREAK_LIST_ASYNC,
7082
WORDBREAK_LIST_SEQUENTIAL,
7183
WORDBREAK_LIST_AND,
72-
WORDBREAK_LIST_OR:
84+
WORDBREAK_LIST_OR,
85+
WORDBREAK_LIST_SEQUENTIAL_DOUBLE,
86+
WORDBREAK_LIST_FALLTHROUGH,
87+
WORDBREAK_LIST_FALLTHROUGH_RETRY,
88+
WORDBREAK_LIST_ASYNC_ERRCHECK:
7389
return true
7490
default:
7591
return false
@@ -82,6 +98,8 @@ func (w WordbreakType) IsRedirect() bool {
8298
WORDBREAK_REDIRECT_INPUT,
8399
WORDBREAK_REDIRECT_OUTPUT,
84100
WORDBREAK_REDIRECT_OUTPUT_APPEND,
101+
WORDBREAK_REDIRECT_OUTPUT_FORCE,
102+
WORDBREAK_REDIRECT_OUTPUT_APPEND_FORCE,
85103
WORDBREAK_REDIRECT_OUTPUT_BOTH,
86104
WORDBREAK_REDIRECT_OUTPUT_BOTH_APPEND,
87105
WORDBREAK_REDIRECT_INPUT_STRING,
@@ -103,6 +121,8 @@ func bashWordbreakType(raw string) WordbreakType {
103121
return WORDBREAK_REDIRECT_OUTPUT
104122
case ">>":
105123
return WORDBREAK_REDIRECT_OUTPUT_APPEND
124+
case ">|":
125+
return WORDBREAK_REDIRECT_OUTPUT_FORCE
106126
case "&>", ">&":
107127
return WORDBREAK_REDIRECT_OUTPUT_BOTH
108128
case "&>>":
@@ -125,6 +145,8 @@ func bashWordbreakType(raw string) WordbreakType {
125145
return WORDBREAK_LIST_AND
126146
case "||":
127147
return WORDBREAK_LIST_OR
148+
case ";;":
149+
return WORDBREAK_LIST_SEQUENTIAL_DOUBLE
128150
default:
129151
// TODO check COMP_WORDBREAKS -> WORDBREAK_OTHER
130152
return WORDBREAK_UNKNOWN

0 commit comments

Comments
 (0)