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 PARSE_DEPTH.with(|d| d.set(0));
11 PARSE_WORK.with(|w| w.set(0));
12 PARSE_WORK_LIMIT
13 .with(|l| {
14 let budget = MAX_PARSE_WORK_BASE + MAX_PARSE_WORK_PER_BYTE * input.len() as u64;
15 l.set(budget.min(MAX_PARSE_WORK_CEILING));
16 });
17 let result = script.parse(input).ok();
18 reset_heredoc_queue();
19 result
20}
21
22fn backtrack<T>() -> ModalResult<T> {
23 Err(ErrMode::Backtrack(ContextError::new()))
24}
25
26thread_local! {
27 static PARSE_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
28 static PARSE_WORK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
32 static PARSE_WORK_LIMIT: std::cell::Cell<u64> = const { std::cell::Cell::new(u64::MAX) };
33}
34
35const MAX_PARSE_DEPTH: u32 = 48;
45
46const MAX_PARSE_WORK_CEILING: u64 = 20_000;
60
61const MAX_PARSE_WORK_BASE: u64 = 2_048;
62const MAX_PARSE_WORK_PER_BYTE: u64 = 8;
63
64struct DepthGuard;
68
69impl DepthGuard {
70 fn enter() -> Option<Self> {
71 let over_budget = PARSE_WORK.with(|w| {
72 let n = w.get().saturating_add(1);
73 w.set(n);
74 n > PARSE_WORK_LIMIT.with(|l| l.get())
75 });
76 if over_budget {
77 return None;
78 }
79 PARSE_DEPTH.with(|d| {
80 if d.get() >= MAX_PARSE_DEPTH {
81 None
82 } else {
83 d.set(d.get() + 1);
84 Some(DepthGuard)
85 }
86 })
87 }
88}
89
90impl Drop for DepthGuard {
91 fn drop(&mut self) {
92 PARSE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
93 }
94}
95
96fn comment(input: &mut &str) -> ModalResult<()> {
97 if input.starts_with('#') {
98 if let Some(pos) = input.find('\n') {
99 *input = &input[pos + 1..];
100 } else {
101 *input = "";
102 }
103 }
104 Ok(())
105}
106
107fn ws(input: &mut &str) -> ModalResult<()> {
108 loop {
109 take_while(0.., [' ', '\t']).void().parse_next(input)?;
110 if input.starts_with('#') {
111 comment(input)?;
112 } else {
113 break;
114 }
115 }
116 Ok(())
117}
118
119fn sep(input: &mut &str) -> ModalResult<()> {
120 loop {
121 while let Some(c) = input.chars().next() {
124 if input.starts_with(";;") {
125 return Ok(());
126 }
127 if !matches!(c, ' ' | '\t' | ';' | '\n') {
128 break;
129 }
130 *input = &input[c.len_utf8()..];
131 }
132 if input.starts_with('#') {
133 comment(input)?;
134 } else {
135 break;
136 }
137 }
138 Ok(())
139}
140
141fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
142 if !input.starts_with(kw) {
143 return backtrack();
144 }
145 if input
146 .as_bytes()
147 .get(kw.len())
148 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
149 {
150 return backtrack();
151 }
152 *input = &input[kw.len()..];
153 Ok(())
154}
155
156const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "esac", "fi", "then"];
157
158fn at_script_stop(input: &str) -> bool {
159 input.starts_with(')')
160 || input.starts_with('}')
161 || input.starts_with(";;")
164 || SCRIPT_STOPS.iter().any(|kw| {
165 input.starts_with(kw)
166 && !input
167 .as_bytes()
168 .get(kw.len())
169 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
170 })
171}
172
173fn is_word_boundary(c: char) -> bool {
174 matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
175}
176
177fn is_word_literal(c: char) -> bool {
178 !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
179}
180
181fn is_dq_literal(c: char) -> bool {
182 !matches!(c, '"' | '\\' | '`' | '$')
183}
184
185fn script(input: &mut &str) -> ModalResult<Script> {
188 let Some(_depth) = DepthGuard::enter() else {
191 return backtrack();
192 };
193 sep.parse_next(input)?;
194 let mut stmts = Vec::new();
195 while let Some(pl) = opt(pipeline).parse_next(input)? {
196 ws.parse_next(input)?;
197 let op = opt(list_op).parse_next(input)?;
198 stmts.push(Stmt { pipeline: pl, op });
199 drain_pending_heredocs(input);
204 if op.is_none() {
205 break;
206 }
207 sep.parse_next(input)?;
208 }
209 Ok(Script(stmts))
210}
211
212fn list_op(input: &mut &str) -> ModalResult<ListOp> {
213 ws.parse_next(input)?;
214 alt((
215 "&&".value(ListOp::And),
216 "||".value(ListOp::Or),
217 '\n'.value(ListOp::Semi),
218 (';', not(';')).value(ListOp::Semi),
221 ('&', not('>')).value(ListOp::Amp),
222 ))
223 .parse_next(input)
224}
225
226fn pipe_sep(input: &mut &str) -> ModalResult<()> {
230 (ws, alt(("|&".void(), ('|', not('|')).void())), ws).void().parse_next(input)
231}
232
233fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
236 ws.parse_next(input)?;
237 if at_script_stop(input) {
238 return backtrack();
239 }
240 let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
241 opt_time_keyword_before_compound(input);
242 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
243 Ok(Pipeline { bang, commands })
244}
245
246fn opt_time_keyword_before_compound(input: &mut &str) -> bool {
269 let mut probe = *input;
270 if eat_keyword(&mut probe, "time").is_err() || !probe.starts_with([' ', '\t', '\n']) {
271 return false;
272 }
273 if ws.parse_next(&mut probe).is_err() {
274 return false;
275 }
276 if probe.starts_with("-p") {
278 let mut with_flag = &probe[2..];
279 if with_flag.starts_with([' ', '\t', '\n']) && ws.parse_next(&mut with_flag).is_ok() {
280 probe = with_flag;
281 }
282 }
283 if !probe.starts_with(['(', '{']) {
284 return false;
285 }
286 *input = probe;
287 true
288}
289
290fn command(input: &mut &str) -> ModalResult<Cmd> {
293 ws.parse_next(input)?;
294 if at_script_stop(input) {
295 return backtrack();
296 }
297 alt((
298 subshell,
299 brace_group,
300 for_cmd,
301 while_cmd,
302 until_cmd,
303 if_cmd,
304 case_cmd,
305 double_bracket_cmd,
306 function_def,
307 simple_cmd.map(Cmd::Simple),
308 ))
309 .parse_next(input)
310}
311
312fn function_def(input: &mut &str) -> ModalResult<Cmd> {
316 let had_keyword = opt_function_keyword(input);
317 let name = function_name(input)?;
318 ws.parse_next(input)?;
319 let has_parens = opt_paren_pair(input);
320 if !had_keyword && !has_parens {
321 return backtrack();
322 }
323 take_while(0.., [' ', '\t', '\n']).void().parse_next(input)?;
325 let body = function_body(input)?;
326 Ok(Cmd::FunctionDef { name, body })
327}
328
329fn opt_function_keyword(input: &mut &str) -> bool {
332 let mut probe = *input;
333 if eat_keyword(&mut probe, "function").is_ok()
334 && probe.starts_with([' ', '\t', '\n'])
335 && ws.parse_next(&mut probe).is_ok()
336 {
337 *input = probe;
338 return true;
339 }
340 false
341}
342
343fn opt_paren_pair(input: &mut &str) -> bool {
345 let mut probe = *input;
346 if let Some(rest) = probe.strip_prefix('(') {
347 probe = rest;
348 if ws.parse_next(&mut probe).is_ok()
349 && let Some(rest) = probe.strip_prefix(')')
350 {
351 *input = rest;
352 return true;
353 }
354 }
355 false
356}
357
358fn function_name(input: &mut &str) -> ModalResult<String> {
359 take_while(1.., |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+'))
360 .map(|s: &str| s.to_string())
361 .parse_next(input)
362}
363
364fn function_body(input: &mut &str) -> ModalResult<Script> {
367 if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
368 return Ok(body);
369 }
370 if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
371 return Ok(body);
372 }
373 backtrack()
374}
375
376fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
377 let mut redirs = Vec::new();
378 loop {
379 ws.parse_next(input)?;
380 if let Some(r) = opt(redirect).parse_next(input)? {
381 redirs.push(r);
382 } else {
383 break;
384 }
385 }
386 Ok(redirs)
387}
388
389fn subshell(input: &mut &str) -> ModalResult<Cmd> {
390 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
391 let redirs = trailing_redirs(input)?;
392 Ok(Cmd::Subshell { body, redirs })
393}
394
395fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
396 if !input.starts_with('{') {
397 return backtrack();
398 }
399 if !input
400 .as_bytes()
401 .get(1)
402 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
403 {
404 return backtrack();
405 }
406 *input = &input[1..];
407 sep.parse_next(input)?;
408 let body = script.parse_next(input)?;
409 if body.0.is_empty() {
410 return backtrack();
411 }
412 sep.parse_next(input)?;
413 if !input.starts_with('}') {
414 return backtrack();
415 }
416 let last_op = body.0.last().and_then(|s| s.op);
417 if last_op.is_none() {
418 return backtrack();
419 }
420 *input = &input[1..];
421 let redirs = trailing_redirs(input)?;
422 Ok(Cmd::BraceGroup { body, redirs })
423}
424
425fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
428 let env: Vec<(String, Word)> =
429 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
430 let mut words = Vec::new();
431 let mut redirs = Vec::new();
432
433 loop {
434 ws.parse_next(input)?;
435 if at_cmd_end(input) {
436 break;
437 }
438 if let Some(r) = opt(redirect).parse_next(input)? {
439 redirs.push(r);
440 } else if let Some(w) = opt(word).parse_next(input)? {
441 words.push(w);
442 } else {
443 break;
444 }
445 }
446
447 if env.is_empty() && words.is_empty() && redirs.is_empty() {
448 return backtrack();
449 }
450 Ok(SimpleCmd { env, words, redirs })
451}
452
453fn at_cmd_end(input: &str) -> bool {
454 if input.starts_with("&>") {
457 return false;
458 }
459 input.is_empty()
460 || matches!(
461 input.as_bytes().first(),
462 Some(b'\n' | b';' | b'|' | b'&' | b')')
463 )
464}
465
466fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
467 let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
468 .parse_next(input)?;
469 '='.parse_next(input)?;
470 let value = opt(word)
471 .parse_next(input)?
472 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
473 Ok((n.to_string(), value))
474}
475
476fn redirect(input: &mut &str) -> ModalResult<Redir> {
479 let fd = opt(fd_prefix).parse_next(input)?;
480 alt((
481 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
482 heredoc,
483 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
484 fd: fd.unwrap_or(1),
485 target,
486 mode: WriteMode::Append,
487 }),
488 preceded("&>>", (ws, word)).map(|(_, target)| Redir::Write {
491 fd: 1,
492 target,
493 mode: WriteMode::AppendBoth,
494 }),
495 preceded("&>", (ws, word)).map(|(_, target)| Redir::Write {
496 fd: 1,
497 target,
498 mode: WriteMode::TruncateBoth,
499 }),
500 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
501 src: fd.unwrap_or(1),
502 dst,
503 }),
504 preceded(">&", (ws, word)).map(|(_, target)| Redir::Write {
508 fd: 1,
509 target,
510 mode: WriteMode::TruncateBoth,
511 }),
512 preceded(">|", (ws, word)).map(move |(_, target)| Redir::Write {
516 fd: fd.unwrap_or(1),
517 target,
518 mode: WriteMode::Clobber,
519 }),
520 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
521 fd: fd.unwrap_or(1),
522 target,
523 mode: WriteMode::Truncate,
524 }),
525 preceded("<>", (ws, word)).map(move |(_, target)| Redir::ReadWrite {
528 fd: fd.unwrap_or(0),
529 target,
530 }),
531 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
532 fd: fd.unwrap_or(0),
533 target,
534 }),
535 ))
536 .parse_next(input)
537}
538
539fn heredoc(input: &mut &str) -> ModalResult<Redir> {
540 "<<".parse_next(input)?;
541 let strip_tabs = opt('-').parse_next(input)?.is_some();
542 ws.parse_next(input)?;
543 let (delimiter, expands) = heredoc_delimiter.parse_next(input)?;
544 let body = if expands {
548 heredoc_body_word(input, &delimiter, strip_tabs)?
549 } else {
550 Word(Vec::new())
551 };
552 PENDING_HEREDOCS.with(|q| {
558 q.borrow_mut().push(PendingHeredoc {
559 delimiter: delimiter.clone(),
560 strip_tabs,
561 });
562 });
563 Ok(Redir::HereDoc { delimiter, strip_tabs, body })
564}
565
566fn heredoc_body_word(input: &str, delimiter: &str, strip_tabs: bool) -> ModalResult<Word> {
576 let Some(nl) = input.find('\n') else {
577 return Ok(Word(Vec::new())); };
579 let mut rest = &input[nl + 1..];
580 let priors: Vec<PendingHeredoc> = PENDING_HEREDOCS.with(|q| q.borrow().clone());
581 for prior in &priors {
582 let Some((_, after)) = split_heredoc_body(rest, &prior.delimiter, prior.strip_tabs) else {
583 return Ok(Word(Vec::new()));
584 };
585 rest = after;
586 }
587 let Some((body, _)) = split_heredoc_body(rest, delimiter, strip_tabs) else {
588 return Ok(Word(Vec::new()));
589 };
590 let mut text = body;
591 let parts: Vec<WordPart> = repeat(0.., heredoc_part).parse_next(&mut text)?;
592 if !text.is_empty() {
593 return backtrack();
594 }
595 Ok(Word(parts))
596}
597
598fn is_heredoc_literal(c: char) -> bool {
602 !matches!(c, '"' | '\\' | '`' | '$')
603}
604
605fn heredoc_part(input: &mut &str) -> ModalResult<WordPart> {
606 if input.is_empty() {
607 return backtrack();
608 }
609 if input.starts_with('"') {
610 *input = &input[1..];
611 return Ok(WordPart::Lit("\"".to_string()));
612 }
613 alt((
614 dq_escape,
615 arith_sub,
616 cmd_sub,
617 backtick_part,
618 dollar_lit(is_heredoc_literal),
619 lit(is_heredoc_literal),
620 ))
621 .parse_next(input)
622}
623
624#[derive(Debug, Clone)]
625struct PendingHeredoc {
626 delimiter: String,
627 strip_tabs: bool,
628}
629
630thread_local! {
631 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
632 const { std::cell::RefCell::new(Vec::new()) };
633}
634
635fn drain_pending_heredocs(input: &mut &str) {
636 let pending: Vec<PendingHeredoc> =
637 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
638 for h in pending {
639 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
640 return;
644 }
645 }
646}
647
648fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
649 match split_heredoc_body(input, delimiter, strip_tabs) {
650 Some((_, rest)) => {
651 *input = rest;
652 true
653 }
654 None => false,
655 }
656}
657
658fn split_heredoc_body<'a>(
662 s: &'a str,
663 delimiter: &str,
664 strip_tabs: bool,
665) -> Option<(&'a str, &'a str)> {
666 let bytes = s.as_bytes();
667 let mut line_start = 0;
668 while line_start <= bytes.len() {
669 let line_end = match s[line_start..].find('\n') {
670 Some(rel) => line_start + rel,
671 None => bytes.len(),
672 };
673 let line = &s[line_start..line_end];
674 let line = if strip_tabs { line.trim_start_matches('\t') } else { line };
675 if line == delimiter {
676 let advance = line_end + usize::from(line_end < bytes.len());
677 return Some((&s[..line_start], &s[advance..]));
678 }
679 if line_end >= bytes.len() {
680 return None;
681 }
682 line_start = line_end + 1;
683 }
684 None
685}
686
687fn reset_heredoc_queue() {
688 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
689}
690
691fn heredoc_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
696 alt((
697 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| (s.to_string(), false)),
698 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| (s.to_string(), false)),
699 escaped_delimiter,
700 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
701 .map(|s: &str| (s.to_string(), true)),
702 ))
703 .parse_next(input)
704}
705
706fn escaped_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
711 let mut rest = *input;
712 let mut name = String::new();
713 let mut quoted = false;
714 loop {
715 let mut chars = rest.chars();
716 match chars.next() {
717 Some('\\') => match chars.next() {
718 Some(c) => {
719 name.push(c);
720 quoted = true;
721 rest = &rest[1 + c.len_utf8()..];
722 }
723 None => break,
724 },
725 Some(q @ ('\'' | '"')) => {
726 let inner_end = rest[1..].find(q).map(|i| i + 1);
727 let Some(end) = inner_end else { break };
728 name.push_str(&rest[1..end]);
729 quoted = true;
730 rest = &rest[end + 1..];
731 }
732 Some(c) if c.is_ascii_alphanumeric() || c == '_' => {
733 name.push(c);
734 rest = &rest[c.len_utf8()..];
735 }
736 _ => break,
737 }
738 }
739 if !quoted || name.is_empty() {
740 return backtrack();
741 }
742 *input = rest;
743 Ok((name, false))
744}
745
746fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
747 let b = input.as_bytes();
748 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
749 let d = (b[0] - b'0') as u32;
750 *input = &input[1..];
751 Ok(d)
752 } else {
753 backtrack()
754 }
755}
756
757fn fd_target(input: &mut &str) -> ModalResult<String> {
758 alt((
759 '-'.value("-".to_string()),
760 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
761 ))
762 .parse_next(input)
763}
764
765fn word(input: &mut &str) -> ModalResult<Word> {
768 repeat(1.., word_part)
769 .map(Word)
770 .parse_next(input)
771}
772
773fn word_part(input: &mut &str) -> ModalResult<WordPart> {
774 if input.is_empty() {
775 return backtrack();
776 }
777 if input.starts_with("<(") || input.starts_with(">(") {
778 return proc_sub(input);
779 }
780 if is_word_boundary(input.as_bytes()[0] as char) {
781 return backtrack();
782 }
783 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
784 .parse_next(input)
785}
786
787fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
788 delimited('\'', take_while(0.., |c| c != '\''), '\'')
789 .map(|s: &str| WordPart::SQuote(s.to_string()))
790 .parse_next(input)
791}
792
793fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
794 delimited('"', repeat(0.., dq_part).map(Word), '"')
795 .map(WordPart::DQuote)
796 .parse_next(input)
797}
798
799fn find_sub_close(body: &str) -> Option<usize> {
807 let b = body.as_bytes();
808 let mut i = 0;
809 let mut depth: usize = 0;
810 while i < b.len() {
811 match b[i] {
812 b'\\' => i += 1, b'\'' => {
814 i += 1;
815 while i < b.len() && b[i] != b'\'' {
816 i += 1;
817 }
818 if i >= b.len() {
819 return None;
820 }
821 }
822 b'"' => {
823 i += 1;
824 while i < b.len() && b[i] != b'"' {
825 i += if b[i] == b'\\' { 2 } else { 1 };
826 }
827 if i >= b.len() {
828 return None;
829 }
830 }
831 b'`' => {
832 i += 1;
833 while i < b.len() && b[i] != b'`' {
834 i += if b[i] == b'\\' { 2 } else { 1 };
837 }
838 if i >= b.len() {
839 return None;
840 }
841 }
842 b'(' => depth += 1,
843 b')' => {
844 if depth == 0 {
845 return Some(i);
846 }
847 depth -= 1;
848 }
849 _ => {}
850 }
851 i += 1;
852 }
853 None
854}
855
856fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
874 let body = &input[open_len..];
875 let Some(rel) = find_sub_close(body) else {
876 return if body.contains("<<") {
877 sub_body_via_grammar(input, body)
878 } else {
879 backtrack()
880 };
881 };
882 let interior = &body[..rel];
888 if !interior.contains("<<") && !interior.contains("case") {
894 let mut fast: &str = interior;
895 if let Ok(parsed) = script.parse_next(&mut fast) {
896 ws.parse_next(&mut fast)?;
897 if fast.is_empty() {
898 *input = &body[rel + 1..];
899 return Ok(parsed);
900 }
901 }
902 }
903 sub_body_via_grammar(input, body)
904}
905
906fn sub_body_via_grammar<'a>(input: &mut &'a str, body: &'a str) -> ModalResult<Script> {
909 let mut rest: &str = body;
910 ws.parse_next(&mut rest)?;
911 let parsed = script.parse_next(&mut rest)?;
912 ws.parse_next(&mut rest)?;
913 if !rest.starts_with(')') {
914 return backtrack();
915 }
916 *input = &rest[1..];
917 Ok(parsed)
918}
919
920fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
921 if !input.starts_with("$(") {
922 return backtrack();
923 }
924 sub_body(input, 2).map(WordPart::CmdSub)
925}
926
927fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
928 if !(input.starts_with("<(") || input.starts_with(">(")) {
929 return backtrack();
930 }
931 sub_body(input, 2).map(WordPart::ProcSub)
932}
933
934fn arith_body_part(input: &mut &str) -> ModalResult<WordPart> {
936 if input.is_empty() {
937 return backtrack();
938 }
939 alt((
940 dq_escape,
941 arith_sub,
946 cmd_sub,
947 backtick_part,
948 dollar_lit(is_heredoc_literal),
949 lit(is_heredoc_literal),
950 ))
951 .parse_next(input)
952}
953
954fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
955 if !input.starts_with("$((") {
956 return backtrack();
957 }
958 let Some(_depth) = DepthGuard::enter() else {
965 return backtrack();
966 };
967 let body_start = 3;
968 let bytes = input.as_bytes();
969 let mut depth: i32 = 1;
970 let mut i = body_start;
971 while i < bytes.len() {
972 match bytes[i] {
973 b'(' => depth += 1,
974 b')' => {
975 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
976 let mut body = &input[body_start..i];
988 let parts: Vec<WordPart> = repeat(0.., arith_body_part).parse_next(&mut body)?;
989 if !body.is_empty() {
990 return backtrack();
991 }
992 *input = &input[i + 2..];
993 return Ok(WordPart::Arith(Word(parts)));
994 }
995 depth -= 1;
996 if depth < 0 {
997 return backtrack();
998 }
999 }
1000 _ => {}
1001 }
1002 i += 1;
1003 }
1004 backtrack()
1005}
1006
1007fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
1008 delimited('`', backtick_inner, '`')
1009 .map(WordPart::Backtick)
1010 .parse_next(input)
1011}
1012
1013fn escaped(input: &mut &str) -> ModalResult<WordPart> {
1014 preceded('\\', any).map(WordPart::Escape).parse_next(input)
1015}
1016
1017fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
1018 move |input: &mut &str| {
1019 take_while(1.., pred)
1020 .map(|s: &str| WordPart::Lit(s.to_string()))
1021 .parse_next(input)
1022 }
1023}
1024
1025fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
1026 move |input: &mut &str| {
1027 ('$', not('(')).void().parse_next(input)?;
1028 let rest: &str = take_while(0.., pred).parse_next(input)?;
1029 Ok(WordPart::Lit(format!("${rest}")))
1030 }
1031}
1032
1033fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
1036 if input.is_empty() || input.starts_with('"') {
1037 return backtrack();
1038 }
1039 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
1040 .parse_next(input)
1041}
1042
1043fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
1044 preceded('\\', any)
1045 .map(|c: char| match c {
1046 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
1047 _ => WordPart::Lit(format!("\\{c}")),
1048 })
1049 .parse_next(input)
1050}
1051
1052fn backtick_inner(input: &mut &str) -> ModalResult<String> {
1055 repeat(0.., alt((bt_escape, bt_literal)))
1056 .fold(String::new, |mut acc, chunk: &str| {
1057 acc.push_str(chunk);
1058 acc
1059 })
1060 .parse_next(input)
1061}
1062
1063fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1064 ('\\', any).take().parse_next(input)
1065}
1066
1067fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1068 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
1069}
1070
1071fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
1074 eat_keyword(input, "for")?;
1075 ws.parse_next(input)?;
1076 let var = name.parse_next(input)?;
1077 ws.parse_next(input)?;
1078
1079 let items = if eat_keyword(input, "in").is_ok() {
1080 ws.parse_next(input)?;
1081 repeat(0.., terminated(word, ws)).parse_next(input)?
1082 } else {
1083 vec![]
1084 };
1085
1086 let body = do_done_body.parse_next(input)?;
1087 let redirs = trailing_redirs(input)?;
1088 Ok(Cmd::For { var, items, body, redirs })
1089}
1090
1091fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
1092 eat_keyword(input, "while")?;
1093 ws.parse_next(input)?;
1094 let cond = script.parse_next(input)?;
1095 let body = do_done_body.parse_next(input)?;
1096 let redirs = trailing_redirs(input)?;
1097 Ok(Cmd::While { cond, body, redirs })
1098}
1099
1100fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
1101 eat_keyword(input, "until")?;
1102 ws.parse_next(input)?;
1103 let cond = script.parse_next(input)?;
1104 let body = do_done_body.parse_next(input)?;
1105 let redirs = trailing_redirs(input)?;
1106 Ok(Cmd::Until { cond, body, redirs })
1107}
1108
1109fn do_done_body(input: &mut &str) -> ModalResult<Script> {
1110 sep.parse_next(input)?;
1111 eat_keyword(input, "do")?;
1112 sep.parse_next(input)?;
1113 let body = script.parse_next(input)?;
1114 sep.parse_next(input)?;
1115 eat_keyword(input, "done")?;
1116 Ok(body)
1117}
1118
1119fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
1120 eat_keyword(input, "if")?;
1121 ws.parse_next(input)?;
1122 let mut branches = vec![cond_then_body.parse_next(input)?];
1123 let mut else_body = None;
1124
1125 loop {
1126 sep.parse_next(input)?;
1127 if eat_keyword(input, "elif").is_ok() {
1128 ws.parse_next(input)?;
1129 branches.push(cond_then_body.parse_next(input)?);
1130 } else if eat_keyword(input, "else").is_ok() {
1131 sep.parse_next(input)?;
1132 else_body = Some(script.parse_next(input)?);
1133 break;
1134 } else {
1135 break;
1136 }
1137 }
1138
1139 sep.parse_next(input)?;
1140 eat_keyword(input, "fi")?;
1141 let redirs = trailing_redirs(input)?;
1142 Ok(Cmd::If { branches, else_body, redirs })
1143}
1144
1145fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
1146 let cond = script.parse_next(input)?;
1147 sep.parse_next(input)?;
1148 eat_keyword(input, "then")?;
1149 sep.parse_next(input)?;
1150 let body = script.parse_next(input)?;
1151 Ok(Branch { cond, body })
1152}
1153
1154fn case_cmd(input: &mut &str) -> ModalResult<Cmd> {
1156 eat_keyword(input, "case")?;
1157 ws.parse_next(input)?;
1158 let subject = word.parse_next(input)?;
1159 blank.parse_next(input)?;
1160 eat_keyword(input, "in")?;
1161
1162 let mut arms = Vec::new();
1163 loop {
1164 blank.parse_next(input)?;
1165 if eat_keyword(input, "esac").is_ok() {
1166 break;
1167 }
1168 let arm = case_arm.parse_next(input)?;
1169 let had_terminator = opt(";;").parse_next(input)?.is_some();
1170 arms.push(arm);
1171 if !had_terminator {
1174 blank.parse_next(input)?;
1175 eat_keyword(input, "esac")?;
1176 break;
1177 }
1178 }
1179
1180 let redirs = trailing_redirs(input)?;
1181 Ok(Cmd::Case { subject, arms, redirs })
1182}
1183
1184fn case_arm(input: &mut &str) -> ModalResult<CaseArm> {
1185 blank.parse_next(input)?;
1186 opt('(').parse_next(input)?;
1187 let mut patterns = Vec::new();
1188 loop {
1189 ws.parse_next(input)?;
1190 patterns.push(word.parse_next(input)?);
1191 ws.parse_next(input)?;
1192 if opt('|').parse_next(input)?.is_none() {
1193 break;
1194 }
1195 }
1196 ')'.parse_next(input)?;
1197 let body = script.parse_next(input)?;
1198 blank.parse_next(input)?;
1199 Ok(CaseArm { patterns, body })
1200}
1201
1202fn blank(input: &mut &str) -> ModalResult<()> {
1204 take_while(0.., [' ', '\t', '\n']).void().parse_next(input)
1205}
1206
1207fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
1208 if !input.starts_with("[[") {
1209 return backtrack();
1210 }
1211 let bytes = input.as_bytes();
1212 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
1213 return backtrack();
1214 }
1215 *input = &input[2..];
1216
1217 let mut words: Vec<Word> = Vec::new();
1218 loop {
1219 ws.parse_next(input)?;
1220 if at_double_bracket_end(input) {
1221 *input = &input[2..];
1222 let redirs = trailing_redirs(input)?;
1223 return Ok(Cmd::DoubleBracket { words, redirs });
1224 }
1225 if input.is_empty() {
1226 return backtrack();
1227 }
1228 let w = bracket_word.parse_next(input)?;
1229 words.push(w);
1230 }
1231}
1232
1233fn at_double_bracket_end(input: &str) -> bool {
1234 if !input.starts_with("]]") {
1235 return false;
1236 }
1237 let after = &input[2..];
1238 after.is_empty()
1239 || after.starts_with(|c: char| {
1240 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
1241 })
1242}
1243
1244fn bracket_word(input: &mut &str) -> ModalResult<Word> {
1245 repeat(1.., bracket_word_part).map(Word).parse_next(input)
1246}
1247
1248fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
1249 if input.is_empty() {
1250 return backtrack();
1251 }
1252 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
1253 return backtrack();
1254 }
1255 if at_double_bracket_end(input) {
1256 return backtrack();
1257 }
1258 alt((
1259 single_quoted,
1260 double_quoted,
1261 arith_sub,
1262 cmd_sub,
1263 backtick_part,
1264 escaped,
1265 dollar_lit(is_bracket_literal),
1266 bracket_lit,
1267 ))
1268 .parse_next(input)
1269}
1270
1271fn is_bracket_literal(c: char) -> bool {
1272 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
1273}
1274
1275fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
1276 let bytes = input.as_bytes();
1281 let mut end = 0;
1282 while end < bytes.len() {
1283 let c = bytes[end] as char;
1284 if !is_bracket_literal(c) {
1285 break;
1286 }
1287 if c == ']' && at_double_bracket_end(&input[end..]) {
1288 break;
1289 }
1290 end += 1;
1291 }
1292 if end == 0 {
1293 return backtrack();
1294 }
1295 let lit = input[..end].to_string();
1296 *input = &input[end..];
1297 Ok(WordPart::Lit(lit))
1298}
1299
1300fn name(input: &mut &str) -> ModalResult<String> {
1301 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
1302 .map(|s: &str| s.to_string())
1303 .parse_next(input)
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 #[test]
1326 fn the_parse_work_budget_bounds_nesting_without_refusing_real_commands() {
1327 let worst_real = crate::registry::corpus_examples()
1328 .into_iter()
1329 .flat_map(|(_, safe, denied)| safe.iter().chain(denied.iter()).cloned().collect::<Vec<_>>())
1330 .map(|ex| {
1331 let _ = super::parse(&ex);
1332 super::PARSE_WORK.with(|w| w.get())
1333 })
1334 .max()
1335 .expect("the registry ships examples");
1336 assert!(
1337 worst_real * 100 < MAX_PARSE_WORK_CEILING,
1338 "a real example needs {worst_real} entries against a {MAX_PARSE_WORK_CEILING} ceiling; \
1339 the margin that makes this constant safe is gone"
1340 );
1341
1342 let flat = format!("echo {}", "$((1)) ".repeat(400));
1348 let _ = super::parse(&flat);
1349 let flat_work = super::PARSE_WORK.with(|w| w.get());
1350 assert!(
1351 flat_work < MAX_PARSE_WORK_CEILING / 10,
1352 "flat input cost {flat_work}, close to the {MAX_PARSE_WORK_CEILING} ceiling — a long \
1353 flat command is at risk of being refused"
1354 );
1355
1356 let work_at = |depth: usize| {
1362 let deep = format!("echo {}1{}", "$((1+".repeat(depth), "))".repeat(depth));
1363 let _ = super::parse(&deep);
1364 super::PARSE_WORK.with(|w| w.get())
1365 };
1366 let (w2k, w4k) = (work_at(2000), work_at(4000));
1367 assert!(w2k > 1000, "the nest should actually reach the bound, or this proves nothing");
1368 assert!(
1369 w4k <= w2k + w2k / 10,
1370 "work still scales with input length: depth 2000 used {w2k}, depth 4000 used {w4k}"
1371 );
1372 assert!(
1373 w4k < MAX_PARSE_WORK_CEILING + MAX_PARSE_WORK_CEILING / 10,
1374 "work {w4k} ran far past the {MAX_PARSE_WORK_CEILING} ceiling"
1375 );
1376 }
1377
1378
1379 use super::*;
1380
1381 fn p(input: &str) -> Script {
1382 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
1383 }
1384
1385 fn words(script: &Script) -> Vec<String> {
1386 match &script.0[0].pipeline.commands[0] {
1387 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
1388 _ => panic!("expected simple command"),
1389 }
1390 }
1391
1392 fn simple(script: &Script) -> &SimpleCmd {
1393 match &script.0[0].pipeline.commands[0] {
1394 Cmd::Simple(s) => s,
1395 _ => panic!("expected simple command"),
1396 }
1397 }
1398
1399 #[test]
1400 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
1401 #[test]
1402 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
1403 #[test]
1404 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
1405 #[test]
1406 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
1407 #[test]
1408 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
1409
1410 #[test]
1411 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
1412 #[test]
1413 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
1414 #[test]
1415 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
1416 #[test]
1417 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
1418 #[test]
1419 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
1420 #[test]
1421 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
1422 #[test]
1423 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
1424 #[test]
1425 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
1426 #[test]
1427 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
1428 #[test]
1429 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
1430
1431 #[test]
1432 fn brace_group_simple() {
1433 assert!(matches!(
1434 &p("{ echo hello; }").0[0].pipeline.commands[0],
1435 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1436 ));
1437 }
1438 #[test]
1439 fn brace_group_multiple_stmts() {
1440 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1441 assert_eq!(body.0.len(), 3);
1442 } else { panic!("expected BraceGroup"); }
1443 }
1444 #[test]
1445 fn brace_group_with_redirect() {
1446 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1447 assert_eq!(redirs.len(), 1);
1448 assert!(matches!(redirs[0], Redir::Write { .. }));
1449 } else { panic!("expected BraceGroup"); }
1450 }
1451 #[test]
1452 fn brace_group_with_append_redirect() {
1453 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1454 assert!(matches!(redirs[0], Redir::Write { mode: WriteMode::Append, .. }));
1455 } else { panic!("expected BraceGroup"); }
1456 }
1457 #[test]
1458 fn brace_group_with_stderr_redirect() {
1459 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1460 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1461 } else { panic!("expected BraceGroup"); }
1462 }
1463 #[test]
1464 fn brace_group_newline_separated() {
1465 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
1466 assert_eq!(body.0.len(), 2);
1467 } else { panic!("expected BraceGroup"); }
1468 }
1469 #[test]
1476 fn time_keyword_prefixes_a_compound() {
1477 for (src, want_subshell) in
1478 [("time (ls)", true), ("time -p (ls)", true), ("time { ls; }", false)]
1479 {
1480 let pl = &p(src).0[0].pipeline;
1481 assert_eq!(pl.commands.len(), 1, "{src}: one compound command");
1482 if want_subshell {
1483 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }), "{src}: kept the subshell");
1484 } else {
1485 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }), "{src}: kept the group");
1486 }
1487 }
1488 let pl = &p("time (ls | head -5)").0[0].pipeline;
1490 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }));
1491
1492 let pl = &p("time ls").0[0].pipeline;
1495 assert!(matches!(&pl.commands[0], Cmd::Simple(_)), "time ls stays a simple command");
1496
1497 let pl = &p("timeout 5 ls").0[0].pipeline;
1499 assert!(matches!(&pl.commands[0], Cmd::Simple(_)), "timeout is not the time keyword");
1500
1501 let pl = &p("! time (ls)").0[0].pipeline;
1506 assert!(pl.bang, "the bang survives the time keyword");
1507 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }), "and the compound survives too");
1508 }
1509
1510 #[test]
1511 fn brace_group_in_pipeline() {
1512 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1513 assert_eq!(pl.commands.len(), 2);
1514 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1515 }
1516 #[test]
1517 fn brace_group_followed_by_other() {
1518 let stmts = &p("{ echo a; }; echo b").0;
1519 assert_eq!(stmts.len(), 2);
1520 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1521 }
1522 #[test]
1523 fn brace_group_nested() {
1524 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1525 assert_eq!(body.0.len(), 2);
1526 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1527 } else { panic!("expected outer BraceGroup"); }
1528 }
1529 #[test]
1530 fn brace_group_with_subshell_inside() {
1531 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1532 assert_eq!(body.0.len(), 2);
1533 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1534 } else { panic!("expected BraceGroup"); }
1535 }
1536 #[test]
1537 fn brace_open_requires_whitespace() {
1538 let cmds = &p("{echo a}").0;
1542 if !cmds.is_empty() {
1545 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1546 }
1547 }
1548 #[test]
1549 fn subshell_with_redirect() {
1550 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1551 assert_eq!(redirs.len(), 1);
1552 } else { panic!("expected Subshell with redir"); }
1553 }
1554 #[test]
1555 fn for_loop_with_redirect() {
1556 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1557 assert_eq!(redirs.len(), 1);
1558 } else { panic!("expected For with redir"); }
1559 }
1560 #[test]
1561 fn for_loop_redirect_then_pipe() {
1562 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1564 assert_eq!(pl.commands.len(), 2);
1565 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1566 }
1567 #[test]
1568 fn while_and_if_with_redirect() {
1569 assert!(matches!(
1570 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1571 Cmd::While { redirs, .. } if redirs.len() == 1
1572 ));
1573 assert!(matches!(
1574 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1575 Cmd::If { redirs, .. } if redirs.len() == 1
1576 ));
1577 }
1578 #[test]
1579 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1580
1581 #[test]
1582 fn redirect_dev_null() {
1583 let s = p("echo hello > /dev/null");
1584 let cmd = simple(&s);
1585 assert_eq!(cmd.words.len(), 2);
1586 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, mode: WriteMode::Truncate, .. }));
1587 }
1588 #[test]
1589 fn redirect_stderr() {
1590 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1591 }
1592 #[test]
1593 fn here_string() {
1594 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1595 }
1596 #[test]
1597 fn heredoc_bare() {
1598 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false, .. } if delimiter == "EOF"));
1599 }
1600 #[test]
1601 fn heredoc_with_content() {
1602 let s = p("cat <<EOF\nhello world\nEOF");
1603 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1604 }
1605 #[test]
1606 fn heredoc_quoted_delimiter() {
1607 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1608 }
1609 #[test]
1610 fn heredoc_strip_tabs() {
1611 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1612 }
1613 #[test]
1614 fn heredoc_pipe_on_command_line() {
1615 let s = p("cat <<EOF | grep hello\nhello\nEOF");
1618 assert_eq!(s.0[0].pipeline.commands.len(), 2);
1619 }
1620 #[test]
1621 fn heredoc_body_does_not_swallow_pipe() {
1622 let s = p("cat <<EOF | bash\nrm\nEOF");
1626 assert_eq!(
1627 s.0[0].pipeline.commands.len(),
1628 2,
1629 "pipeline must keep `bash` as a second command"
1630 );
1631 }
1632 #[test]
1633 fn heredoc_followed_by_next_statement() {
1634 let s = p("cat <<EOF\nhello\nEOF\nls");
1637 assert_eq!(s.0.len(), 2);
1638 }
1639
1640 #[test]
1641 fn env_prefix() {
1642 let s = p("FOO='bar baz' ls -la");
1643 let cmd = simple(&s);
1644 assert_eq!(cmd.env[0].0, "FOO");
1645 assert_eq!(cmd.env[0].1.eval(), "bar baz");
1646 }
1647 #[test]
1648 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1649 #[test]
1650 fn backtick_substitution() {
1651 assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB_WORKTREE__");
1654 assert_eq!(simple(&p("ls `hostname`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__");
1656 }
1657 #[test]
1658 fn nested_substitution() {
1659 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1660 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1661 } else { panic!("expected CmdSub"); }
1662 }
1663
1664 #[test]
1665 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1666 #[test]
1667 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1668
1669 #[test]
1670 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")); }
1671 #[test]
1672 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1673 #[test]
1674 fn if_then_fi() {
1675 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1676 assert_eq!(branches.len(), 1);
1677 assert!(else_body.is_none());
1678 } else { panic!("expected If"); }
1679 }
1680 #[test]
1681 fn if_elif_else() {
1682 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] {
1683 assert_eq!(branches.len(), 2);
1684 assert!(else_body.is_some());
1685 } else { panic!("expected If"); }
1686 }
1687
1688 #[test]
1689 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1690 #[test]
1691 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1692 #[test]
1693 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1694
1695 #[test]
1696 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1697 #[test]
1698 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1699 #[test]
1700 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1701 #[test]
1702 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1703 #[test]
1704 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1705 #[test]
1706 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1707
1708 #[test]
1709 fn subshell_for() {
1710 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1711 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1712 } else { panic!("expected Subshell"); }
1713 }
1714 #[test]
1715 fn proc_sub_input() {
1716 let s = p("diff <(sort a.txt) <(sort b.txt)");
1717 let cmd = simple(&s);
1718 assert_eq!(cmd.words.len(), 3);
1719 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1720 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1721 }
1722 #[test]
1723 fn proc_sub_output() {
1724 let s = p("tee >(grep error > /dev/null)");
1725 let cmd = simple(&s);
1726 assert_eq!(cmd.words.len(), 2);
1727 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1728 }
1729
1730 fn func_def(s: &Script) -> (&str, &Script) {
1732 match &s.0[0].pipeline.commands[0] {
1733 Cmd::FunctionDef { name, body } => (name.as_str(), body),
1734 other => panic!("expected FunctionDef, got {other:?}"),
1735 }
1736 }
1737 #[test]
1738 fn function_def_posix_form() {
1739 let s = p("probe(){ echo hi; }");
1740 let (name, body) = func_def(&s);
1741 assert_eq!(name, "probe");
1742 assert_eq!(words(body), ["echo", "hi"]);
1743 }
1744 #[test]
1745 fn function_def_spaced_and_keyword_forms() {
1746 assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1747 assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1748 assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1749 }
1750 #[test]
1751 fn function_def_subshell_body() {
1752 let s = p("foo() ( echo sub )");
1753 assert_eq!(func_def(&s).0, "foo");
1754 }
1755 #[test]
1756 fn function_def_body_on_next_line() {
1757 assert_eq!(func_def(&p("foo()\n{\n echo hi\n}")).0, "foo");
1758 }
1759 #[test]
1760 fn function_def_name_with_dashes_and_dots() {
1761 assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1762 }
1763 #[test]
1764 fn plain_command_is_not_a_function_def() {
1765 assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1767 assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1768 }
1769 #[test]
1770 fn function_def_roundtrips() {
1771 let rendered = p("greet(){ echo hi; }").to_string();
1772 assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1773 assert_eq!(func_def(&p(&rendered)).0, "greet");
1774 }
1775 #[test]
1776 fn comment_only() {
1777 let s = p("# just a comment");
1778 assert!(s.0.is_empty());
1779 }
1780 #[test]
1781 fn comment_before_command() {
1782 let s = p("# comment\necho hello");
1783 assert_eq!(words(&s), ["echo", "hello"]);
1784 }
1785 #[test]
1786 fn inline_comment() {
1787 let s = p("echo hello # this is a comment");
1788 assert_eq!(words(&s), ["echo", "hello"]);
1789 }
1790 #[test]
1791 fn comment_between_commands() {
1792 let s = p("echo hello\n# middle comment\necho world");
1793 assert_eq!(s.0.len(), 2);
1794 }
1795 #[test]
1796 fn comment_after_semicolon() {
1797 let s = p("echo hello; # comment\necho world");
1798 assert_eq!(s.0.len(), 2);
1799 }
1800 #[test]
1801 fn comment_in_for_loop() {
1802 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1803 }
1804 #[test]
1805 fn quoted_redirect_in_echo() {
1806 let s = p("echo 'greater > than' test");
1807 let cmd = simple(&s);
1808 assert_eq!(cmd.words.len(), 3);
1809 assert_eq!(cmd.redirs.len(), 0);
1810 }
1811
1812 #[test]
1813 fn parses_all_safe_commands() {
1814 let cmds = [
1815 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1816 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1817 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1818 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1819 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1820 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1821 "((echo hello))", "(for x in 1 2; do echo $x; done)",
1822 "echo 'greater > than' test", "echo '$(safe)' arg",
1823 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1824 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1825 "grep foo file.txt | head -5", "cat file | sort | uniq",
1826 "ls && echo done", "ls; echo done", "ls & echo done",
1827 "grep -c , <<< 'hello,world,test'",
1828 "cat <<EOF\nhello world\nEOF",
1829 "cat <<'MARKER'\nsome text\nMARKER",
1830 "cat <<-EOF\n\thello\nEOF",
1831 "echo foo\necho bar", "ls\ncat file.txt",
1832 "git log --oneline -20 | head -5",
1833 "echo hello > /dev/null", "echo hello 2> /dev/null",
1834 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1835 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1836 "for x in 1 2 3; do echo $x; done",
1837 "for f in *.txt; do cat $f | grep pattern; done",
1838 "for x in 1 2 3; do; done",
1839 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1840 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1841 "for x in 1 2; do echo $x; done && echo finished",
1842 "for x in $(seq 1 5); do echo $x; done",
1843 "while test -f /tmp/foo; do sleep 1; done",
1844 "while ! test -f /tmp/done; do sleep 1; done",
1845 "until test -f /tmp/ready; do sleep 1; done",
1846 "if test -f foo; then echo exists; fi",
1847 "if test -f foo; then echo yes; else echo no; fi",
1848 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1849 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1850 "if true; then for x in 1 2; do echo $x; done; fi",
1851 "diff <(sort a.txt) <(sort b.txt)",
1852 "comm -23 file.txt <(sort other.txt)",
1853 "cat <(echo hello)",
1854 "# comment only",
1855 "# comment\necho hello",
1856 "echo hello # inline comment",
1857 "echo one\n# between\necho two",
1858 "! echo hello", "! test -f foo",
1859 "echo for; echo done; echo if; echo fi",
1860 ];
1861 let mut failures = Vec::new();
1862 for cmd in &cmds {
1863 if parse(cmd).is_none() { failures.push(*cmd); }
1864 }
1865 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1866 }
1867
1868 fn inner_sub(s: &Script) -> &Script {
1874 match &simple(s).words[1].0[0] {
1875 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1876 other => panic!("expected a substitution, got {other:?}"),
1877 }
1878 }
1879
1880 #[test]
1881 fn cmd_sub_squote_paren_is_not_close() {
1882 assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1883 }
1884 #[test]
1885 fn cmd_sub_dquote_paren_is_not_close() {
1886 assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1887 }
1888 #[test]
1889 fn cmd_sub_escaped_paren_is_not_close() {
1890 assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1891 }
1892 #[test]
1893 fn cmd_sub_backtick_paren_is_not_close() {
1894 let s = p("echo $(x `)` y)");
1896 let inner = simple(inner_sub(&s));
1897 assert_eq!(inner.words.len(), 3);
1898 assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1899 }
1900 #[test]
1901 fn cmd_sub_escaped_backtick_does_not_end_span() {
1902 let s = p("echo $(`\\`)`)");
1906 assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1907 }
1908 #[test]
1909 fn proc_sub_squote_paren_is_not_close() {
1910 assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1911 }
1912 #[test]
1913 fn proc_sub_out_squote_paren_is_not_close() {
1914 assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1915 }
1916 #[test]
1917 fn cmd_sub_nested_picks_outer_close() {
1918 let s = p("echo $(a $(b) c)");
1919 let inner = simple(inner_sub(&s));
1920 assert_eq!(inner.words.len(), 3);
1921 assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1922 }
1923 #[test]
1924 fn cmd_sub_literal_after_close_stays_in_outer_word() {
1925 let s = p("echo $(ls)tail");
1926 let w = &simple(&s).words[1];
1927 assert_eq!(w.0.len(), 2);
1928 assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1929 assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1930 }
1931 #[test]
1932 fn cmd_sub_heredoc_body_paren_does_not_close() {
1933 let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1937 assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1938 }
1939 #[test]
1940 fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1941 assert!(parse("echo $(echo ')").is_none());
1943 }
1944 #[test]
1945 fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1946 assert!(parse("cat <(grep ')").is_none());
1947 }
1948}