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