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