Skip to main content

safe_chains/cst/
parse.rs

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