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 reset_heredoc_queue();
10 let result = script.parse(input).ok();
11 reset_heredoc_queue();
12 result
13}
14
15fn backtrack<T>() -> ModalResult<T> {
16 Err(ErrMode::Backtrack(ContextError::new()))
17}
18
19fn comment(input: &mut &str) -> ModalResult<()> {
20 if input.starts_with('#') {
21 if let Some(pos) = input.find('\n') {
22 *input = &input[pos + 1..];
23 } else {
24 *input = "";
25 }
26 }
27 Ok(())
28}
29
30fn ws(input: &mut &str) -> ModalResult<()> {
31 loop {
32 take_while(0.., [' ', '\t']).void().parse_next(input)?;
33 if input.starts_with('#') {
34 comment(input)?;
35 } else {
36 break;
37 }
38 }
39 Ok(())
40}
41
42fn sep(input: &mut &str) -> ModalResult<()> {
43 loop {
44 take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
45 if input.starts_with('#') {
46 comment(input)?;
47 } else {
48 break;
49 }
50 }
51 Ok(())
52}
53
54fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
55 if !input.starts_with(kw) {
56 return backtrack();
57 }
58 if input
59 .as_bytes()
60 .get(kw.len())
61 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
62 {
63 return backtrack();
64 }
65 *input = &input[kw.len()..];
66 Ok(())
67}
68
69const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
70
71fn at_script_stop(input: &str) -> bool {
72 input.starts_with(')')
73 || input.starts_with('}')
74 || SCRIPT_STOPS.iter().any(|kw| {
75 input.starts_with(kw)
76 && !input
77 .as_bytes()
78 .get(kw.len())
79 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
80 })
81}
82
83fn is_word_boundary(c: char) -> bool {
84 matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
85}
86
87fn is_word_literal(c: char) -> bool {
88 !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
89}
90
91fn is_dq_literal(c: char) -> bool {
92 !matches!(c, '"' | '\\' | '`' | '$')
93}
94
95fn script(input: &mut &str) -> ModalResult<Script> {
98 sep.parse_next(input)?;
99 let mut stmts = Vec::new();
100 while let Some(pl) = opt(pipeline).parse_next(input)? {
101 ws.parse_next(input)?;
102 let op = opt(list_op).parse_next(input)?;
103 stmts.push(Stmt { pipeline: pl, op });
104 drain_pending_heredocs(input);
109 if op.is_none() {
110 break;
111 }
112 sep.parse_next(input)?;
113 }
114 Ok(Script(stmts))
115}
116
117fn list_op(input: &mut &str) -> ModalResult<ListOp> {
118 ws.parse_next(input)?;
119 alt((
120 "&&".value(ListOp::And),
121 "||".value(ListOp::Or),
122 '\n'.value(ListOp::Semi),
123 ';'.value(ListOp::Semi),
124 ('&', not('>')).value(ListOp::Amp),
125 ))
126 .parse_next(input)
127}
128
129fn pipe_sep(input: &mut &str) -> ModalResult<()> {
130 (ws, '|', not('|'), ws).void().parse_next(input)
131}
132
133fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
136 ws.parse_next(input)?;
137 if at_script_stop(input) {
138 return backtrack();
139 }
140 let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
141 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
142 Ok(Pipeline { bang, commands })
143}
144
145fn command(input: &mut &str) -> ModalResult<Cmd> {
148 ws.parse_next(input)?;
149 if at_script_stop(input) {
150 return backtrack();
151 }
152 alt((
153 subshell,
154 brace_group,
155 for_cmd,
156 while_cmd,
157 until_cmd,
158 if_cmd,
159 double_bracket_cmd,
160 simple_cmd.map(Cmd::Simple),
161 ))
162 .parse_next(input)
163}
164
165fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
166 let mut redirs = Vec::new();
167 loop {
168 ws.parse_next(input)?;
169 if let Some(r) = opt(redirect).parse_next(input)? {
170 redirs.push(r);
171 } else {
172 break;
173 }
174 }
175 Ok(redirs)
176}
177
178fn subshell(input: &mut &str) -> ModalResult<Cmd> {
179 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
180 let redirs = trailing_redirs(input)?;
181 Ok(Cmd::Subshell { body, redirs })
182}
183
184fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
185 if !input.starts_with('{') {
186 return backtrack();
187 }
188 if !input
189 .as_bytes()
190 .get(1)
191 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
192 {
193 return backtrack();
194 }
195 *input = &input[1..];
196 sep.parse_next(input)?;
197 let body = script.parse_next(input)?;
198 if body.0.is_empty() {
199 return backtrack();
200 }
201 sep.parse_next(input)?;
202 if !input.starts_with('}') {
203 return backtrack();
204 }
205 let last_op = body.0.last().and_then(|s| s.op);
206 if last_op.is_none() {
207 return backtrack();
208 }
209 *input = &input[1..];
210 let redirs = trailing_redirs(input)?;
211 Ok(Cmd::BraceGroup { body, redirs })
212}
213
214fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
217 let env: Vec<(String, Word)> =
218 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
219 let mut words = Vec::new();
220 let mut redirs = Vec::new();
221
222 loop {
223 ws.parse_next(input)?;
224 if at_cmd_end(input) {
225 break;
226 }
227 if let Some(r) = opt(redirect).parse_next(input)? {
228 redirs.push(r);
229 } else if let Some(w) = opt(word).parse_next(input)? {
230 words.push(w);
231 } else {
232 break;
233 }
234 }
235
236 if env.is_empty() && words.is_empty() && redirs.is_empty() {
237 return backtrack();
238 }
239 Ok(SimpleCmd { env, words, redirs })
240}
241
242fn at_cmd_end(input: &str) -> bool {
243 input.is_empty()
244 || matches!(
245 input.as_bytes().first(),
246 Some(b'\n' | b';' | b'|' | b'&' | b')')
247 )
248}
249
250fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
251 let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
252 .parse_next(input)?;
253 '='.parse_next(input)?;
254 let value = opt(word)
255 .parse_next(input)?
256 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
257 Ok((n.to_string(), value))
258}
259
260fn redirect(input: &mut &str) -> ModalResult<Redir> {
263 let fd = opt(fd_prefix).parse_next(input)?;
264 alt((
265 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
266 heredoc,
267 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
268 fd: fd.unwrap_or(1),
269 target,
270 append: true,
271 }),
272 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
273 src: fd.unwrap_or(1),
274 dst,
275 }),
276 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
277 fd: fd.unwrap_or(1),
278 target,
279 append: false,
280 }),
281 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
282 fd: fd.unwrap_or(0),
283 target,
284 }),
285 ))
286 .parse_next(input)
287}
288
289fn heredoc(input: &mut &str) -> ModalResult<Redir> {
290 "<<".parse_next(input)?;
291 let strip_tabs = opt('-').parse_next(input)?.is_some();
292 ws.parse_next(input)?;
293 let delimiter = heredoc_delimiter.parse_next(input)?;
294 PENDING_HEREDOCS.with(|q| {
300 q.borrow_mut().push(PendingHeredoc {
301 delimiter: delimiter.clone(),
302 strip_tabs,
303 });
304 });
305 Ok(Redir::HereDoc { delimiter, strip_tabs })
306}
307
308#[derive(Debug, Clone)]
309struct PendingHeredoc {
310 delimiter: String,
311 strip_tabs: bool,
312}
313
314thread_local! {
315 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
316 const { std::cell::RefCell::new(Vec::new()) };
317}
318
319fn drain_pending_heredocs(input: &mut &str) {
320 let pending: Vec<PendingHeredoc> =
321 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
322 for h in pending {
323 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
324 return;
328 }
329 }
330}
331
332fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
333 let s = *input;
334 let bytes = s.as_bytes();
335 let mut line_start = 0;
336 while line_start <= bytes.len() {
337 let line_end = match s[line_start..].find('\n') {
338 Some(rel) => line_start + rel,
339 None => bytes.len(),
340 };
341 let line_bytes = &bytes[line_start..line_end];
342 let line = if strip_tabs {
343 std::str::from_utf8(line_bytes)
344 .unwrap_or("")
345 .trim_start_matches('\t')
346 } else {
347 std::str::from_utf8(line_bytes).unwrap_or("")
348 };
349 if line == delimiter {
350 let advance = line_end + usize::from(line_end < bytes.len());
352 *input = &s[advance..];
353 return true;
354 }
355 if line_end >= bytes.len() {
356 return false;
357 }
358 line_start = line_end + 1;
359 }
360 false
361}
362
363fn reset_heredoc_queue() {
364 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
365}
366
367fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
368 alt((
369 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
370 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
371 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
372 ))
373 .parse_next(input)
374}
375
376fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
377 let b = input.as_bytes();
378 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
379 let d = (b[0] - b'0') as u32;
380 *input = &input[1..];
381 Ok(d)
382 } else {
383 backtrack()
384 }
385}
386
387fn fd_target(input: &mut &str) -> ModalResult<String> {
388 alt((
389 '-'.value("-".to_string()),
390 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
391 ))
392 .parse_next(input)
393}
394
395fn word(input: &mut &str) -> ModalResult<Word> {
398 repeat(1.., word_part)
399 .map(Word)
400 .parse_next(input)
401}
402
403fn word_part(input: &mut &str) -> ModalResult<WordPart> {
404 if input.is_empty() {
405 return backtrack();
406 }
407 if input.starts_with("<(") || input.starts_with(">(") {
408 return proc_sub(input);
409 }
410 if is_word_boundary(input.as_bytes()[0] as char) {
411 return backtrack();
412 }
413 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
414 .parse_next(input)
415}
416
417fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
418 delimited('\'', take_while(0.., |c| c != '\''), '\'')
419 .map(|s: &str| WordPart::SQuote(s.to_string()))
420 .parse_next(input)
421}
422
423fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
424 delimited('"', repeat(0.., dq_part).map(Word), '"')
425 .map(WordPart::DQuote)
426 .parse_next(input)
427}
428
429fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
430 delimited(("$(", ws), script, (ws, ')'))
431 .map(WordPart::CmdSub)
432 .parse_next(input)
433}
434
435fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
436 if !(input.starts_with("<(") || input.starts_with(">(")) {
437 return backtrack();
438 }
439 *input = &input[1..];
440 delimited(('(', ws), script, (ws, ')'))
441 .map(WordPart::ProcSub)
442 .parse_next(input)
443}
444
445fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
446 if !input.starts_with("$((") {
447 return backtrack();
448 }
449 let body_start = 3;
450 let bytes = input.as_bytes();
451 let mut depth: i32 = 1;
452 let mut i = body_start;
453 while i < bytes.len() {
454 match bytes[i] {
455 b'(' => depth += 1,
456 b')' => {
457 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
458 let body = input[body_start..i].to_string();
459 if body.contains("$(") || body.contains('`') {
460 return backtrack();
461 }
462 *input = &input[i + 2..];
463 return Ok(WordPart::Arith(body));
464 }
465 depth -= 1;
466 if depth < 0 {
467 return backtrack();
468 }
469 }
470 _ => {}
471 }
472 i += 1;
473 }
474 backtrack()
475}
476
477fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
478 delimited('`', backtick_inner, '`')
479 .map(WordPart::Backtick)
480 .parse_next(input)
481}
482
483fn escaped(input: &mut &str) -> ModalResult<WordPart> {
484 preceded('\\', any).map(WordPart::Escape).parse_next(input)
485}
486
487fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
488 move |input: &mut &str| {
489 take_while(1.., pred)
490 .map(|s: &str| WordPart::Lit(s.to_string()))
491 .parse_next(input)
492 }
493}
494
495fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
496 move |input: &mut &str| {
497 ('$', not('(')).void().parse_next(input)?;
498 let rest: &str = take_while(0.., pred).parse_next(input)?;
499 Ok(WordPart::Lit(format!("${rest}")))
500 }
501}
502
503fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
506 if input.is_empty() || input.starts_with('"') {
507 return backtrack();
508 }
509 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
510 .parse_next(input)
511}
512
513fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
514 preceded('\\', any)
515 .map(|c: char| match c {
516 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
517 _ => WordPart::Lit(format!("\\{c}")),
518 })
519 .parse_next(input)
520}
521
522fn backtick_inner(input: &mut &str) -> ModalResult<String> {
525 repeat(0.., alt((bt_escape, bt_literal)))
526 .fold(String::new, |mut acc, chunk: &str| {
527 acc.push_str(chunk);
528 acc
529 })
530 .parse_next(input)
531}
532
533fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
534 ('\\', any).take().parse_next(input)
535}
536
537fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
538 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
539}
540
541fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
544 eat_keyword(input, "for")?;
545 ws.parse_next(input)?;
546 let var = name.parse_next(input)?;
547 ws.parse_next(input)?;
548
549 let items = if eat_keyword(input, "in").is_ok() {
550 ws.parse_next(input)?;
551 repeat(0.., terminated(word, ws)).parse_next(input)?
552 } else {
553 vec![]
554 };
555
556 let body = do_done_body.parse_next(input)?;
557 let redirs = trailing_redirs(input)?;
558 Ok(Cmd::For { var, items, body, redirs })
559}
560
561fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
562 eat_keyword(input, "while")?;
563 ws.parse_next(input)?;
564 let cond = script.parse_next(input)?;
565 let body = do_done_body.parse_next(input)?;
566 let redirs = trailing_redirs(input)?;
567 Ok(Cmd::While { cond, body, redirs })
568}
569
570fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
571 eat_keyword(input, "until")?;
572 ws.parse_next(input)?;
573 let cond = script.parse_next(input)?;
574 let body = do_done_body.parse_next(input)?;
575 let redirs = trailing_redirs(input)?;
576 Ok(Cmd::Until { cond, body, redirs })
577}
578
579fn do_done_body(input: &mut &str) -> ModalResult<Script> {
580 sep.parse_next(input)?;
581 eat_keyword(input, "do")?;
582 sep.parse_next(input)?;
583 let body = script.parse_next(input)?;
584 sep.parse_next(input)?;
585 eat_keyword(input, "done")?;
586 Ok(body)
587}
588
589fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
590 eat_keyword(input, "if")?;
591 ws.parse_next(input)?;
592 let mut branches = vec![cond_then_body.parse_next(input)?];
593 let mut else_body = None;
594
595 loop {
596 sep.parse_next(input)?;
597 if eat_keyword(input, "elif").is_ok() {
598 ws.parse_next(input)?;
599 branches.push(cond_then_body.parse_next(input)?);
600 } else if eat_keyword(input, "else").is_ok() {
601 sep.parse_next(input)?;
602 else_body = Some(script.parse_next(input)?);
603 break;
604 } else {
605 break;
606 }
607 }
608
609 sep.parse_next(input)?;
610 eat_keyword(input, "fi")?;
611 let redirs = trailing_redirs(input)?;
612 Ok(Cmd::If { branches, else_body, redirs })
613}
614
615fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
616 let cond = script.parse_next(input)?;
617 sep.parse_next(input)?;
618 eat_keyword(input, "then")?;
619 sep.parse_next(input)?;
620 let body = script.parse_next(input)?;
621 Ok(Branch { cond, body })
622}
623
624fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
625 if !input.starts_with("[[") {
626 return backtrack();
627 }
628 let bytes = input.as_bytes();
629 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
630 return backtrack();
631 }
632 *input = &input[2..];
633
634 let mut words: Vec<Word> = Vec::new();
635 loop {
636 ws.parse_next(input)?;
637 if at_double_bracket_end(input) {
638 *input = &input[2..];
639 let redirs = trailing_redirs(input)?;
640 return Ok(Cmd::DoubleBracket { words, redirs });
641 }
642 if input.is_empty() {
643 return backtrack();
644 }
645 let w = bracket_word.parse_next(input)?;
646 words.push(w);
647 }
648}
649
650fn at_double_bracket_end(input: &str) -> bool {
651 if !input.starts_with("]]") {
652 return false;
653 }
654 let after = &input[2..];
655 after.is_empty()
656 || after.starts_with(|c: char| {
657 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
658 })
659}
660
661fn bracket_word(input: &mut &str) -> ModalResult<Word> {
662 repeat(1.., bracket_word_part).map(Word).parse_next(input)
663}
664
665fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
666 if input.is_empty() {
667 return backtrack();
668 }
669 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
670 return backtrack();
671 }
672 if at_double_bracket_end(input) {
673 return backtrack();
674 }
675 alt((
676 single_quoted,
677 double_quoted,
678 arith_sub,
679 cmd_sub,
680 backtick_part,
681 escaped,
682 dollar_lit(is_bracket_literal),
683 bracket_lit,
684 ))
685 .parse_next(input)
686}
687
688fn is_bracket_literal(c: char) -> bool {
689 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
690}
691
692fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
693 let bytes = input.as_bytes();
698 let mut end = 0;
699 while end < bytes.len() {
700 let c = bytes[end] as char;
701 if !is_bracket_literal(c) {
702 break;
703 }
704 if c == ']' && at_double_bracket_end(&input[end..]) {
705 break;
706 }
707 end += 1;
708 }
709 if end == 0 {
710 return backtrack();
711 }
712 let lit = input[..end].to_string();
713 *input = &input[end..];
714 Ok(WordPart::Lit(lit))
715}
716
717fn name(input: &mut &str) -> ModalResult<String> {
718 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
719 .map(|s: &str| s.to_string())
720 .parse_next(input)
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 fn p(input: &str) -> Script {
728 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
729 }
730
731 fn words(script: &Script) -> Vec<String> {
732 match &script.0[0].pipeline.commands[0] {
733 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
734 _ => panic!("expected simple command"),
735 }
736 }
737
738 fn simple(script: &Script) -> &SimpleCmd {
739 match &script.0[0].pipeline.commands[0] {
740 Cmd::Simple(s) => s,
741 _ => panic!("expected simple command"),
742 }
743 }
744
745 #[test]
746 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
747 #[test]
748 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
749 #[test]
750 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
751 #[test]
752 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
753 #[test]
754 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
755
756 #[test]
757 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
758 #[test]
759 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
760 #[test]
761 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
762 #[test]
763 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
764 #[test]
765 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
766 #[test]
767 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
768 #[test]
769 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
770 #[test]
771 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
772 #[test]
773 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
774 #[test]
775 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
776
777 #[test]
778 fn brace_group_simple() {
779 assert!(matches!(
780 &p("{ echo hello; }").0[0].pipeline.commands[0],
781 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
782 ));
783 }
784 #[test]
785 fn brace_group_multiple_stmts() {
786 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
787 assert_eq!(body.0.len(), 3);
788 } else { panic!("expected BraceGroup"); }
789 }
790 #[test]
791 fn brace_group_with_redirect() {
792 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
793 assert_eq!(redirs.len(), 1);
794 assert!(matches!(redirs[0], Redir::Write { .. }));
795 } else { panic!("expected BraceGroup"); }
796 }
797 #[test]
798 fn brace_group_with_append_redirect() {
799 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
800 assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
801 } else { panic!("expected BraceGroup"); }
802 }
803 #[test]
804 fn brace_group_with_stderr_redirect() {
805 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
806 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
807 } else { panic!("expected BraceGroup"); }
808 }
809 #[test]
810 fn brace_group_newline_separated() {
811 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
812 assert_eq!(body.0.len(), 2);
813 } else { panic!("expected BraceGroup"); }
814 }
815 #[test]
816 fn brace_group_in_pipeline() {
817 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
818 assert_eq!(pl.commands.len(), 2);
819 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
820 }
821 #[test]
822 fn brace_group_followed_by_other() {
823 let stmts = &p("{ echo a; }; echo b").0;
824 assert_eq!(stmts.len(), 2);
825 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
826 }
827 #[test]
828 fn brace_group_nested() {
829 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
830 assert_eq!(body.0.len(), 2);
831 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
832 } else { panic!("expected outer BraceGroup"); }
833 }
834 #[test]
835 fn brace_group_with_subshell_inside() {
836 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
837 assert_eq!(body.0.len(), 2);
838 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
839 } else { panic!("expected BraceGroup"); }
840 }
841 #[test]
842 fn brace_open_requires_whitespace() {
843 let cmds = &p("{echo a}").0;
847 if !cmds.is_empty() {
850 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
851 }
852 }
853 #[test]
854 fn subshell_with_redirect() {
855 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
856 assert_eq!(redirs.len(), 1);
857 } else { panic!("expected Subshell with redir"); }
858 }
859 #[test]
860 fn for_loop_with_redirect() {
861 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
862 assert_eq!(redirs.len(), 1);
863 } else { panic!("expected For with redir"); }
864 }
865 #[test]
866 fn for_loop_redirect_then_pipe() {
867 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
869 assert_eq!(pl.commands.len(), 2);
870 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
871 }
872 #[test]
873 fn while_and_if_with_redirect() {
874 assert!(matches!(
875 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
876 Cmd::While { redirs, .. } if redirs.len() == 1
877 ));
878 assert!(matches!(
879 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
880 Cmd::If { redirs, .. } if redirs.len() == 1
881 ));
882 }
883 #[test]
884 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
885
886 #[test]
887 fn redirect_dev_null() {
888 let s = p("echo hello > /dev/null");
889 let cmd = simple(&s);
890 assert_eq!(cmd.words.len(), 2);
891 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
892 }
893 #[test]
894 fn redirect_stderr() {
895 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
896 }
897 #[test]
898 fn here_string() {
899 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
900 }
901 #[test]
902 fn heredoc_bare() {
903 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
904 }
905 #[test]
906 fn heredoc_with_content() {
907 let s = p("cat <<EOF\nhello world\nEOF");
908 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
909 }
910 #[test]
911 fn heredoc_quoted_delimiter() {
912 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
913 }
914 #[test]
915 fn heredoc_strip_tabs() {
916 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
917 }
918 #[test]
919 fn heredoc_pipe_on_command_line() {
920 let s = p("cat <<EOF | grep hello\nhello\nEOF");
923 assert_eq!(s.0[0].pipeline.commands.len(), 2);
924 }
925 #[test]
926 fn heredoc_body_does_not_swallow_pipe() {
927 let s = p("cat <<EOF | bash\nrm\nEOF");
931 assert_eq!(
932 s.0[0].pipeline.commands.len(),
933 2,
934 "pipeline must keep `bash` as a second command"
935 );
936 }
937 #[test]
938 fn heredoc_followed_by_next_statement() {
939 let s = p("cat <<EOF\nhello\nEOF\nls");
942 assert_eq!(s.0.len(), 2);
943 }
944
945 #[test]
946 fn env_prefix() {
947 let s = p("FOO='bar baz' ls -la");
948 let cmd = simple(&s);
949 assert_eq!(cmd.env[0].0, "FOO");
950 assert_eq!(cmd.env[0].1.eval(), "bar baz");
951 }
952 #[test]
953 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
954 #[test]
955 fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_SUB__"); }
956 #[test]
957 fn nested_substitution() {
958 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
959 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
960 } else { panic!("expected CmdSub"); }
961 }
962
963 #[test]
964 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
965 #[test]
966 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
967
968 #[test]
969 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")); }
970 #[test]
971 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
972 #[test]
973 fn if_then_fi() {
974 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
975 assert_eq!(branches.len(), 1);
976 assert!(else_body.is_none());
977 } else { panic!("expected If"); }
978 }
979 #[test]
980 fn if_elif_else() {
981 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] {
982 assert_eq!(branches.len(), 2);
983 assert!(else_body.is_some());
984 } else { panic!("expected If"); }
985 }
986
987 #[test]
988 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
989 #[test]
990 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
991 #[test]
992 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
993
994 #[test]
995 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
996 #[test]
997 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
998 #[test]
999 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1000 #[test]
1001 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1002 #[test]
1003 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1004 #[test]
1005 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1006
1007 #[test]
1008 fn subshell_for() {
1009 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1010 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1011 } else { panic!("expected Subshell"); }
1012 }
1013 #[test]
1014 fn proc_sub_input() {
1015 let s = p("diff <(sort a.txt) <(sort b.txt)");
1016 let cmd = simple(&s);
1017 assert_eq!(cmd.words.len(), 3);
1018 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1019 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1020 }
1021 #[test]
1022 fn proc_sub_output() {
1023 let s = p("tee >(grep error > /dev/null)");
1024 let cmd = simple(&s);
1025 assert_eq!(cmd.words.len(), 2);
1026 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1027 }
1028 #[test]
1029 fn comment_only() {
1030 let s = p("# just a comment");
1031 assert!(s.0.is_empty());
1032 }
1033 #[test]
1034 fn comment_before_command() {
1035 let s = p("# comment\necho hello");
1036 assert_eq!(words(&s), ["echo", "hello"]);
1037 }
1038 #[test]
1039 fn inline_comment() {
1040 let s = p("echo hello # this is a comment");
1041 assert_eq!(words(&s), ["echo", "hello"]);
1042 }
1043 #[test]
1044 fn comment_between_commands() {
1045 let s = p("echo hello\n# middle comment\necho world");
1046 assert_eq!(s.0.len(), 2);
1047 }
1048 #[test]
1049 fn comment_after_semicolon() {
1050 let s = p("echo hello; # comment\necho world");
1051 assert_eq!(s.0.len(), 2);
1052 }
1053 #[test]
1054 fn comment_in_for_loop() {
1055 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1056 }
1057 #[test]
1058 fn quoted_redirect_in_echo() {
1059 let s = p("echo 'greater > than' test");
1060 let cmd = simple(&s);
1061 assert_eq!(cmd.words.len(), 3);
1062 assert_eq!(cmd.redirs.len(), 0);
1063 }
1064
1065 #[test]
1066 fn parses_all_safe_commands() {
1067 let cmds = [
1068 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1069 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1070 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1071 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1072 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1073 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1074 "((echo hello))", "(for x in 1 2; do echo $x; done)",
1075 "echo 'greater > than' test", "echo '$(safe)' arg",
1076 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1077 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1078 "grep foo file.txt | head -5", "cat file | sort | uniq",
1079 "ls && echo done", "ls; echo done", "ls & echo done",
1080 "grep -c , <<< 'hello,world,test'",
1081 "cat <<EOF\nhello world\nEOF",
1082 "cat <<'MARKER'\nsome text\nMARKER",
1083 "cat <<-EOF\n\thello\nEOF",
1084 "echo foo\necho bar", "ls\ncat file.txt",
1085 "git log --oneline -20 | head -5",
1086 "echo hello > /dev/null", "echo hello 2> /dev/null",
1087 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1088 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1089 "for x in 1 2 3; do echo $x; done",
1090 "for f in *.txt; do cat $f | grep pattern; done",
1091 "for x in 1 2 3; do; done",
1092 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1093 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1094 "for x in 1 2; do echo $x; done && echo finished",
1095 "for x in $(seq 1 5); do echo $x; done",
1096 "while test -f /tmp/foo; do sleep 1; done",
1097 "while ! test -f /tmp/done; do sleep 1; done",
1098 "until test -f /tmp/ready; do sleep 1; done",
1099 "if test -f foo; then echo exists; fi",
1100 "if test -f foo; then echo yes; else echo no; fi",
1101 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1102 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1103 "if true; then for x in 1 2; do echo $x; done; fi",
1104 "diff <(sort a.txt) <(sort b.txt)",
1105 "comm -23 file.txt <(sort other.txt)",
1106 "cat <(echo hello)",
1107 "# comment only",
1108 "# comment\necho hello",
1109 "echo hello # inline comment",
1110 "echo one\n# between\necho two",
1111 "! echo hello", "! test -f foo",
1112 "echo for; echo done; echo if; echo fi",
1113 ];
1114 let mut failures = Vec::new();
1115 for cmd in &cmds {
1116 if parse(cmd).is_none() { failures.push(*cmd); }
1117 }
1118 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1119 }
1120}