Skip to main content

safe_chains/cst/
parse.rs

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