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