Skip to content

Commit 563c5f8

Browse files
committed
Fix nushell escape sequences and stream redirect operators
Nushell double-quoted strings use a richer C-style escape set than bash (e.g. \n produces a newline character, not literal "n"). Add the EscapingQuoteUnescaper interface so formats can provide full escape translation instead of just backslash-dropping. Nushell stream redirect operators (out>, err>, o+e>, e>|, etc.) are multi-rune sequences that the single-rune tokenizer splits into separate word and wordbreak tokens. Add a PostProcess step that merges these into single WORDBREAK_TOKENs with correct classification, enabling proper redirect filtering and pipeline splitting. Only bare words are merged — quoted strings like 'out' are not treated as stream operators. Assisted-by: Crush:glm-5.2
1 parent e436f91 commit 563c5f8

5 files changed

Lines changed: 431 additions & 8 deletions

File tree

format.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,21 @@ type Format interface {
5858
QuoteWord(s string) string
5959
}
6060

61+
// EscapingQuoteUnescaper is an optional interface for formats that need to
62+
// transform escape sequences inside double quotes beyond simple
63+
// backslash-dropping. When implemented, the ESCAPING_QUOTED_STATE handler
64+
// calls EscapingQuoteUnescape for the rune following a backslash. If the
65+
// rune is a recognized escape, the replacement string is used; otherwise
66+
// both the backslash and the rune are kept literally. Formats implementing
67+
// this interface take priority over EscapingQuoteEscapeChars.
68+
type EscapingQuoteUnescaper interface {
69+
EscapingQuoteUnescape(r rune) (replacement string, handled bool)
70+
}
71+
6172
// PostProcessor is an optional interface for formats that need to reclassify
6273
// tokens after the main tokenization pass. Used by formats that require
6374
// context not available in the flat state machine (e.g. elvish brace/lambda
64-
// context for | disambiguation).
75+
// context for | disambiguation, nushell stream-redirect operator merging).
6576
type PostProcessor interface {
6677
PostProcess(tokens TokenSlice) TokenSlice
6778
}

format_nushell.go

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package shlex
22

3+
import "strings"
4+
35
// nushellFormat implements Format for nushell lexing.
46
// Key differences from bash:
57
// - Backtick (`) is a quote character (not an escape like PowerShell)
68
// - $'...' and $"..." are interpolated strings ($ prefix + standard quote)
7-
// - r#'...'# raw strings need multi-rune opener support (deferred)
9+
// - C-style escapes in double quotes with a richer set than bash:
10+
// \" \' \\ \/ \b \f \r \n \t \0 \a \e \( \) \{ \} \$ \^ \# \| \~ \xHH \u{X}
811
// - No POSIX list operators (no &&, ||, &)
9-
// - C-style escapes in double quotes (same as bash)
12+
// - Stream redirect operators: out>, err>, out+err>, o>, e>, o+e>
13+
// and pipe variants: e>|, err>|, o+e>|, out+err>|
14+
// - r#'...'# raw strings need multi-rune opener support (deferred)
1015
type nushellFormat struct{}
1116

