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        take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
111        if input.starts_with('#') {
112            comment(input)?;
113        } else {
114            break;
115        }
116    }
117    Ok(())
118}
119
120fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
121    if !input.starts_with(kw) {
122        return backtrack();
123    }
124    if input
125        .as_bytes()
126        .get(kw.len())
127        .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
128    {
129        return backtrack();
130    }
131    *input = &input[kw.len()..];
132    Ok(())
133}
134
135const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
136
137fn at_script_stop(input: &str) -> bool {
138    input.starts_with(')')
139        || input.starts_with('}')
140        || SCRIPT_STOPS.iter().any(|kw| {
141            input.starts_with(kw)
142                && !input
143                    .as_bytes()
144                    .get(kw.len())
145                    .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
146        })
147}
148
149fn is_word_boundary(c: char) -> bool {
150    matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
151}
152
153fn is_word_literal(c: char) -> bool {
154    !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
155}
156
157fn is_dq_literal(c: char) -> bool {
158    !matches!(c, '"' | '\\' | '`' | '$')
159}
160
161// === Script ===
162
163fn script(input: &mut &str) -> ModalResult<Script> {
164    // Bound recursion depth: every nested `(`/`{`/`$(`/`<(`/`` ` `` funnels back through `script`,
165    // so this one guard caps stack depth against deeply-nested adversarial input (see MAX_PARSE_DEPTH).
166    let Some(_depth) = DepthGuard::enter() else {
167        return backtrack();
168    };
169    sep.parse_next(input)?;
170    let mut stmts = Vec::new();
171    while let Some(pl) = opt(pipeline).parse_next(input)? {
172        ws.parse_next(input)?;
173        let op = opt(list_op).parse_next(input)?;
174        stmts.push(Stmt { pipeline: pl, op });
175        // Drain any heredoc bodies pending from this statement before
176        // the next pipeline starts; otherwise the body would be parsed
177        // as the next statement (which would either misvalidate or
178        // misalign the line counter).
179        drain_pending_heredocs(input);
180        if op.is_none() {
181            break;
182        }
183        sep.parse_next(input)?;
184    }
185    Ok(Script(stmts))
186}
187
188fn list_op(input: &mut &str) -> ModalResult<ListOp> {
189    ws.parse_next(input)?;
190    alt((
191        "&&".value(ListOp::And),
192        "||".value(ListOp::Or),
193        '\n'.value(ListOp::Semi),
194        ';'.value(ListOp::Semi),
195        ('&', not('>')).value(ListOp::Amp),
196    ))
197    .parse_next(input)
198}
199
200fn pipe_sep(input: &mut &str) -> ModalResult<()> {
201    (ws, '|', not('|'), ws).void().parse_next(input)
202}
203
204// === Pipeline ===
205
206fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
207    ws.parse_next(input)?;
208    if at_script_stop(input) {
209        return backtrack();
210    }
211    let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
212    let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
213    Ok(Pipeline { bang, commands })
214}
215
216// === Command ===
217
218fn command(input: &mut &str) -> ModalResult<Cmd> {
219    ws.parse_next(input)?;
220    if at_script_stop(input) {
221        return backtrack();
222    }
223    alt((
224        subshell,
225        brace_group,
226        for_cmd,
227        while_cmd,
228        until_cmd,
229        if_cmd,
230        double_bracket_cmd,
231        function_def,
232        simple_cmd.map(Cmd::Simple),
233    ))
234    .parse_next(input)
235}
236
237/// `name() { body }` / `name() ( body )` (POSIX form) or `function name [()] { body }` (bash form).
238/// Tried before `simple_cmd`; a bare `name` with no `()` and no `function` keyword backtracks so an
239/// ordinary command is not misread as a definition.
240fn function_def(input: &mut &str) -> ModalResult<Cmd> {
241    let had_keyword = opt_function_keyword(input);
242    let name = function_name(input)?;
243    ws.parse_next(input)?;
244    let has_parens = opt_paren_pair(input);
245    if !had_keyword && !has_parens {
246        return backtrack();
247    }
248    // The body may sit on the next line (`foo()\n{ … }`); consume ws/newlines but not `;`.
249    take_while(0.., [' ', '\t', '\n']).void().parse_next(input)?;
250    let body = function_body(input)?;
251    Ok(Cmd::FunctionDef { name, body })
252}
253
254/// Consume a leading `function` keyword (must be followed by whitespace, else it's a command named
255/// `function`). Returns whether it was present; only commits `*input` when it was.
256fn opt_function_keyword(input: &mut &str) -> bool {
257    let mut probe = *input;
258    if eat_keyword(&mut probe, "function").is_ok()
259        && probe.starts_with([' ', '\t', '\n'])
260        && ws.parse_next(&mut probe).is_ok()
261    {
262        *input = probe;
263        return true;
264    }
265    false
266}
267
268/// Consume a `(` ws `)` function-def paren pair. Commits `*input` only on a full match.
269fn opt_paren_pair(input: &mut &str) -> bool {
270    let mut probe = *input;
271    if let Some(rest) = probe.strip_prefix('(') {
272        probe = rest;
273        if ws.parse_next(&mut probe).is_ok()
274            && let Some(rest) = probe.strip_prefix(')')
275        {
276            *input = rest;
277            return true;
278        }
279    }
280    false
281}
282
283fn function_name(input: &mut &str) -> ModalResult<String> {
284    take_while(1.., |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+'))
285        .map(|s: &str| s.to_string())
286        .parse_next(input)
287}
288
289/// The compound body of a function — `{ …; }` or `( … )`. Redirects attached to the definition
290/// itself (rare) are dropped, which is conservative for a construct classified Inert anyway.
291fn function_body(input: &mut &str) -> ModalResult<Script> {
292    if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
293        return Ok(body);
294    }
295    if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
296        return Ok(body);
297    }
298    backtrack()
299}
300
301fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
302    let mut redirs = Vec::new();
303    loop {
304        ws.parse_next(input)?;
305        if let Some(r) = opt(redirect).parse_next(input)? {
306            redirs.push(r);
307        } else {
308            break;
309        }
310    }
311    Ok(redirs)
312}
313
314fn subshell(input: &mut &str) -> ModalResult<Cmd> {
315    let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
316    let redirs = trailing_redirs(input)?;
317    Ok(Cmd::Subshell { body, redirs })
318}
319
320fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
321    if !input.starts_with('{') {
322        return backtrack();
323    }
324    if !input
325        .as_bytes()
326        .get(1)
327        .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
328    {
329        return backtrack();
330    }
331    *input = &input[1..];
332    sep.parse_next(input)?;
333    let body = script.parse_next(input)?;
334    if body.0.is_empty() {
335        return backtrack();
336    }
337    sep.parse_next(input)?;
338    if !input.starts_with('}') {
339        return backtrack();
340    }
341    let last_op = body.0.last().and_then(|s| s.op);
342    if last_op.is_none() {
343        return backtrack();
344    }
345    *input = &input[1..];
346    let redirs = trailing_redirs(input)?;
347    Ok(Cmd::BraceGroup { body, redirs })
348}
349
350// === Simple Command ===
351
352fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
353    let env: Vec<(String, Word)> =
354        repeat(0.., terminated(assignment, ws)).parse_next(input)?;
355    let mut words = Vec::new();
356    let mut redirs = Vec::new();
357
358    loop {
359        ws.parse_next(input)?;
360        if at_cmd_end(input) {
361            break;
362        }
363        if let Some(r) = opt(redirect).parse_next(input)? {
364            redirs.push(r);
365        } else if let Some(w) = opt(word).parse_next(input)? {
366            words.push(w);
367        } else {
368            break;
369        }
370    }
371
372    if env.is_empty() && words.is_empty() && redirs.is_empty() {
373        return backtrack();
374    }
375    Ok(SimpleCmd { env, words, redirs })
376}
377
378fn at_cmd_end(input: &str) -> bool {
379    input.is_empty()
380        || matches!(
381            input.as_bytes().first(),
382            Some(b'\n' | b';' | b'|' | b'&' | b')')
383        )
384}
385
386fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
387    let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
388        .parse_next(input)?;
389    '='.parse_next(input)?;
390    let value = opt(word)
391        .parse_next(input)?
392        .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
393    Ok((n.to_string(), value))
394}
395
396// === Redirect ===
397
398fn redirect(input: &mut &str) -> ModalResult<Redir> {
399    let fd = opt(fd_prefix).parse_next(input)?;
400    alt((
401        preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
402        heredoc,
403        preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
404            fd: fd.unwrap_or(1),
405            target,
406            append: true,
407        }),
408        preceded(">&", fd_target).map(move |dst| Redir::DupFd {
409            src: fd.unwrap_or(1),
410            dst,
411        }),
412        preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
413            fd: fd.unwrap_or(1),
414            target,
415            append: false,
416        }),
417        preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
418            fd: fd.unwrap_or(0),
419            target,
420        }),
421    ))
422    .parse_next(input)
423}
424
425fn heredoc(input: &mut &str) -> ModalResult<Redir> {
426    "<<".parse_next(input)?;
427    let strip_tabs = opt('-').parse_next(input)?.is_some();
428    ws.parse_next(input)?;
429    let delimiter = heredoc_delimiter.parse_next(input)?;
430    // Bash semantics: the heredoc body lives on lines AFTER the
431    // command line is finished, not immediately after `<<DELIM`. The
432    // command line can continue with more redirects, a pipe, etc.
433    // Push the delimiter onto a thread-local queue; the body is
434    // drained at the next `\n`/`;` separator by drain_pending_heredocs.
435    PENDING_HEREDOCS.with(|q| {
436        q.borrow_mut().push(PendingHeredoc {
437            delimiter: delimiter.clone(),
438            strip_tabs,
439        });
440    });
441    Ok(Redir::HereDoc { delimiter, strip_tabs })
442}
443
444#[derive(Debug, Clone)]
445struct PendingHeredoc {
446    delimiter: String,
447    strip_tabs: bool,
448}
449
450thread_local! {
451    static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
452        const { std::cell::RefCell::new(Vec::new()) };
453}
454
455fn drain_pending_heredocs(input: &mut &str) {
456    let pending: Vec<PendingHeredoc> =
457        PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
458    for h in pending {
459        if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
460            // Couldn't find the matching delimiter line. Leave input
461            // as-is; the parser will likely fail on the leftover body
462            // text, which is the safe outcome (we deny on parse fail).
463            return;
464        }
465    }
466}
467
468fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
469    let s = *input;
470    let bytes = s.as_bytes();
471    let mut line_start = 0;
472    while line_start <= bytes.len() {
473        let line_end = match s[line_start..].find('\n') {
474            Some(rel) => line_start + rel,
475            None => bytes.len(),
476        };
477        let line_bytes = &bytes[line_start..line_end];
478        let line = if strip_tabs {
479            std::str::from_utf8(line_bytes)
480                .unwrap_or("")
481                .trim_start_matches('\t')
482        } else {
483            std::str::from_utf8(line_bytes).unwrap_or("")
484        };
485        if line == delimiter {
486            // Advance past the delimiter line + its newline.
487            let advance = line_end + usize::from(line_end < bytes.len());
488            *input = &s[advance..];
489            return true;
490        }
491        if line_end >= bytes.len() {
492            return false;
493        }
494        line_start = line_end + 1;
495    }
496    false
497}
498
499fn reset_heredoc_queue() {
500    PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
501}
502
503fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
504    alt((
505        delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
506        delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
507        take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
508    ))
509    .parse_next(input)
510}
511
512fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
513    let b = input.as_bytes();
514    if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
515        let d = (b[0] - b'0') as u32;
516        *input = &input[1..];
517        Ok(d)
518    } else {
519        backtrack()
520    }
521}
522
523fn fd_target(input: &mut &str) -> ModalResult<String> {
524    alt((
525        '-'.value("-".to_string()),
526        take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
527    ))
528    .parse_next(input)
529}
530
531// === Word ===
532
533fn word(input: &mut &str) -> ModalResult<Word> {
534    repeat(1.., word_part)
535        .map(Word)
536        .parse_next(input)
537}
538
539fn word_part(input: &mut &str) -> ModalResult<WordPart> {
540    if input.is_empty() {
541        return backtrack();
542    }
543    if input.starts_with("<(") || input.starts_with(">(") {
544        return proc_sub(input);
545    }
546    if is_word_boundary(input.as_bytes()[0] as char) {
547        return backtrack();
548    }
549    alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
550        .parse_next(input)
551}
552
553fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
554    delimited('\'', take_while(0.., |c| c != '\''), '\'')
555        .map(|s: &str| WordPart::SQuote(s.to_string()))
556        .parse_next(input)
557}
558
559fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
560    delimited('"', repeat(0.., dq_part).map(Word), '"')
561        .map(WordPart::DQuote)
562        .parse_next(input)
563}
564
565/// Byte offset of the `)` that closes a substitution body starting at `body[0]` — the first `)` at
566/// paren-depth zero — or `None` if it is never closed. Quote (`'…'`, `"…"`), backtick, and backslash
567/// spans are skipped so a `)` inside them does not count, mirroring how the grammar's own
568/// `single_quoted`/`double_quoted`/`backtick`/`escaped` parsers treat those regions. This is what
569/// keeps `cmd_sub`/`proc_sub` linear: the interior is parsed only once, over a bounded slice, instead
570/// of the old `delimited(script, ')')` shape that recursed into the tail BEFORE knowing a close even
571/// existed — the source of the `a$(a<(a` × N exponential.
572fn find_sub_close(body: &str) -> Option<usize> {
573    let b = body.as_bytes();
574    let mut i = 0;
575    let mut depth: usize = 0;
576    while i < b.len() {
577        match b[i] {
578            b'\\' => i += 1, // escape: skip the next byte too (the trailing `+= 1` handles it)
579            b'\'' => {
580                i += 1;
581                while i < b.len() && b[i] != b'\'' {
582                    i += 1;
583                }
584                if i >= b.len() {
585                    return None;
586                }
587            }
588            b'"' => {
589                i += 1;
590                while i < b.len() && b[i] != b'"' {
591                    i += if b[i] == b'\\' { 2 } else { 1 };
592                }
593                if i >= b.len() {
594                    return None;
595                }
596            }
597            b'`' => {
598                i += 1;
599                while i < b.len() && b[i] != b'`' {
600                    // `bt_escape` treats `\<any>` inside backticks as a literal, so an escaped
601                    // backtick does NOT close the span — skip the escaped byte too.
602                    i += if b[i] == b'\\' { 2 } else { 1 };
603                }
604                if i >= b.len() {
605                    return None;
606                }
607            }
608            b'(' => depth += 1,
609            b')' => {
610                if depth == 0 {
611                    return Some(i);
612                }
613                depth -= 1;
614            }
615            _ => {}
616        }
617        i += 1;
618    }
619    None
620}
621
622/// Parse a substitution body (`$( … )`, `<( … )`, `>( … )`) as a full script.
623///
624/// FAST PATH: `find_sub_close` locates the matching `)` and we parse only the bounded interior, so
625/// nested substitutions stay linear instead of the old `delimited(script, ')')` shape that recursed
626/// into the tail before knowing a close existed (the `a$(a<(a` × N exponential).
627///
628/// FALLBACK: a few grammar constructs move the real close PAST that first balanced `)` — chiefly a
629/// heredoc body, whose text (including any `)`) is consumed out-of-band by `drain_pending_heredocs`
630/// and which `find_sub_close` does not model. When the bounded interior does not parse cleanly we
631/// re-run the EXACT old grammar over the full body, preserving classification for those inputs. The
632/// fallback is the recursive shape, but the per-parse work budget (`MAX_PARSE_WORK_*`) bounds it, so
633/// it cannot reintroduce the hang. A `None` from `find_sub_close` means no unquoted `)` exists at all
634/// — the grammar could not close the sub either — so we fail fast without the fallback.
635fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
636    let body = &input[open_len..];
637    let Some(rel) = find_sub_close(body) else {
638        return backtrack();
639    };
640    // A heredoc body is drained out-of-band (`drain_pending_heredocs`) and can run PAST `rel`, so the
641    // bounded interior would be truncated mid-heredoc and still parse "clean" — the fast path is
642    // unreliable whenever the interior holds a heredoc operator. Skip straight to the grammar fallback
643    // there. `<<` covers `<<`, `<<-`, and `<<<`; the latter (herestring) is inline and would be fine,
644    // but taking the fallback for it is merely slower, never wrong.
645    let interior = &body[..rel];
646    if !interior.contains("<<") {
647        let mut fast: &str = interior;
648        if let Ok(parsed) = script.parse_next(&mut fast) {
649            ws.parse_next(&mut fast)?;
650            if fast.is_empty() {
651                *input = &body[rel + 1..];
652                return Ok(parsed);
653            }
654        }
655    }
656    let mut rest: &str = body;
657    ws.parse_next(&mut rest)?;
658    let parsed = script.parse_next(&mut rest)?;
659    ws.parse_next(&mut rest)?;
660    if !rest.starts_with(')') {
661        return backtrack();
662    }
663    *input = &rest[1..];
664    Ok(parsed)
665}
666
667fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
668    if !input.starts_with("$(") {
669        return backtrack();
670    }
671    sub_body(input, 2).map(WordPart::CmdSub)
672}
673
674fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
675    if !(input.starts_with("<(") || input.starts_with(">(")) {
676        return backtrack();
677    }
678    sub_body(input, 2).map(WordPart::ProcSub)
679}
680
681fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
682    if !input.starts_with("$((") {
683        return backtrack();
684    }
685    let body_start = 3;
686    let bytes = input.as_bytes();
687    let mut depth: i32 = 1;
688    let mut i = body_start;
689    while i < bytes.len() {
690        match bytes[i] {
691            b'(' => depth += 1,
692            b')' => {
693                if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
694                    let body = input[body_start..i].to_string();
695                    if body.contains("$(") || body.contains('`') {
696                        return backtrack();
697                    }
698                    *input = &input[i + 2..];
699                    return Ok(WordPart::Arith(body));
700                }
701                depth -= 1;
702                if depth < 0 {
703                    return backtrack();
704                }
705            }
706            _ => {}
707        }
708        i += 1;
709    }
710    backtrack()
711}
712
713fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
714    delimited('`', backtick_inner, '`')
715        .map(WordPart::Backtick)
716        .parse_next(input)
717}
718
719fn escaped(input: &mut &str) -> ModalResult<WordPart> {
720    preceded('\\', any).map(WordPart::Escape).parse_next(input)
721}
722
723fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
724    move |input: &mut &str| {
725        take_while(1.., pred)
726            .map(|s: &str| WordPart::Lit(s.to_string()))
727            .parse_next(input)
728    }
729}
730
731fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
732    move |input: &mut &str| {
733        ('$', not('(')).void().parse_next(input)?;
734        let rest: &str = take_while(0.., pred).parse_next(input)?;
735        Ok(WordPart::Lit(format!("${rest}")))
736    }
737}
738
739// === Double-quoted parts ===
740
741fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
742    if input.is_empty() || input.starts_with('"') {
743        return backtrack();
744    }
745    alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
746        .parse_next(input)
747}
748
749fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
750    preceded('\\', any)
751        .map(|c: char| match c {
752            '"' | '\\' | '$' | '`' => WordPart::Escape(c),
753            _ => WordPart::Lit(format!("\\{c}")),
754        })
755        .parse_next(input)
756}
757
758// === Backtick inner content ===
759
760fn backtick_inner(input: &mut &str) -> ModalResult<String> {
761    repeat(0.., alt((bt_escape, bt_literal)))
762        .fold(String::new, |mut acc, chunk: &str| {
763            acc.push_str(chunk);
764            acc
765        })
766        .parse_next(input)
767}
768
769fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
770    ('\\', any).take().parse_next(input)
771}
772
773fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
774    take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
775}
776
777// === Compound Commands ===
778
779fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
780    eat_keyword(input, "for")?;
781    ws.parse_next(input)?;
782    let var = name.parse_next(input)?;
783    ws.parse_next(input)?;
784
785    let items = if eat_keyword(input, "in").is_ok() {
786        ws.parse_next(input)?;
787        repeat(0.., terminated(word, ws)).parse_next(input)?
788    } else {
789        vec![]
790    };
791
792    let body = do_done_body.parse_next(input)?;
793    let redirs = trailing_redirs(input)?;
794    Ok(Cmd::For { var, items, body, redirs })
795}
796
797fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
798    eat_keyword(input, "while")?;
799    ws.parse_next(input)?;
800    let cond = script.parse_next(input)?;
801    let body = do_done_body.parse_next(input)?;
802    let redirs = trailing_redirs(input)?;
803    Ok(Cmd::While { cond, body, redirs })
804}
805
806fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
807    eat_keyword(input, "until")?;
808    ws.parse_next(input)?;
809    let cond = script.parse_next(input)?;
810    let body = do_done_body.parse_next(input)?;
811    let redirs = trailing_redirs(input)?;
812    Ok(Cmd::Until { cond, body, redirs })
813}
814
815fn do_done_body(input: &mut &str) -> ModalResult<Script> {
816    sep.parse_next(input)?;
817    eat_keyword(input, "do")?;
818    sep.parse_next(input)?;
819    let body = script.parse_next(input)?;
820    sep.parse_next(input)?;
821    eat_keyword(input, "done")?;
822    Ok(body)
823}
824
825fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
826    eat_keyword(input, "if")?;
827    ws.parse_next(input)?;
828    let mut branches = vec![cond_then_body.parse_next(input)?];
829    let mut else_body = None;
830
831    loop {
832        sep.parse_next(input)?;
833        if eat_keyword(input, "elif").is_ok() {
834            ws.parse_next(input)?;
835            branches.push(cond_then_body.parse_next(input)?);
836        } else if eat_keyword(input, "else").is_ok() {
837            sep.parse_next(input)?;
838            else_body = Some(script.parse_next(input)?);
839            break;
840        } else {
841            break;
842        }
843    }
844
845    sep.parse_next(input)?;
846    eat_keyword(input, "fi")?;
847    let redirs = trailing_redirs(input)?;
848    Ok(Cmd::If { branches, else_body, redirs })
849}
850
851fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
852    let cond = script.parse_next(input)?;
853    sep.parse_next(input)?;
854    eat_keyword(input, "then")?;
855    sep.parse_next(input)?;
856    let body = script.parse_next(input)?;
857    Ok(Branch { cond, body })
858}
859
860fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
861    if !input.starts_with("[[") {
862        return backtrack();
863    }
864    let bytes = input.as_bytes();
865    if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
866        return backtrack();
867    }
868    *input = &input[2..];
869
870    let mut words: Vec<Word> = Vec::new();
871    loop {
872        ws.parse_next(input)?;
873        if at_double_bracket_end(input) {
874            *input = &input[2..];
875            let redirs = trailing_redirs(input)?;
876            return Ok(Cmd::DoubleBracket { words, redirs });
877        }
878        if input.is_empty() {
879            return backtrack();
880        }
881        let w = bracket_word.parse_next(input)?;
882        words.push(w);
883    }
884}
885
886fn at_double_bracket_end(input: &str) -> bool {
887    if !input.starts_with("]]") {
888        return false;
889    }
890    let after = &input[2..];
891    after.is_empty()
892        || after.starts_with(|c: char| {
893            matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
894        })
895}
896
897fn bracket_word(input: &mut &str) -> ModalResult<Word> {
898    repeat(1.., bracket_word_part).map(Word).parse_next(input)
899}
900
901fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
902    if input.is_empty() {
903        return backtrack();
904    }
905    if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
906        return backtrack();
907    }
908    if at_double_bracket_end(input) {
909        return backtrack();
910    }
911    alt((
912        single_quoted,
913        double_quoted,
914        arith_sub,
915        cmd_sub,
916        backtick_part,
917        escaped,
918        dollar_lit(is_bracket_literal),
919        bracket_lit,
920    ))
921    .parse_next(input)
922}
923
924fn is_bracket_literal(c: char) -> bool {
925    !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
926}
927
928fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
929    // Byte-by-byte scan relies on every stop char being single-byte ASCII —
930    // multibyte UTF-8 continuation bytes always pass `is_bracket_literal` and
931    // get consumed as part of the same `Lit`, so `end` only lands on a char
932    // boundary.
933    let bytes = input.as_bytes();
934    let mut end = 0;
935    while end < bytes.len() {
936        let c = bytes[end] as char;
937        if !is_bracket_literal(c) {
938            break;
939        }
940        if c == ']' && at_double_bracket_end(&input[end..]) {
941            break;
942        }
943        end += 1;
944    }
945    if end == 0 {
946        return backtrack();
947    }
948    let lit = input[..end].to_string();
949    *input = &input[end..];
950    Ok(WordPart::Lit(lit))
951}
952
953fn name(input: &mut &str) -> ModalResult<String> {
954    take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
955        .map(|s: &str| s.to_string())
956        .parse_next(input)
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    fn p(input: &str) -> Script {
964        parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
965    }
966
967    fn words(script: &Script) -> Vec<String> {
968        match &script.0[0].pipeline.commands[0] {
969            Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
970            _ => panic!("expected simple command"),
971        }
972    }
973
974    fn simple(script: &Script) -> &SimpleCmd {
975        match &script.0[0].pipeline.commands[0] {
976            Cmd::Simple(s) => s,
977            _ => panic!("expected simple command"),
978        }
979    }
980
981    #[test]
982    fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
983    #[test]
984    fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
985    #[test]
986    fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
987    #[test]
988    fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
989    #[test]
990    fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
991
992    #[test]
993    fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
994    #[test]
995    fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
996    #[test]
997    fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
998    #[test]
999    fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
1000    #[test]
1001    fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
1002    #[test]
1003    fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
1004    #[test]
1005    fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n   \necho bar").0.len(), 2); }
1006    #[test]
1007    fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
1008    #[test]
1009    fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
1010    #[test]
1011    fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
1012
1013    #[test]
1014    fn brace_group_simple() {
1015        assert!(matches!(
1016            &p("{ echo hello; }").0[0].pipeline.commands[0],
1017            Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1018        ));
1019    }
1020    #[test]
1021    fn brace_group_multiple_stmts() {
1022        if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1023            assert_eq!(body.0.len(), 3);
1024        } else { panic!("expected BraceGroup"); }
1025    }
1026    #[test]
1027    fn brace_group_with_redirect() {
1028        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1029            assert_eq!(redirs.len(), 1);
1030            assert!(matches!(redirs[0], Redir::Write { .. }));
1031        } else { panic!("expected BraceGroup"); }
1032    }
1033    #[test]
1034    fn brace_group_with_append_redirect() {
1035        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1036            assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
1037        } else { panic!("expected BraceGroup"); }
1038    }
1039    #[test]
1040    fn brace_group_with_stderr_redirect() {
1041        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1042            assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1043        } else { panic!("expected BraceGroup"); }
1044    }
1045    #[test]
1046    fn brace_group_newline_separated() {
1047        if let Cmd::BraceGroup { body, .. } = &p("{\n  echo a\n  echo b\n}").0[0].pipeline.commands[0] {
1048            assert_eq!(body.0.len(), 2);
1049        } else { panic!("expected BraceGroup"); }
1050    }
1051    #[test]
1052    fn brace_group_in_pipeline() {
1053        let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1054        assert_eq!(pl.commands.len(), 2);
1055        assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1056    }
1057    #[test]
1058    fn brace_group_followed_by_other() {
1059        let stmts = &p("{ echo a; }; echo b").0;
1060        assert_eq!(stmts.len(), 2);
1061        assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1062    }
1063    #[test]
1064    fn brace_group_nested() {
1065        if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1066            assert_eq!(body.0.len(), 2);
1067            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1068        } else { panic!("expected outer BraceGroup"); }
1069    }
1070    #[test]
1071    fn brace_group_with_subshell_inside() {
1072        if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1073            assert_eq!(body.0.len(), 2);
1074            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1075        } else { panic!("expected BraceGroup"); }
1076    }
1077    #[test]
1078    fn brace_open_requires_whitespace() {
1079        // {echo (no space) is NOT a brace group; it's a literal word
1080        // that becomes part of a simple command. Parser should not
1081        // treat it as a brace group.
1082        let cmds = &p("{echo a}").0;
1083        // Either parsed as a simple_cmd with a literal `{echo` token,
1084        // or fails. Either way, it should NOT be a BraceGroup.
1085        if !cmds.is_empty() {
1086            assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1087        }
1088    }
1089    #[test]
1090    fn subshell_with_redirect() {
1091        if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1092            assert_eq!(redirs.len(), 1);
1093        } else { panic!("expected Subshell with redir"); }
1094    }
1095    #[test]
1096    fn for_loop_with_redirect() {
1097        if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1098            assert_eq!(redirs.len(), 1);
1099        } else { panic!("expected For with redir"); }
1100    }
1101    #[test]
1102    fn for_loop_redirect_then_pipe() {
1103        // `done 2>&1 | head` — redirect on the loop, then a pipe.
1104        let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1105        assert_eq!(pl.commands.len(), 2);
1106        assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1107    }
1108    #[test]
1109    fn while_and_if_with_redirect() {
1110        assert!(matches!(
1111            &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1112            Cmd::While { redirs, .. } if redirs.len() == 1
1113        ));
1114        assert!(matches!(
1115            &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1116            Cmd::If { redirs, .. } if redirs.len() == 1
1117        ));
1118    }
1119    #[test]
1120    fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1121
1122    #[test]
1123    fn redirect_dev_null() {
1124        let s = p("echo hello > /dev/null");
1125        let cmd = simple(&s);
1126        assert_eq!(cmd.words.len(), 2);
1127        assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
1128    }
1129    #[test]
1130    fn redirect_stderr() {
1131        assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1132    }
1133    #[test]
1134    fn here_string() {
1135        assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1136    }
1137    #[test]
1138    fn heredoc_bare() {
1139        assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
1140    }
1141    #[test]
1142    fn heredoc_with_content() {
1143        let s = p("cat <<EOF\nhello world\nEOF");
1144        assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1145    }
1146    #[test]
1147    fn heredoc_quoted_delimiter() {
1148        assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1149    }
1150    #[test]
1151    fn heredoc_strip_tabs() {
1152        assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1153    }
1154    #[test]
1155    fn heredoc_pipe_on_command_line() {
1156        // Correct bash: pipe is on the command line BEFORE the body,
1157        // body terminator is on its own line.
1158        let s = p("cat <<EOF | grep hello\nhello\nEOF");
1159        assert_eq!(s.0[0].pipeline.commands.len(), 2);
1160    }
1161    #[test]
1162    fn heredoc_body_does_not_swallow_pipe() {
1163        // Regression for the `cat <<EOF | bash\n...\nEOF` bypass: the
1164        // heredoc parser must NOT consume the pipe + downstream
1165        // commands as part of the body.
1166        let s = p("cat <<EOF | bash\nrm\nEOF");
1167        assert_eq!(
1168            s.0[0].pipeline.commands.len(),
1169            2,
1170            "pipeline must keep `bash` as a second command"
1171        );
1172    }
1173    #[test]
1174    fn heredoc_followed_by_next_statement() {
1175        // After the heredoc body terminator, the script can continue
1176        // with another statement.
1177        let s = p("cat <<EOF\nhello\nEOF\nls");
1178        assert_eq!(s.0.len(), 2);
1179    }
1180
1181    #[test]
1182    fn env_prefix() {
1183        let s = p("FOO='bar baz' ls -la");
1184        let cmd = simple(&s);
1185        assert_eq!(cmd.env[0].0, "FOO");
1186        assert_eq!(cmd.env[0].1.eval(), "bar baz");
1187    }
1188    #[test]
1189    fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1190    #[test]
1191    fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__"); }
1192    #[test]
1193    fn nested_substitution() {
1194        if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1195            assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1196        } else { panic!("expected CmdSub"); }
1197    }
1198
1199    #[test]
1200    fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1201    #[test]
1202    fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1203
1204    #[test]
1205    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")); }
1206    #[test]
1207    fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1208    #[test]
1209    fn if_then_fi() {
1210        if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1211            assert_eq!(branches.len(), 1);
1212            assert!(else_body.is_none());
1213        } else { panic!("expected If"); }
1214    }
1215    #[test]
1216    fn if_elif_else() {
1217        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] {
1218            assert_eq!(branches.len(), 2);
1219            assert!(else_body.is_some());
1220        } else { panic!("expected If"); }
1221    }
1222
1223    #[test]
1224    fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1225    #[test]
1226    fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1227    #[test]
1228    fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1229
1230    #[test]
1231    fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1232    #[test]
1233    fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1234    #[test]
1235    fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1236    #[test]
1237    fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1238    #[test]
1239    fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1240    #[test]
1241    fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1242
1243    #[test]
1244    fn subshell_for() {
1245        if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1246            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1247        } else { panic!("expected Subshell"); }
1248    }
1249    #[test]
1250    fn proc_sub_input() {
1251        let s = p("diff <(sort a.txt) <(sort b.txt)");
1252        let cmd = simple(&s);
1253        assert_eq!(cmd.words.len(), 3);
1254        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1255        assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1256    }
1257    #[test]
1258    fn proc_sub_output() {
1259        let s = p("tee >(grep error > /dev/null)");
1260        let cmd = simple(&s);
1261        assert_eq!(cmd.words.len(), 2);
1262        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1263    }
1264
1265    // === Function definitions ===
1266    fn func_def(s: &Script) -> (&str, &Script) {
1267        match &s.0[0].pipeline.commands[0] {
1268            Cmd::FunctionDef { name, body } => (name.as_str(), body),
1269            other => panic!("expected FunctionDef, got {other:?}"),
1270        }
1271    }
1272    #[test]
1273    fn function_def_posix_form() {
1274        let s = p("probe(){ echo hi; }");
1275        let (name, body) = func_def(&s);
1276        assert_eq!(name, "probe");
1277        assert_eq!(words(body), ["echo", "hi"]);
1278    }
1279    #[test]
1280    fn function_def_spaced_and_keyword_forms() {
1281        assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1282        assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1283        assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1284    }
1285    #[test]
1286    fn function_def_subshell_body() {
1287        let s = p("foo() ( echo sub )");
1288        assert_eq!(func_def(&s).0, "foo");
1289    }
1290    #[test]
1291    fn function_def_body_on_next_line() {
1292        assert_eq!(func_def(&p("foo()\n{\n  echo hi\n}")).0, "foo");
1293    }
1294    #[test]
1295    fn function_def_name_with_dashes_and_dots() {
1296        assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1297    }
1298    #[test]
1299    fn plain_command_is_not_a_function_def() {
1300        // A bare `name` with no `()` must NOT be read as a definition.
1301        assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1302        assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1303    }
1304    #[test]
1305    fn function_def_roundtrips() {
1306        let rendered = p("greet(){ echo hi; }").to_string();
1307        assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1308        assert_eq!(func_def(&p(&rendered)).0, "greet");
1309    }
1310    #[test]
1311    fn comment_only() {
1312        let s = p("# just a comment");
1313        assert!(s.0.is_empty());
1314    }
1315    #[test]
1316    fn comment_before_command() {
1317        let s = p("# comment\necho hello");
1318        assert_eq!(words(&s), ["echo", "hello"]);
1319    }
1320    #[test]
1321    fn inline_comment() {
1322        let s = p("echo hello # this is a comment");
1323        assert_eq!(words(&s), ["echo", "hello"]);
1324    }
1325    #[test]
1326    fn comment_between_commands() {
1327        let s = p("echo hello\n# middle comment\necho world");
1328        assert_eq!(s.0.len(), 2);
1329    }
1330    #[test]
1331    fn comment_after_semicolon() {
1332        let s = p("echo hello; # comment\necho world");
1333        assert_eq!(s.0.len(), 2);
1334    }
1335    #[test]
1336    fn comment_in_for_loop() {
1337        assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1338    }
1339    #[test]
1340    fn quoted_redirect_in_echo() {
1341        let s = p("echo 'greater > than' test");
1342        let cmd = simple(&s);
1343        assert_eq!(cmd.words.len(), 3);
1344        assert_eq!(cmd.redirs.len(), 0);
1345    }
1346
1347    #[test]
1348    fn parses_all_safe_commands() {
1349        let cmds = [
1350            "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1351            "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1352            "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1353            "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1354            "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1355            "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1356            "((echo hello))", "(for x in 1 2; do echo $x; done)",
1357            "echo 'greater > than' test", "echo '$(safe)' arg",
1358            "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1359            "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1360            "grep foo file.txt | head -5", "cat file | sort | uniq",
1361            "ls && echo done", "ls; echo done", "ls & echo done",
1362            "grep -c , <<< 'hello,world,test'",
1363            "cat <<EOF\nhello world\nEOF",
1364            "cat <<'MARKER'\nsome text\nMARKER",
1365            "cat <<-EOF\n\thello\nEOF",
1366            "echo foo\necho bar", "ls\ncat file.txt",
1367            "git log --oneline -20 | head -5",
1368            "echo hello > /dev/null", "echo hello 2> /dev/null",
1369            "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1370            "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1371            "for x in 1 2 3; do echo $x; done",
1372            "for f in *.txt; do cat $f | grep pattern; done",
1373            "for x in 1 2 3; do; done",
1374            "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1375            "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1376            "for x in 1 2; do echo $x; done && echo finished",
1377            "for x in $(seq 1 5); do echo $x; done",
1378            "while test -f /tmp/foo; do sleep 1; done",
1379            "while ! test -f /tmp/done; do sleep 1; done",
1380            "until test -f /tmp/ready; do sleep 1; done",
1381            "if test -f foo; then echo exists; fi",
1382            "if test -f foo; then echo yes; else echo no; fi",
1383            "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1384            "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1385            "if true; then for x in 1 2; do echo $x; done; fi",
1386            "diff <(sort a.txt) <(sort b.txt)",
1387            "comm -23 file.txt <(sort other.txt)",
1388            "cat <(echo hello)",
1389            "# comment only",
1390            "# comment\necho hello",
1391            "echo hello # inline comment",
1392            "echo one\n# between\necho two",
1393            "! echo hello", "! test -f foo",
1394            "echo for; echo done; echo if; echo fi",
1395        ];
1396        let mut failures = Vec::new();
1397        for cmd in &cmds {
1398            if parse(cmd).is_none() { failures.push(*cmd); }
1399        }
1400        assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1401    }
1402
1403    // === Balanced-scan divergence guards ===
1404    // `cmd_sub`/`proc_sub` find their closing `)` with `find_sub_close`, then parse the bounded
1405    // interior. The `roundtrip` proptest exercises this, but its `arb_shell_word` is paren-free, so
1406    // these lock the case it can't reach: a `)` inside a quote / escape / backtick / nested sub must
1407    // NOT be mistaken for the substitution's own close.
1408    fn inner_sub(s: &Script) -> &Script {
1409        match &simple(s).words[1].0[0] {
1410            WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1411            other => panic!("expected a substitution, got {other:?}"),
1412        }
1413    }
1414
1415    #[test]
1416    fn cmd_sub_squote_paren_is_not_close() {
1417        assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1418    }
1419    #[test]
1420    fn cmd_sub_dquote_paren_is_not_close() {
1421        assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1422    }
1423    #[test]
1424    fn cmd_sub_escaped_paren_is_not_close() {
1425        assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1426    }
1427    #[test]
1428    fn cmd_sub_backtick_paren_is_not_close() {
1429        // the ) lives inside a backtick span in the sub body; the real close is the final ).
1430        let s = p("echo $(x `)` y)");
1431        let inner = simple(inner_sub(&s));
1432        assert_eq!(inner.words.len(), 3);
1433        assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1434    }
1435    #[test]
1436    fn cmd_sub_escaped_backtick_does_not_end_span() {
1437        // The `\` escapes the next backtick, so the span — and the ) inside it — belong to the sub
1438        // body; the real close is the final ). Until find_sub_close honored backtick escapes it
1439        // mis-placed the span boundary and rejected this (a fail-closed divergence from the grammar).
1440        let s = p("echo $(`\\`)`)");
1441        assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1442    }
1443    #[test]
1444    fn proc_sub_squote_paren_is_not_close() {
1445        assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1446    }
1447    #[test]
1448    fn proc_sub_out_squote_paren_is_not_close() {
1449        assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1450    }
1451    #[test]
1452    fn cmd_sub_nested_picks_outer_close() {
1453        let s = p("echo $(a $(b) c)");
1454        let inner = simple(inner_sub(&s));
1455        assert_eq!(inner.words.len(), 3);
1456        assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1457    }
1458    #[test]
1459    fn cmd_sub_literal_after_close_stays_in_outer_word() {
1460        let s = p("echo $(ls)tail");
1461        let w = &simple(&s).words[1];
1462        assert_eq!(w.0.len(), 2);
1463        assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1464        assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1465    }
1466    #[test]
1467    fn cmd_sub_heredoc_body_paren_does_not_close() {
1468        // The ) sits in the heredoc body (drained out-of-band), so it must not close the sub — the
1469        // real close is the final ). find_sub_close can't see heredocs, so sub_body falls back to the
1470        // full grammar here. This parsed before the balanced-scan rewrite and must keep parsing.
1471        let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1472        assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1473    }
1474    #[test]
1475    fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1476        // the sole ) is single-quoted, so the sub never closes → whole parse fails (fail closed).
1477        assert!(parse("echo $(echo ')").is_none());
1478    }
1479    #[test]
1480    fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1481        assert!(parse("cat <(grep ')").is_none());
1482    }
1483}