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| {
14            let budget = MAX_PARSE_WORK_BASE + MAX_PARSE_WORK_PER_BYTE * input.len() as u64;
15            l.set(budget.min(MAX_PARSE_WORK_CEILING));
16        });
17    let result = script.parse(input).ok();
18    reset_heredoc_queue();
19    result
20}
21
22fn backtrack<T>() -> ModalResult<T> {
23    Err(ErrMode::Backtrack(ContextError::new()))
24}
25
26thread_local! {
27    static PARSE_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
28    /// MONOTONIC work counter for one `parse()` call — every `script()` entry bumps it and it is
29    /// never decremented (unlike `PARSE_DEPTH`), so it counts total recursive-descent work, not
30    /// concurrent depth. Reset per parse; compared against `PARSE_WORK_LIMIT`.
31    static PARSE_WORK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
32    static PARSE_WORK_LIMIT: std::cell::Cell<u64> = const { std::cell::Cell::new(u64::MAX) };
33}
34
35/// Nesting depth beyond which the parser bails instead of recursing further. EVERY recursion source
36/// — subshells `( )`, brace groups `{ }`, command/process substitutions `$( )`/`<( )`, and
37/// double-quote-nested subs — funnels through `script()`, so bounding it there caps stack depth. A
38/// deeply-nested adversarial input (`"$("` × 100 000) would otherwise overflow the stack and ABORT
39/// the process — a fail-open CRASH of the hook that `catch_unwind` cannot recover (a stack overflow
40/// is not an unwindable panic). 200 is far beyond any real command; past it the parse fails and the
41/// command is denied (fail closed). Found by `classifier_terminates_on_adversarial_input`. Kept
42/// LOW (winnow's combinator frames are fat — ~200 levels alone overflowed a 2 MB stack), yet still
43/// far beyond any real command, which nests a handful of levels at most.
44const MAX_PARSE_DEPTH: u32 = 48;
45
46/// Cumulative `script()` entries allowed per parse, as `BASE + PER_BYTE * input.len()`. A correct
47/// recursive-descent parse is linear in input length, so this bound is loose for every real command
48/// yet trips fast on combinator BACKTRACKING blow-up — inputs where nested constructs make winnow
49/// re-parse overlapping tails super-linearly (the `a$(a<(a` × N interleaved-substitution class the
50/// depth cap misses because its nesting stays shallow). The balanced-scan in `cmd_sub`/`proc_sub`
51/// removes the known source; this is the belt-and-suspenders backstop that fails ANY future
52/// exponential closed rather than hanging the hook. Found by `classifier_terminates_on_adversarial_input`.
53/// Absolute ceiling on the per-parse work budget, whatever the input length.
54///
55/// Without it the allowance grows with the input, so a LARGER adversarial input buys itself more
56/// time — the opposite of what a bound is for. Measured across all 1338 registry examples the most
57/// any real command needs is 2 entries, so a flat ceiling four orders of magnitude above that
58/// cannot refuse anything real while capping the worst case at a fixed cost.
59const MAX_PARSE_WORK_CEILING: u64 = 20_000;
60
61const MAX_PARSE_WORK_BASE: u64 = 2_048;
62const MAX_PARSE_WORK_PER_BYTE: u64 = 8;
63
64/// RAII depth counter for the recursive descent — increments on `enter`, decrements on drop (winnow
65/// returns errors rather than panicking, so drops balance even on the bail path). `enter` also bumps
66/// the monotonic work counter and bails (→ fail closed) once it exceeds the per-parse work budget.
67struct DepthGuard;
68
69impl DepthGuard {
70    fn enter() -> Option<Self> {
71        let over_budget = PARSE_WORK.with(|w| {
72            let n = w.get().saturating_add(1);
73            w.set(n);
74            n > PARSE_WORK_LIMIT.with(|l| l.get())
75        });
76        if over_budget {
77            return None;
78        }
79        PARSE_DEPTH.with(|d| {
80            if d.get() >= MAX_PARSE_DEPTH {
81                None
82            } else {
83                d.set(d.get() + 1);
84                Some(DepthGuard)
85            }
86        })
87    }
88}
89
90impl Drop for DepthGuard {
91    fn drop(&mut self) {
92        PARSE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
93    }
94}
95
96fn comment(input: &mut &str) -> ModalResult<()> {
97    if input.starts_with('#') {
98        if let Some(pos) = input.find('\n') {
99            *input = &input[pos + 1..];
100        } else {
101            *input = "";
102        }
103    }
104    Ok(())
105}
106
107fn ws(input: &mut &str) -> ModalResult<()> {
108    loop {
109        take_while(0.., [' ', '\t']).void().parse_next(input)?;
110        if input.starts_with('#') {
111            comment(input)?;
112        } else {
113            break;
114        }
115    }
116    Ok(())
117}
118
119fn sep(input: &mut &str) -> ModalResult<()> {
120    loop {
121        // Consume separators one at a time so a `;;` stays intact: it terminates a case arm, and
122        // eating its first `;` here would let the arm's body run on into the next arm's pattern.
123        while let Some(c) = input.chars().next() {
124            if input.starts_with(";;") {
125                return Ok(());
126            }
127            if !matches!(c, ' ' | '\t' | ';' | '\n') {
128                break;
129            }
130            *input = &input[c.len_utf8()..];
131        }
132        if input.starts_with('#') {
133            comment(input)?;
134        } else {
135            break;
136        }
137    }
138    Ok(())
139}
140
141fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
142    if !input.starts_with(kw) {
143        return backtrack();
144    }
145    if input
146        .as_bytes()
147        .get(kw.len())
148        .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
149    {
150        return backtrack();
151    }
152    *input = &input[kw.len()..];
153    Ok(())
154}
155
156const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "esac", "fi", "then"];
157
158fn at_script_stop(input: &str) -> bool {
159    input.starts_with(')')
160        || input.starts_with('}')
161        // `;;` ends a case arm's body. Without this a body script would run on into the next arm's
162        // pattern and read it as a command.
163        || input.starts_with(";;")
164        || SCRIPT_STOPS.iter().any(|kw| {
165            input.starts_with(kw)
166                && !input
167                    .as_bytes()
168                    .get(kw.len())
169                    .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
170        })
171}
172
173fn is_word_boundary(c: char) -> bool {
174    matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
175}
176
177fn is_word_literal(c: char) -> bool {
178    !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
179}
180
181fn is_dq_literal(c: char) -> bool {
182    !matches!(c, '"' | '\\' | '`' | '$')
183}
184
185// === Script ===
186
187fn script(input: &mut &str) -> ModalResult<Script> {
188    // Bound recursion depth: every nested `(`/`{`/`$(`/`<(`/`` ` `` funnels back through `script`,
189    // so this one guard caps stack depth against deeply-nested adversarial input (see MAX_PARSE_DEPTH).
190    let Some(_depth) = DepthGuard::enter() else {
191        return backtrack();
192    };
193    sep.parse_next(input)?;
194    let mut stmts = Vec::new();
195    while let Some(pl) = opt(pipeline).parse_next(input)? {
196        ws.parse_next(input)?;
197        let op = opt(list_op).parse_next(input)?;
198        stmts.push(Stmt { pipeline: pl, op });
199        // Drain any heredoc bodies pending from this statement before
200        // the next pipeline starts; otherwise the body would be parsed
201        // as the next statement (which would either misvalidate or
202        // misalign the line counter).
203        drain_pending_heredocs(input);
204        if op.is_none() {
205            break;
206        }
207        sep.parse_next(input)?;
208    }
209    Ok(Script(stmts))
210}
211
212fn list_op(input: &mut &str) -> ModalResult<ListOp> {
213    ws.parse_next(input)?;
214    alt((
215        "&&".value(ListOp::And),
216        "||".value(ListOp::Or),
217        '\n'.value(ListOp::Semi),
218        // `;;` is a case-arm terminator, not a statement separator — matching the first `;` here
219        // would let the body swallow it and continue into the next arm.
220        (';', not(';')).value(ListOp::Semi),
221        ('&', not('>')).value(ListOp::Amp),
222    ))
223    .parse_next(input)
224}
225
226/// A pipe. `|&` (bash) pipes stdout AND stderr into the next command; what flows through the pipe
227/// does not change which commands run, so it classifies exactly as `|`. Matched before the bare
228/// `|` so the `&` is not left to start a bogus background statement. `||` stays an OR, not a pipe.
229fn pipe_sep(input: &mut &str) -> ModalResult<()> {
230    (ws, alt(("|&".void(), ('|', not('|')).void())), ws).void().parse_next(input)
231}
232
233// === Pipeline ===
234
235fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
236    ws.parse_next(input)?;
237    if at_script_stop(input) {
238        return backtrack();
239    }
240    let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
241    let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
242    Ok(Pipeline { bang, commands })
243}
244
245// === Command ===
246
247fn command(input: &mut &str) -> ModalResult<Cmd> {
248    ws.parse_next(input)?;
249    if at_script_stop(input) {
250        return backtrack();
251    }
252    alt((
253        subshell,
254        brace_group,
255        for_cmd,
256        while_cmd,
257        until_cmd,
258        if_cmd,
259        case_cmd,
260        double_bracket_cmd,
261        function_def,
262        simple_cmd.map(Cmd::Simple),
263    ))
264    .parse_next(input)
265}
266
267/// `name() { body }` / `name() ( body )` (POSIX form) or `function name [()] { body }` (bash form).
268/// Tried before `simple_cmd`; a bare `name` with no `()` and no `function` keyword backtracks so an
269/// ordinary command is not misread as a definition.
270fn function_def(input: &mut &str) -> ModalResult<Cmd> {
271    let had_keyword = opt_function_keyword(input);
272    let name = function_name(input)?;
273    ws.parse_next(input)?;
274    let has_parens = opt_paren_pair(input);
275    if !had_keyword && !has_parens {
276        return backtrack();
277    }
278    // The body may sit on the next line (`foo()\n{ … }`); consume ws/newlines but not `;`.
279    take_while(0.., [' ', '\t', '\n']).void().parse_next(input)?;
280    let body = function_body(input)?;
281    Ok(Cmd::FunctionDef { name, body })
282}
283
284/// Consume a leading `function` keyword (must be followed by whitespace, else it's a command named
285/// `function`). Returns whether it was present; only commits `*input` when it was.
286fn opt_function_keyword(input: &mut &str) -> bool {
287    let mut probe = *input;
288    if eat_keyword(&mut probe, "function").is_ok()
289        && probe.starts_with([' ', '\t', '\n'])
290        && ws.parse_next(&mut probe).is_ok()
291    {
292        *input = probe;
293        return true;
294    }
295    false
296}
297
298/// Consume a `(` ws `)` function-def paren pair. Commits `*input` only on a full match.
299fn opt_paren_pair(input: &mut &str) -> bool {
300    let mut probe = *input;
301    if let Some(rest) = probe.strip_prefix('(') {
302        probe = rest;
303        if ws.parse_next(&mut probe).is_ok()
304            && let Some(rest) = probe.strip_prefix(')')
305        {
306            *input = rest;
307            return true;
308        }
309    }
310    false
311}
312
313fn function_name(input: &mut &str) -> ModalResult<String> {
314    take_while(1.., |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+'))
315        .map(|s: &str| s.to_string())
316        .parse_next(input)
317}
318
319/// The compound body of a function — `{ …; }` or `( … )`. Redirects attached to the definition
320/// itself (rare) are dropped, which is conservative for a construct classified Inert anyway.
321fn function_body(input: &mut &str) -> ModalResult<Script> {
322    if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
323        return Ok(body);
324    }
325    if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
326        return Ok(body);
327    }
328    backtrack()
329}
330
331fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
332    let mut redirs = Vec::new();
333    loop {
334        ws.parse_next(input)?;
335        if let Some(r) = opt(redirect).parse_next(input)? {
336            redirs.push(r);
337        } else {
338            break;
339        }
340    }
341    Ok(redirs)
342}
343
344fn subshell(input: &mut &str) -> ModalResult<Cmd> {
345    let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
346    let redirs = trailing_redirs(input)?;
347    Ok(Cmd::Subshell { body, redirs })
348}
349
350fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
351    if !input.starts_with('{') {
352        return backtrack();
353    }
354    if !input
355        .as_bytes()
356        .get(1)
357        .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
358    {
359        return backtrack();
360    }
361    *input = &input[1..];
362    sep.parse_next(input)?;
363    let body = script.parse_next(input)?;
364    if body.0.is_empty() {
365        return backtrack();
366    }
367    sep.parse_next(input)?;
368    if !input.starts_with('}') {
369        return backtrack();
370    }
371    let last_op = body.0.last().and_then(|s| s.op);
372    if last_op.is_none() {
373        return backtrack();
374    }
375    *input = &input[1..];
376    let redirs = trailing_redirs(input)?;
377    Ok(Cmd::BraceGroup { body, redirs })
378}
379
380// === Simple Command ===
381
382fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
383    let env: Vec<(String, Word)> =
384        repeat(0.., terminated(assignment, ws)).parse_next(input)?;
385    let mut words = Vec::new();
386    let mut redirs = Vec::new();
387
388    loop {
389        ws.parse_next(input)?;
390        if at_cmd_end(input) {
391            break;
392        }
393        if let Some(r) = opt(redirect).parse_next(input)? {
394            redirs.push(r);
395        } else if let Some(w) = opt(word).parse_next(input)? {
396            words.push(w);
397        } else {
398            break;
399        }
400    }
401
402    if env.is_empty() && words.is_empty() && redirs.is_empty() {
403        return backtrack();
404    }
405    Ok(SimpleCmd { env, words, redirs })
406}
407
408fn at_cmd_end(input: &str) -> bool {
409    // `&>`/`&>>` REDIRECT this command; only a bare `&` backgrounds it and ends it. Without this
410    // the command stopped at the `&` and the redirect parser never saw the operator at all.
411    if input.starts_with("&>") {
412        return false;
413    }
414    input.is_empty()
415        || matches!(
416            input.as_bytes().first(),
417            Some(b'\n' | b';' | b'|' | b'&' | b')')
418        )
419}
420
421fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
422    let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
423        .parse_next(input)?;
424    '='.parse_next(input)?;
425    let value = opt(word)
426        .parse_next(input)?
427        .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
428    Ok((n.to_string(), value))
429}
430
431// === Redirect ===
432
433fn redirect(input: &mut &str) -> ModalResult<Redir> {
434    let fd = opt(fd_prefix).parse_next(input)?;
435    alt((
436        preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
437        heredoc,
438        preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
439            fd: fd.unwrap_or(1),
440            target,
441            mode: WriteMode::Append,
442        }),
443        // `&>>` / `&>` send stdout AND stderr to a FILE (bash). `&>` must follow `&>>` so the
444        // append form is not read as a truncate followed by a stray `>`.
445        preceded("&>>", (ws, word)).map(|(_, target)| Redir::Write {
446            fd: 1,
447            target,
448            mode: WriteMode::AppendBoth,
449        }),
450        preceded("&>", (ws, word)).map(|(_, target)| Redir::Write {
451            fd: 1,
452            target,
453            mode: WriteMode::TruncateBoth,
454        }),
455        preceded(">&", fd_target).map(move |dst| Redir::DupFd {
456            src: fd.unwrap_or(1),
457            dst,
458        }),
459        // `>&WORD` where WORD is not a file descriptor is the older spelling of `&>`: it opens a
460        // FILE for both streams. It must follow the `>&`-fd form, so `>&2` stays a descriptor dup
461        // rather than a write to a file named `2`.
462        preceded(">&", (ws, word)).map(|(_, target)| Redir::Write {
463            fd: 1,
464            target,
465            mode: WriteMode::TruncateBoth,
466        }),
467        // `>|` (POSIX 2.7.2) overrides `noclobber`. The override is about whether the shell
468        // REFUSES an existing file, not about what lands there, so it classifies as the plain
469        // overwrite it is. Must precede `>` or the `|` reads as a pipe into an empty command.
470        preceded(">|", (ws, word)).map(move |(_, target)| Redir::Write {
471            fd: fd.unwrap_or(1),
472            target,
473            mode: WriteMode::Clobber,
474        }),
475        preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
476            fd: fd.unwrap_or(1),
477            target,
478            mode: WriteMode::Truncate,
479        }),
480        // `<>` (POSIX 2.7.5) opens the target for BOTH reading and writing. Must precede `<`,
481        // which would otherwise match and leave `>` to start a bogus second redirect.
482        preceded("<>", (ws, word)).map(move |(_, target)| Redir::ReadWrite {
483            fd: fd.unwrap_or(0),
484            target,
485        }),
486        preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
487            fd: fd.unwrap_or(0),
488            target,
489        }),
490    ))
491    .parse_next(input)
492}
493
494fn heredoc(input: &mut &str) -> ModalResult<Redir> {
495    "<<".parse_next(input)?;
496    let strip_tabs = opt('-').parse_next(input)?.is_some();
497    ws.parse_next(input)?;
498    let (delimiter, expands) = heredoc_delimiter.parse_next(input)?;
499    // With a BARE delimiter the shell expands the body, so `cat <<EOF` with `$(rm -rf /)` in it
500    // runs that command. The body is looked up now (it is already present in `input`, after this
501    // line) rather than at drain time, so the expansions land on the redirect that owns them.
502    let body = if expands {
503        heredoc_body_word(input, &delimiter, strip_tabs)?
504    } else {
505        Word(Vec::new())
506    };
507    // Bash semantics: the heredoc body lives on lines AFTER the
508    // command line is finished, not immediately after `<<DELIM`. The
509    // command line can continue with more redirects, a pipe, etc.
510    // Push the delimiter onto a thread-local queue; the body is
511    // drained at the next `\n`/`;` separator by drain_pending_heredocs.
512    PENDING_HEREDOCS.with(|q| {
513        q.borrow_mut().push(PendingHeredoc {
514            delimiter: delimiter.clone(),
515            strip_tabs,
516        });
517    });
518    Ok(Redir::HereDoc { delimiter, strip_tabs, body })
519}
520
521/// This heredoc's body, parsed for the expansions the shell performs on it.
522///
523/// Bodies begin after the CURRENT line, and a heredoc declared earlier on the same line
524/// (`cat <<A <<B`) owns an earlier body — so the pending queue is replayed to find where this
525/// one starts. Reads without consuming; `drain_pending_heredocs` still does the consuming.
526///
527/// FAILS CLOSED: a body that does not parse (a lone backtick opens a substitution that is never
528/// closed) refuses the whole parse, which denies. That matches the shell, which reports
529/// `unexpected EOF while looking for matching backquote` and runs nothing.
530fn heredoc_body_word(input: &str, delimiter: &str, strip_tabs: bool) -> ModalResult<Word> {
531    let Some(nl) = input.find('\n') else {
532        return Ok(Word(Vec::new())); // no body yet; the drain will fail the parse
533    };
534    let mut rest = &input[nl + 1..];
535    let priors: Vec<PendingHeredoc> = PENDING_HEREDOCS.with(|q| q.borrow().clone());
536    for prior in &priors {
537        let Some((_, after)) = split_heredoc_body(rest, &prior.delimiter, prior.strip_tabs) else {
538            return Ok(Word(Vec::new()));
539        };
540        rest = after;
541    }
542    let Some((body, _)) = split_heredoc_body(rest, delimiter, strip_tabs) else {
543        return Ok(Word(Vec::new()));
544    };
545    let mut text = body;
546    let parts: Vec<WordPart> = repeat(0.., heredoc_part).parse_next(&mut text)?;
547    if !text.is_empty() {
548        return backtrack();
549    }
550    Ok(Word(parts))
551}
552
553/// A heredoc body expands like a double-quoted string, with one difference that matters: `"` and
554/// `'` are ORDINARY characters there, so `'$(id)'` in a body still runs `id`. Skipping quoted spans
555/// the way `find_sub_close` does would therefore miss a live substitution.
556fn is_heredoc_literal(c: char) -> bool {
557    !matches!(c, '"' | '\\' | '`' | '$')
558}
559
560fn heredoc_part(input: &mut &str) -> ModalResult<WordPart> {
561    if input.is_empty() {
562        return backtrack();
563    }
564    if input.starts_with('"') {
565        *input = &input[1..];
566        return Ok(WordPart::Lit("\"".to_string()));
567    }
568    alt((
569        dq_escape,
570        arith_sub,
571        cmd_sub,
572        backtick_part,
573        dollar_lit(is_heredoc_literal),
574        lit(is_heredoc_literal),
575    ))
576    .parse_next(input)
577}
578
579#[derive(Debug, Clone)]
580struct PendingHeredoc {
581    delimiter: String,
582    strip_tabs: bool,
583}
584
585thread_local! {
586    static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
587        const { std::cell::RefCell::new(Vec::new()) };
588}
589
590fn drain_pending_heredocs(input: &mut &str) {
591    let pending: Vec<PendingHeredoc> =
592        PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
593    for h in pending {
594        if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
595            // Couldn't find the matching delimiter line. Leave input
596            // as-is; the parser will likely fail on the leftover body
597            // text, which is the safe outcome (we deny on parse fail).
598            return;
599        }
600    }
601}
602
603fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
604    match split_heredoc_body(input, delimiter, strip_tabs) {
605        Some((_, rest)) => {
606            *input = rest;
607            true
608        }
609        None => false,
610    }
611}
612
613/// Split at the delimiter line: `(body, rest-after-the-delimiter-line)`, or `None` when the
614/// delimiter never appears. `strip_tabs` (`<<-`) strips leading TABS only, matching the shell —
615/// spaces do not terminate a `<<-` body.
616fn split_heredoc_body<'a>(
617    s: &'a str,
618    delimiter: &str,
619    strip_tabs: bool,
620) -> Option<(&'a str, &'a str)> {
621    let bytes = s.as_bytes();
622    let mut line_start = 0;
623    while line_start <= bytes.len() {
624        let line_end = match s[line_start..].find('\n') {
625            Some(rel) => line_start + rel,
626            None => bytes.len(),
627        };
628        let line = &s[line_start..line_end];
629        let line = if strip_tabs { line.trim_start_matches('\t') } else { line };
630        if line == delimiter {
631            let advance = line_end + usize::from(line_end < bytes.len());
632            return Some((&s[..line_start], &s[advance..]));
633        }
634        if line_end >= bytes.len() {
635            return None;
636        }
637        line_start = line_end + 1;
638    }
639    None
640}
641
642fn reset_heredoc_queue() {
643    PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
644}
645
646/// The delimiter, and whether the body EXPANDS. Any quoting or escaping anywhere in the delimiter
647/// suppresses expansion (`<<'EOF'`, `<<"EOF"`, `<<\EOF`, `<<E"O"F`); only a wholly bare word leaves
648/// the body live. Reported as a flag because that single bit decides whether the body is data or
649/// code, and treating a quoted body as code would over-deny every ordinary commit message.
650fn heredoc_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
651    alt((
652        delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| (s.to_string(), false)),
653        delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| (s.to_string(), false)),
654        escaped_delimiter,
655        take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
656            .map(|s: &str| (s.to_string(), true)),
657    ))
658    .parse_next(input)
659}
660
661/// A delimiter carrying a backslash or an inner quote — `<<\EOF`, `<<E"O"F`, `<<EO'F'`. The shell
662/// treats ANY such quoting as suppressing expansion over the WHOLE delimiter, so these parse to the
663/// unquoted spelling with expansion off. Without this they failed to parse at all, denying a valid
664/// (and, being unexpanded, entirely inert) heredoc.
665fn escaped_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
666    let mut rest = *input;
667    let mut name = String::new();
668    let mut quoted = false;
669    loop {
670        let mut chars = rest.chars();
671        match chars.next() {
672            Some('\\') => match chars.next() {
673                Some(c) => {
674                    name.push(c);
675                    quoted = true;
676                    rest = &rest[1 + c.len_utf8()..];
677                }
678                None => break,
679            },
680            Some(q @ ('\'' | '"')) => {
681                let inner_end = rest[1..].find(q).map(|i| i + 1);
682                let Some(end) = inner_end else { break };
683                name.push_str(&rest[1..end]);
684                quoted = true;
685                rest = &rest[end + 1..];
686            }
687            Some(c) if c.is_ascii_alphanumeric() || c == '_' => {
688                name.push(c);
689                rest = &rest[c.len_utf8()..];
690            }
691            _ => break,
692        }
693    }
694    if !quoted || name.is_empty() {
695        return backtrack();
696    }
697    *input = rest;
698    Ok((name, false))
699}
700
701fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
702    let b = input.as_bytes();
703    if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
704        let d = (b[0] - b'0') as u32;
705        *input = &input[1..];
706        Ok(d)
707    } else {
708        backtrack()
709    }
710}
711
712fn fd_target(input: &mut &str) -> ModalResult<String> {
713    alt((
714        '-'.value("-".to_string()),
715        take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
716    ))
717    .parse_next(input)
718}
719
720// === Word ===
721
722fn word(input: &mut &str) -> ModalResult<Word> {
723    repeat(1.., word_part)
724        .map(Word)
725        .parse_next(input)
726}
727
728fn word_part(input: &mut &str) -> ModalResult<WordPart> {
729    if input.is_empty() {
730        return backtrack();
731    }
732    if input.starts_with("<(") || input.starts_with(">(") {
733        return proc_sub(input);
734    }
735    if is_word_boundary(input.as_bytes()[0] as char) {
736        return backtrack();
737    }
738    alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
739        .parse_next(input)
740}
741
742fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
743    delimited('\'', take_while(0.., |c| c != '\''), '\'')
744        .map(|s: &str| WordPart::SQuote(s.to_string()))
745        .parse_next(input)
746}
747
748fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
749    delimited('"', repeat(0.., dq_part).map(Word), '"')
750        .map(WordPart::DQuote)
751        .parse_next(input)
752}
753
754/// Byte offset of the `)` that closes a substitution body starting at `body[0]` — the first `)` at
755/// paren-depth zero — or `None` if it is never closed. Quote (`'…'`, `"…"`), backtick, and backslash
756/// spans are skipped so a `)` inside them does not count, mirroring how the grammar's own
757/// `single_quoted`/`double_quoted`/`backtick`/`escaped` parsers treat those regions. This is what
758/// keeps `cmd_sub`/`proc_sub` linear: the interior is parsed only once, over a bounded slice, instead
759/// of the old `delimited(script, ')')` shape that recursed into the tail BEFORE knowing a close even
760/// existed — the source of the `a$(a<(a` × N exponential.
761fn find_sub_close(body: &str) -> Option<usize> {
762    let b = body.as_bytes();
763    let mut i = 0;
764    let mut depth: usize = 0;
765    while i < b.len() {
766        match b[i] {
767            b'\\' => i += 1, // escape: skip the next byte too (the trailing `+= 1` handles it)
768            b'\'' => {
769                i += 1;
770                while i < b.len() && b[i] != b'\'' {
771                    i += 1;
772                }
773                if i >= b.len() {
774                    return None;
775                }
776            }
777            b'"' => {
778                i += 1;
779                while i < b.len() && b[i] != b'"' {
780                    i += if b[i] == b'\\' { 2 } else { 1 };
781                }
782                if i >= b.len() {
783                    return None;
784                }
785            }
786            b'`' => {
787                i += 1;
788                while i < b.len() && b[i] != b'`' {
789                    // `bt_escape` treats `\<any>` inside backticks as a literal, so an escaped
790                    // backtick does NOT close the span — skip the escaped byte too.
791                    i += if b[i] == b'\\' { 2 } else { 1 };
792                }
793                if i >= b.len() {
794                    return None;
795                }
796            }
797            b'(' => depth += 1,
798            b')' => {
799                if depth == 0 {
800                    return Some(i);
801                }
802                depth -= 1;
803            }
804            _ => {}
805        }
806        i += 1;
807    }
808    None
809}
810
811/// Parse a substitution body (`$( … )`, `<( … )`, `>( … )`) as a full script.
812///
813/// FAST PATH: `find_sub_close` locates the matching `)` and we parse only the bounded interior, so
814/// nested substitutions stay linear instead of the old `delimited(script, ')')` shape that recursed
815/// into the tail before knowing a close existed (the `a$(a<(a` × N exponential).
816///
817/// FALLBACK: a few grammar constructs move the real close PAST that first balanced `)` — chiefly a
818/// heredoc body, whose text (including any `)`) is consumed out-of-band by `drain_pending_heredocs`
819/// and which `find_sub_close` does not model. When the bounded interior does not parse cleanly we
820/// re-run the EXACT old grammar over the full body, preserving classification for those inputs. The
821/// fallback is the recursive shape, but the per-parse work budget (`MAX_PARSE_WORK_*`) bounds it, so
822/// it cannot reintroduce the hang. A `None` from `find_sub_close` normally means no unquoted `)`
823/// exists at all — the grammar could not close the sub either — so we fail fast. The exception is a
824/// heredoc: its body is data the scanner reads as code, so a lone apostrophe in prose (`the shell's
825/// grammar`) opens a quote that never closes and swallows the real `)`. `git commit -m "$(cat <<EOF`
826/// with any contraction in the message lands here, so `None` + a heredoc operator takes the grammar
827/// fallback — which drains the body correctly — rather than failing the whole parse.
828fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
829    let body = &input[open_len..];
830    let Some(rel) = find_sub_close(body) else {
831        return if body.contains("<<") {
832            sub_body_via_grammar(input, body)
833        } else {
834            backtrack()
835        };
836    };
837    // A heredoc body is drained out-of-band (`drain_pending_heredocs`) and can run PAST `rel`, so the
838    // bounded interior would be truncated mid-heredoc and still parse "clean" — the fast path is
839    // unreliable whenever the interior holds a heredoc operator. Skip straight to the grammar fallback
840    // there. `<<` covers `<<`, `<<-`, and `<<<`; the latter (herestring) is inline and would be fine,
841    // but taking the fallback for it is merely slower, never wrong.
842    let interior = &body[..rel];
843    // `case` has the same hazard as a heredoc: the `)` closing an arm's pattern is not a nesting
844    // paren, so `find_sub_close` stops at `$(case A in *)` and the truncated interior still parses
845    // "clean" — as a simple command whose words are `case A in *`. A wrong-but-clean fast parse is
846    // worse than a slow one, so hand any interior mentioning `case` to the grammar fallback. The
847    // test is deliberately the bare substring: a literal word `case` merely costs a slower path.
848    if !interior.contains("<<") && !interior.contains("case") {
849        let mut fast: &str = interior;
850        if let Ok(parsed) = script.parse_next(&mut fast) {
851            ws.parse_next(&mut fast)?;
852            if fast.is_empty() {
853                *input = &body[rel + 1..];
854                return Ok(parsed);
855            }
856        }
857    }
858    sub_body_via_grammar(input, body)
859}
860
861/// The EXACT old grammar over the full body: it drains heredocs and tracks `case` arms, so it finds
862/// a close that `find_sub_close`'s byte scan places wrongly or misses. Bounded by `MAX_PARSE_WORK_*`.
863fn sub_body_via_grammar<'a>(input: &mut &'a str, body: &'a str) -> ModalResult<Script> {
864    let mut rest: &str = body;
865    ws.parse_next(&mut rest)?;
866    let parsed = script.parse_next(&mut rest)?;
867    ws.parse_next(&mut rest)?;
868    if !rest.starts_with(')') {
869        return backtrack();
870    }
871    *input = &rest[1..];
872    Ok(parsed)
873}
874
875fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
876    if !input.starts_with("$(") {
877        return backtrack();
878    }
879    sub_body(input, 2).map(WordPart::CmdSub)
880}
881
882fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
883    if !(input.starts_with("<(") || input.starts_with(">(")) {
884        return backtrack();
885    }
886    sub_body(input, 2).map(WordPart::ProcSub)
887}
888
889/// The parts of an arithmetic BODY: literal text plus the substitutions that actually run.
890fn arith_body_part(input: &mut &str) -> ModalResult<WordPart> {
891    if input.is_empty() {
892        return backtrack();
893    }
894    alt((
895        dq_escape,
896        // Nested `$(( ))` must be recognised as ARITHMETIC before `cmd_sub` sees it, or `$((` is
897        // read as `$(` plus a subshell and `$(( $((1+1)) ))` refuses on an inner "command" `(1+1)`.
898        // It cannot be skipped as literal text either: `$(( $(( $(rm -rf /) )) ))` would then hide
899        // a real substitution, which is a fail-OPEN. So it recurses, bounded by the guard below.
900        arith_sub,
901        cmd_sub,
902        backtick_part,
903        dollar_lit(is_heredoc_literal),
904        lit(is_heredoc_literal),
905    ))
906    .parse_next(input)
907}
908
909fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
910    if !input.starts_with("$((") {
911        return backtrack();
912    }
913    // Parsing the body makes this a recursion source that does NOT funnel through `script()`, where
914    // MAX_PARSE_DEPTH is enforced — unguarded, `$((1+` x50000 overflowed the stack and ABORTED,
915    // which for a hook is a fail-open crash `catch_unwind` cannot recover. Taking the guard here
916    // was previously rejected because bailing backtracks into `cmd_sub`, which re-parsed the same
917    // nest for 68 seconds; that is affordable now only because the work budget gained a flat
918    // ceiling, which bounds the retry as well as the descent.
919    let Some(_depth) = DepthGuard::enter() else {
920        return backtrack();
921    };
922    let body_start = 3;
923    let bytes = input.as_bytes();
924    let mut depth: i32 = 1;
925    let mut i = body_start;
926    while i < bytes.len() {
927        match bytes[i] {
928            b'(' => depth += 1,
929            b')' => {
930                if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
931                    // The body is PARSED, not kept as text. It used to backtrack whenever it held
932                    // a substitution, which handed `$((` to `cmd_sub` and re-read it as `$(` plus a
933                    // subshell — `--explain` rendered `$( (1 + …))`, a command nobody wrote, and
934                    // refused it because `(1` is not a command. That cost a false deny on the
935                    // everyday `$(( now - $(date +%s) ))`.
936                    //
937                    // The old backtrack was the conservative choice: treating the body as opaque
938                    // text would hide the inner command, a fail-OPEN. Parsing keeps it visible and
939                    // stops the misparse. `arith_body_part` deliberately excludes `arith_sub`, so
940                    // arithmetic is not a recursion source — nested `$(( ))` is literal text here,
941                    // which costs nothing since arithmetic is inert either way.
942                    let mut body = &input[body_start..i];
943                    let parts: Vec<WordPart> = repeat(0.., arith_body_part).parse_next(&mut body)?;
944                    if !body.is_empty() {
945                        return backtrack();
946                    }
947                    *input = &input[i + 2..];
948                    return Ok(WordPart::Arith(Word(parts)));
949                }
950                depth -= 1;
951                if depth < 0 {
952                    return backtrack();
953                }
954            }
955            _ => {}
956        }
957        i += 1;
958    }
959    backtrack()
960}
961
962fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
963    delimited('`', backtick_inner, '`')
964        .map(WordPart::Backtick)
965        .parse_next(input)
966}
967
968fn escaped(input: &mut &str) -> ModalResult<WordPart> {
969    preceded('\\', any).map(WordPart::Escape).parse_next(input)
970}
971
972fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
973    move |input: &mut &str| {
974        take_while(1.., pred)
975            .map(|s: &str| WordPart::Lit(s.to_string()))
976            .parse_next(input)
977    }
978}
979
980fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
981    move |input: &mut &str| {
982        ('$', not('(')).void().parse_next(input)?;
983        let rest: &str = take_while(0.., pred).parse_next(input)?;
984        Ok(WordPart::Lit(format!("${rest}")))
985    }
986}
987
988// === Double-quoted parts ===
989
990fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
991    if input.is_empty() || input.starts_with('"') {
992        return backtrack();
993    }
994    alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
995        .parse_next(input)
996}
997
998fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
999    preceded('\\', any)
1000        .map(|c: char| match c {
1001            '"' | '\\' | '$' | '`' => WordPart::Escape(c),
1002            _ => WordPart::Lit(format!("\\{c}")),
1003        })
1004        .parse_next(input)
1005}
1006
1007// === Backtick inner content ===
1008
1009fn backtick_inner(input: &mut &str) -> ModalResult<String> {
1010    repeat(0.., alt((bt_escape, bt_literal)))
1011        .fold(String::new, |mut acc, chunk: &str| {
1012            acc.push_str(chunk);
1013            acc
1014        })
1015        .parse_next(input)
1016}
1017
1018fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1019    ('\\', any).take().parse_next(input)
1020}
1021
1022fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1023    take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
1024}
1025
1026// === Compound Commands ===
1027
1028fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
1029    eat_keyword(input, "for")?;
1030    ws.parse_next(input)?;
1031    let var = name.parse_next(input)?;
1032    ws.parse_next(input)?;
1033
1034    let items = if eat_keyword(input, "in").is_ok() {
1035        ws.parse_next(input)?;
1036        repeat(0.., terminated(word, ws)).parse_next(input)?
1037    } else {
1038        vec![]
1039    };
1040
1041    let body = do_done_body.parse_next(input)?;
1042    let redirs = trailing_redirs(input)?;
1043    Ok(Cmd::For { var, items, body, redirs })
1044}
1045
1046fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
1047    eat_keyword(input, "while")?;
1048    ws.parse_next(input)?;
1049    let cond = script.parse_next(input)?;
1050    let body = do_done_body.parse_next(input)?;
1051    let redirs = trailing_redirs(input)?;
1052    Ok(Cmd::While { cond, body, redirs })
1053}
1054
1055fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
1056    eat_keyword(input, "until")?;
1057    ws.parse_next(input)?;
1058    let cond = script.parse_next(input)?;
1059    let body = do_done_body.parse_next(input)?;
1060    let redirs = trailing_redirs(input)?;
1061    Ok(Cmd::Until { cond, body, redirs })
1062}
1063
1064fn do_done_body(input: &mut &str) -> ModalResult<Script> {
1065    sep.parse_next(input)?;
1066    eat_keyword(input, "do")?;
1067    sep.parse_next(input)?;
1068    let body = script.parse_next(input)?;
1069    sep.parse_next(input)?;
1070    eat_keyword(input, "done")?;
1071    Ok(body)
1072}
1073
1074fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
1075    eat_keyword(input, "if")?;
1076    ws.parse_next(input)?;
1077    let mut branches = vec![cond_then_body.parse_next(input)?];
1078    let mut else_body = None;
1079
1080    loop {
1081        sep.parse_next(input)?;
1082        if eat_keyword(input, "elif").is_ok() {
1083            ws.parse_next(input)?;
1084            branches.push(cond_then_body.parse_next(input)?);
1085        } else if eat_keyword(input, "else").is_ok() {
1086            sep.parse_next(input)?;
1087            else_body = Some(script.parse_next(input)?);
1088            break;
1089        } else {
1090            break;
1091        }
1092    }
1093
1094    sep.parse_next(input)?;
1095    eat_keyword(input, "fi")?;
1096    let redirs = trailing_redirs(input)?;
1097    Ok(Cmd::If { branches, else_body, redirs })
1098}
1099
1100fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
1101    let cond = script.parse_next(input)?;
1102    sep.parse_next(input)?;
1103    eat_keyword(input, "then")?;
1104    sep.parse_next(input)?;
1105    let body = script.parse_next(input)?;
1106    Ok(Branch { cond, body })
1107}
1108
1109/// `case WORD in [(] PATTERN [| PATTERN]… ) BODY ;; … esac` (POSIX 2.9.4.3).
1110fn case_cmd(input: &mut &str) -> ModalResult<Cmd> {
1111    eat_keyword(input, "case")?;
1112    ws.parse_next(input)?;
1113    let subject = word.parse_next(input)?;
1114    blank.parse_next(input)?;
1115    eat_keyword(input, "in")?;
1116
1117    let mut arms = Vec::new();
1118    loop {
1119        blank.parse_next(input)?;
1120        if eat_keyword(input, "esac").is_ok() {
1121            break;
1122        }
1123        let arm = case_arm.parse_next(input)?;
1124        let had_terminator = opt(";;").parse_next(input)?.is_some();
1125        arms.push(arm);
1126        // POSIX lets the LAST arm omit `;;`, and only the last. Anything else here is malformed —
1127        // backtrack rather than guess, so a shape we don't understand fails closed.
1128        if !had_terminator {
1129            blank.parse_next(input)?;
1130            eat_keyword(input, "esac")?;
1131            break;
1132        }
1133    }
1134
1135    let redirs = trailing_redirs(input)?;
1136    Ok(Cmd::Case { subject, arms, redirs })
1137}
1138
1139fn case_arm(input: &mut &str) -> ModalResult<CaseArm> {
1140    blank.parse_next(input)?;
1141    opt('(').parse_next(input)?;
1142    let mut patterns = Vec::new();
1143    loop {
1144        ws.parse_next(input)?;
1145        patterns.push(word.parse_next(input)?);
1146        ws.parse_next(input)?;
1147        if opt('|').parse_next(input)?.is_none() {
1148            break;
1149        }
1150    }
1151    ')'.parse_next(input)?;
1152    let body = script.parse_next(input)?;
1153    blank.parse_next(input)?;
1154    Ok(CaseArm { patterns, body })
1155}
1156
1157/// Whitespace including newlines, but NOT `;` — used where a `;;` must stay visible to the caller.
1158fn blank(input: &mut &str) -> ModalResult<()> {
1159    take_while(0.., [' ', '\t', '\n']).void().parse_next(input)
1160}
1161
1162fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
1163    if !input.starts_with("[[") {
1164        return backtrack();
1165    }
1166    let bytes = input.as_bytes();
1167    if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
1168        return backtrack();
1169    }
1170    *input = &input[2..];
1171
1172    let mut words: Vec<Word> = Vec::new();
1173    loop {
1174        ws.parse_next(input)?;
1175        if at_double_bracket_end(input) {
1176            *input = &input[2..];
1177            let redirs = trailing_redirs(input)?;
1178            return Ok(Cmd::DoubleBracket { words, redirs });
1179        }
1180        if input.is_empty() {
1181            return backtrack();
1182        }
1183        let w = bracket_word.parse_next(input)?;
1184        words.push(w);
1185    }
1186}
1187
1188fn at_double_bracket_end(input: &str) -> bool {
1189    if !input.starts_with("]]") {
1190        return false;
1191    }
1192    let after = &input[2..];
1193    after.is_empty()
1194        || after.starts_with(|c: char| {
1195            matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
1196        })
1197}
1198
1199fn bracket_word(input: &mut &str) -> ModalResult<Word> {
1200    repeat(1.., bracket_word_part).map(Word).parse_next(input)
1201}
1202
1203fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
1204    if input.is_empty() {
1205        return backtrack();
1206    }
1207    if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
1208        return backtrack();
1209    }
1210    if at_double_bracket_end(input) {
1211        return backtrack();
1212    }
1213    alt((
1214        single_quoted,
1215        double_quoted,
1216        arith_sub,
1217        cmd_sub,
1218        backtick_part,
1219        escaped,
1220        dollar_lit(is_bracket_literal),
1221        bracket_lit,
1222    ))
1223    .parse_next(input)
1224}
1225
1226fn is_bracket_literal(c: char) -> bool {
1227    !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
1228}
1229
1230fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
1231    // Byte-by-byte scan relies on every stop char being single-byte ASCII —
1232    // multibyte UTF-8 continuation bytes always pass `is_bracket_literal` and
1233    // get consumed as part of the same `Lit`, so `end` only lands on a char
1234    // boundary.
1235    let bytes = input.as_bytes();
1236    let mut end = 0;
1237    while end < bytes.len() {
1238        let c = bytes[end] as char;
1239        if !is_bracket_literal(c) {
1240            break;
1241        }
1242        if c == ']' && at_double_bracket_end(&input[end..]) {
1243            break;
1244        }
1245        end += 1;
1246    }
1247    if end == 0 {
1248        return backtrack();
1249    }
1250    let lit = input[..end].to_string();
1251    *input = &input[end..];
1252    Ok(WordPart::Lit(lit))
1253}
1254
1255fn name(input: &mut &str) -> ModalResult<String> {
1256    take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
1257        .map(|s: &str| s.to_string())
1258        .parse_next(input)
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    /// The parse work budget must bound NESTED input without refusing anything real.
1264    ///
1265    /// Both halves are measured, because the constant is only defensible as a ratio between them:
1266    ///
1267    ///   - Real commands are FLAT and cost 1-2 `script()` entries. Across every example the
1268    ///     registry ships (1338 of them) the most any one needs is 2 — the worst being
1269    ///     `eval "$(conda shell.bash hook)"`. Flat input is free regardless of size: 400
1270    ///     side-by-side `$((1))` in 2805 bytes costs ONE entry.
1271    ///   - Nested input is what blows up, and it blew up because the budget GREW with the input
1272    ///     (`16384 + 512 * len`), so a bigger adversarial input bought itself more time. At depth
1273    ///     400 that allowed 1_453_119 entries and took 1.55s in release purely to fail; depth 1000
1274    ///     read as a hang.
1275    ///
1276    /// So the guard pins the ratio, not a timing: real examples must stay far under the ceiling,
1277    /// and a deep nest must be stopped BY the ceiling rather than by exhausting a length-scaled
1278    /// allowance. Timings are deliberately not asserted — they are machine-dependent — but for the
1279    /// record the same depth-400 input now parses in 0.028s, below process startup.
1280    #[test]
1281    fn the_parse_work_budget_bounds_nesting_without_refusing_real_commands() {
1282        let worst_real = crate::registry::corpus_examples()
1283            .into_iter()
1284            .flat_map(|(_, safe, denied)| safe.iter().chain(denied.iter()).cloned().collect::<Vec<_>>())
1285            .map(|ex| {
1286                let _ = super::parse(&ex);
1287                super::PARSE_WORK.with(|w| w.get())
1288            })
1289            .max()
1290            .expect("the registry ships examples");
1291        assert!(
1292            worst_real * 100 < MAX_PARSE_WORK_CEILING,
1293            "a real example needs {worst_real} entries against a {MAX_PARSE_WORK_CEILING} ceiling; \
1294             the margin that makes this constant safe is gone"
1295        );
1296
1297        // Flat input stays LINEAR however long it is — that is why a flat ceiling is safe, and it
1298        // is the property, not a particular number. Arithmetic began costing one unit each when
1299        // `arith_sub` took the depth guard (it is a recursion source now), so 400 expansions cost
1300        // ~400 rather than the ~1 they cost when arithmetic was outside the accounting. Still two
1301        // orders of magnitude under the ceiling, which is what matters.
1302        let flat = format!("echo {}", "$((1)) ".repeat(400));
1303        let _ = super::parse(&flat);
1304        let flat_work = super::PARSE_WORK.with(|w| w.get());
1305        assert!(
1306            flat_work < MAX_PARSE_WORK_CEILING / 10,
1307            "flat input cost {flat_work}, close to the {MAX_PARSE_WORK_CEILING} ceiling — a long \
1308             flat command is at risk of being refused"
1309        );
1310
1311        // A deep nest is stopped by the CEILING, not by a length-scaled allowance. The property
1312        // asserted is that the work stops GROWING with the input — doubling the nest must not
1313        // double the work — which is precisely what the old `BASE + PER_BYTE * len` budget failed
1314        // to do. An exact cap is not asserted: `enter()` bumps the counter before it checks, so
1315        // calls on the unwind path overshoot slightly (59 entries when this was written).
1316        let work_at = |depth: usize| {
1317            let deep = format!("echo {}1{}", "$((1+".repeat(depth), "))".repeat(depth));
1318            let _ = super::parse(&deep);
1319            super::PARSE_WORK.with(|w| w.get())
1320        };
1321        let (w2k, w4k) = (work_at(2000), work_at(4000));
1322        assert!(w2k > 1000, "the nest should actually reach the bound, or this proves nothing");
1323        assert!(
1324            w4k <= w2k + w2k / 10,
1325            "work still scales with input length: depth 2000 used {w2k}, depth 4000 used {w4k}"
1326        );
1327        assert!(
1328            w4k < MAX_PARSE_WORK_CEILING + MAX_PARSE_WORK_CEILING / 10,
1329            "work {w4k} ran far past the {MAX_PARSE_WORK_CEILING} ceiling"
1330        );
1331    }
1332
1333
1334    use super::*;
1335
1336    fn p(input: &str) -> Script {
1337        parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
1338    }
1339
1340    fn words(script: &Script) -> Vec<String> {
1341        match &script.0[0].pipeline.commands[0] {
1342            Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
1343            _ => panic!("expected simple command"),
1344        }
1345    }
1346
1347    fn simple(script: &Script) -> &SimpleCmd {
1348        match &script.0[0].pipeline.commands[0] {
1349            Cmd::Simple(s) => s,
1350            _ => panic!("expected simple command"),
1351        }
1352    }
1353
1354    #[test]
1355    fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
1356    #[test]
1357    fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
1358    #[test]
1359    fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
1360    #[test]
1361    fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
1362    #[test]
1363    fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
1364
1365    #[test]
1366    fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
1367    #[test]
1368    fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
1369    #[test]
1370    fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
1371    #[test]
1372    fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
1373    #[test]
1374    fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
1375    #[test]
1376    fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
1377    #[test]
1378    fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n   \necho bar").0.len(), 2); }
1379    #[test]
1380    fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
1381    #[test]
1382    fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
1383    #[test]
1384    fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
1385
1386    #[test]
1387    fn brace_group_simple() {
1388        assert!(matches!(
1389            &p("{ echo hello; }").0[0].pipeline.commands[0],
1390            Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1391        ));
1392    }
1393    #[test]
1394    fn brace_group_multiple_stmts() {
1395        if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1396            assert_eq!(body.0.len(), 3);
1397        } else { panic!("expected BraceGroup"); }
1398    }
1399    #[test]
1400    fn brace_group_with_redirect() {
1401        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1402            assert_eq!(redirs.len(), 1);
1403            assert!(matches!(redirs[0], Redir::Write { .. }));
1404        } else { panic!("expected BraceGroup"); }
1405    }
1406    #[test]
1407    fn brace_group_with_append_redirect() {
1408        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1409            assert!(matches!(redirs[0], Redir::Write { mode: WriteMode::Append, .. }));
1410        } else { panic!("expected BraceGroup"); }
1411    }
1412    #[test]
1413    fn brace_group_with_stderr_redirect() {
1414        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1415            assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1416        } else { panic!("expected BraceGroup"); }
1417    }
1418    #[test]
1419    fn brace_group_newline_separated() {
1420        if let Cmd::BraceGroup { body, .. } = &p("{\n  echo a\n  echo b\n}").0[0].pipeline.commands[0] {
1421            assert_eq!(body.0.len(), 2);
1422        } else { panic!("expected BraceGroup"); }
1423    }
1424    #[test]
1425    fn brace_group_in_pipeline() {
1426        let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1427        assert_eq!(pl.commands.len(), 2);
1428        assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1429    }
1430    #[test]
1431    fn brace_group_followed_by_other() {
1432        let stmts = &p("{ echo a; }; echo b").0;
1433        assert_eq!(stmts.len(), 2);
1434        assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1435    }
1436    #[test]
1437    fn brace_group_nested() {
1438        if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1439            assert_eq!(body.0.len(), 2);
1440            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1441        } else { panic!("expected outer BraceGroup"); }
1442    }
1443    #[test]
1444    fn brace_group_with_subshell_inside() {
1445        if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1446            assert_eq!(body.0.len(), 2);
1447            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1448        } else { panic!("expected BraceGroup"); }
1449    }
1450    #[test]
1451    fn brace_open_requires_whitespace() {
1452        // {echo (no space) is NOT a brace group; it's a literal word
1453        // that becomes part of a simple command. Parser should not
1454        // treat it as a brace group.
1455        let cmds = &p("{echo a}").0;
1456        // Either parsed as a simple_cmd with a literal `{echo` token,
1457        // or fails. Either way, it should NOT be a BraceGroup.
1458        if !cmds.is_empty() {
1459            assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1460        }
1461    }
1462    #[test]
1463    fn subshell_with_redirect() {
1464        if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1465            assert_eq!(redirs.len(), 1);
1466        } else { panic!("expected Subshell with redir"); }
1467    }
1468    #[test]
1469    fn for_loop_with_redirect() {
1470        if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1471            assert_eq!(redirs.len(), 1);
1472        } else { panic!("expected For with redir"); }
1473    }
1474    #[test]
1475    fn for_loop_redirect_then_pipe() {
1476        // `done 2>&1 | head` — redirect on the loop, then a pipe.
1477        let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1478        assert_eq!(pl.commands.len(), 2);
1479        assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1480    }
1481    #[test]
1482    fn while_and_if_with_redirect() {
1483        assert!(matches!(
1484            &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1485            Cmd::While { redirs, .. } if redirs.len() == 1
1486        ));
1487        assert!(matches!(
1488            &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1489            Cmd::If { redirs, .. } if redirs.len() == 1
1490        ));
1491    }
1492    #[test]
1493    fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1494
1495    #[test]
1496    fn redirect_dev_null() {
1497        let s = p("echo hello > /dev/null");
1498        let cmd = simple(&s);
1499        assert_eq!(cmd.words.len(), 2);
1500        assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, mode: WriteMode::Truncate, .. }));
1501    }
1502    #[test]
1503    fn redirect_stderr() {
1504        assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1505    }
1506    #[test]
1507    fn here_string() {
1508        assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1509    }
1510    #[test]
1511    fn heredoc_bare() {
1512        assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false, .. } if delimiter == "EOF"));
1513    }
1514    #[test]
1515    fn heredoc_with_content() {
1516        let s = p("cat <<EOF\nhello world\nEOF");
1517        assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1518    }
1519    #[test]
1520    fn heredoc_quoted_delimiter() {
1521        assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1522    }
1523    #[test]
1524    fn heredoc_strip_tabs() {
1525        assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1526    }
1527    #[test]
1528    fn heredoc_pipe_on_command_line() {
1529        // Correct bash: pipe is on the command line BEFORE the body,
1530        // body terminator is on its own line.
1531        let s = p("cat <<EOF | grep hello\nhello\nEOF");
1532        assert_eq!(s.0[0].pipeline.commands.len(), 2);
1533    }
1534    #[test]
1535    fn heredoc_body_does_not_swallow_pipe() {
1536        // Regression for the `cat <<EOF | bash\n...\nEOF` bypass: the
1537        // heredoc parser must NOT consume the pipe + downstream
1538        // commands as part of the body.
1539        let s = p("cat <<EOF | bash\nrm\nEOF");
1540        assert_eq!(
1541            s.0[0].pipeline.commands.len(),
1542            2,
1543            "pipeline must keep `bash` as a second command"
1544        );
1545    }
1546    #[test]
1547    fn heredoc_followed_by_next_statement() {
1548        // After the heredoc body terminator, the script can continue
1549        // with another statement.
1550        let s = p("cat <<EOF\nhello\nEOF\nls");
1551        assert_eq!(s.0.len(), 2);
1552    }
1553
1554    #[test]
1555    fn env_prefix() {
1556        let s = p("FOO='bar baz' ls -la");
1557        let cmd = simple(&s);
1558        assert_eq!(cmd.env[0].0, "FOO");
1559        assert_eq!(cmd.env[0].1.eval(), "bar baz");
1560    }
1561    #[test]
1562    fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1563    #[test]
1564    fn backtick_substitution() {
1565        // `pwd` declares `[command.output]`, so its value is bounded rather than worst-cased —
1566        // and a backtick must reach that the same way `$( … )` does.
1567        assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB_WORKTREE__");
1568        // An undeclared inner command keeps the opaque sentinel.
1569        assert_eq!(simple(&p("ls `hostname`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__");
1570    }
1571    #[test]
1572    fn nested_substitution() {
1573        if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1574            assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1575        } else { panic!("expected CmdSub"); }
1576    }
1577
1578    #[test]
1579    fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1580    #[test]
1581    fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1582
1583    #[test]
1584    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")); }
1585    #[test]
1586    fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1587    #[test]
1588    fn if_then_fi() {
1589        if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1590            assert_eq!(branches.len(), 1);
1591            assert!(else_body.is_none());
1592        } else { panic!("expected If"); }
1593    }
1594    #[test]
1595    fn if_elif_else() {
1596        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] {
1597            assert_eq!(branches.len(), 2);
1598            assert!(else_body.is_some());
1599        } else { panic!("expected If"); }
1600    }
1601
1602    #[test]
1603    fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1604    #[test]
1605    fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1606    #[test]
1607    fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1608
1609    #[test]
1610    fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1611    #[test]
1612    fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1613    #[test]
1614    fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1615    #[test]
1616    fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1617    #[test]
1618    fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1619    #[test]
1620    fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1621
1622    #[test]
1623    fn subshell_for() {
1624        if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1625            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1626        } else { panic!("expected Subshell"); }
1627    }
1628    #[test]
1629    fn proc_sub_input() {
1630        let s = p("diff <(sort a.txt) <(sort b.txt)");
1631        let cmd = simple(&s);
1632        assert_eq!(cmd.words.len(), 3);
1633        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1634        assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1635    }
1636    #[test]
1637    fn proc_sub_output() {
1638        let s = p("tee >(grep error > /dev/null)");
1639        let cmd = simple(&s);
1640        assert_eq!(cmd.words.len(), 2);
1641        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1642    }
1643
1644    // === Function definitions ===
1645    fn func_def(s: &Script) -> (&str, &Script) {
1646        match &s.0[0].pipeline.commands[0] {
1647            Cmd::FunctionDef { name, body } => (name.as_str(), body),
1648            other => panic!("expected FunctionDef, got {other:?}"),
1649        }
1650    }
1651    #[test]
1652    fn function_def_posix_form() {
1653        let s = p("probe(){ echo hi; }");
1654        let (name, body) = func_def(&s);
1655        assert_eq!(name, "probe");
1656        assert_eq!(words(body), ["echo", "hi"]);
1657    }
1658    #[test]
1659    fn function_def_spaced_and_keyword_forms() {
1660        assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1661        assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1662        assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1663    }
1664    #[test]
1665    fn function_def_subshell_body() {
1666        let s = p("foo() ( echo sub )");
1667        assert_eq!(func_def(&s).0, "foo");
1668    }
1669    #[test]
1670    fn function_def_body_on_next_line() {
1671        assert_eq!(func_def(&p("foo()\n{\n  echo hi\n}")).0, "foo");
1672    }
1673    #[test]
1674    fn function_def_name_with_dashes_and_dots() {
1675        assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1676    }
1677    #[test]
1678    fn plain_command_is_not_a_function_def() {
1679        // A bare `name` with no `()` must NOT be read as a definition.
1680        assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1681        assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1682    }
1683    #[test]
1684    fn function_def_roundtrips() {
1685        let rendered = p("greet(){ echo hi; }").to_string();
1686        assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1687        assert_eq!(func_def(&p(&rendered)).0, "greet");
1688    }
1689    #[test]
1690    fn comment_only() {
1691        let s = p("# just a comment");
1692        assert!(s.0.is_empty());
1693    }
1694    #[test]
1695    fn comment_before_command() {
1696        let s = p("# comment\necho hello");
1697        assert_eq!(words(&s), ["echo", "hello"]);
1698    }
1699    #[test]
1700    fn inline_comment() {
1701        let s = p("echo hello # this is a comment");
1702        assert_eq!(words(&s), ["echo", "hello"]);
1703    }
1704    #[test]
1705    fn comment_between_commands() {
1706        let s = p("echo hello\n# middle comment\necho world");
1707        assert_eq!(s.0.len(), 2);
1708    }
1709    #[test]
1710    fn comment_after_semicolon() {
1711        let s = p("echo hello; # comment\necho world");
1712        assert_eq!(s.0.len(), 2);
1713    }
1714    #[test]
1715    fn comment_in_for_loop() {
1716        assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1717    }
1718    #[test]
1719    fn quoted_redirect_in_echo() {
1720        let s = p("echo 'greater > than' test");
1721        let cmd = simple(&s);
1722        assert_eq!(cmd.words.len(), 3);
1723        assert_eq!(cmd.redirs.len(), 0);
1724    }
1725
1726    #[test]
1727    fn parses_all_safe_commands() {
1728        let cmds = [
1729            "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1730            "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1731            "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1732            "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1733            "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1734            "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1735            "((echo hello))", "(for x in 1 2; do echo $x; done)",
1736            "echo 'greater > than' test", "echo '$(safe)' arg",
1737            "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1738            "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1739            "grep foo file.txt | head -5", "cat file | sort | uniq",
1740            "ls && echo done", "ls; echo done", "ls & echo done",
1741            "grep -c , <<< 'hello,world,test'",
1742            "cat <<EOF\nhello world\nEOF",
1743            "cat <<'MARKER'\nsome text\nMARKER",
1744            "cat <<-EOF\n\thello\nEOF",
1745            "echo foo\necho bar", "ls\ncat file.txt",
1746            "git log --oneline -20 | head -5",
1747            "echo hello > /dev/null", "echo hello 2> /dev/null",
1748            "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1749            "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1750            "for x in 1 2 3; do echo $x; done",
1751            "for f in *.txt; do cat $f | grep pattern; done",
1752            "for x in 1 2 3; do; done",
1753            "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1754            "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1755            "for x in 1 2; do echo $x; done && echo finished",
1756            "for x in $(seq 1 5); do echo $x; done",
1757            "while test -f /tmp/foo; do sleep 1; done",
1758            "while ! test -f /tmp/done; do sleep 1; done",
1759            "until test -f /tmp/ready; do sleep 1; done",
1760            "if test -f foo; then echo exists; fi",
1761            "if test -f foo; then echo yes; else echo no; fi",
1762            "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1763            "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1764            "if true; then for x in 1 2; do echo $x; done; fi",
1765            "diff <(sort a.txt) <(sort b.txt)",
1766            "comm -23 file.txt <(sort other.txt)",
1767            "cat <(echo hello)",
1768            "# comment only",
1769            "# comment\necho hello",
1770            "echo hello # inline comment",
1771            "echo one\n# between\necho two",
1772            "! echo hello", "! test -f foo",
1773            "echo for; echo done; echo if; echo fi",
1774        ];
1775        let mut failures = Vec::new();
1776        for cmd in &cmds {
1777            if parse(cmd).is_none() { failures.push(*cmd); }
1778        }
1779        assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1780    }
1781
1782    // === Balanced-scan divergence guards ===
1783    // `cmd_sub`/`proc_sub` find their closing `)` with `find_sub_close`, then parse the bounded
1784    // interior. The `roundtrip` proptest exercises this, but its `arb_shell_word` is paren-free, so
1785    // these lock the case it can't reach: a `)` inside a quote / escape / backtick / nested sub must
1786    // NOT be mistaken for the substitution's own close.
1787    fn inner_sub(s: &Script) -> &Script {
1788        match &simple(s).words[1].0[0] {
1789            WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1790            other => panic!("expected a substitution, got {other:?}"),
1791        }
1792    }
1793
1794    #[test]
1795    fn cmd_sub_squote_paren_is_not_close() {
1796        assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1797    }
1798    #[test]
1799    fn cmd_sub_dquote_paren_is_not_close() {
1800        assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1801    }
1802    #[test]
1803    fn cmd_sub_escaped_paren_is_not_close() {
1804        assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1805    }
1806    #[test]
1807    fn cmd_sub_backtick_paren_is_not_close() {
1808        // the ) lives inside a backtick span in the sub body; the real close is the final ).
1809        let s = p("echo $(x `)` y)");
1810        let inner = simple(inner_sub(&s));
1811        assert_eq!(inner.words.len(), 3);
1812        assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1813    }
1814    #[test]
1815    fn cmd_sub_escaped_backtick_does_not_end_span() {
1816        // The `\` escapes the next backtick, so the span — and the ) inside it — belong to the sub
1817        // body; the real close is the final ). Until find_sub_close honored backtick escapes it
1818        // mis-placed the span boundary and rejected this (a fail-closed divergence from the grammar).
1819        let s = p("echo $(`\\`)`)");
1820        assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1821    }
1822    #[test]
1823    fn proc_sub_squote_paren_is_not_close() {
1824        assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1825    }
1826    #[test]
1827    fn proc_sub_out_squote_paren_is_not_close() {
1828        assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1829    }
1830    #[test]
1831    fn cmd_sub_nested_picks_outer_close() {
1832        let s = p("echo $(a $(b) c)");
1833        let inner = simple(inner_sub(&s));
1834        assert_eq!(inner.words.len(), 3);
1835        assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1836    }
1837    #[test]
1838    fn cmd_sub_literal_after_close_stays_in_outer_word() {
1839        let s = p("echo $(ls)tail");
1840        let w = &simple(&s).words[1];
1841        assert_eq!(w.0.len(), 2);
1842        assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1843        assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1844    }
1845    #[test]
1846    fn cmd_sub_heredoc_body_paren_does_not_close() {
1847        // The ) sits in the heredoc body (drained out-of-band), so it must not close the sub — the
1848        // real close is the final ). find_sub_close can't see heredocs, so sub_body falls back to the
1849        // full grammar here. This parsed before the balanced-scan rewrite and must keep parsing.
1850        let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1851        assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1852    }
1853    #[test]
1854    fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1855        // the sole ) is single-quoted, so the sub never closes → whole parse fails (fail closed).
1856        assert!(parse("echo $(echo ')").is_none());
1857    }
1858    #[test]
1859    fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1860        assert!(parse("cat <(grep ')").is_none());
1861    }
1862}