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