Skip to content

Commit 1c42226

Browse files
committed
Add PowerShell block comments, line continuation, stop-parsing, and stream redirects
Implement four PowerShell-specific tokenizer features identified from the PowerShell source code (tokenizer.cs, parser.cs): - Block comments <# ... #>: multi-line comments via new BlockCommenter interface, with a dedicated BLOCK_COMMENT_STATE that scans until the #> closer - Backtick line continuation: backtick + newline is consumed and discarded (not added to word value) via new LineContinuationEscaper interface, matching PowerShell's LineContinuation token behavior - --% stop-parsing token: raw lexing mode for the remainder of the line via new StopParsingToken interface, with dedicated scanStopParsing that reads until newline or | (double-quote toggles literal mode) - Stream redirects 2>, 2>>, 2>&1, 1>&2, *>, *>>: merged in PostProcess from adjacent WORD_TOKEN + WORDBREAK_TOKEN pairs Assisted-by: Crush:glm-5.2
1 parent b30969b commit 1c42226

4 files changed

Lines changed: 646 additions & 3 deletions

File tree

format.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,41 @@ type EscapingQuoteUnescaper interface {
9090
type PostProcessor interface {
9191
PostProcess(tokens TokenSlice) TokenSlice
9292
}
93+
94+
// LineContinuationEscaper is an optional interface for formats where the
95+
// escape character followed by a newline (or carriage return) acts as a
96+
// line continuation — the escape+newline sequence is consumed and discarded,
97+
// NOT added to the word value. This matches PowerShell's backtick line
98+
// continuation behavior.
99+
//
100+
// Without this interface, the ESCAPING_STATE handler always adds the
101+
// post-escape rune to the word value, which is correct for POSIX shells
102+
// where backslash-newline is handled differently.
103+
type LineContinuationEscaper interface {
104+
// IsLineContinuation returns true if the rune following the escape
105+
// character should be treated as a line continuation. The parameter
106+
// is the rune that follows the escape character (e.g. '\n' or '\r').
107+
IsLineContinuation(r rune) bool
108+
}
109+
110+
// BlockCommenter is an optional interface for formats that support
111+
// multi-line block comments (e.g. PowerShell's <# ... #>). When the
112+
// tokenizer encounters the blockCommentOpener runes at a word boundary,
113+
// it enters a dedicated BLOCK_COMMENT_STATE that scans until the
114+
// blockCommentCloser runes are found, spanning multiple lines.
115+
type BlockCommenter interface {
116+
BlockCommentOpener() string // e.g. "<#" for PowerShell
117+
BlockCommentCloser() string // e.g. "#>" for PowerShell
118+
}
119+
120+
// StopParsingToken is an optional interface for formats that support a
121+
// stop-parsing token (e.g. PowerShell's --%). When the tokenizer encounters
122+
// this token as a bare word, it switches to a raw lexing mode for the
123+
// remainder of the line (until newline or pipeline delimiter), where
124+
// all characters except the pipeline delimiters are treated as literal
125+
// word content.
126+
type StopParsingToken interface {
127+
// StopParsingWord returns the literal token that triggers raw mode.
128+
// e.g. "--%" for PowerShell.
129+
StopParsingWord() string
130+
}

format_powershell.go

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ package shlex
77
// - "" inside double quotes → literal " (doubled quote)
88
// - No single-quote-as-quote for outer quote pairs in the POSIX sense;
99
// both ' and " are quote chars
10-
// - Here-strings (@'...'@, @"..."@) and --% are deferred to Phase 4
10+
// - Backtick + newline is line continuation (consumed, not part of word)
11+
// - Block comments <# ... #> (multi-line)
12+
// - --% stop-parsing token (raw mode for remainder of line)
13+
// - Stream redirects: 2>, 2>>, 2>&1, 1>&2, *>, *>> (merged in PostProcess)
14+
// - Here-strings (@'...'@, @"..."@) are deferred
1115
type powershellFormat struct{}
1216

1317
// PowershellFormat returns the PowerShell lexical format.
@@ -65,3 +69,93 @@ func (powershellFormat) EscapingQuoteEscapeChars() map[rune]bool { return nil }
6569
func (powershellFormat) QuoteWord(s string) string { return powershellQuoteWord(s) }
6670
func (powershellFormat) TripleQuoteSupport() bool { return false }
6771
func (powershellFormat) RawPrefixSupport() bool { return false }
72+
73+
// IsLineContinuation implements LineContinuationEscaper. PowerShell's
74+
// backtick followed by \n or \r is a line continuation — the sequence is
75+
// consumed and the word continues on the next line.
76+
func (powershellFormat) IsLineContinuation(r rune) bool {
77+
return r == '\n' || r == '\r'
78+
}
79+
80+
// BlockCommentOpener implements BlockCommenter. PowerShell supports
81+
// multi-line block comments delimited by <# and #>.
82+
func (powershellFormat) BlockCommentOpener() string { return "<#" }
83+
84+
// BlockCommentCloser implements BlockCommenter.
85+
func (powershellFormat) BlockCommentCloser() string { return "#>" }
86+
87+
// StopParsingWord implements StopParsingToken. PowerShell's --% token
88+
// stops PowerShell from interpreting subsequent input.
89+
func (powershellFormat) StopParsingWord() string { return "--%" }
90+
91+
// PostProcess merges PowerShell stream-redirect operators. The tokenizer
92+
// produces e.g. `2` as a WORD_TOKEN and `>` (or `>>`) as a WORDBREAK_TOKEN.
93+
// This step detects adjacent word+wordbreak sequences like `2>`, `2>>`,
94+
// `2>&1`, `1>&2`, `*>`, `*>>` and reclassifies them as single
95+
// WORDBREAK_TOKENs with the appropriate WordbreakType.
96+
func (powershellFormat) PostProcess(tokens TokenSlice) TokenSlice {
97+
result := make(TokenSlice, 0, len(tokens))
98+
for i := 0; i < len(tokens); i++ {
99+
t := tokens[i]
100+
101+
// Look for bare WORD_TOKEN (digit or *) immediately followed by
102+
// WORDBREAK_TOKEN starting with '>' (redirect operator)
103+
if t.Type == WORD_TOKEN && t.Value == t.RawValue && i+1 < len(tokens) {
104+
next := tokens[i+1]
105+
if next.Type == WORDBREAK_TOKEN && next.adjoins(t) &&
106+
next.WordbreakType.IsRedirect() && len(next.RawValue) > 0 && next.RawValue[0] == '>' {
107+
// Check if the word is a valid stream number or *
108+
if t.Value == "*" || (len(t.Value) == 1 && t.Value[0] >= '1' && t.Value[0] <= '6') {
109+
// Check for merging redirect: next token after > is &N
110+
// e.g. 2>&1 — the & and digit are separate wordbreak/word tokens
111+
wbType := next.WordbreakType
112+
mergedRaw := t.RawValue + next.RawValue
113+
mergedVal := t.Value + next.Value
114+
mergedSpan := Span{Start: t.Span.Start, End: next.Span.End}
115+
116+
// Check for &N pattern (stream merge) in the token after next
117+
if i+2 < len(tokens) && tokens[i+2].Type == WORDBREAK_TOKEN &&
118+
tokens[i+2].Value == "&" && tokens[i+2].adjoins(next) {
119+
if i+3 < len(tokens) && tokens[i+3].Type == WORD_TOKEN &&
120+
tokens[i+3].Value == tokens[i+3].RawValue &&
121+
tokens[i+3].adjoins(tokens[i+2]) &&
122+
len(tokens[i+3].Value) == 1 &&
123+
(tokens[i+3].Value[0] == '1' || tokens[i+3].Value[0] == '2') {
124+
// 2>&1 pattern — merge all four tokens
125+
mergedRaw += tokens[i+2].RawValue + tokens[i+3].RawValue
126+
mergedVal += tokens[i+2].Value + tokens[i+3].Value
127+
mergedSpan.End = tokens[i+3].Span.End
128+
wbType = WORDBREAK_REDIRECT_OUTPUT_BOTH
129+
merged := Token{
130+
Type: WORDBREAK_TOKEN,
131+
Value: mergedVal,
132+
RawValue: mergedRaw,
133+
Span: mergedSpan,
134+
State: tokens[i+3].State,
135+
WordbreakType: wbType,
136+
}
137+
result = append(result, merged)
138+
i += 3
139+
continue
140+
}
141+
}
142+
143+
merged := Token{
144+
Type: WORDBREAK_TOKEN,
145+
Value: mergedVal,
146+
RawValue: mergedRaw,
147+
Span: mergedSpan,
148+
State: next.State,
149+
WordbreakType: wbType,
150+
}
151+
result = append(result, merged)
152+
i += 1
153+
continue
154+
}
155+
}
156+
}
157+
158+
result = append(result, t)
159+
}
160+
return result
161+
}

format_powershell_test.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,188 @@ func TestPowershellFormat_OpenDoubleQuote(t *testing.T) {
111111
t.Errorf("powershell open double: State = %v, want QUOTING_ESCAPING_STATE", last.State)
112112
}
113113
}
114+
115+
func TestPowershellFormat_BacktickLineContinuation(t *testing.T) {
116+
// backtick + newline should be consumed as line continuation, not part of word
117+
tokens, err := SplitWith("echo foo`\nbar", PowershellFormat())
118+
if err != nil {
119+
t.Fatal(err)
120+
}
121+
words := tokens.Words().Strings()
122+
if len(words) != 2 || words[1] != "foobar" {
123+
t.Errorf("powershell line continuation: Words = %v, want [echo foobar]", words)
124+
}
125+
}
126+
127+
func TestPowershellFormat_BacktickLineContinuationCRLF(t *testing.T) {
128+
tokens, err := SplitWith("echo foo`\r\nbar", PowershellFormat())
129+
if err != nil {
130+
t.Fatal(err)
131+
}
132+
words := tokens.Words().Strings()
133+
if len(words) != 2 || words[1] != "foobar" {
134+
t.Errorf("powershell line continuation CRLF: Words = %v, want [echo foobar]", words)
135+
}
136+
}
137+
138+
func TestPowershellFormat_BacktickLineContinuationStartOfWord(t *testing.T) {
139+
// backtick + newline at start of word — word continues on next line
140+
tokens, err := SplitWith("echo `\nbar", PowershellFormat())
141+
if err != nil {
142+
t.Fatal(err)
143+
}
144+
words := tokens.Words().Strings()
145+
if len(words) != 2 || words[1] != "bar" {
146+
t.Errorf("powershell line continuation start: Words = %v, want [echo bar]", words)
147+
}
148+
}
149+
150+
func TestPowershellFormat_BlockComment(t *testing.T) {
151+
tokens, err := SplitWith("echo <# multi\nline\ncomment #> foo", PowershellFormat())
152+
if err != nil {
153+
t.Fatal(err)
154+
}
155+
words := tokens.Words().Strings()
156+
if len(words) != 2 || words[0] != "echo" || words[1] != "foo" {
157+
t.Errorf("powershell block comment: Words = %v, want [echo foo]", words)
158+
}
159+
}
160+
161+
func TestPowershellFormat_BlockCommentSingleLine(t *testing.T) {
162+
tokens, err := SplitWith("echo <# inline comment #> foo", PowershellFormat())
163+
if err != nil {
164+
t.Fatal(err)
165+
}
166+
words := tokens.Words().Strings()
167+
if len(words) != 2 || words[0] != "echo" || words[1] != "foo" {
168+
t.Errorf("powershell block comment inline: Words = %v, want [echo foo]", words)
169+
}
170+
}
171+
172+
func TestPowershellFormat_StopParsingToken(t *testing.T) {
173+
// After --%, everything is literal until newline or |
174+
tokens, err := SplitWith("echo --% /grant Dom\\HVAdmin:(CI)(OI)F", PowershellFormat())
175+
if err != nil {
176+
t.Fatal(err)
177+
}
178+
words := tokens.Words().Strings()
179+
// --% should be a word, then the rest is raw text as one word
180+
if len(words) < 3 {
181+
t.Errorf("powershell --%%: Words = %v, expected at least 3 words", words)
182+
}
183+
if words[0] != "echo" {
184+
t.Errorf("powershell --%%: first word = %q, want echo", words[0])
185+
}
186+
if words[1] != "--%" {
187+
t.Errorf("powershell --%%: second word = %q, want --%%", words[1])
188+
}
189+
}
190+
191+
func TestPowershellFormat_StopParsingPipeDelim(t *testing.T) {
192+
// After --%, | is still a pipeline delimiter
193+
tokens, err := SplitWith("echo --% foo | Select-String bar", PowershellFormat())
194+
if err != nil {
195+
t.Fatal(err)
196+
}
197+
pipelines := tokens.Pipelines()
198+
if len(pipelines) != 2 {
199+
t.Errorf("powershell --%% pipe: %d pipelines, want 2", len(pipelines))
200+
}
201+
}
202+
203+
func TestPowershellFormat_StopParsingRawContent(t *testing.T) {
204+
// After --%, content like (CI) should be literal, not split
205+
tokens, err := SplitWith("icacls X: --% /grant Dom\\HVAdmin:(CI)(OI)F", PowershellFormat())
206+
if err != nil {
207+
t.Fatal(err)
208+
}
209+
words := tokens.Words().Strings()
210+
// The raw content after --% should be one word
211+
if len(words) != 4 {
212+
t.Errorf("powershell --%% raw: Words = %v, want 4 words", words)
213+
}
214+
if words[2] != "--%" {
215+
t.Errorf("powershell --%% raw: third word = %q, want --%%", words[2])
216+
}
217+
rawContent := words[3]
218+
if rawContent != "/grant Dom\\HVAdmin:(CI)(OI)F" {
219+
t.Errorf("powershell --%% raw: content = %q, want /grant Dom\\HVAdmin:(CI)(OI)F", rawContent)
220+
}
221+
}
222+
223+
func TestPowershellFormat_StreamRedirect2(t *testing.T) {
224+
// 2> should be recognized as a stream redirect
225+
tokens, err := SplitWith("echo foo 2> error.txt", PowershellFormat())
226+
if err != nil {
227+
t.Fatal(err)
228+
}
229+
// Check that 2> is a single WORDBREAK_TOKEN with redirect type
230+
found := false
231+
for _, tok := range tokens {
232+
if tok.Type == WORDBREAK_TOKEN && tok.RawValue == "2>" {
233+
if !tok.WordbreakType.IsRedirect() {
234+
t.Errorf("powershell 2>: WordbreakType = %v, want redirect", tok.WordbreakType)
235+
}
236+
found = true
237+
}
238+
}
239+
if !found {
240+
t.Errorf("powershell 2>: no merged 2> token found in %v", tokens)
241+
}
242+
}
243+
244+
func TestPowershellFormat_StreamRedirect2Append(t *testing.T) {
245+
tokens, err := SplitWith("echo foo 2>> error.txt", PowershellFormat())
246+
if err != nil {
247+
t.Fatal(err)
248+
}
249+
found := false
250+
for _, tok := range tokens {
251+
if tok.Type == WORDBREAK_TOKEN && tok.RawValue == "2>>" {
252+
if !tok.WordbreakType.IsRedirect() {
253+
t.Errorf("powershell 2>>: WordbreakType = %v, want redirect", tok.WordbreakType)
254+
}
255+
found = true
256+
}
257+
}
258+
if !found {
259+
t.Errorf("powershell 2>>: no merged 2>> token found in %v", tokens)
260+
}
261+
}
262+
263+
func TestPowershellFormat_StreamRedirectMerge(t *testing.T) {
264+
// 2>&1 should be recognized as a merged stream redirect
265+
tokens, err := SplitWith("echo foo 2>&1", PowershellFormat())
266+
if err != nil {
267+
t.Fatal(err)
268+
}
269+
found := false
270+
for _, tok := range tokens {
271+
if tok.Type == WORDBREAK_TOKEN && tok.RawValue == "2>&1" {
272+
found = true
273+
}
274+
}
275+
if !found {
276+
t.Errorf("powershell 2>&1: no merged token found in %v", tokens)
277+
}
278+
}
279+
280+
func TestPowershellFormat_StreamRedirectStar(t *testing.T) {
281+
// *> should be recognized as all-streams redirect
282+
tokens, err := SplitWith("echo foo *> output.txt", PowershellFormat())
283+
if err != nil {
284+
t.Fatal(err)
285+
}
286+
found := false
287+
for _, tok := range tokens {
288+
if tok.Type == WORDBREAK_TOKEN && tok.RawValue == "*>" {
289+
if !tok.WordbreakType.IsRedirect() {
290+
t.Errorf("powershell *>: WordbreakType = %v, want redirect", tok.WordbreakType)
291+
}
292+
found = true
293+
}
294+
}
295+
if !found {
296+
t.Errorf("powershell *>: no merged *> token found in %v", tokens)
297+
}
298+
}

0 commit comments

Comments
 (0)