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
66fn 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
97fn 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
109fn 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
133fn 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
179fn 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
249fn 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() || is_word_boundary(input.as_bytes()[0] as char) {
259 return backtrack();
260 }
261 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
262 .parse_next(input)
263}
264
265fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
266 delimited('\'', take_while(0.., |c| c != '\''), '\'')
267 .map(|s: &str| WordPart::SQuote(s.to_string()))
268 .parse_next(input)
269}
270
271fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
272 delimited('"', repeat(0.., dq_part).map(Word), '"')
273 .map(WordPart::DQuote)
274 .parse_next(input)
275}
276
277fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
278 delimited(("$(", ws), script, (ws, ')'))
279 .map(WordPart::CmdSub)
280 .parse_next(input)
281}
282
283fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
284 if !input.starts_with("$((") {
285 return backtrack();
286 }
287 let body_start = 3;
288 let bytes = input.as_bytes();
289 let mut depth: i32 = 1;
290 let mut i = body_start;
291 while i < bytes.len() {
292 match bytes[i] {
293 b'(' => depth += 1,
294 b')' => {
295 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
296 let body = input[body_start..i].to_string();
297 if body.contains("$(") || body.contains('`') {
298 return backtrack();
299 }
300 *input = &input[i + 2..];
301 return Ok(WordPart::Arith(body));
302 }
303 depth -= 1;
304 if depth < 0 {
305 return backtrack();
306 }
307 }
308 _ => {}
309 }
310 i += 1;
311 }
312 backtrack()
313}
314
315fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
316 delimited('`', backtick_inner, '`')
317 .map(WordPart::Backtick)
318 .parse_next(input)
319}
320
321fn escaped(input: &mut &str) -> ModalResult<WordPart> {
322 preceded('\\', any).map(WordPart::Escape).parse_next(input)
323}
324
325fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
326 move |input: &mut &str| {
327 take_while(1.., pred)
328 .map(|s: &str| WordPart::Lit(s.to_string()))
329 .parse_next(input)
330 }
331}
332
333fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
334 move |input: &mut &str| {
335 ('$', not('(')).void().parse_next(input)?;
336 let rest: &str = take_while(0.., pred).parse_next(input)?;
337 Ok(WordPart::Lit(format!("${rest}")))
338 }
339}
340
341fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
344 if input.is_empty() || input.starts_with('"') {
345 return backtrack();
346 }
347 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
348 .parse_next(input)
349}
350
351fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
352 preceded('\\', any)
353 .map(|c: char| match c {
354 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
355 _ => WordPart::Lit(format!("\\{c}")),
356 })
357 .parse_next(input)
358}
359
360fn backtick_inner(input: &mut &str) -> ModalResult<String> {
363 repeat(0.., alt((bt_escape, bt_literal)))
364 .fold(String::new, |mut acc, chunk: &str| {
365 acc.push_str(chunk);
366 acc
367 })
368 .parse_next(input)
369}
370
371fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
372 ('\\', any).take().parse_next(input)
373}
374
375fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
376 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
377}
378
379fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
382 eat_keyword(input, "for")?;
383 ws.parse_next(input)?;
384 let var = name.parse_next(input)?;
385 ws.parse_next(input)?;
386
387 let items = if eat_keyword(input, "in").is_ok() {
388 ws.parse_next(input)?;
389 repeat(0.., terminated(word, ws)).parse_next(input)?
390 } else {
391 vec![]
392 };
393
394 let body = do_done_body.parse_next(input)?;
395 Ok(Cmd::For { var, items, body })
396}
397
398fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
399 eat_keyword(input, "while")?;
400 ws.parse_next(input)?;
401 let cond = script.parse_next(input)?;
402 let body = do_done_body.parse_next(input)?;
403 Ok(Cmd::While { cond, body })
404}
405
406fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
407 eat_keyword(input, "until")?;
408 ws.parse_next(input)?;
409 let cond = script.parse_next(input)?;
410 let body = do_done_body.parse_next(input)?;
411 Ok(Cmd::Until { cond, body })
412}
413
414fn do_done_body(input: &mut &str) -> ModalResult<Script> {
415 sep.parse_next(input)?;
416 eat_keyword(input, "do")?;
417 sep.parse_next(input)?;
418 let body = script.parse_next(input)?;
419 sep.parse_next(input)?;
420 eat_keyword(input, "done")?;
421 Ok(body)
422}
423
424fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
425 eat_keyword(input, "if")?;
426 ws.parse_next(input)?;
427 let mut branches = vec![cond_then_body.parse_next(input)?];
428 let mut else_body = None;
429
430 loop {
431 sep.parse_next(input)?;
432 if eat_keyword(input, "elif").is_ok() {
433 ws.parse_next(input)?;
434 branches.push(cond_then_body.parse_next(input)?);
435 } else if eat_keyword(input, "else").is_ok() {
436 sep.parse_next(input)?;
437 else_body = Some(script.parse_next(input)?);
438 break;
439 } else {
440 break;
441 }
442 }
443
444 sep.parse_next(input)?;
445 eat_keyword(input, "fi")?;
446 Ok(Cmd::If { branches, else_body })
447}
448
449fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
450 let cond = script.parse_next(input)?;
451 sep.parse_next(input)?;
452 eat_keyword(input, "then")?;
453 sep.parse_next(input)?;
454 let body = script.parse_next(input)?;
455 Ok(Branch { cond, body })
456}
457
458fn name(input: &mut &str) -> ModalResult<String> {
459 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
460 .map(|s: &str| s.to_string())
461 .parse_next(input)
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 fn p(input: &str) -> Script {
469 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
470 }
471
472 fn words(script: &Script) -> Vec<String> {
473 match &script.0[0].pipeline.commands[0] {
474 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
475 _ => panic!("expected simple command"),
476 }
477 }
478
479 fn simple(script: &Script) -> &SimpleCmd {
480 match &script.0[0].pipeline.commands[0] {
481 Cmd::Simple(s) => s,
482 _ => panic!("expected simple command"),
483 }
484 }
485
486 #[test]
487 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
488 #[test]
489 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
490 #[test]
491 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
492 #[test]
493 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
494 #[test]
495 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
496
497 #[test]
498 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
499 #[test]
500 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
501 #[test]
502 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
503 #[test]
504 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
505 #[test]
506 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
507
508 #[test]
509 fn redirect_dev_null() {
510 let s = p("echo hello > /dev/null");
511 let cmd = simple(&s);
512 assert_eq!(cmd.words.len(), 2);
513 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
514 }
515 #[test]
516 fn redirect_stderr() {
517 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
518 }
519 #[test]
520 fn here_string() {
521 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
522 }
523 #[test]
524 fn heredoc_bare() {
525 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
526 }
527 #[test]
528 fn heredoc_with_content() {
529 let s = p("cat <<EOF\nhello world\nEOF");
530 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
531 }
532 #[test]
533 fn heredoc_quoted_delimiter() {
534 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
535 }
536 #[test]
537 fn heredoc_strip_tabs() {
538 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
539 }
540 #[test]
541 fn heredoc_then_pipe() {
542 let s = p("cat <<EOF\nhello\nEOF | grep hello");
543 assert_eq!(s.0[0].pipeline.commands.len(), 2);
544 }
545 #[test]
546 fn heredoc_then_pipe_next_line() {
547 let s = p("cat <<EOF\nhello\nEOF\n| grep hello");
548 assert_eq!(s.0[0].pipeline.commands.len(), 2);
549 }
550
551 #[test]
552 fn env_prefix() {
553 let s = p("FOO='bar baz' ls -la");
554 let cmd = simple(&s);
555 assert_eq!(cmd.env[0].0, "FOO");
556 assert_eq!(cmd.env[0].1.eval(), "bar baz");
557 }
558 #[test]
559 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
560 #[test]
561 fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_SUB__"); }
562 #[test]
563 fn nested_substitution() {
564 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
565 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
566 } else { panic!("expected CmdSub"); }
567 }
568
569 #[test]
570 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell(_))); }
571 #[test]
572 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
573
574 #[test]
575 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")); }
576 #[test]
577 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
578 #[test]
579 fn if_then_fi() {
580 if let Cmd::If { branches, else_body } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
581 assert_eq!(branches.len(), 1);
582 assert!(else_body.is_none());
583 } else { panic!("expected If"); }
584 }
585 #[test]
586 fn if_elif_else() {
587 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] {
588 assert_eq!(branches.len(), 2);
589 assert!(else_body.is_some());
590 } else { panic!("expected If"); }
591 }
592
593 #[test]
594 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
595 #[test]
596 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
597 #[test]
598 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
599
600 #[test]
601 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
602 #[test]
603 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
604 #[test]
605 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
606 #[test]
607 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
608 #[test]
609 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
610 #[test]
611 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
612
613 #[test]
614 fn subshell_for() {
615 if let Cmd::Subshell(inner) = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
616 assert!(matches!(&inner.0[0].pipeline.commands[0], Cmd::For { .. }));
617 } else { panic!("expected Subshell"); }
618 }
619 #[test]
620 fn quoted_redirect_in_echo() {
621 let s = p("echo 'greater > than' test");
622 let cmd = simple(&s);
623 assert_eq!(cmd.words.len(), 3);
624 assert_eq!(cmd.redirs.len(), 0);
625 }
626
627 #[test]
628 fn parses_all_safe_commands() {
629 let cmds = [
630 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
631 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
632 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
633 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
634 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
635 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
636 "((echo hello))", "(for x in 1 2; do echo $x; done)",
637 "echo 'greater > than' test", "echo '$(safe)' arg",
638 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
639 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
640 "grep foo file.txt | head -5", "cat file | sort | uniq",
641 "ls && echo done", "ls; echo done", "ls & echo done",
642 "grep -c , <<< 'hello,world,test'",
643 "cat <<EOF\nhello world\nEOF",
644 "cat <<'MARKER'\nsome text\nMARKER",
645 "cat <<-EOF\n\thello\nEOF",
646 "echo foo\necho bar", "ls\ncat file.txt",
647 "git log --oneline -20 | head -5",
648 "echo hello > /dev/null", "echo hello 2> /dev/null",
649 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
650 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
651 "for x in 1 2 3; do echo $x; done",
652 "for f in *.txt; do cat $f | grep pattern; done",
653 "for x in 1 2 3; do; done",
654 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
655 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
656 "for x in 1 2; do echo $x; done && echo finished",
657 "for x in $(seq 1 5); do echo $x; done",
658 "while test -f /tmp/foo; do sleep 1; done",
659 "while ! test -f /tmp/done; do sleep 1; done",
660 "until test -f /tmp/ready; do sleep 1; done",
661 "if test -f foo; then echo exists; fi",
662 "if test -f foo; then echo yes; else echo no; fi",
663 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
664 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
665 "if true; then for x in 1 2; do echo $x; done; fi",
666 "! echo hello", "! test -f foo",
667 "echo for; echo done; echo if; echo fi",
668 ];
669 let mut failures = Vec::new();
670 for cmd in &cmds {
671 if parse(cmd).is_none() { failures.push(*cmd); }
672 }
673 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
674 }
675}