1217
// NushellFormat returns the nushell lexical format.
@@ -59,3 +64,115 @@ func (nushellFormat) NonEscapingQuoteBackslashEscapes() bool { return false }
5964
func (nushellFormat) EscapeNotBareword() bool { return true }
6065
func (nushellFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
6166
func (nushellFormat) QuoteWord(s string) string { return nushellQuoteWord(s) }
67+
68+
// EscapingQuoteUnescape implements the EscapingQuoteUnescaper interface.
69+
// Nushell double-quoted strings support C-style escapes with a richer set
70+
// than bash. Recognized escapes produce the corresponding character(s);
71+
// unrecognized escapes keep both the backslash and the rune literally
72+
// (nushell itself errors on unrecognized escapes, but for completion being
73+
// lenient is better than failing).
74+
func (nushellFormat) EscapingQuoteUnescape(r rune) (string, bool) {
75+
switch r {
76+
case '"':
77+
return "\"", true
78+
case '\'':
79+
return "'", true
80+
case '\\':
81+
return "\\", true
82+
case '/':
83+
return "/", true
84+
case 'b':
85+
return "\b", true
86+
case 'f':
87+
return "\f", true
88+
case 'r':
89+
return "\r", true
90+
case 'n':
91+
return "\n", true
92+
case 't':
93+
return "\t", true
94+
case '0':
95+
return "\x00", true
96+
case 'a':
97+
return "\a", true
98+
case 'e':
99+
return "\x1b", true
100+
case '(':
101+
return "(", true
102+
case ')':
103+
return ")", true
104+
case '{':
105+
return "{", true
106+
case '}':
107+
return "}", true
108+
case '$':
109+
return "$", true
110+
case '^':
111+
return "^", true
112+
case '#':
113+
return "#", true
114+
case '|':
115+
return "|", true
116+
case '~':
117+
return "~", true
118+
default:
119+
return "", false
120+
}
121+
}
122+
123+
// nushellStreamRedirects maps the word portion of stream-redirect operators
124+
// (the part before >) to their WordbreakType. The PostProcess step merges
125+
// these with a following > or >| wordbreak token.
126+
var nushellStreamRedirects = map[string]WordbreakType{
127+
"out": WORDBREAK_REDIRECT_OUTPUT,
128+
"err": WORDBREAK_REDIRECT_OUTPUT,
129+
"o": WORDBREAK_REDIRECT_OUTPUT,
130+
"e": WORDBREAK_REDIRECT_OUTPUT,
131+
"out+err": WORDBREAK_REDIRECT_OUTPUT_BOTH,
132+
"o+e": WORDBREAK_REDIRECT_OUTPUT_BOTH,
133+
}
134+
135+
// PostProcess merges nushell stream-redirect operators. The tokenizer
136+
// produces e.g. `out` as a WORD_TOKEN and `>` (or `>|`, `>>`) as a
137+
// WORDBREAK_TOKEN because the rune-classifier only handles single-rune
138+
// word breaks. This step detects adjacent word+wordbreak sequences like
139+
// `out>`, `err>`, `o+e>`, `e>|`, `o+e>|` and reclassifies them as single
140+
// WORDBREAK_TOKENs with the appropriate WordbreakType.
141+
func (nushellFormat) PostProcess(tokens TokenSlice) TokenSlice {
142+
result := make(TokenSlice, 0, len(tokens))
143+
for i := 0; i < len(tokens); i++ {
144+
t := tokens[i]
145+
146+
// Look for bare WORD_TOKEN immediately followed by WORDBREAK_TOKEN starting with '>'
147+
// Only merge bare words (Value == RawValue) — quoted words like 'out' or "out"
148+
// are string literals, not stream redirect operators.
149+
if t.Type == WORD_TOKEN && t.Value == t.RawValue && i+1 < len(tokens) {
150+
next := tokens[i+1]
151+
if next.Type == WORDBREAK_TOKEN && next.adjoins(t) && len(next.RawValue) > 0 && next.RawValue[0] == '>' {
152+
if wbType, ok := nushellStreamRedirects[t.Value]; ok {
153+
// Check if the wordbreak token includes a pipe suffix (e.g. >|)
154+
// which makes it a pipe-with-stderr variant
155+
if strings.Contains(next.RawValue, "|") {
156+
wbType = WORDBREAK_PIPE_WITH_STDERR
157+
}
158+
159+
merged := Token{
160+
Type: WORDBREAK_TOKEN,
161+
Value: t.Value + next.Value,
162+
RawValue: t.RawValue + next.RawValue,
163+
Span: Span{Start: t.Span.Start, End: next.Span.End},
164+
State: next.State,
165+
WordbreakType: wbType,
166+
}
167+
168+
result = append(result, merged)
169+
i += 1
170+
continue
171+
}
172+
}
173+
}
174+
175+
result = append(result, t)
176+
}
177+
return result
178+
}

0 commit comments

Comments
 (0)