Skip to main content

safe_chains/cst/
parse.rs

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