Skip to main content

safe_chains/cst/
parse.rs

1use super::*;
2use winnow::ModalResult;
3use winnow::combinator::{alt, delimited, not, opt, preceded, repeat, separated, terminated};
4use winnow::error::{ContextError, ErrMode};
5use winnow::prelude::*;
6use winnow::token::{any, take_while};
7
8pub fn parse(input: &str) -> Option<Script> {
9    reset_heredoc_queue();
10    PARSE_DEPTH.with(|d| d.set(0));
11    PARSE_WORK.with(|w| w.set(0));
12    PARSE_WORK_LIMIT
13        .with(|l| l.set(MAX_PARSE_WORK_BASE + MAX_PARSE_WORK_PER_BYTE * input.len() as u64));
14    let result = script.parse(input).ok();
15    reset_heredoc_queue();
16    result
17}
18
19fn backtrack<T>() -> ModalResult<T> {
20    Err(ErrMode::Backtrack(ContextError::new()))
21}
22
23thread_local! {
24    static PARSE_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
25    /// MONOTONIC work counter for one `parse()` call — every `script()` entry bumps it and it is
26    /// never decremented (unlike `PARSE_DEPTH`), so it counts total recursive-descent work, not
27    /// concurrent depth. Reset per parse; compared against `PARSE_WORK_LIMIT`.
28    static PARSE_WORK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
29    static PARSE_WORK_LIMIT: std::cell::Cell<u64> = const { std::cell::Cell::new(u64::MAX) };
30}
31
32/// Nesting depth beyond which the parser bails instead of recursing further. EVERY recursion source
33/// — subshells `( )`, brace groups `{ }`, command/process substitutions `$( )`/`<( )`, and
34/// double-quote-nested subs — funnels through `script()`, so bounding it there caps stack depth. A
35/// deeply-nested adversarial input (`"$("` × 100 000) would otherwise overflow the stack and ABORT
36/// the process — a fail-open CRASH of the hook that `catch_unwind` cannot recover (a stack overflow
37/// is not an unwindable panic). 200 is far beyond any real command; past it the parse fails and the
38/// command is denied (fail closed). Found by `classifier_terminates_on_adversarial_input`. Kept
39/// LOW (winnow's combinator frames are fat — ~200 levels alone overflowed a 2 MB stack), yet still
40/// far beyond any real command, which nests a handful of levels at most.
41const MAX_PARSE_DEPTH: u32 = 48;
42
43/// Cumulative `script()` entries allowed per parse, as `BASE + PER_BYTE * input.len()`. A correct
44/// recursive-descent parse is linear in input length, so this bound is loose for every real command
45/// yet trips fast on combinator BACKTRACKING blow-up — inputs where nested constructs make winnow
46/// re-parse overlapping tails super-linearly (the `a$(a<(a` × N interleaved-substitution class the
47/// depth cap misses because its nesting stays shallow). The balanced-scan in `cmd_sub`/`proc_sub`
48/// removes the known source; this is the belt-and-suspenders backstop that fails ANY future
49/// exponential closed rather than hanging the hook. Found by `classifier_terminates_on_adversarial_input`.
50const MAX_PARSE_WORK_BASE: u64 = 16_384;
51const MAX_PARSE_WORK_PER_BYTE: u64 = 512;
52
53/// RAII depth counter for the recursive descent — increments on `enter`, decrements on drop (winnow
54/// returns errors rather than panicking, so drops balance even on the bail path). `enter` also bumps
55/// the monotonic work counter and bails (→ fail closed) once it exceeds the per-parse work budget.
56struct DepthGuard;
57
58impl DepthGuard {
59    fn enter() -> Option<Self> {
60        let over_budget = PARSE_WORK.with(|w| {
61            let n = w.get().saturating_add(1);
62            w.set(n);
63            n > PARSE_WORK_LIMIT.with(|l| l.get())
64        });
65        if over_budget {
66            return None;
67        }
68        PARSE_DEPTH.with(|d| {
69            if d.get() >= MAX_PARSE_DEPTH {
70                None
71            } else {
72                d.set(d.get() + 1);
73                Some(DepthGuard)
74            }
75        })
76    }
77}
78
79impl Drop for DepthGuard {
80    fn drop(&mut self) {
81        PARSE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
82    }
83}
84
85fn comment(input: &mut &str) -> ModalResult<()> {
86    if input.starts_with('#') {
87        if let Some(pos) = input.find('\n') {
88            *input = &input[pos + 1..];
89        } else {
90            *input = "";
91        }
92    }
93    Ok(())
94}
95
96fn ws(input: &mut &str) -> ModalResult<()> {
97    loop {
98        take_while(0.., [' ', '\t']).void().parse_next(input)?;
99        if input.starts_with('#') {
100            comment(input)?;
101        } else {
102            break;
103        }
104    }
105    Ok(())
106}
107
108fn sep(input: &mut &str) -> ModalResult<()> {
109    loop {
110        take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
111        if input.starts_with('#') {
112            comment(input)?;
113        } else {
114            break;
115        }
116    }
117    Ok(())
118}
119
120fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
121    if !input.starts_with(kw) {
122        return backtrack();
123    }
124    if input
125        .as_bytes()
126        .get(kw.len())
127        .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
128    {
129        return backtrack();
130    }
131    *input = &input[kw.len()..];
132    Ok(())
133}
134
135const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
136
137fn at_script_stop(input: &str) -> bool {
138    input.starts_with(')')
139        || input.starts_with('}')
140        || SCRIPT_STOPS.iter().any(|kw| {
141            input.starts_with(kw)
142                && !input
143                    .as_bytes()
144                    .get(kw.len())
145                    .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
146        })
147}
148
149fn is_word_boundary(c: char) -> bool {
150    matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
151}
152
153fn is_word_literal(c: char) -> bool {
154    !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
155}
156
157fn is_dq_literal(c: char) -> bool {
158    !matches!(c, '"' | '\\' | '`' | '$')
159}
160
161// === Script ===
162
163fn script(input: &mut &str) -> ModalResult<Script> {
164    // Bound recursion depth: every nested `(`/`{`/`$(`/`<(`/`` ` `` funnels back through `script`,
165    // so this one guard caps stack depth against deeply-nested adversarial input (see MAX_PARSE_DEPTH).
166    let Some(_depth) = DepthGuard::enter() else {
167        return backtrack();
168    };
169    sep.parse_next(input)?;
170    let mut stmts = Vec::new();
171    while let Some(pl) = opt(pipeline).parse_next(input)? {
172        ws.parse_next(input)?;
173        let op = opt(list_op).parse_next(input)?;
174        stmts.push(Stmt { pipeline: pl, op });
175        // Drain any heredoc bodies pending from this statement before
176        // the next pipeline starts; otherwise the body would be parsed
177        // as the next statement (which would either misvalidate or
178        // misalign the line counter).
179        drain_pending_heredocs(input);
180        if op.is_none() {
181            break;
182        }
183        sep.parse_next(input)?;
184    }
185    Ok(Script(stmts))
186}
187
188fn list_op(input: &mut &str) -> ModalResult<ListOp> {
189    ws.parse_next(input)?;
190    alt((
191        "&&".value(ListOp::And),
192        "||".value(ListOp::Or),
193        '\n'.value(ListOp::Semi),
194        ';'.value(ListOp::Semi),
195        ('&', not('>')).value(ListOp::Amp),
196    ))
197    .parse_next(input)
198}
199
200fn pipe_sep(input: &mut &str) -> ModalResult<()> {
201    (ws, '|', not('|'), ws).void().parse_next(input)
202}
203
204// === Pipeline ===
205
206fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
207    ws.parse_next(input)?;
208    if at_script_stop(input) {
209        return backtrack();
210    }
211    let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
212    let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
213    Ok(Pipeline { bang, commands })
214}
215
216// === Command ===
217
218fn command(input: &mut &str) -> ModalResult<Cmd> {
219    ws.parse_next(input)?;
220    if at_script_stop(input) {
221        return backtrack();
222    }
223    alt((
224        subshell,
225        brace_group,
226        for_cmd,
227        while_cmd,
228        until_cmd,
229        if_cmd,
230        double_bracket_cmd,
231        simple_cmd.map(Cmd::Simple),
232    ))
233    .parse_next(input)
234}
235
236fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
237    let mut redirs = Vec::new();
238    loop {
239        ws.parse_next(input)?;
240        if let Some(r) = opt(redirect).parse_next(input)? {
241            redirs.push(r);
242        } else {
243            break;
244        }
245    }
246    Ok(redirs)
247}
248
249fn subshell(input: &mut &str) -> ModalResult<Cmd> {
250    let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
251    let redirs = trailing_redirs(input)?;
252    Ok(Cmd::Subshell { body, redirs })
253}
254
255fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
256    if !input.starts_with('{') {
257        return backtrack();
258    }
259    if !input
260        .as_bytes()
261        .get(1)
262        .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
263    {
264        return backtrack();
265    }
266    *input = &input[1..];
267    sep.parse_next(input)?;
268    let body = script.parse_next(input)?;
269    if body.0.is_empty() {
270        return backtrack();
271    }
272    sep.parse_next(input)?;
273    if !input.starts_with('}') {
274        return backtrack();
275    }
276    let last_op = body.0.last().and_then(|s| s.op);
277    if last_op.is_none() {
278        return backtrack();
279    }
280    *input = &input[1..];
281    let redirs = trailing_redirs(input)?;
282    Ok(Cmd::BraceGroup { body, redirs })
283}
284
285// === Simple Command ===
286
287fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
288    let env: Vec<(String, Word)> =
289        repeat(0.., terminated(assignment, ws)).parse_next(input)?;
290    let mut words = Vec::new();
291    let mut redirs = Vec::new();
292
293    loop {
294        ws.parse_next(input)?;
295        if at_cmd_end(input) {
296            break;
297        }
298        if let Some(r) = opt(redirect).parse_next(input)? {
299            redirs.push(r);
300        } else if let Some(w) = opt(word).parse_next(input)? {
301            words.push(w);
302        } else {
303            break;
304        }
305    }
306
307    if env.is_empty() && words.is_empty() && redirs.is_empty() {
308        return backtrack();
309    }
310    Ok(SimpleCmd { env, words, redirs })
311}
312
313fn at_cmd_end(input: &str) -> bool {
314    input.is_empty()
315        || matches!(
316            input.as_bytes().first(),
317            Some(b'\n' | b';' | b'|' | b'&' | b')')
318        )
319}
320
321fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
322    let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
323        .parse_next(input)?;
324    '='.parse_next(input)?;
325    let value = opt(word)
326        .parse_next(input)?
327        .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
328    Ok((n.to_string(), value))
329}
330
331// === Redirect ===
332
333fn redirect(input: &mut &str) -> ModalResult<Redir> {
334    let fd = opt(fd_prefix).parse_next(input)?;
335    alt((
336        preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
337        heredoc,
338        preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
339            fd: fd.unwrap_or(1),
340            target,
341            append: true,
342        }),
343        preceded(">&", fd_target).map(move |dst| Redir::DupFd {
344            src: fd.unwrap_or(1),
345            dst,
346        }),
347        preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
348            fd: fd.unwrap_or(1),
349            target,
350            append: false,
351        }),
352        preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
353            fd: fd.unwrap_or(0),
354            target,
355        }),
356    ))
357    .parse_next(input)
358}
359
360fn heredoc(input: &mut &str) -> ModalResult<Redir> {
361    "<<".parse_next(input)?;
362    let strip_tabs = opt('-').parse_next(input)?.is_some();
363    ws.parse_next(input)?;
364    let delimiter = heredoc_delimiter.parse_next(input)?;
365    // Bash semantics: the heredoc body lives on lines AFTER the
366    // command line is finished, not immediately after `<<DELIM`. The
367    // command line can continue with more redirects, a pipe, etc.
368    // Push the delimiter onto a thread-local queue; the body is
369    // drained at the next `\n`/`;` separator by drain_pending_heredocs.
370    PENDING_HEREDOCS.with(|q| {
371        q.borrow_mut().push(PendingHeredoc {
372            delimiter: delimiter.clone(),
373            strip_tabs,
374        });
375    });
376    Ok(Redir::HereDoc { delimiter, strip_tabs })
377}
378
379#[derive(Debug, Clone)]
380struct PendingHeredoc {
381    delimiter: String,
382    strip_tabs: bool,
383}
384
385thread_local! {
386    static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
387        const { std::cell::RefCell::new(Vec::new()) };
388}
389
390fn drain_pending_heredocs(input: &mut &str) {
391    let pending: Vec<PendingHeredoc> =
392        PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
393    for h in pending {
394        if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
395            // Couldn't find the matching delimiter line. Leave input
396            // as-is; the parser will likely fail on the leftover body
397            // text, which is the safe outcome (we deny on parse fail).
398            return;
399        }
400    }
401}
402
403fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
404    let s = *input;
405    let bytes = s.as_bytes();
406    let mut line_start = 0;
407    while line_start <= bytes.len() {
408        let line_end = match s[line_start..].find('\n') {
409            Some(rel) => line_start + rel,
410            None => bytes.len(),
411        };
412        let line_bytes = &bytes[line_start..line_end];
413        let line = if strip_tabs {
414            std::str::from_utf8(line_bytes)
415                .unwrap_or("")
416                .trim_start_matches('\t')
417        } else {
418            std::str::from_utf8(line_bytes).unwrap_or("")
419        };
420        if line == delimiter {
421            // Advance past the delimiter line + its newline.
422            let advance = line_end + usize::from(line_end < bytes.len());
423            *input = &s[advance..];
424            return true;
425        }
426        if line_end >= bytes.len() {
427            return false;
428        }
429        line_start = line_end + 1;
430    }
431    false
432}
433
434fn reset_heredoc_queue() {
435    PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
436}
437
438fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
439    alt((
440        delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
441        delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
442        take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
443    ))
444    .parse_next(input)
445}
446
447fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
448    let b = input.as_bytes();
449    if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
450        let d = (b[0] - b'0') as u32;
451        *input = &input[1..];
452        Ok(d)
453    } else {
454        backtrack()
455    }
456}
457
458fn fd_target(input: &mut &str) -> ModalResult<String> {
459    alt((
460        '-'.value("-".to_string()),
461        take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
462    ))
463    .parse_next(input)
464}
465
466// === Word ===
467
468fn word(input: &mut &str) -> ModalResult<Word> {
469    repeat(1.., word_part)
470        .map(Word)
471        .parse_next(input)
472}
473
474fn word_part(input: &mut &str) -> ModalResult<WordPart> {
475    if input.is_empty() {
476        return backtrack();
477    }
478    if input.starts_with("<(") || input.starts_with(">(") {
479        return proc_sub(input);
480    }
481    if is_word_boundary(input.as_bytes()[0] as char) {
482        return backtrack();
483    }
484    alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
485        .parse_next(input)
486}
487
488fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
489    delimited('\'', take_while(0.., |c| c != '\''), '\'')
490        .map(|s: &str| WordPart::SQuote(s.to_string()))
491        .parse_next(input)
492}
493
494fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
495    delimited('"', repeat(0.., dq_part).map(Word), '"')
496        .map(WordPart::DQuote)
497        .parse_next(input)
498}
499
500/// Byte offset of the `)` that closes a substitution body starting at `body[0]` — the first `)` at
501/// paren-depth zero — or `None` if it is never closed. Quote (`'…'`, `"…"`), backtick, and backslash
502/// spans are skipped so a `)` inside them does not count, mirroring how the grammar's own
503/// `single_quoted`/`double_quoted`/`backtick`/`escaped` parsers treat those regions. This is what
504/// keeps `cmd_sub`/`proc_sub` linear: the interior is parsed only once, over a bounded slice, instead
505/// of the old `delimited(script, ')')` shape that recursed into the tail BEFORE knowing a close even
506/// existed — the source of the `a$(a<(a` × N exponential.
507fn find_sub_close(body: &str) -> Option<usize> {
508    let b = body.as_bytes();
509    let mut i = 0;
510    let mut depth: usize = 0;
511    while i < b.len() {
512        match b[i] {
513            b'\\' => i += 1, // escape: skip the next byte too (the trailing `+= 1` handles it)
514            b'\'' => {
515                i += 1;
516                while i < b.len() && b[i] != b'\'' {
517                    i += 1;
518                }
519                if i >= b.len() {
520                    return None;
521                }
522            }
523            b'"' => {
524                i += 1;
525                while i < b.len() && b[i] != b'"' {
526                    i += if b[i] == b'\\' { 2 } else { 1 };
527                }
528                if i >= b.len() {
529                    return None;
530                }
531            }
532            b'`' => {
533                i += 1;
534                while i < b.len() && b[i] != b'`' {
535                    // `bt_escape` treats `\<any>` inside backticks as a literal, so an escaped
536                    // backtick does NOT close the span — skip the escaped byte too.
537                    i += if b[i] == b'\\' { 2 } else { 1 };
538                }
539                if i >= b.len() {
540                    return None;
541                }
542            }
543            b'(' => depth += 1,
544            b')' => {
545                if depth == 0 {
546                    return Some(i);
547                }
548                depth -= 1;
549            }
550            _ => {}
551        }
552        i += 1;
553    }
554    None
555}
556
557/// Parse a substitution body (`$( … )`, `<( … )`, `>( … )`) as a full script.
558///
559/// FAST PATH: `find_sub_close` locates the matching `)` and we parse only the bounded interior, so
560/// nested substitutions stay linear instead of the old `delimited(script, ')')` shape that recursed
561/// into the tail before knowing a close existed (the `a$(a<(a` × N exponential).
562///
563/// FALLBACK: a few grammar constructs move the real close PAST that first balanced `)` — chiefly a
564/// heredoc body, whose text (including any `)`) is consumed out-of-band by `drain_pending_heredocs`
565/// and which `find_sub_close` does not model. When the bounded interior does not parse cleanly we
566/// re-run the EXACT old grammar over the full body, preserving classification for those inputs. The
567/// fallback is the recursive shape, but the per-parse work budget (`MAX_PARSE_WORK_*`) bounds it, so
568/// it cannot reintroduce the hang. A `None` from `find_sub_close` means no unquoted `)` exists at all
569/// — the grammar could not close the sub either — so we fail fast without the fallback.
570fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
571    let body = &input[open_len..];
572    let Some(rel) = find_sub_close(body) else {
573        return backtrack();
574    };
575    // A heredoc body is drained out-of-band (`drain_pending_heredocs`) and can run PAST `rel`, so the
576    // bounded interior would be truncated mid-heredoc and still parse "clean" — the fast path is
577    // unreliable whenever the interior holds a heredoc operator. Skip straight to the grammar fallback
578    // there. `<<` covers `<<`, `<<-`, and `<<<`; the latter (herestring) is inline and would be fine,
579    // but taking the fallback for it is merely slower, never wrong.
580    let interior = &body[..rel];
581    if !interior.contains("<<") {
582        let mut fast: &str = interior;
583        if let Ok(parsed) = script.parse_next(&mut fast) {
584            ws.parse_next(&mut fast)?;
585            if fast.is_empty() {
586                *input = &body[rel + 1..];
587                return Ok(parsed);
588            }
589        }
590    }
591    let mut rest: &str = body;
592    ws.parse_next(&mut rest)?;
593    let parsed = script.parse_next(&mut rest)?;
594    ws.parse_next(&mut rest)?;
595    if !rest.starts_with(')') {
596        return backtrack();
597    }
598    *input = &rest[1..];
599    Ok(parsed)
600}
601
602fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
603    if !input.starts_with("$(") {
604        return backtrack();
605    }
606    sub_body(input, 2).map(WordPart::CmdSub)
607}
608
609fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
610    if !(input.starts_with("<(") || input.starts_with(">(")) {
611        return backtrack();
612    }
613    sub_body(input, 2).map(WordPart::ProcSub)
614}
615
616fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
617    if !input.starts_with("$((") {
618        return backtrack();
619    }
620    let body_start = 3;
621    let bytes = input.as_bytes();
622    let mut depth: i32 = 1;
623    let mut i = body_start;
624    while i < bytes.len() {
625        match bytes[i] {
626            b'(' => depth += 1,
627            b')' => {
628                if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
629                    let body = input[body_start..i].to_string();
630                    if body.contains("$(") || body.contains('`') {
631                        return backtrack();
632                    }
633                    *input = &input[i + 2..];
634                    return Ok(WordPart::Arith(body));
635                }
636                depth -= 1;
637                if depth < 0 {
638                    return backtrack();
639                }
640            }
641            _ => {}
642        }
643        i += 1;
644    }
645    backtrack()
646}
647
648fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
649    delimited('`', backtick_inner, '`')
650        .map(WordPart::Backtick)
651        .parse_next(input)
652}
653
654fn escaped(input: &mut &str) -> ModalResult<WordPart> {
655    preceded('\\', any).map(WordPart::Escape).parse_next(input)
656}
657
658fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
659    move |input: &mut &str| {
660        take_while(1.., pred)
661            .map(|s: &str| WordPart::Lit(s.to_string()))
662            .parse_next(input)
663    }
664}
665
666fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
667    move |input: &mut &str| {
668        ('$', not('(')).void().parse_next(input)?;
669        let rest: &str = take_while(0.., pred).parse_next(input)?;
670        Ok(WordPart::Lit(format!("${rest}")))
671    }
672}
673
674// === Double-quoted parts ===
675
676fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
677    if input.is_empty() || input.starts_with('"') {
678        return backtrack();
679    }
680    alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
681        .parse_next(input)
682}
683
684fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
685    preceded('\\', any)
686        .map(|c: char| match c {
687            '"' | '\\' | '$' | '`' => WordPart::Escape(c),
688            _ => WordPart::Lit(format!("\\{c}")),
689        })
690        .parse_next(input)
691}
692
693// === Backtick inner content ===
694
695fn backtick_inner(input: &mut &str) -> ModalResult<String> {
696    repeat(0.., alt((bt_escape, bt_literal)))
697        .fold(String::new, |mut acc, chunk: &str| {
698            acc.push_str(chunk);
699            acc
700        })
701        .parse_next(input)
702}
703
704fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
705    ('\\', any).take().parse_next(input)
706}
707
708fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
709    take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
710}
711
712// === Compound Commands ===
713
714fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
715    eat_keyword(input, "for")?;
716    ws.parse_next(input)?;
717    let var = name.parse_next(input)?;
718    ws.parse_next(input)?;
719
720    let items = if eat_keyword(input, "in").is_ok() {
721        ws.parse_next(input)?;
722        repeat(0.., terminated(word, ws)).parse_next(input)?
723    } else {
724        vec![]
725    };
726
727    let body = do_done_body.parse_next(input)?;
728    let redirs = trailing_redirs(input)?;
729    Ok(Cmd::For { var, items, body, redirs })
730}
731
732fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
733    eat_keyword(input, "while")?;
734    ws.parse_next(input)?;
735    let cond = script.parse_next(input)?;
736    let body = do_done_body.parse_next(input)?;
737    let redirs = trailing_redirs(input)?;
738    Ok(Cmd::While { cond, body, redirs })
739}
740
741fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
742    eat_keyword(input, "until")?;
743    ws.parse_next(input)?;
744    let cond = script.parse_next(input)?;
745    let body = do_done_body.parse_next(input)?;
746    let redirs = trailing_redirs(input)?;
747    Ok(Cmd::Until { cond, body, redirs })
748}
749
750fn do_done_body(input: &mut &str) -> ModalResult<Script> {
751    sep.parse_next(input)?;
752    eat_keyword(input, "do")?;
753    sep.parse_next(input)?;
754    let body = script.parse_next(input)?;
755    sep.parse_next(input)?;
756    eat_keyword(input, "done")?;
757    Ok(body)
758}
759
760fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
761    eat_keyword(input, "if")?;
762    ws.parse_next(input)?;
763    let mut branches = vec![cond_then_body.parse_next(input)?];
764    let mut else_body = None;
765
766    loop {
767        sep.parse_next(input)?;
768        if eat_keyword(input, "elif").is_ok() {
769            ws.parse_next(input)?;
770            branches.push(cond_then_body.parse_next(input)?);
771        } else if eat_keyword(input, "else").is_ok() {
772            sep.parse_next(input)?;
773            else_body = Some(script.parse_next(input)?);
774            break;
775        } else {
776            break;
777        }
778    }
779
780    sep.parse_next(input)?;
781    eat_keyword(input, "fi")?;
782    let redirs = trailing_redirs(input)?;
783    Ok(Cmd::If { branches, else_body, redirs })
784}
785
786fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
787    let cond = script.parse_next(input)?;
788    sep.parse_next(input)?;
789    eat_keyword(input, "then")?;
790    sep.parse_next(input)?;
791    let body = script.parse_next(input)?;
792    Ok(Branch { cond, body })
793}
794
795fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
796    if !input.starts_with("[[") {
797        return backtrack();
798    }
799    let bytes = input.as_bytes();
800    if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
801        return backtrack();
802    }
803    *input = &input[2..];
804
805    let mut words: Vec<Word> = Vec::new();
806    loop {
807        ws.parse_next(input)?;
808        if at_double_bracket_end(input) {
809            *input = &input[2..];
810            let redirs = trailing_redirs(input)?;
811            return Ok(Cmd::DoubleBracket { words, redirs });
812        }
813        if input.is_empty() {
814            return backtrack();
815        }
816        let w = bracket_word.parse_next(input)?;
817        words.push(w);
818    }
819}
820
821fn at_double_bracket_end(input: &str) -> bool {
822    if !input.starts_with("]]") {
823        return false;
824    }
825    let after = &input[2..];
826    after.is_empty()
827        || after.starts_with(|c: char| {
828            matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
829        })
830}
831
832fn bracket_word(input: &mut &str) -> ModalResult<Word> {
833    repeat(1.., bracket_word_part).map(Word).parse_next(input)
834}
835
836fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
837    if input.is_empty() {
838        return backtrack();
839    }
840    if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
841        return backtrack();
842    }
843    if at_double_bracket_end(input) {
844        return backtrack();
845    }
846    alt((
847        single_quoted,
848        double_quoted,
849        arith_sub,
850        cmd_sub,
851        backtick_part,
852        escaped,
853        dollar_lit(is_bracket_literal),
854        bracket_lit,
855    ))
856    .parse_next(input)
857}
858
859fn is_bracket_literal(c: char) -> bool {
860    !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
861}
862
863fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
864    // Byte-by-byte scan relies on every stop char being single-byte ASCII —
865    // multibyte UTF-8 continuation bytes always pass `is_bracket_literal` and
866    // get consumed as part of the same `Lit`, so `end` only lands on a char
867    // boundary.
868    let bytes = input.as_bytes();
869    let mut end = 0;
870    while end < bytes.len() {
871        let c = bytes[end] as char;
872        if !is_bracket_literal(c) {
873            break;
874        }
875        if c == ']' && at_double_bracket_end(&input[end..]) {
876            break;
877        }
878        end += 1;
879    }
880    if end == 0 {
881        return backtrack();
882    }
883    let lit = input[..end].to_string();
884    *input = &input[end..];
885    Ok(WordPart::Lit(lit))
886}
887
888fn name(input: &mut &str) -> ModalResult<String> {
889    take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
890        .map(|s: &str| s.to_string())
891        .parse_next(input)
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897
898    fn p(input: &str) -> Script {
899        parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
900    }
901
902    fn words(script: &Script) -> Vec<String> {
903        match &script.0[0].pipeline.commands[0] {
904            Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
905            _ => panic!("expected simple command"),
906        }
907    }
908
909    fn simple(script: &Script) -> &SimpleCmd {
910        match &script.0[0].pipeline.commands[0] {
911            Cmd::Simple(s) => s,
912            _ => panic!("expected simple command"),
913        }
914    }
915
916    #[test]
917    fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
918    #[test]
919    fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
920    #[test]
921    fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
922    #[test]
923    fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
924    #[test]
925    fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
926
927    #[test]
928    fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
929    #[test]
930    fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
931    #[test]
932    fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
933    #[test]
934    fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
935    #[test]
936    fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
937    #[test]
938    fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
939    #[test]
940    fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n   \necho bar").0.len(), 2); }
941    #[test]
942    fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
943    #[test]
944    fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
945    #[test]
946    fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
947
948    #[test]
949    fn brace_group_simple() {
950        assert!(matches!(
951            &p("{ echo hello; }").0[0].pipeline.commands[0],
952            Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
953        ));
954    }
955    #[test]
956    fn brace_group_multiple_stmts() {
957        if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
958            assert_eq!(body.0.len(), 3);
959        } else { panic!("expected BraceGroup"); }
960    }
961    #[test]
962    fn brace_group_with_redirect() {
963        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
964            assert_eq!(redirs.len(), 1);
965            assert!(matches!(redirs[0], Redir::Write { .. }));
966        } else { panic!("expected BraceGroup"); }
967    }
968    #[test]
969    fn brace_group_with_append_redirect() {
970        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
971            assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
972        } else { panic!("expected BraceGroup"); }
973    }
974    #[test]
975    fn brace_group_with_stderr_redirect() {
976        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
977            assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
978        } else { panic!("expected BraceGroup"); }
979    }
980    #[test]
981    fn brace_group_newline_separated() {
982        if let Cmd::BraceGroup { body, .. } = &p("{\n  echo a\n  echo b\n}").0[0].pipeline.commands[0] {
983            assert_eq!(body.0.len(), 2);
984        } else { panic!("expected BraceGroup"); }
985    }
986    #[test]
987    fn brace_group_in_pipeline() {
988        let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
989        assert_eq!(pl.commands.len(), 2);
990        assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
991    }
992    #[test]
993    fn brace_group_followed_by_other() {
994        let stmts = &p("{ echo a; }; echo b").0;
995        assert_eq!(stmts.len(), 2);
996        assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
997    }
998    #[test]
999    fn brace_group_nested() {
1000        if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1001            assert_eq!(body.0.len(), 2);
1002            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1003        } else { panic!("expected outer BraceGroup"); }
1004    }
1005    #[test]
1006    fn brace_group_with_subshell_inside() {
1007        if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1008            assert_eq!(body.0.len(), 2);
1009            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1010        } else { panic!("expected BraceGroup"); }
1011    }
1012    #[test]
1013    fn brace_open_requires_whitespace() {
1014        // {echo (no space) is NOT a brace group; it's a literal word
1015        // that becomes part of a simple command. Parser should not
1016        // treat it as a brace group.
1017        let cmds = &p("{echo a}").0;
1018        // Either parsed as a simple_cmd with a literal `{echo` token,
1019        // or fails. Either way, it should NOT be a BraceGroup.
1020        if !cmds.is_empty() {
1021            assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1022        }
1023    }
1024    #[test]
1025    fn subshell_with_redirect() {
1026        if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1027            assert_eq!(redirs.len(), 1);
1028        } else { panic!("expected Subshell with redir"); }
1029    }
1030    #[test]
1031    fn for_loop_with_redirect() {
1032        if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1033            assert_eq!(redirs.len(), 1);
1034        } else { panic!("expected For with redir"); }
1035    }
1036    #[test]
1037    fn for_loop_redirect_then_pipe() {
1038        // `done 2>&1 | head` — redirect on the loop, then a pipe.
1039        let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1040        assert_eq!(pl.commands.len(), 2);
1041        assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1042    }
1043    #[test]
1044    fn while_and_if_with_redirect() {
1045        assert!(matches!(
1046            &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1047            Cmd::While { redirs, .. } if redirs.len() == 1
1048        ));
1049        assert!(matches!(
1050            &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1051            Cmd::If { redirs, .. } if redirs.len() == 1
1052        ));
1053    }
1054    #[test]
1055    fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1056
1057    #[test]
1058    fn redirect_dev_null() {
1059        let s = p("echo hello > /dev/null");
1060        let cmd = simple(&s);
1061        assert_eq!(cmd.words.len(), 2);
1062        assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
1063    }
1064    #[test]
1065    fn redirect_stderr() {
1066        assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1067    }
1068    #[test]
1069    fn here_string() {
1070        assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1071    }
1072    #[test]
1073    fn heredoc_bare() {
1074        assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
1075    }
1076    #[test]
1077    fn heredoc_with_content() {
1078        let s = p("cat <<EOF\nhello world\nEOF");
1079        assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1080    }
1081    #[test]
1082    fn heredoc_quoted_delimiter() {
1083        assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1084    }
1085    #[test]
1086    fn heredoc_strip_tabs() {
1087        assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1088    }
1089    #[test]
1090    fn heredoc_pipe_on_command_line() {
1091        // Correct bash: pipe is on the command line BEFORE the body,
1092        // body terminator is on its own line.
1093        let s = p("cat <<EOF | grep hello\nhello\nEOF");
1094        assert_eq!(s.0[0].pipeline.commands.len(), 2);
1095    }
1096    #[test]
1097    fn heredoc_body_does_not_swallow_pipe() {
1098        // Regression for the `cat <<EOF | bash\n...\nEOF` bypass: the
1099        // heredoc parser must NOT consume the pipe + downstream
1100        // commands as part of the body.
1101        let s = p("cat <<EOF | bash\nrm\nEOF");
1102        assert_eq!(
1103            s.0[0].pipeline.commands.len(),
1104            2,
1105            "pipeline must keep `bash` as a second command"
1106        );
1107    }
1108    #[test]
1109    fn heredoc_followed_by_next_statement() {
1110        // After the heredoc body terminator, the script can continue
1111        // with another statement.
1112        let s = p("cat <<EOF\nhello\nEOF\nls");
1113        assert_eq!(s.0.len(), 2);
1114    }
1115
1116    #[test]
1117    fn env_prefix() {
1118        let s = p("FOO='bar baz' ls -la");
1119        let cmd = simple(&s);
1120        assert_eq!(cmd.env[0].0, "FOO");
1121        assert_eq!(cmd.env[0].1.eval(), "bar baz");
1122    }
1123    #[test]
1124    fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1125    #[test]
1126    fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__"); }
1127    #[test]
1128    fn nested_substitution() {
1129        if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1130            assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1131        } else { panic!("expected CmdSub"); }
1132    }
1133
1134    #[test]
1135    fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1136    #[test]
1137    fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1138
1139    #[test]
1140    fn for_loop() { assert!(matches!(&p("for x in 1 2 3; do echo $x; done").0[0].pipeline.commands[0], Cmd::For { var, .. } if var == "x")); }
1141    #[test]
1142    fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1143    #[test]
1144    fn if_then_fi() {
1145        if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1146            assert_eq!(branches.len(), 1);
1147            assert!(else_body.is_none());
1148        } else { panic!("expected If"); }
1149    }
1150    #[test]
1151    fn if_elif_else() {
1152        if let Cmd::If { branches, else_body, .. } = &p("if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi").0[0].pipeline.commands[0] {
1153            assert_eq!(branches.len(), 2);
1154            assert!(else_body.is_some());
1155        } else { panic!("expected If"); }
1156    }
1157
1158    #[test]
1159    fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1160    #[test]
1161    fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1162    #[test]
1163    fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1164
1165    #[test]
1166    fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1167    #[test]
1168    fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1169    #[test]
1170    fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1171    #[test]
1172    fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1173    #[test]
1174    fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1175    #[test]
1176    fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1177
1178    #[test]
1179    fn subshell_for() {
1180        if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1181            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1182        } else { panic!("expected Subshell"); }
1183    }
1184    #[test]
1185    fn proc_sub_input() {
1186        let s = p("diff <(sort a.txt) <(sort b.txt)");
1187        let cmd = simple(&s);
1188        assert_eq!(cmd.words.len(), 3);
1189        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1190        assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1191    }
1192    #[test]
1193    fn proc_sub_output() {
1194        let s = p("tee >(grep error > /dev/null)");
1195        let cmd = simple(&s);
1196        assert_eq!(cmd.words.len(), 2);
1197        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1198    }
1199    #[test]
1200    fn comment_only() {
1201        let s = p("# just a comment");
1202        assert!(s.0.is_empty());
1203    }
1204    #[test]
1205    fn comment_before_command() {
1206        let s = p("# comment\necho hello");
1207        assert_eq!(words(&s), ["echo", "hello"]);
1208    }
1209    #[test]
1210    fn inline_comment() {
1211        let s = p("echo hello # this is a comment");
1212        assert_eq!(words(&s), ["echo", "hello"]);
1213    }
1214    #[test]
1215    fn comment_between_commands() {
1216        let s = p("echo hello\n# middle comment\necho world");
1217        assert_eq!(s.0.len(), 2);
1218    }
1219    #[test]
1220    fn comment_after_semicolon() {
1221        let s = p("echo hello; # comment\necho world");
1222        assert_eq!(s.0.len(), 2);
1223    }
1224    #[test]
1225    fn comment_in_for_loop() {
1226        assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1227    }
1228    #[test]
1229    fn quoted_redirect_in_echo() {
1230        let s = p("echo 'greater > than' test");
1231        let cmd = simple(&s);
1232        assert_eq!(cmd.words.len(), 3);
1233        assert_eq!(cmd.redirs.len(), 0);
1234    }
1235
1236    #[test]
1237    fn parses_all_safe_commands() {
1238        let cmds = [
1239            "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1240            "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1241            "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1242            "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1243            "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1244            "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1245            "((echo hello))", "(for x in 1 2; do echo $x; done)",
1246            "echo 'greater > than' test", "echo '$(safe)' arg",
1247            "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1248            "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1249            "grep foo file.txt | head -5", "cat file | sort | uniq",
1250            "ls && echo done", "ls; echo done", "ls & echo done",
1251            "grep -c , <<< 'hello,world,test'",
1252            "cat <<EOF\nhello world\nEOF",
1253            "cat <<'MARKER'\nsome text\nMARKER",
1254            "cat <<-EOF\n\thello\nEOF",
1255            "echo foo\necho bar", "ls\ncat file.txt",
1256            "git log --oneline -20 | head -5",
1257            "echo hello > /dev/null", "echo hello 2> /dev/null",
1258            "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1259            "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1260            "for x in 1 2 3; do echo $x; done",
1261            "for f in *.txt; do cat $f | grep pattern; done",
1262            "for x in 1 2 3; do; done",
1263            "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1264            "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1265            "for x in 1 2; do echo $x; done && echo finished",
1266            "for x in $(seq 1 5); do echo $x; done",
1267            "while test -f /tmp/foo; do sleep 1; done",
1268            "while ! test -f /tmp/done; do sleep 1; done",
1269            "until test -f /tmp/ready; do sleep 1; done",
1270            "if test -f foo; then echo exists; fi",
1271            "if test -f foo; then echo yes; else echo no; fi",
1272            "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1273            "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1274            "if true; then for x in 1 2; do echo $x; done; fi",
1275            "diff <(sort a.txt) <(sort b.txt)",
1276            "comm -23 file.txt <(sort other.txt)",
1277            "cat <(echo hello)",
1278            "# comment only",
1279            "# comment\necho hello",
1280            "echo hello # inline comment",
1281            "echo one\n# between\necho two",
1282            "! echo hello", "! test -f foo",
1283            "echo for; echo done; echo if; echo fi",
1284        ];
1285        let mut failures = Vec::new();
1286        for cmd in &cmds {
1287            if parse(cmd).is_none() { failures.push(*cmd); }
1288        }
1289        assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1290    }
1291
1292    // === Balanced-scan divergence guards ===
1293    // `cmd_sub`/`proc_sub` find their closing `)` with `find_sub_close`, then parse the bounded
1294    // interior. The `roundtrip` proptest exercises this, but its `arb_shell_word` is paren-free, so
1295    // these lock the case it can't reach: a `)` inside a quote / escape / backtick / nested sub must
1296    // NOT be mistaken for the substitution's own close.
1297    fn inner_sub(s: &Script) -> &Script {
1298        match &simple(s).words[1].0[0] {
1299            WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1300            other => panic!("expected a substitution, got {other:?}"),
1301        }
1302    }
1303
1304    #[test]
1305    fn cmd_sub_squote_paren_is_not_close() {
1306        assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1307    }
1308    #[test]
1309    fn cmd_sub_dquote_paren_is_not_close() {
1310        assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1311    }
1312    #[test]
1313    fn cmd_sub_escaped_paren_is_not_close() {
1314        assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1315    }
1316    #[test]
1317    fn cmd_sub_backtick_paren_is_not_close() {
1318        // the ) lives inside a backtick span in the sub body; the real close is the final ).
1319        let s = p("echo $(x `)` y)");
1320        let inner = simple(inner_sub(&s));
1321        assert_eq!(inner.words.len(), 3);
1322        assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1323    }
1324    #[test]
1325    fn cmd_sub_escaped_backtick_does_not_end_span() {
1326        // The `\` escapes the next backtick, so the span — and the ) inside it — belong to the sub
1327        // body; the real close is the final ). Until find_sub_close honored backtick escapes it
1328        // mis-placed the span boundary and rejected this (a fail-closed divergence from the grammar).
1329        let s = p("echo $(`\\`)`)");
1330        assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1331    }
1332    #[test]
1333    fn proc_sub_squote_paren_is_not_close() {
1334        assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1335    }
1336    #[test]
1337    fn proc_sub_out_squote_paren_is_not_close() {
1338        assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1339    }
1340    #[test]
1341    fn cmd_sub_nested_picks_outer_close() {
1342        let s = p("echo $(a $(b) c)");
1343        let inner = simple(inner_sub(&s));
1344        assert_eq!(inner.words.len(), 3);
1345        assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1346    }
1347    #[test]
1348    fn cmd_sub_literal_after_close_stays_in_outer_word() {
1349        let s = p("echo $(ls)tail");
1350        let w = &simple(&s).words[1];
1351        assert_eq!(w.0.len(), 2);
1352        assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1353        assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1354    }
1355    #[test]
1356    fn cmd_sub_heredoc_body_paren_does_not_close() {
1357        // The ) sits in the heredoc body (drained out-of-band), so it must not close the sub — the
1358        // real close is the final ). find_sub_close can't see heredocs, so sub_body falls back to the
1359        // full grammar here. This parsed before the balanced-scan rewrite and must keep parsing.
1360        let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1361        assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1362    }
1363    #[test]
1364    fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1365        // the sole ) is single-quoted, so the sub never closes → whole parse fails (fail closed).
1366        assert!(parse("echo $(echo ')").is_none());
1367    }
1368    #[test]
1369    fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1370        assert!(parse("cat <(grep ')").is_none());
1371    }
1372}