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    script.parse(input).ok()
10}
11
12fn backtrack<T>() -> ModalResult<T> {
13    Err(ErrMode::Backtrack(ContextError::new()))
14}
15
16fn comment(input: &mut &str) -> ModalResult<()> {
17    if input.starts_with('#') {
18        if let Some(pos) = input.find('\n') {
19            *input = &input[pos + 1..];
20        } else {
21            *input = "";
22        }
23    }
24    Ok(())
25}
26
27fn ws(input: &mut &str) -> ModalResult<()> {
28    loop {
29        take_while(0.., [' ', '\t']).void().parse_next(input)?;
30        if input.starts_with('#') {
31            comment(input)?;
32        } else {
33            break;
34        }
35    }
36    Ok(())
37}
38
39fn sep(input: &mut &str) -> ModalResult<()> {
40    loop {
41        take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
42        if input.starts_with('#') {
43            comment(input)?;
44        } else {
45            break;
46        }
47    }
48    Ok(())
49}
50
51fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
52    if !input.starts_with(kw) {
53        return backtrack();
54    }
55    if input
56        .as_bytes()
57        .get(kw.len())
58        .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
59    {
60        return backtrack();
61    }
62    *input = &input[kw.len()..];
63    Ok(())
64}
65
66const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
67
68fn at_script_stop(input: &str) -> bool {
69    input.starts_with(')')
70        || input.starts_with('}')
71        || SCRIPT_STOPS.iter().any(|kw| {
72            input.starts_with(kw)
73                && !input
74                    .as_bytes()
75                    .get(kw.len())
76                    .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
77        })
78}
79
80fn is_word_boundary(c: char) -> bool {
81    matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
82}
83
84fn is_word_literal(c: char) -> bool {
85    !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
86}
87
88fn is_dq_literal(c: char) -> bool {
89    !matches!(c, '"' | '\\' | '`' | '$')
90}
91
92// === Script ===
93
94fn script(input: &mut &str) -> ModalResult<Script> {
95    sep.parse_next(input)?;
96    let mut stmts = Vec::new();
97    while let Some(pl) = opt(pipeline).parse_next(input)? {
98        ws.parse_next(input)?;
99        let op = opt(list_op).parse_next(input)?;
100        stmts.push(Stmt { pipeline: pl, op });
101        if op.is_none() {
102            break;
103        }
104        sep.parse_next(input)?;
105    }
106    Ok(Script(stmts))
107}
108
109fn list_op(input: &mut &str) -> ModalResult<ListOp> {
110    ws.parse_next(input)?;
111    alt((
112        "&&".value(ListOp::And),
113        "||".value(ListOp::Or),
114        '\n'.value(ListOp::Semi),
115        ';'.value(ListOp::Semi),
116        ('&', not('>')).value(ListOp::Amp),
117    ))
118    .parse_next(input)
119}
120
121fn pipe_sep(input: &mut &str) -> ModalResult<()> {
122    (ws, '|', not('|'), ws).void().parse_next(input)
123}
124
125// === Pipeline ===
126
127fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
128    ws.parse_next(input)?;
129    if at_script_stop(input) {
130        return backtrack();
131    }
132    let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
133    let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
134    Ok(Pipeline { bang, commands })
135}
136
137// === Command ===
138
139fn command(input: &mut &str) -> ModalResult<Cmd> {
140    ws.parse_next(input)?;
141    if at_script_stop(input) {
142        return backtrack();
143    }
144    alt((
145        subshell,
146        brace_group,
147        for_cmd,
148        while_cmd,
149        until_cmd,
150        if_cmd,
151        simple_cmd.map(Cmd::Simple),
152    ))
153    .parse_next(input)
154}
155
156fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
157    let mut redirs = Vec::new();
158    loop {
159        ws.parse_next(input)?;
160        if let Some(r) = opt(redirect).parse_next(input)? {
161            redirs.push(r);
162        } else {
163            break;
164        }
165    }
166    Ok(redirs)
167}
168
169fn subshell(input: &mut &str) -> ModalResult<Cmd> {
170    let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
171    let redirs = trailing_redirs(input)?;
172    Ok(Cmd::Subshell { body, redirs })
173}
174
175fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
176    if !input.starts_with('{') {
177        return backtrack();
178    }
179    if !input
180        .as_bytes()
181        .get(1)
182        .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
183    {
184        return backtrack();
185    }
186    *input = &input[1..];
187    sep.parse_next(input)?;
188    let body = script.parse_next(input)?;
189    if body.0.is_empty() {
190        return backtrack();
191    }
192    sep.parse_next(input)?;
193    if !input.starts_with('}') {
194        return backtrack();
195    }
196    let last_op = body.0.last().and_then(|s| s.op);
197    if last_op.is_none() {
198        return backtrack();
199    }
200    *input = &input[1..];
201    let redirs = trailing_redirs(input)?;
202    Ok(Cmd::BraceGroup { body, redirs })
203}
204
205// === Simple Command ===
206
207fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
208    let env: Vec<(String, Word)> =
209        repeat(0.., terminated(assignment, ws)).parse_next(input)?;
210    let mut words = Vec::new();
211    let mut redirs = Vec::new();
212
213    loop {
214        ws.parse_next(input)?;
215        if at_cmd_end(input) {
216            break;
217        }
218        if let Some(r) = opt(redirect).parse_next(input)? {
219            redirs.push(r);
220        } else if let Some(w) = opt(word).parse_next(input)? {
221            words.push(w);
222        } else {
223            break;
224        }
225    }
226
227    if env.is_empty() && words.is_empty() && redirs.is_empty() {
228        return backtrack();
229    }
230    Ok(SimpleCmd { env, words, redirs })
231}
232
233fn at_cmd_end(input: &str) -> bool {
234    input.is_empty()
235        || matches!(
236            input.as_bytes().first(),
237            Some(b'\n' | b';' | b'|' | b'&' | b')')
238        )
239}
240
241fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
242    let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
243        .parse_next(input)?;
244    '='.parse_next(input)?;
245    let value = opt(word)
246        .parse_next(input)?
247        .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
248    Ok((n.to_string(), value))
249}
250
251// === Redirect ===
252
253fn redirect(input: &mut &str) -> ModalResult<Redir> {
254    let fd = opt(fd_prefix).parse_next(input)?;
255    alt((
256        preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
257        heredoc,
258        preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
259            fd: fd.unwrap_or(1),
260            target,
261            append: true,
262        }),
263        preceded(">&", fd_target).map(move |dst| Redir::DupFd {
264            src: fd.unwrap_or(1),
265            dst,
266        }),
267        preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
268            fd: fd.unwrap_or(1),
269            target,
270            append: false,
271        }),
272        preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
273            fd: fd.unwrap_or(0),
274            target,
275        }),
276    ))
277    .parse_next(input)
278}
279
280fn heredoc(input: &mut &str) -> ModalResult<Redir> {
281    "<<".parse_next(input)?;
282    let strip_tabs = opt('-').parse_next(input)?.is_some();
283    ws.parse_next(input)?;
284    let delimiter = heredoc_delimiter.parse_next(input)?;
285    let needle = format!("\n{delimiter}");
286    if let Some(pos) = input.find(&needle) {
287        let after = pos + needle.len();
288        *input = input[after..].trim_start_matches([' ', '\t', '\n']);
289    }
290    Ok(Redir::HereDoc { delimiter, strip_tabs })
291}
292
293fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
294    alt((
295        delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
296        delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
297        take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
298    ))
299    .parse_next(input)
300}
301
302fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
303    let b = input.as_bytes();
304    if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
305        let d = (b[0] - b'0') as u32;
306        *input = &input[1..];
307        Ok(d)
308    } else {
309        backtrack()
310    }
311}
312
313fn fd_target(input: &mut &str) -> ModalResult<String> {
314    alt((
315        '-'.value("-".to_string()),
316        take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
317    ))
318    .parse_next(input)
319}
320
321// === Word ===
322
323fn word(input: &mut &str) -> ModalResult<Word> {
324    repeat(1.., word_part)
325        .map(Word)
326        .parse_next(input)
327}
328
329fn word_part(input: &mut &str) -> ModalResult<WordPart> {
330    if input.is_empty() {
331        return backtrack();
332    }
333    if input.starts_with("<(") || input.starts_with(">(") {
334        return proc_sub(input);
335    }
336    if is_word_boundary(input.as_bytes()[0] as char) {
337        return backtrack();
338    }
339    alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
340        .parse_next(input)
341}
342
343fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
344    delimited('\'', take_while(0.., |c| c != '\''), '\'')
345        .map(|s: &str| WordPart::SQuote(s.to_string()))
346        .parse_next(input)
347}
348
349fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
350    delimited('"', repeat(0.., dq_part).map(Word), '"')
351        .map(WordPart::DQuote)
352        .parse_next(input)
353}
354
355fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
356    delimited(("$(", ws), script, (ws, ')'))
357        .map(WordPart::CmdSub)
358        .parse_next(input)
359}
360
361fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
362    if !(input.starts_with("<(") || input.starts_with(">(")) {
363        return backtrack();
364    }
365    *input = &input[1..];
366    delimited(('(', ws), script, (ws, ')'))
367        .map(WordPart::ProcSub)
368        .parse_next(input)
369}
370
371fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
372    if !input.starts_with("$((") {
373        return backtrack();
374    }
375    let body_start = 3;
376    let bytes = input.as_bytes();
377    let mut depth: i32 = 1;
378    let mut i = body_start;
379    while i < bytes.len() {
380        match bytes[i] {
381            b'(' => depth += 1,
382            b')' => {
383                if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
384                    let body = input[body_start..i].to_string();
385                    if body.contains("$(") || body.contains('`') {
386                        return backtrack();
387                    }
388                    *input = &input[i + 2..];
389                    return Ok(WordPart::Arith(body));
390                }
391                depth -= 1;
392                if depth < 0 {
393                    return backtrack();
394                }
395            }
396            _ => {}
397        }
398        i += 1;
399    }
400    backtrack()
401}
402
403fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
404    delimited('`', backtick_inner, '`')
405        .map(WordPart::Backtick)
406        .parse_next(input)
407}
408
409fn escaped(input: &mut &str) -> ModalResult<WordPart> {
410    preceded('\\', any).map(WordPart::Escape).parse_next(input)
411}
412
413fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
414    move |input: &mut &str| {
415        take_while(1.., pred)
416            .map(|s: &str| WordPart::Lit(s.to_string()))
417            .parse_next(input)
418    }
419}
420
421fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
422    move |input: &mut &str| {
423        ('$', not('(')).void().parse_next(input)?;
424        let rest: &str = take_while(0.., pred).parse_next(input)?;
425        Ok(WordPart::Lit(format!("${rest}")))
426    }
427}
428
429// === Double-quoted parts ===
430
431fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
432    if input.is_empty() || input.starts_with('"') {
433        return backtrack();
434    }
435    alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
436        .parse_next(input)
437}
438
439fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
440    preceded('\\', any)
441        .map(|c: char| match c {
442            '"' | '\\' | '$' | '`' => WordPart::Escape(c),
443            _ => WordPart::Lit(format!("\\{c}")),
444        })
445        .parse_next(input)
446}
447
448// === Backtick inner content ===
449
450fn backtick_inner(input: &mut &str) -> ModalResult<String> {
451    repeat(0.., alt((bt_escape, bt_literal)))
452        .fold(String::new, |mut acc, chunk: &str| {
453            acc.push_str(chunk);
454            acc
455        })
456        .parse_next(input)
457}
458
459fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
460    ('\\', any).take().parse_next(input)
461}
462
463fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
464    take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
465}
466
467// === Compound Commands ===
468
469fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
470    eat_keyword(input, "for")?;
471    ws.parse_next(input)?;
472    let var = name.parse_next(input)?;
473    ws.parse_next(input)?;
474
475    let items = if eat_keyword(input, "in").is_ok() {
476        ws.parse_next(input)?;
477        repeat(0.., terminated(word, ws)).parse_next(input)?
478    } else {
479        vec![]
480    };
481
482    let body = do_done_body.parse_next(input)?;
483    Ok(Cmd::For { var, items, body })
484}
485
486fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
487    eat_keyword(input, "while")?;
488    ws.parse_next(input)?;
489    let cond = script.parse_next(input)?;
490    let body = do_done_body.parse_next(input)?;
491    Ok(Cmd::While { cond, body })
492}
493
494fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
495    eat_keyword(input, "until")?;
496    ws.parse_next(input)?;
497    let cond = script.parse_next(input)?;
498    let body = do_done_body.parse_next(input)?;
499    Ok(Cmd::Until { cond, body })
500}
501
502fn do_done_body(input: &mut &str) -> ModalResult<Script> {
503    sep.parse_next(input)?;
504    eat_keyword(input, "do")?;
505    sep.parse_next(input)?;
506    let body = script.parse_next(input)?;
507    sep.parse_next(input)?;
508    eat_keyword(input, "done")?;
509    Ok(body)
510}
511
512fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
513    eat_keyword(input, "if")?;
514    ws.parse_next(input)?;
515    let mut branches = vec![cond_then_body.parse_next(input)?];
516    let mut else_body = None;
517
518    loop {
519        sep.parse_next(input)?;
520        if eat_keyword(input, "elif").is_ok() {
521            ws.parse_next(input)?;
522            branches.push(cond_then_body.parse_next(input)?);
523        } else if eat_keyword(input, "else").is_ok() {
524            sep.parse_next(input)?;
525            else_body = Some(script.parse_next(input)?);
526            break;
527        } else {
528            break;
529        }
530    }
531
532    sep.parse_next(input)?;
533    eat_keyword(input, "fi")?;
534    Ok(Cmd::If { branches, else_body })
535}
536
537fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
538    let cond = script.parse_next(input)?;
539    sep.parse_next(input)?;
540    eat_keyword(input, "then")?;
541    sep.parse_next(input)?;
542    let body = script.parse_next(input)?;
543    Ok(Branch { cond, body })
544}
545
546fn name(input: &mut &str) -> ModalResult<String> {
547    take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
548        .map(|s: &str| s.to_string())
549        .parse_next(input)
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    fn p(input: &str) -> Script {
557        parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
558    }
559
560    fn words(script: &Script) -> Vec<String> {
561        match &script.0[0].pipeline.commands[0] {
562            Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
563            _ => panic!("expected simple command"),
564        }
565    }
566
567    fn simple(script: &Script) -> &SimpleCmd {
568        match &script.0[0].pipeline.commands[0] {
569            Cmd::Simple(s) => s,
570            _ => panic!("expected simple command"),
571        }
572    }
573
574    #[test]
575    fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
576    #[test]
577    fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
578    #[test]
579    fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
580    #[test]
581    fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
582    #[test]
583    fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
584
585    #[test]
586    fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
587    #[test]
588    fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
589    #[test]
590    fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
591    #[test]
592    fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
593    #[test]
594    fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
595    #[test]
596    fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
597    #[test]
598    fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n   \necho bar").0.len(), 2); }
599    #[test]
600    fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
601    #[test]
602    fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
603    #[test]
604    fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
605
606    #[test]
607    fn brace_group_simple() {
608        assert!(matches!(
609            &p("{ echo hello; }").0[0].pipeline.commands[0],
610            Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
611        ));
612    }
613    #[test]
614    fn brace_group_multiple_stmts() {
615        if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
616            assert_eq!(body.0.len(), 3);
617        } else { panic!("expected BraceGroup"); }
618    }
619    #[test]
620    fn brace_group_with_redirect() {
621        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
622            assert_eq!(redirs.len(), 1);
623            assert!(matches!(redirs[0], Redir::Write { .. }));
624        } else { panic!("expected BraceGroup"); }
625    }
626    #[test]
627    fn brace_group_with_append_redirect() {
628        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
629            assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
630        } else { panic!("expected BraceGroup"); }
631    }
632    #[test]
633    fn brace_group_with_stderr_redirect() {
634        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
635            assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
636        } else { panic!("expected BraceGroup"); }
637    }
638    #[test]
639    fn brace_group_newline_separated() {
640        if let Cmd::BraceGroup { body, .. } = &p("{\n  echo a\n  echo b\n}").0[0].pipeline.commands[0] {
641            assert_eq!(body.0.len(), 2);
642        } else { panic!("expected BraceGroup"); }
643    }
644    #[test]
645    fn brace_group_in_pipeline() {
646        let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
647        assert_eq!(pl.commands.len(), 2);
648        assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
649    }
650    #[test]
651    fn brace_group_followed_by_other() {
652        let stmts = &p("{ echo a; }; echo b").0;
653        assert_eq!(stmts.len(), 2);
654        assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
655    }
656    #[test]
657    fn brace_group_nested() {
658        if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
659            assert_eq!(body.0.len(), 2);
660            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
661        } else { panic!("expected outer BraceGroup"); }
662    }
663    #[test]
664    fn brace_group_with_subshell_inside() {
665        if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
666            assert_eq!(body.0.len(), 2);
667            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
668        } else { panic!("expected BraceGroup"); }
669    }
670    #[test]
671    fn brace_open_requires_whitespace() {
672        // {echo (no space) is NOT a brace group; it's a literal word
673        // that becomes part of a simple command. Parser should not
674        // treat it as a brace group.
675        let cmds = &p("{echo a}").0;
676        // Either parsed as a simple_cmd with a literal `{echo` token,
677        // or fails. Either way, it should NOT be a BraceGroup.
678        if !cmds.is_empty() {
679            assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
680        }
681    }
682    #[test]
683    fn subshell_with_redirect() {
684        if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
685            assert_eq!(redirs.len(), 1);
686        } else { panic!("expected Subshell with redir"); }
687    }
688    #[test]
689    fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
690
691    #[test]
692    fn redirect_dev_null() {
693        let s = p("echo hello > /dev/null");
694        let cmd = simple(&s);
695        assert_eq!(cmd.words.len(), 2);
696        assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
697    }
698    #[test]
699    fn redirect_stderr() {
700        assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
701    }
702    #[test]
703    fn here_string() {
704        assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
705    }
706    #[test]
707    fn heredoc_bare() {
708        assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
709    }
710    #[test]
711    fn heredoc_with_content() {
712        let s = p("cat <<EOF\nhello world\nEOF");
713        assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
714    }
715    #[test]
716    fn heredoc_quoted_delimiter() {
717        assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
718    }
719    #[test]
720    fn heredoc_strip_tabs() {
721        assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
722    }
723    #[test]
724    fn heredoc_then_pipe() {
725        let s = p("cat <<EOF\nhello\nEOF | grep hello");
726        assert_eq!(s.0[0].pipeline.commands.len(), 2);
727    }
728    #[test]
729    fn heredoc_then_pipe_next_line() {
730        let s = p("cat <<EOF\nhello\nEOF\n| grep hello");
731        assert_eq!(s.0[0].pipeline.commands.len(), 2);
732    }
733
734    #[test]
735    fn env_prefix() {
736        let s = p("FOO='bar baz' ls -la");
737        let cmd = simple(&s);
738        assert_eq!(cmd.env[0].0, "FOO");
739        assert_eq!(cmd.env[0].1.eval(), "bar baz");
740    }
741    #[test]
742    fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
743    #[test]
744    fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_SUB__"); }
745    #[test]
746    fn nested_substitution() {
747        if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
748            assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
749        } else { panic!("expected CmdSub"); }
750    }
751
752    #[test]
753    fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
754    #[test]
755    fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
756
757    #[test]
758    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")); }
759    #[test]
760    fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
761    #[test]
762    fn if_then_fi() {
763        if let Cmd::If { branches, else_body } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
764            assert_eq!(branches.len(), 1);
765            assert!(else_body.is_none());
766        } else { panic!("expected If"); }
767    }
768    #[test]
769    fn if_elif_else() {
770        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] {
771            assert_eq!(branches.len(), 2);
772            assert!(else_body.is_some());
773        } else { panic!("expected If"); }
774    }
775
776    #[test]
777    fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
778    #[test]
779    fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
780    #[test]
781    fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
782
783    #[test]
784    fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
785    #[test]
786    fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
787    #[test]
788    fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
789    #[test]
790    fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
791    #[test]
792    fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
793    #[test]
794    fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
795
796    #[test]
797    fn subshell_for() {
798        if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
799            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
800        } else { panic!("expected Subshell"); }
801    }
802    #[test]
803    fn proc_sub_input() {
804        let s = p("diff <(sort a.txt) <(sort b.txt)");
805        let cmd = simple(&s);
806        assert_eq!(cmd.words.len(), 3);
807        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
808        assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
809    }
810    #[test]
811    fn proc_sub_output() {
812        let s = p("tee >(grep error > /dev/null)");
813        let cmd = simple(&s);
814        assert_eq!(cmd.words.len(), 2);
815        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
816    }
817    #[test]
818    fn comment_only() {
819        let s = p("# just a comment");
820        assert!(s.0.is_empty());
821    }
822    #[test]
823    fn comment_before_command() {
824        let s = p("# comment\necho hello");
825        assert_eq!(words(&s), ["echo", "hello"]);
826    }
827    #[test]
828    fn inline_comment() {
829        let s = p("echo hello # this is a comment");
830        assert_eq!(words(&s), ["echo", "hello"]);
831    }
832    #[test]
833    fn comment_between_commands() {
834        let s = p("echo hello\n# middle comment\necho world");
835        assert_eq!(s.0.len(), 2);
836    }
837    #[test]
838    fn comment_after_semicolon() {
839        let s = p("echo hello; # comment\necho world");
840        assert_eq!(s.0.len(), 2);
841    }
842    #[test]
843    fn comment_in_for_loop() {
844        assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
845    }
846    #[test]
847    fn quoted_redirect_in_echo() {
848        let s = p("echo 'greater > than' test");
849        let cmd = simple(&s);
850        assert_eq!(cmd.words.len(), 3);
851        assert_eq!(cmd.redirs.len(), 0);
852    }
853
854    #[test]
855    fn parses_all_safe_commands() {
856        let cmds = [
857            "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
858            "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
859            "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
860            "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
861            "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
862            "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
863            "((echo hello))", "(for x in 1 2; do echo $x; done)",
864            "echo 'greater > than' test", "echo '$(safe)' arg",
865            "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
866            "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
867            "grep foo file.txt | head -5", "cat file | sort | uniq",
868            "ls && echo done", "ls; echo done", "ls & echo done",
869            "grep -c , <<< 'hello,world,test'",
870            "cat <<EOF\nhello world\nEOF",
871            "cat <<'MARKER'\nsome text\nMARKER",
872            "cat <<-EOF\n\thello\nEOF",
873            "echo foo\necho bar", "ls\ncat file.txt",
874            "git log --oneline -20 | head -5",
875            "echo hello > /dev/null", "echo hello 2> /dev/null",
876            "echo hello >> /dev/null", "git log > /dev/null 2>&1",
877            "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
878            "for x in 1 2 3; do echo $x; done",
879            "for f in *.txt; do cat $f | grep pattern; done",
880            "for x in 1 2 3; do; done",
881            "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
882            "for x in 1 2; do for y in a b; do echo $x $y; done; done",
883            "for x in 1 2; do echo $x; done && echo finished",
884            "for x in $(seq 1 5); do echo $x; done",
885            "while test -f /tmp/foo; do sleep 1; done",
886            "while ! test -f /tmp/done; do sleep 1; done",
887            "until test -f /tmp/ready; do sleep 1; done",
888            "if test -f foo; then echo exists; fi",
889            "if test -f foo; then echo yes; else echo no; fi",
890            "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
891            "for x in 1 2; do if test $x = 1; then echo one; fi; done",
892            "if true; then for x in 1 2; do echo $x; done; fi",
893            "diff <(sort a.txt) <(sort b.txt)",
894            "comm -23 file.txt <(sort other.txt)",
895            "cat <(echo hello)",
896            "# comment only",
897            "# comment\necho hello",
898            "echo hello # inline comment",
899            "echo one\n# between\necho two",
900            "! echo hello", "! test -f foo",
901            "echo for; echo done; echo if; echo fi",
902        ];
903        let mut failures = Vec::new();
904        for cmd in &cmds {
905            if parse(cmd).is_none() { failures.push(*cmd); }
906        }
907        assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
908    }
909}