Skip to main content

safe_chains/cst/
parse.rs

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