1use super::budget::{self, DepthGuard};
2use super::sub_close::find_sub_close;
3use super::*;
4use winnow::ModalResult;
5use winnow::combinator::{alt, delimited, not, opt, preceded, repeat, separated, terminated};
6use winnow::error::{ContextError, ErrMode};
7use winnow::prelude::*;
8use winnow::token::{any, take_while};
9
10pub fn parse(input: &str) -> Option<Script> {
11 reset_heredoc_queue();
12 budget::reset(input.len());
13 let result = script.parse(input).ok().filter(|_| !budget::spent());
14 reset_heredoc_queue();
15 result
16}
17
18fn backtrack<T>() -> ModalResult<T> {
19 Err(ErrMode::Backtrack(ContextError::new()))
20}
21
22fn step() -> ModalResult<()> {
24 if budget::charge(1, 0) { Ok(()) } else { backtrack() }
25}
26
27fn scan(bytes: usize) -> ModalResult<()> {
29 if budget::charge(0, bytes) { Ok(()) } else { backtrack() }
30}
31
32fn consumed<'a, O>(input: &mut &'a str, p: impl FnOnce(&mut &'a str) -> ModalResult<O>) -> ModalResult<O> {
34 let before = input.len();
35 let out = p(input);
36 scan(before - input.len())?;
37 out
38}
39
40fn comment(input: &mut &str) -> ModalResult<()> {
41 if input.starts_with('#') {
42 if let Some(pos) = input.find('\n') {
43 *input = &input[pos + 1..];
44 } else {
45 *input = "";
46 }
47 }
48 Ok(())
49}
50
51fn ws(input: &mut &str) -> ModalResult<()> {
52 consumed(input, ws_raw)
53}
54
55fn ws_raw(input: &mut &str) -> ModalResult<()> {
56 loop {
57 take_while(0.., [' ', '\t']).void().parse_next(input)?;
58 if input.starts_with('#') {
59 comment(input)?;
60 } else {
61 break;
62 }
63 }
64 Ok(())
65}
66
67fn sep(input: &mut &str) -> ModalResult<()> {
68 consumed(input, sep_raw)
69}
70
71fn sep_raw(input: &mut &str) -> ModalResult<()> {
72 loop {
73 while let Some(c) = input.chars().next() {
76 if input.starts_with(";;") {
77 return Ok(());
78 }
79 if !matches!(c, ' ' | '\t' | ';' | '\n') {
80 break;
81 }
82 *input = &input[c.len_utf8()..];
83 }
84 if input.starts_with('#') {
85 comment(input)?;
86 } else {
87 break;
88 }
89 }
90 Ok(())
91}
92
93fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
94 if !input.starts_with(kw) {
95 return backtrack();
96 }
97 if input.as_bytes().get(kw.len()).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_') {
98 return backtrack();
99 }
100 *input = &input[kw.len()..];
101 Ok(())
102}
103
104const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "esac", "fi", "then"];
105
106fn at_script_stop(input: &str) -> bool {
107 input.starts_with(')')
108 || input.starts_with('}')
109 || input.starts_with(";;")
112 || SCRIPT_STOPS.iter().any(|kw| {
113 input.starts_with(kw)
114 && !input
115 .as_bytes()
116 .get(kw.len())
117 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
118 })
119}
120
121fn is_word_boundary(c: char) -> bool {
122 matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
123}
124
125fn is_word_literal(c: char) -> bool {
126 !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
127}
128
129fn is_dq_literal(c: char) -> bool {
130 !matches!(c, '"' | '\\' | '`' | '$')
131}
132
133fn script(input: &mut &str) -> ModalResult<Script> {
136 let Some(_depth) = DepthGuard::enter() else {
139 return backtrack();
140 };
141 sep.parse_next(input)?;
142 let mut stmts = Vec::new();
143 while let Some(pl) = opt(pipeline).parse_next(input)? {
144 ws.parse_next(input)?;
145 let op = opt(list_op).parse_next(input)?;
146 stmts.push(Stmt { pipeline: pl, op });
147 drain_pending_heredocs(input);
152 if op.is_none() {
153 break;
154 }
155 sep.parse_next(input)?;
156 }
157 Ok(Script(stmts))
158}
159
160fn list_op(input: &mut &str) -> ModalResult<ListOp> {
161 ws.parse_next(input)?;
162 alt((
163 "&&".value(ListOp::And),
164 "||".value(ListOp::Or),
165 '\n'.value(ListOp::Semi),
166 (';', not(';')).value(ListOp::Semi),
169 ('&', not('>')).value(ListOp::Amp),
170 ))
171 .parse_next(input)
172}
173
174fn pipe_sep(input: &mut &str) -> ModalResult<()> {
178 (ws, alt(("|&".void(), ('|', not('|')).void())), ws).void().parse_next(input)
179}
180
181fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
184 ws.parse_next(input)?;
185 if at_script_stop(input) {
186 return backtrack();
187 }
188 let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
189 opt_time_keyword_before_compound(input);
190 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
191 Ok(Pipeline { bang, commands })
192}
193
194fn opt_time_keyword_before_compound(input: &mut &str) -> bool {
217 let mut probe = *input;
218 if eat_keyword(&mut probe, "time").is_err() || !probe.starts_with([' ', '\t', '\n']) {
219 return false;
220 }
221 if ws.parse_next(&mut probe).is_err() {
222 return false;
223 }
224 if probe.starts_with("-p") {
226 let mut with_flag = &probe[2..];
227 if with_flag.starts_with([' ', '\t', '\n']) && ws.parse_next(&mut with_flag).is_ok() {
228 probe = with_flag;
229 }
230 }
231 if !probe.starts_with(['(', '{']) {
232 return false;
233 }
234 *input = probe;
235 true
236}
237
238fn command(input: &mut &str) -> ModalResult<Cmd> {
241 step()?;
242 ws.parse_next(input)?;
243 if at_script_stop(input) {
244 return backtrack();
245 }
246 let committed = super::reserved::opens_compound(input);
247 alt((subshell, brace_group, for_cmd, while_cmd, until_cmd, if_cmd, case_cmd, double_bracket_cmd, function_def, move |i: &mut &str| {
248 if committed { backtrack() } else { simple_cmd.map(Cmd::Simple).parse_next(i) }
249 }))
250 .parse_next(input)
251}
252
253fn function_def(input: &mut &str) -> ModalResult<Cmd> {
257 let had_keyword = opt_function_keyword(input);
258 let name = function_name(input)?;
259 ws.parse_next(input)?;
260 let has_parens = opt_paren_pair(input);
261 if !had_keyword && !has_parens {
262 return backtrack();
263 }
264 blank(input)?;
266 let body = function_body(input)?;
267 Ok(Cmd::FunctionDef { name, body })
268}
269
270fn opt_function_keyword(input: &mut &str) -> bool {
273 let mut probe = *input;
274 if eat_keyword(&mut probe, "function").is_ok() && probe.starts_with([' ', '\t', '\n']) && ws.parse_next(&mut probe).is_ok() {
275 *input = probe;
276 return true;
277 }
278 false
279}
280
281fn opt_paren_pair(input: &mut &str) -> bool {
283 let mut probe = *input;
284 if let Some(rest) = probe.strip_prefix('(') {
285 probe = rest;
286 if ws.parse_next(&mut probe).is_ok()
287 && let Some(rest) = probe.strip_prefix(')')
288 {
289 *input = rest;
290 return true;
291 }
292 }
293 false
294}
295
296fn function_name(input: &mut &str) -> ModalResult<String> {
297 run(input, |c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+')).map(String::from)
298}
299
300fn run<'a>(input: &mut &'a str, pred: fn(char) -> bool) -> ModalResult<&'a str> {
302 consumed(input, |i| take_while(1.., pred).parse_next(i))
303}
304
305fn function_body(input: &mut &str) -> ModalResult<Script> {
308 if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
309 return Ok(body);
310 }
311 if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
312 return Ok(body);
313 }
314 backtrack()
315}
316
317fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
318 let mut redirs = Vec::new();
319 loop {
320 ws.parse_next(input)?;
321 if let Some(r) = opt(redirect).parse_next(input)? {
322 redirs.push(r);
323 } else {
324 break;
325 }
326 }
327 Ok(redirs)
328}
329
330fn subshell(input: &mut &str) -> ModalResult<Cmd> {
331 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
332 let redirs = trailing_redirs(input)?;
333 Ok(Cmd::Subshell { body, redirs })
334}
335
336fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
337 if !input.starts_with('{') {
338 return backtrack();
339 }
340 if !input.as_bytes().get(1).is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n')) {
341 return backtrack();
342 }
343 *input = &input[1..];
344 sep.parse_next(input)?;
345 let body = script.parse_next(input)?;
346 if body.0.is_empty() {
347 return backtrack();
348 }
349 sep.parse_next(input)?;
350 if !input.starts_with('}') {
351 return backtrack();
352 }
353 let last_op = body.0.last().and_then(|s| s.op);
354 if last_op.is_none() {
355 return backtrack();
356 }
357 *input = &input[1..];
358 let redirs = trailing_redirs(input)?;
359 Ok(Cmd::BraceGroup { body, redirs })
360}
361
362fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
365 let env: Vec<(String, Word)> = repeat(0.., terminated(assignment, ws)).parse_next(input)?;
366 let mut words = Vec::new();
367 let mut redirs = Vec::new();
368
369 loop {
370 ws.parse_next(input)?;
371 if at_cmd_end(input) {
372 break;
373 }
374 if let Some(r) = opt(redirect).parse_next(input)? {
375 redirs.push(r);
376 } else if let Some(w) = opt(word).parse_next(input)? {
377 words.push(w);
378 } else {
379 break;
380 }
381 }
382
383 if env.is_empty() && words.is_empty() && redirs.is_empty() {
384 return backtrack();
385 }
386 Ok(SimpleCmd { env, words, redirs })
387}
388
389fn at_cmd_end(input: &str) -> bool {
390 if input.starts_with("&>") {
393 return false;
394 }
395 input.is_empty() || matches!(input.as_bytes().first(), Some(b'\n' | b';' | b'|' | b'&' | b')'))
396}
397
398fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
399 let n = run(input, |c| c.is_ascii_alphanumeric() || c == '_')?;
400 '='.parse_next(input)?;
401 let value = opt(word).parse_next(input)?.unwrap_or(Word(vec![WordPart::Lit(String::new())]));
402 Ok((n.to_string(), value))
403}
404
405fn redirect(input: &mut &str) -> ModalResult<Redir> {
408 let fd = opt(fd_prefix).parse_next(input)?;
409 alt((
410 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
411 heredoc,
412 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write { fd: fd.unwrap_or(1), target, mode: WriteMode::Append }),
413 preceded("&>>", (ws, word)).map(|(_, target)| Redir::Write { fd: 1, target, mode: WriteMode::AppendBoth }),
416 preceded("&>", (ws, word)).map(|(_, target)| Redir::Write { fd: 1, target, mode: WriteMode::TruncateBoth }),
417 preceded(">&", fd_target).map(move |dst| Redir::DupFd { src: fd.unwrap_or(1), dst }),
418 preceded(">&", (ws, word)).map(|(_, target)| Redir::Write { fd: 1, target, mode: WriteMode::TruncateBoth }),
422 preceded(">|", (ws, word)).map(move |(_, target)| Redir::Write { fd: fd.unwrap_or(1), target, mode: WriteMode::Clobber }),
426 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write { fd: fd.unwrap_or(1), target, mode: WriteMode::Truncate }),
427 preceded("<>", (ws, word)).map(move |(_, target)| Redir::ReadWrite { fd: fd.unwrap_or(0), target }),
430 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read { fd: fd.unwrap_or(0), target }),
431 ))
432 .parse_next(input)
433}
434
435fn heredoc(input: &mut &str) -> ModalResult<Redir> {
436 "<<".parse_next(input)?;
437 let strip_tabs = opt('-').parse_next(input)?.is_some();
438 ws.parse_next(input)?;
439 let before = input.len();
440 let delimiter = heredoc_delimiter.parse_next(input);
441 scan(if delimiter.is_ok() { before - input.len() } else { before })?;
442 let (delimiter, expands) = delimiter?;
443 let body = if expands { heredoc_body_word(input, &delimiter, strip_tabs)? } else { Word(Vec::new()) };
447 PENDING_HEREDOCS.with(|q| {
453 q.borrow_mut().push(PendingHeredoc { delimiter: delimiter.clone(), strip_tabs });
454 });
455 Ok(Redir::HereDoc { delimiter, strip_tabs, body })
456}
457
458fn heredoc_body_word(input: &str, delimiter: &str, strip_tabs: bool) -> ModalResult<Word> {
468 let Some(nl) = input.find('\n') else {
469 scan(input.len())?;
470 return Ok(Word(Vec::new())); };
472 let mut rest = &input[nl + 1..];
473 let priors: Vec<PendingHeredoc> = PENDING_HEREDOCS.with(|q| q.borrow().clone());
474 for prior in &priors {
475 let Some((_, after)) = split_heredoc_body(rest, &prior.delimiter, prior.strip_tabs) else {
476 scan(input.len())?;
477 return Ok(Word(Vec::new()));
478 };
479 rest = after;
480 }
481 let Some((body, after)) = split_heredoc_body(rest, delimiter, strip_tabs) else {
482 scan(input.len())?;
483 return Ok(Word(Vec::new()));
484 };
485 scan(input.len() - after.len())?;
486 let mut text = body;
487 let parts: Vec<WordPart> = repeat(0.., heredoc_part).parse_next(&mut text)?;
488 if !text.is_empty() {
489 return backtrack();
490 }
491 Ok(Word(parts))
492}
493
494fn is_heredoc_literal(c: char) -> bool {
498 !matches!(c, '"' | '\\' | '`' | '$')
499}
500
501fn heredoc_part(input: &mut &str) -> ModalResult<WordPart> {
502 step()?;
503 if input.is_empty() {
504 return backtrack();
505 }
506 if input.starts_with('"') {
507 *input = &input[1..];
508 return Ok(WordPart::Lit("\"".to_string()));
509 }
510 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_heredoc_literal), lit(is_heredoc_literal))).parse_next(input)
511}
512
513#[derive(Debug, Clone)]
514struct PendingHeredoc {
515 delimiter: String,
516 strip_tabs: bool,
517}
518
519thread_local! {
520 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
521 const { std::cell::RefCell::new(Vec::new()) };
522}
523
524fn drain_pending_heredocs(input: &mut &str) {
525 let pending: Vec<PendingHeredoc> = PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
526 for h in pending {
527 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
528 return;
532 }
533 }
534}
535
536fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
537 let split = split_heredoc_body(input, delimiter, strip_tabs);
538 let _ = scan(split.map_or(input.len(), |(_, rest)| input.len() - rest.len()));
539 match split {
540 Some((_, rest)) => {
541 *input = rest;
542 true
543 }
544 None => false,
545 }
546}
547
548fn split_heredoc_body<'a>(s: &'a str, delimiter: &str, strip_tabs: bool) -> Option<(&'a str, &'a str)> {
552 let bytes = s.as_bytes();
553 let mut line_start = 0;
554 while line_start <= bytes.len() {
555 let line_end = match s[line_start..].find('\n') {
556 Some(rel) => line_start + rel,
557 None => bytes.len(),
558 };
559 let line = &s[line_start..line_end];
560 let line = if strip_tabs { line.trim_start_matches('\t') } else { line };
561 if line == delimiter {
562 let advance = line_end + usize::from(line_end < bytes.len());
563 return Some((&s[..line_start], &s[advance..]));
564 }
565 if line_end >= bytes.len() {
566 return None;
567 }
568 line_start = line_end + 1;
569 }
570 None
571}
572
573fn reset_heredoc_queue() {
574 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
575}
576
577fn heredoc_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
582 alt((
583 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| (s.to_string(), false)),
584 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| (s.to_string(), false)),
585 escaped_delimiter,
586 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| (s.to_string(), true)),
587 ))
588 .parse_next(input)
589}
590
591fn escaped_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
596 let mut rest = *input;
597 let mut name = String::new();
598 let mut quoted = false;
599 loop {
600 let mut chars = rest.chars();
601 match chars.next() {
602 Some('\\') => match chars.next() {
603 Some(c) => {
604 name.push(c);
605 quoted = true;
606 rest = &rest[1 + c.len_utf8()..];
607 }
608 None => break,
609 },
610 Some(q @ ('\'' | '"')) => {
611 let inner_end = rest[1..].find(q).map(|i| i + 1);
612 let Some(end) = inner_end else { break };
613 name.push_str(&rest[1..end]);
614 quoted = true;
615 rest = &rest[end + 1..];
616 }
617 Some(c) if c.is_ascii_alphanumeric() || c == '_' => {
618 name.push(c);
619 rest = &rest[c.len_utf8()..];
620 }
621 _ => break,
622 }
623 }
624 if !quoted || name.is_empty() {
625 return backtrack();
626 }
627 *input = rest;
628 Ok((name, false))
629}
630
631fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
632 let b = input.as_bytes();
633 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
634 let d = (b[0] - b'0') as u32;
635 *input = &input[1..];
636 Ok(d)
637 } else {
638 backtrack()
639 }
640}
641
642fn fd_target(input: &mut &str) -> ModalResult<String> {
643 alt(('-'.value("-".to_string()), take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()))).parse_next(input)
644}
645
646fn word(input: &mut &str) -> ModalResult<Word> {
649 repeat(1.., word_part).map(Word).parse_next(input)
650}
651
652fn word_part(input: &mut &str) -> ModalResult<WordPart> {
653 step()?;
654 if input.is_empty() {
655 return backtrack();
656 }
657 if input.starts_with("<(") || input.starts_with(">(") {
658 return proc_sub(input);
659 }
660 if is_word_boundary(input.as_bytes()[0] as char) {
661 return backtrack();
662 }
663 alt((
664 single_quoted,
665 double_quoted,
666 dollar_quoted,
667 arith_sub,
668 cmd_sub,
669 backtick_part,
670 escaped,
671 dollar_lit(is_word_literal),
672 lit(is_word_literal),
673 ))
674 .parse_next(input)
675}
676
677fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
678 delimited_scan(input, '\'', |i| {
679 delimited('\'', take_while(0.., |c| c != '\''), '\'')
680 .map(|s: &str| WordPart::SQuote(s.to_string()))
681 .parse_next(i)
682 })
683}
684
685fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
686 delimited('"', repeat(0.., dq_part).map(Word), '"').map(WordPart::DQuote).parse_next(input)
687}
688
689fn dollar_quoted(input: &mut &str) -> ModalResult<WordPart> {
690 alt((super::ansi_c::ansi_c_quoted, |i: &mut &str| super::ansi_c::locale_quoted(i, double_quoted))).parse_next(input)
691}
692
693fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
711 let body = &input[open_len..];
712 let close = find_sub_close(body);
713 scan(close.map_or(2 * body.len(), |rel| 3 * rel))?;
714 let Some(rel) = close else {
715 return if body.contains("<<") { sub_body_via_grammar(input, body) } else { backtrack() };
716 };
717 let interior = &body[..rel];
723 if !interior.contains("<<") && !interior.contains("case") {
729 let mut fast: &str = interior;
730 let parsed = script.parse_next(&mut fast)?;
731 ws.parse_next(&mut fast)?;
732 if !fast.is_empty() {
733 return backtrack();
734 }
735 *input = &body[rel + 1..];
736 return Ok(parsed);
737 }
738 sub_body_via_grammar(input, body)
739}
740
741fn sub_body_via_grammar<'a>(input: &mut &'a str, body: &'a str) -> ModalResult<Script> {
744 let mut rest: &str = body;
745 ws.parse_next(&mut rest)?;
746 let parsed = script.parse_next(&mut rest)?;
747 ws.parse_next(&mut rest)?;
748 if !rest.starts_with(')') {
749 return backtrack();
750 }
751 *input = &rest[1..];
752 Ok(parsed)
753}
754
755fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
756 if !input.starts_with("$(") {
757 return backtrack();
758 }
759 sub_body(input, 2).map(WordPart::CmdSub)
760}
761
762fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
763 if !(input.starts_with("<(") || input.starts_with(">(")) {
764 return backtrack();
765 }
766 sub_body(input, 2).map(WordPart::ProcSub)
767}
768
769fn arith_body_part(input: &mut &str) -> ModalResult<WordPart> {
771 step()?;
772 if input.is_empty() {
773 return backtrack();
774 }
775 alt((
776 dq_escape,
777 arith_sub,
782 cmd_sub,
783 backtick_part,
784 dollar_lit(is_heredoc_literal),
785 lit(is_heredoc_literal),
786 ))
787 .parse_next(input)
788}
789
790fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
791 if !input.starts_with("$((") {
792 return backtrack();
793 }
794 let Some(_depth) = DepthGuard::enter() else {
801 return backtrack();
802 };
803 let body_start = 3;
804 let bytes = input.as_bytes();
805 let mut depth: i32 = 1;
806 let mut i = body_start;
807 while i < bytes.len() {
808 match bytes[i] {
809 b'(' => depth += 1,
810 b')' => {
811 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
812 scan(i)?;
824 let mut body = &input[body_start..i];
825 let parts: Vec<WordPart> = repeat(0.., arith_body_part).parse_next(&mut body)?;
826 if !body.is_empty() {
827 return backtrack();
828 }
829 *input = &input[i + 2..];
830 return Ok(WordPart::Arith(Word(parts)));
831 }
832 depth -= 1;
833 if depth < 0 {
834 scan(i)?;
835 return backtrack();
836 }
837 }
838 _ => {}
839 }
840 i += 1;
841 }
842 scan(i)?;
843 backtrack()
844}
845
846fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
847 delimited_scan(input, '`', |i| delimited('`', backtick_inner, '`').map(WordPart::Backtick).parse_next(i))
848}
849
850fn delimited_scan<O>(input: &mut &str, open: char, p: impl FnOnce(&mut &str) -> ModalResult<O>) -> ModalResult<O> {
853 if !input.starts_with(open) {
854 return backtrack();
855 }
856 let before = input.len();
857 let out = p(input);
858 scan(if out.is_ok() { before - input.len() } else { before })?;
859 out
860}
861
862fn escaped(input: &mut &str) -> ModalResult<WordPart> {
863 preceded('\\', any).map(WordPart::Escape).parse_next(input)
864}
865
866fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
867 move |input: &mut &str| {
868 let s: &str = consumed(input, |i| take_while(1.., pred).parse_next(i))?;
869 Ok(WordPart::Lit(s.to_string()))
870 }
871}
872
873fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
874 move |input: &mut &str| {
875 ('$', not('(')).void().parse_next(input)?;
876 let rest: &str = consumed(input, |i| take_while(0.., pred).parse_next(i))?;
877 Ok(WordPart::Lit(format!("${rest}")))
878 }
879}
880
881fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
884 step()?;
885 if input.is_empty() || input.starts_with('"') {
886 return backtrack();
887 }
888 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal))).parse_next(input)
889}
890
891fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
892 preceded('\\', any)
893 .map(|c: char| match c {
894 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
895 _ => WordPart::Lit(format!("\\{c}")),
896 })
897 .parse_next(input)
898}
899
900fn backtick_inner(input: &mut &str) -> ModalResult<String> {
903 repeat(0.., alt((bt_escape, bt_literal)))
904 .fold(String::new, |mut acc, chunk: &str| {
905 acc.push_str(chunk);
906 acc
907 })
908 .parse_next(input)
909}
910
911fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
912 ('\\', any).take().parse_next(input)
913}
914
915fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
916 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
917}
918
919fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
922 eat_keyword(input, "for")?;
923 ws.parse_next(input)?;
924 let var = name.parse_next(input)?;
925 ws.parse_next(input)?;
926
927 let items = if eat_keyword(input, "in").is_ok() {
928 ws.parse_next(input)?;
929 repeat(0.., terminated(word, ws)).parse_next(input)?
930 } else {
931 vec![]
932 };
933
934 let body = do_done_body.parse_next(input)?;
935 let redirs = trailing_redirs(input)?;
936 Ok(Cmd::For { var, items, body, redirs })
937}
938
939fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
940 eat_keyword(input, "while")?;
941 ws.parse_next(input)?;
942 let cond = script.parse_next(input)?;
943 let body = do_done_body.parse_next(input)?;
944 let redirs = trailing_redirs(input)?;
945 Ok(Cmd::While { cond, body, redirs })
946}
947
948fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
949 eat_keyword(input, "until")?;
950 ws.parse_next(input)?;
951 let cond = script.parse_next(input)?;
952 let body = do_done_body.parse_next(input)?;
953 let redirs = trailing_redirs(input)?;
954 Ok(Cmd::Until { cond, body, redirs })
955}
956
957fn do_done_body(input: &mut &str) -> ModalResult<Script> {
958 sep.parse_next(input)?;
959 eat_keyword(input, "do")?;
960 sep.parse_next(input)?;
961 let body = script.parse_next(input)?;
962 sep.parse_next(input)?;
963 eat_keyword(input, "done")?;
964 Ok(body)
965}
966
967fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
968 eat_keyword(input, "if")?;
969 ws.parse_next(input)?;
970 let mut branches = vec![cond_then_body.parse_next(input)?];
971 let mut else_body = None;
972
973 loop {
974 sep.parse_next(input)?;
975 if eat_keyword(input, "elif").is_ok() {
976 ws.parse_next(input)?;
977 branches.push(cond_then_body.parse_next(input)?);
978 } else if eat_keyword(input, "else").is_ok() {
979 sep.parse_next(input)?;
980 else_body = Some(script.parse_next(input)?);
981 break;
982 } else {
983 break;
984 }
985 }
986
987 sep.parse_next(input)?;
988 eat_keyword(input, "fi")?;
989 let redirs = trailing_redirs(input)?;
990 Ok(Cmd::If { branches, else_body, redirs })
991}
992
993fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
994 let cond = script.parse_next(input)?;
995 sep.parse_next(input)?;
996 eat_keyword(input, "then")?;
997 sep.parse_next(input)?;
998 let body = script.parse_next(input)?;
999 Ok(Branch { cond, body })
1000}
1001
1002fn case_cmd(input: &mut &str) -> ModalResult<Cmd> {
1004 eat_keyword(input, "case")?;
1005 ws.parse_next(input)?;
1006 let subject = word.parse_next(input)?;
1007 blank.parse_next(input)?;
1008 eat_keyword(input, "in")?;
1009
1010 let mut arms = Vec::new();
1011 loop {
1012 blank.parse_next(input)?;
1013 if eat_keyword(input, "esac").is_ok() {
1014 break;
1015 }
1016 let arm = case_arm.parse_next(input)?;
1017 let had_terminator = opt(";;").parse_next(input)?.is_some();
1018 arms.push(arm);
1019 if !had_terminator {
1022 blank.parse_next(input)?;
1023 eat_keyword(input, "esac")?;
1024 break;
1025 }
1026 }
1027
1028 let redirs = trailing_redirs(input)?;
1029 Ok(Cmd::Case { subject, arms, redirs })
1030}
1031
1032fn case_arm(input: &mut &str) -> ModalResult<CaseArm> {
1033 blank.parse_next(input)?;
1034 opt('(').parse_next(input)?;
1035 let mut patterns = Vec::new();
1036 loop {
1037 ws.parse_next(input)?;
1038 patterns.push(word.parse_next(input)?);
1039 ws.parse_next(input)?;
1040 if opt('|').parse_next(input)?.is_none() {
1041 break;
1042 }
1043 }
1044 ')'.parse_next(input)?;
1045 let body = script.parse_next(input)?;
1046 blank.parse_next(input)?;
1047 Ok(CaseArm { patterns, body })
1048}
1049
1050fn blank(input: &mut &str) -> ModalResult<()> {
1052 consumed(input, |i| take_while(0.., [' ', '\t', '\n']).void().parse_next(i))
1053}
1054
1055fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
1056 if !input.starts_with("[[") {
1057 return backtrack();
1058 }
1059 let bytes = input.as_bytes();
1060 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
1061 return backtrack();
1062 }
1063 *input = &input[2..];
1064
1065 let mut words: Vec<Word> = Vec::new();
1066 loop {
1067 ws.parse_next(input)?;
1068 if at_double_bracket_end(input) {
1069 *input = &input[2..];
1070 let redirs = trailing_redirs(input)?;
1071 return Ok(Cmd::DoubleBracket { words, redirs });
1072 }
1073 if input.is_empty() {
1074 return backtrack();
1075 }
1076 let w = bracket_word.parse_next(input)?;
1077 words.push(w);
1078 }
1079}
1080
1081fn at_double_bracket_end(input: &str) -> bool {
1082 if !input.starts_with("]]") {
1083 return false;
1084 }
1085 let after = &input[2..];
1086 after.is_empty() || after.starts_with([' ', '\t', '\n', ';', '&', '|', ')', '>', '<'])
1087}
1088
1089fn bracket_word(input: &mut &str) -> ModalResult<Word> {
1090 repeat(1.., bracket_word_part).map(Word).parse_next(input)
1091}
1092
1093fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
1094 step()?;
1095 if input.is_empty() {
1096 return backtrack();
1097 }
1098 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
1099 return backtrack();
1100 }
1101 if at_double_bracket_end(input) {
1102 return backtrack();
1103 }
1104 alt((
1105 single_quoted,
1106 double_quoted,
1107 dollar_quoted,
1108 arith_sub,
1109 cmd_sub,
1110 backtick_part,
1111 escaped,
1112 dollar_lit(is_bracket_literal),
1113 bracket_lit,
1114 ))
1115 .parse_next(input)
1116}
1117
1118fn is_bracket_literal(c: char) -> bool {
1119 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
1120}
1121
1122fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
1123 let bytes = input.as_bytes();
1128 let mut end = 0;
1129 while end < bytes.len() {
1130 let c = bytes[end] as char;
1131 if !is_bracket_literal(c) {
1132 break;
1133 }
1134 if c == ']' && at_double_bracket_end(&input[end..]) {
1135 break;
1136 }
1137 end += 1;
1138 }
1139 scan(end)?;
1140 if end == 0 {
1141 return backtrack();
1142 }
1143 let lit = input[..end].to_string();
1144 *input = &input[end..];
1145 Ok(WordPart::Lit(lit))
1146}
1147
1148fn name(input: &mut &str) -> ModalResult<String> {
1149 run(input, |c| c.is_ascii_alphanumeric() || c == '_').map(String::from)
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::budget::MAX_PARSE_WORK_CEILING;
1155 #[test]
1173 fn the_parse_work_budget_bounds_nesting_without_refusing_real_commands() {
1174 let worst_real = crate::registry::corpus_examples()
1175 .into_iter()
1176 .flat_map(|(_, safe, denied)| safe.iter().chain(denied.iter()).cloned().collect::<Vec<_>>())
1177 .map(|ex| {
1178 let _ = super::parse(&ex);
1179 budget::work()
1180 })
1181 .max()
1182 .expect("the registry ships examples");
1183 assert!(
1184 worst_real * 100 < MAX_PARSE_WORK_CEILING,
1185 "a real example needs {worst_real} entries against a {MAX_PARSE_WORK_CEILING} ceiling; \
1186 the margin that makes this constant safe is gone"
1187 );
1188
1189 let flat = format!("echo {}", "$((1)) ".repeat(400));
1195 let _ = super::parse(&flat);
1196 let flat_work = budget::work();
1197 assert!(
1198 flat_work < MAX_PARSE_WORK_CEILING / 10,
1199 "flat input cost {flat_work}, close to the {MAX_PARSE_WORK_CEILING} ceiling — a long \
1200 flat command is at risk of being refused"
1201 );
1202
1203 let work_at = |depth: usize| {
1209 let deep = format!("echo {}1{}", "$((1+".repeat(depth), "))".repeat(depth));
1210 let _ = super::parse(&deep);
1211 assert!(budget::spent(), "the nest should actually reach a bound, or this proves nothing");
1212 budget::work()
1213 };
1214 let (w2k, w4k) = (work_at(2000), work_at(4000));
1215 assert!(w4k <= w2k + w2k / 10, "work still scales with input length: depth 2000 used {w2k}, depth 4000 used {w4k}");
1216 assert!(w4k < MAX_PARSE_WORK_CEILING + MAX_PARSE_WORK_CEILING / 10, "work {w4k} ran far past the {MAX_PARSE_WORK_CEILING} ceiling");
1217 }
1218
1219 use super::*;
1220
1221 #[test]
1230 fn unclosed_compound_nesting_costs_linear_work() {
1231 let unclosed = |opener: &str| match opener {
1232 "{" => "{\n",
1233 "[[" => "[[ a\n",
1234 "if" => "if a; then\n",
1235 "for" => "for x in a; do\n",
1236 "while" => "while a; do\n",
1237 "until" => "until a; do\n",
1238 "case" => "case x in a)\n",
1239 "function" => "function f {\n",
1240 other => panic!("no unclosed form for the reserved word {other:?}; add one here"),
1241 };
1242 let tail = "\"$([[ <<\"\"<\"$( [[ x ]] ) $(ls <<EOF\n)\nEOF\n)\" ls";
1243 let openers = super::super::reserved::BLANK_OPENERS.iter().chain(super::super::reserved::KEYWORD_OPENERS.iter());
1244 for opener in openers {
1245 let unit = unclosed(opener);
1246 for prefix in [unit.to_string(), format!("{unit}{{\n")] {
1247 for depth in [10u64, 20, 40] {
1248 let text = format!("{}{tail}", prefix.repeat(depth as usize));
1249 let _ = parse(&text);
1250 let work = budget::work();
1251 assert!(
1252 work <= 4 * depth + 64,
1253 "{depth} nested unclosed {prefix:?} cost {work} parse entries, more than \
1254 linear in the nesting: a failed compound is being re-read another way"
1255 );
1256 }
1257 }
1258 }
1259 }
1260
1261 #[test]
1269 fn refused_substitution_nesting_costs_linear_work() {
1270 for refused in ["[[ a", "{ a", "if a", "a; }"] {
1271 for (open, close) in [("$(", ")"), ("\"$(", ")\""), ("<(", ")")] {
1272 for depth in [10u64, 20, 40] {
1273 let n = depth as usize;
1274 let text = format!("echo {}x{}", format!("{open}{refused} ").repeat(n), close.repeat(n));
1275 let _ = parse(&text);
1276 let work = budget::work();
1277 assert!(
1278 work <= 4 * depth + 64,
1279 "{depth} nested {open}{refused} cost {work} parse entries, more than linear: \
1280 a refused interior is being parsed a second time"
1281 );
1282 }
1283 }
1284 }
1285 }
1286
1287 #[test]
1295 fn a_backtracking_nest_over_a_long_tail_is_stopped_by_what_it_reads() {
1296 let tail = "a ".repeat(50_000);
1297 for (open, close) in [("$(( $({ ", ") ))"), ("\"$(( $( [[ ", ") ))\"")] {
1298 let text = format!("echo {}x {tail}{}", open.repeat(20), close.repeat(20));
1299 assert!(parse(&text).is_none(), "{open:?} nest parsed");
1300 assert!(budget::spent(), "{open:?} nest was refused, but not by the budget");
1301 let work = budget::work();
1302 assert!(work < 1_000, "{open:?} nest ran {work} entries before the step budget stopped it");
1303 }
1304 }
1305
1306 #[test]
1314 fn the_explain_render_timeout_seed_scales_linearly_in_its_braces() {
1315 let seed = include_bytes!("../../fuzz/corpus/explain_render/seed-unclosed-brace-nest");
1316 let seed = String::from_utf8_lossy(seed);
1317 let body = seed.split('`').nth(1).expect("the seed carries a backtick body");
1318 let deep = body.find("$([[").expect("the seed's tail opens a `$([[`");
1319 let tail = &body[body[..deep].rfind("\n{").expect("the nest ends before the tail") + 2..];
1320 let cost = |k: usize| {
1321 let _ = parse(&format!("{}{tail}", "{\n".repeat(k)));
1322 assert!(!budget::spent(), "{k} braces spent the budget; the nest is not linear");
1323 (budget::work(), budget::steps())
1324 };
1325 let [(w10, s10), (w20, s20), (w40, s40)] = [cost(10), cost(20), cost(40)];
1326 assert!(w40 - w20 <= 2 * (w20 - w10) + 4, "entries {w10}/{w20}/{w40} for 10/20/40 braces");
1327 assert!(s40 - s20 <= 2 * (s20 - s10) + 16, "steps {s10}/{s20}/{s40} for 10/20/40 braces");
1328 assert!(!crate::is_safe_command(&seed));
1329 }
1330
1331 proptest::proptest! {
1332 #![proptest_config(proptest::prelude::ProptestConfig::with_cases(300))]
1333
1334 #[test]
1347 fn a_random_unclosed_nest_parses_in_linear_work(
1348 opener in proptest::sample::select(OPENERS.to_vec()),
1349 depth in 0..24usize,
1350 mixed in proptest::collection::vec(proptest::sample::select(OPENERS.to_vec()), 0..8),
1351 tail in proptest::collection::vec(proptest::sample::select(vec![
1352 "}", " ]]", "fi", "done", ";;", "esac", ")", "\"", "<<", "<<E\n", "\n", ";", "a ",
1353 "$(", "[[ ", "`",
1354 ]), 0..24),
1355 closes in 0..24usize,
1356 ) {
1357 let input = format!(
1358 "{}{}{}{}", opener.repeat(depth), mixed.concat(), tail.concat(), ")".repeat(closes)
1359 );
1360 let _ = parse(&input);
1361 let work = budget::work();
1362 let tokens = (depth + mixed.len() + tail.len() + closes) as u64;
1363 proptest::prop_assert!(!budget::spent(), "spent the budget on {input:?}");
1364 proptest::prop_assert!(work <= 4 * tokens + 16, "{work} entries for {tokens} tokens: {input:?}");
1365 }
1366 }
1367
1368 const OPENERS: [&str; 14] = [
1369 "{\n", "{ ", "[[ a\n", "if a; then\n", "for x in a; do\n", "while a; do\n", "case x in a)\n", "function f {\n", "(\n", "$(",
1370 "\"$(", "<(", "$([[ ", "$({ ",
1371 ];
1372
1373 #[test]
1381 fn committed_command_seeds_parse_in_linear_work() {
1382 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fuzz/corpus");
1383 let mut checked = 0;
1384 for target in ["parse", "explain_render"] {
1385 let dir = std::fs::read_dir(root.join(target)).expect("the fuzz corpus is committed");
1386 for path in dir.flatten().map(|e| e.path()) {
1387 if !path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("seed-")) {
1388 continue;
1389 }
1390 let text = String::from_utf8_lossy(&std::fs::read(&path).expect("readable seed")).into_owned();
1391 for piece in std::iter::once(text.as_str()).chain(text.split('`').skip(1).step_by(2)) {
1394 let _ = parse(piece);
1395 let (work, len) = (budget::work(), piece.len() as u64);
1396 assert!(!budget::spent(), "{} spent the parse budget", path.display());
1397 assert!(work <= len + 16, "{} cost {work} entries for {len} bytes", path.display());
1398 }
1399 checked += 1;
1400 }
1401 }
1402 assert!(checked >= 16, "only {checked} seeds found; the corpus has moved");
1403 }
1404
1405 #[test]
1408 fn the_step_budget_refuses_nothing_real() {
1409 for (_, safe, denied) in crate::registry::corpus_examples() {
1410 for example in safe.iter().chain(denied.iter()) {
1411 let _ = parse(example);
1412 assert!(!budget::spent(), "a registry example spent the step budget: {example}");
1413 }
1414 }
1415 let message = "it's a line of prose (with parens) and $vars\n".repeat(5_000);
1416 for text in [format!("git commit -m \"$(cat <<'EOF'\n{message}EOF\n)\""), "ls -la foo/bar; ".repeat(20_000)] {
1417 assert!(parse(&text).is_some(), "a long real command no longer parses");
1418 let (steps, len) = (budget::steps(), text.len() as u64);
1419 assert!(
1420 steps * 4 < budget::STEPS_PER_BYTE * len,
1421 "{len} bytes of ordinary input spent {steps} steps, within 4x of the allowance"
1422 );
1423 }
1424 }
1425
1426 fn p(input: &str) -> Script {
1427 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
1428 }
1429
1430 fn words(script: &Script) -> Vec<String> {
1431 match &script.0[0].pipeline.commands[0] {
1432 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
1433 _ => panic!("expected simple command"),
1434 }
1435 }
1436
1437 fn simple(script: &Script) -> &SimpleCmd {
1438 match &script.0[0].pipeline.commands[0] {
1439 Cmd::Simple(s) => s,
1440 _ => panic!("expected simple command"),
1441 }
1442 }
1443
1444 #[test]
1445 fn simple_command() {
1446 assert_eq!(words(&p("echo hello")), ["echo", "hello"]);
1447 }
1448 #[test]
1449 fn flags() {
1450 assert_eq!(words(&p("ls -la")), ["ls", "-la"]);
1451 }
1452 #[test]
1453 fn single_quoted() {
1454 assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]);
1455 }
1456 #[test]
1457 fn double_quoted() {
1458 assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]);
1459 }
1460 #[test]
1461 fn mixed_quotes() {
1462 assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]);
1463 }
1464
1465 #[test]
1466 fn pipeline_test() {
1467 assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2);
1468 }
1469 #[test]
1470 fn sequence_and() {
1471 assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And));
1472 }
1473 #[test]
1474 fn sequence_semi() {
1475 assert_eq!(p("ls; echo done").0.len(), 2);
1476 }
1477 #[test]
1478 fn newline_separator() {
1479 assert_eq!(p("echo foo\necho bar").0.len(), 2);
1480 }
1481 #[test]
1482 fn blank_line_between_statements() {
1483 assert_eq!(p("echo foo\n\necho bar").0.len(), 2);
1484 }
1485 #[test]
1486 fn multiple_blank_lines() {
1487 assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2);
1488 }
1489 #[test]
1490 fn blank_line_with_whitespace() {
1491 assert_eq!(p("echo foo\n \necho bar").0.len(), 2);
1492 }
1493 #[test]
1494 fn comment_between_statements() {
1495 assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2);
1496 }
1497 #[test]
1498 fn semi_then_blank() {
1499 assert_eq!(p("echo foo;\n\necho bar").0.len(), 2);
1500 }
1501 #[test]
1502 fn and_then_blank() {
1503 assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2);
1504 }
1505
1506 #[test]
1507 fn brace_group_simple() {
1508 assert!(matches!(
1509 &p("{ echo hello; }").0[0].pipeline.commands[0],
1510 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1511 ));
1512 }
1513 #[test]
1514 fn brace_group_multiple_stmts() {
1515 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1516 assert_eq!(body.0.len(), 3);
1517 } else {
1518 panic!("expected BraceGroup");
1519 }
1520 }
1521 #[test]
1522 fn brace_group_with_redirect() {
1523 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1524 assert_eq!(redirs.len(), 1);
1525 assert!(matches!(redirs[0], Redir::Write { .. }));
1526 } else {
1527 panic!("expected BraceGroup");
1528 }
1529 }
1530 #[test]
1531 fn brace_group_with_append_redirect() {
1532 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1533 assert!(matches!(redirs[0], Redir::Write { mode: WriteMode::Append, .. }));
1534 } else {
1535 panic!("expected BraceGroup");
1536 }
1537 }
1538 #[test]
1539 fn brace_group_with_stderr_redirect() {
1540 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1541 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1542 } else {
1543 panic!("expected BraceGroup");
1544 }
1545 }
1546 #[test]
1547 fn brace_group_newline_separated() {
1548 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
1549 assert_eq!(body.0.len(), 2);
1550 } else {
1551 panic!("expected BraceGroup");
1552 }
1553 }
1554 #[test]
1561 fn time_keyword_prefixes_a_compound() {
1562 for (src, want_subshell) in [("time (ls)", true), ("time -p (ls)", true), ("time { ls; }", false)] {
1563 let pl = &p(src).0[0].pipeline;
1564 assert_eq!(pl.commands.len(), 1, "{src}: one compound command");
1565 if want_subshell {
1566 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }), "{src}: kept the subshell");
1567 } else {
1568 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }), "{src}: kept the group");
1569 }
1570 }
1571 let pl = &p("time (ls | head -5)").0[0].pipeline;
1573 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }));
1574
1575 let pl = &p("time ls").0[0].pipeline;
1578 assert!(matches!(&pl.commands[0], Cmd::Simple(_)), "time ls stays a simple command");
1579
1580 let pl = &p("timeout 5 ls").0[0].pipeline;
1582 assert!(matches!(&pl.commands[0], Cmd::Simple(_)), "timeout is not the time keyword");
1583
1584 let pl = &p("! time (ls)").0[0].pipeline;
1589 assert!(pl.bang, "the bang survives the time keyword");
1590 assert!(matches!(&pl.commands[0], Cmd::Subshell { .. }), "and the compound survives too");
1591 }
1592
1593 #[test]
1594 fn brace_group_in_pipeline() {
1595 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1596 assert_eq!(pl.commands.len(), 2);
1597 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1598 }
1599 #[test]
1600 fn brace_group_followed_by_other() {
1601 let stmts = &p("{ echo a; }; echo b").0;
1602 assert_eq!(stmts.len(), 2);
1603 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1604 }
1605 #[test]
1606 fn brace_group_nested() {
1607 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1608 assert_eq!(body.0.len(), 2);
1609 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1610 } else {
1611 panic!("expected outer BraceGroup");
1612 }
1613 }
1614 #[test]
1615 fn brace_group_with_subshell_inside() {
1616 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1617 assert_eq!(body.0.len(), 2);
1618 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1619 } else {
1620 panic!("expected BraceGroup");
1621 }
1622 }
1623 #[test]
1624 fn brace_open_requires_whitespace() {
1625 let cmds = &p("{echo a}").0;
1629 if !cmds.is_empty() {
1632 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1633 }
1634 }
1635 #[test]
1636 fn subshell_with_redirect() {
1637 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1638 assert_eq!(redirs.len(), 1);
1639 } else {
1640 panic!("expected Subshell with redir");
1641 }
1642 }
1643 #[test]
1644 fn for_loop_with_redirect() {
1645 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1646 assert_eq!(redirs.len(), 1);
1647 } else {
1648 panic!("expected For with redir");
1649 }
1650 }
1651 #[test]
1652 fn for_loop_redirect_then_pipe() {
1653 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1655 assert_eq!(pl.commands.len(), 2);
1656 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1657 }
1658 #[test]
1659 fn while_and_if_with_redirect() {
1660 assert!(matches!(
1661 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1662 Cmd::While { redirs, .. } if redirs.len() == 1
1663 ));
1664 assert!(matches!(
1665 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1666 Cmd::If { redirs, .. } if redirs.len() == 1
1667 ));
1668 }
1669 #[test]
1670 fn background() {
1671 assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp));
1672 }
1673
1674 #[test]
1675 fn redirect_dev_null() {
1676 let s = p("echo hello > /dev/null");
1677 let cmd = simple(&s);
1678 assert_eq!(cmd.words.len(), 2);
1679 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, mode: WriteMode::Truncate, .. }));
1680 }
1681 #[test]
1682 fn redirect_stderr() {
1683 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1684 }
1685 #[test]
1686 fn here_string() {
1687 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1688 }
1689 #[test]
1690 fn heredoc_bare() {
1691 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false, .. } if delimiter == "EOF"));
1692 }
1693 #[test]
1694 fn heredoc_with_content() {
1695 let s = p("cat <<EOF\nhello world\nEOF");
1696 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1697 }
1698 #[test]
1699 fn heredoc_quoted_delimiter() {
1700 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1701 }
1702 #[test]
1703 fn heredoc_strip_tabs() {
1704 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1705 }
1706 #[test]
1707 fn heredoc_pipe_on_command_line() {
1708 let s = p("cat <<EOF | grep hello\nhello\nEOF");
1711 assert_eq!(s.0[0].pipeline.commands.len(), 2);
1712 }
1713 #[test]
1714 fn heredoc_body_does_not_swallow_pipe() {
1715 let s = p("cat <<EOF | bash\nrm\nEOF");
1719 assert_eq!(s.0[0].pipeline.commands.len(), 2, "pipeline must keep `bash` as a second command");
1720 }
1721 #[test]
1722 fn heredoc_followed_by_next_statement() {
1723 let s = p("cat <<EOF\nhello\nEOF\nls");
1726 assert_eq!(s.0.len(), 2);
1727 }
1728
1729 #[test]
1730 fn env_prefix() {
1731 let s = p("FOO='bar baz' ls -la");
1732 let cmd = simple(&s);
1733 assert_eq!(cmd.env[0].0, "FOO");
1734 assert_eq!(cmd.env[0].1.eval(), "bar baz");
1735 }
1736 #[test]
1737 fn cmd_substitution() {
1738 assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_)));
1739 }
1740 #[test]
1741 fn backtick_substitution() {
1742 assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB_WORKTREE__");
1745 assert_eq!(simple(&p("ls `hostname`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__");
1747 }
1748 #[test]
1749 fn nested_substitution() {
1750 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1751 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1752 } else {
1753 panic!("expected CmdSub");
1754 }
1755 }
1756
1757 #[test]
1758 fn subshell_test() {
1759 assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1760 }
1761 #[test]
1762 fn negation() {
1763 assert!(p("! echo hello").0[0].pipeline.bang);
1764 }
1765
1766 #[test]
1767 fn for_loop() {
1768 assert!(matches!(&p("for x in 1 2 3; do echo $x; done").0[0].pipeline.commands[0], Cmd::For { var, .. } if var == "x"));
1769 }
1770 #[test]
1771 fn while_loop() {
1772 assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. }));
1773 }
1774 #[test]
1775 fn if_then_fi() {
1776 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1777 assert_eq!(branches.len(), 1);
1778 assert!(else_body.is_none());
1779 } else {
1780 panic!("expected If");
1781 }
1782 }
1783 #[test]
1784 fn if_elif_else() {
1785 if let Cmd::If { branches, else_body, .. } =
1786 &p("if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi").0[0].pipeline.commands[0]
1787 {
1788 assert_eq!(branches.len(), 2);
1789 assert!(else_body.is_some());
1790 } else {
1791 panic!("expected If");
1792 }
1793 }
1794
1795 #[test]
1796 fn escaped_outside_quotes() {
1797 assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]);
1798 }
1799 #[test]
1800 fn double_quoted_escape() {
1801 assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]);
1802 }
1803 #[test]
1804 fn assign_subst() {
1805 assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out");
1806 }
1807
1808 #[test]
1809 fn unmatched_single_quote_fails() {
1810 assert!(parse("echo 'hello").is_none());
1811 }
1812 #[test]
1813 fn unmatched_double_quote_fails() {
1814 assert!(parse("echo \"hello").is_none());
1815 }
1816 #[test]
1817 fn unclosed_subshell_fails() {
1818 assert!(parse("(echo hello").is_none());
1819 }
1820 #[test]
1821 fn unclosed_cmd_sub_fails() {
1822 assert!(parse("echo $(ls").is_none());
1823 }
1824 #[test]
1825 fn for_missing_do_fails() {
1826 assert!(parse("for x in 1 2 3; echo $x; done").is_none());
1827 }
1828 #[test]
1829 fn if_missing_fi_fails() {
1830 assert!(parse("if true; then echo hello").is_none());
1831 }
1832
1833 #[test]
1834 fn subshell_for() {
1835 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1836 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1837 } else {
1838 panic!("expected Subshell");
1839 }
1840 }
1841 #[test]
1842 fn proc_sub_input() {
1843 let s = p("diff <(sort a.txt) <(sort b.txt)");
1844 let cmd = simple(&s);
1845 assert_eq!(cmd.words.len(), 3);
1846 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1847 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1848 }
1849 #[test]
1850 fn proc_sub_output() {
1851 let s = p("tee >(grep error > /dev/null)");
1852 let cmd = simple(&s);
1853 assert_eq!(cmd.words.len(), 2);
1854 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1855 }
1856
1857 fn func_def(s: &Script) -> (&str, &Script) {
1859 match &s.0[0].pipeline.commands[0] {
1860 Cmd::FunctionDef { name, body } => (name.as_str(), body),
1861 other => panic!("expected FunctionDef, got {other:?}"),
1862 }
1863 }
1864 #[test]
1865 fn function_def_posix_form() {
1866 let s = p("probe(){ echo hi; }");
1867 let (name, body) = func_def(&s);
1868 assert_eq!(name, "probe");
1869 assert_eq!(words(body), ["echo", "hi"]);
1870 }
1871 #[test]
1872 fn function_def_spaced_and_keyword_forms() {
1873 assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1874 assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1875 assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1876 }
1877 #[test]
1878 fn function_def_subshell_body() {
1879 let s = p("foo() ( echo sub )");
1880 assert_eq!(func_def(&s).0, "foo");
1881 }
1882 #[test]
1883 fn function_def_body_on_next_line() {
1884 assert_eq!(func_def(&p("foo()\n{\n echo hi\n}")).0, "foo");
1885 }
1886 #[test]
1887 fn function_def_name_with_dashes_and_dots() {
1888 assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1889 }
1890 #[test]
1891 fn plain_command_is_not_a_function_def() {
1892 assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1894 assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1895 }
1896 #[test]
1897 fn function_def_roundtrips() {
1898 let rendered = p("greet(){ echo hi; }").to_string();
1899 assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1900 assert_eq!(func_def(&p(&rendered)).0, "greet");
1901 }
1902 #[test]
1903 fn comment_only() {
1904 let s = p("# just a comment");
1905 assert!(s.0.is_empty());
1906 }
1907 #[test]
1908 fn comment_before_command() {
1909 let s = p("# comment\necho hello");
1910 assert_eq!(words(&s), ["echo", "hello"]);
1911 }
1912 #[test]
1913 fn inline_comment() {
1914 let s = p("echo hello # this is a comment");
1915 assert_eq!(words(&s), ["echo", "hello"]);
1916 }
1917 #[test]
1918 fn comment_between_commands() {
1919 let s = p("echo hello\n# middle comment\necho world");
1920 assert_eq!(s.0.len(), 2);
1921 }
1922 #[test]
1923 fn comment_after_semicolon() {
1924 let s = p("echo hello; # comment\necho world");
1925 assert_eq!(s.0.len(), 2);
1926 }
1927 #[test]
1928 fn comment_in_for_loop() {
1929 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1930 }
1931 #[test]
1932 fn quoted_redirect_in_echo() {
1933 let s = p("echo 'greater > than' test");
1934 let cmd = simple(&s);
1935 assert_eq!(cmd.words.len(), 3);
1936 assert_eq!(cmd.redirs.len(), 0);
1937 }
1938
1939 #[test]
1940 fn parses_all_safe_commands() {
1941 let cmds = [
1942 "grep foo file.txt",
1943 "cat /etc/hosts",
1944 "jq '.key' file.json",
1945 "base64 -d",
1946 "ls -la",
1947 "wc -l file.txt",
1948 "ps aux",
1949 "echo hello",
1950 "cat file.txt",
1951 "echo $(ls)",
1952 "ls `pwd`",
1953 "echo $(echo $(ls))",
1954 "echo \"$(ls)\"",
1955 "out=$(ls)",
1956 "out=$(git status)",
1957 "a=$(ls) b=$(pwd)",
1958 "(echo hello)",
1959 "(ls)",
1960 "(ls && echo done)",
1961 "(echo hello; echo world)",
1962 "(ls | grep foo)",
1963 "(echo hello) | grep hello",
1964 "(ls) && echo done",
1965 "((echo hello))",
1966 "(for x in 1 2; do echo $x; done)",
1967 "echo 'greater > than' test",
1968 "echo '$(safe)' arg",
1969 "FOO='bar baz' ls -la",
1970 "FOO=\"bar baz\" ls -la",
1971 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1972 "grep foo file.txt | head -5",
1973 "cat file | sort | uniq",
1974 "ls && echo done",
1975 "ls; echo done",
1976 "ls & echo done",
1977 "grep -c , <<< 'hello,world,test'",
1978 "cat <<EOF\nhello world\nEOF",
1979 "cat <<'MARKER'\nsome text\nMARKER",
1980 "cat <<-EOF\n\thello\nEOF",
1981 "echo foo\necho bar",
1982 "ls\ncat file.txt",
1983 "git log --oneline -20 | head -5",
1984 "echo hello > /dev/null",
1985 "echo hello 2> /dev/null",
1986 "echo hello >> /dev/null",
1987 "git log > /dev/null 2>&1",
1988 "ls 2>&1",
1989 "cargo clippy 2>&1",
1990 "git log < /dev/null",
1991 "for x in 1 2 3; do echo $x; done",
1992 "for f in *.txt; do cat $f | grep pattern; done",
1993 "for x in 1 2 3; do; done",
1994 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1995 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1996 "for x in 1 2; do echo $x; done && echo finished",
1997 "for x in $(seq 1 5); do echo $x; done",
1998 "while test -f /tmp/foo; do sleep 1; done",
1999 "while ! test -f /tmp/done; do sleep 1; done",
2000 "until test -f /tmp/ready; do sleep 1; done",
2001 "if test -f foo; then echo exists; fi",
2002 "if test -f foo; then echo yes; else echo no; fi",
2003 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
2004 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
2005 "if true; then for x in 1 2; do echo $x; done; fi",
2006 "diff <(sort a.txt) <(sort b.txt)",
2007 "comm -23 file.txt <(sort other.txt)",
2008 "cat <(echo hello)",
2009 "# comment only",
2010 "# comment\necho hello",
2011 "echo hello # inline comment",
2012 "echo one\n# between\necho two",
2013 "! echo hello",
2014 "! test -f foo",
2015 "echo for; echo done; echo if; echo fi",
2016 ];
2017 let mut failures = Vec::new();
2018 for cmd in &cmds {
2019 if parse(cmd).is_none() {
2020 failures.push(*cmd);
2021 }
2022 }
2023 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
2024 }
2025
2026 fn inner_sub(s: &Script) -> &Script {
2032 match &simple(s).words[1].0[0] {
2033 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
2034 other => panic!("expected a substitution, got {other:?}"),
2035 }
2036 }
2037
2038 #[test]
2039 fn cmd_sub_squote_paren_is_not_close() {
2040 assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
2041 }
2042 #[test]
2043 fn cmd_sub_dquote_paren_is_not_close() {
2044 assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
2045 }
2046 #[test]
2047 fn cmd_sub_escaped_paren_is_not_close() {
2048 assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
2049 }
2050 #[test]
2051 fn cmd_sub_backtick_paren_is_not_close() {
2052 let s = p("echo $(x `)` y)");
2054 let inner = simple(inner_sub(&s));
2055 assert_eq!(inner.words.len(), 3);
2056 assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
2057 }
2058 #[test]
2059 fn cmd_sub_escaped_backtick_does_not_end_span() {
2060 let s = p("echo $(`\\`)`)");
2064 assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
2065 }
2066 #[test]
2067 fn proc_sub_squote_paren_is_not_close() {
2068 assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
2069 }
2070 #[test]
2071 fn proc_sub_out_squote_paren_is_not_close() {
2072 assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
2073 }
2074 #[test]
2075 fn cmd_sub_nested_picks_outer_close() {
2076 let s = p("echo $(a $(b) c)");
2077 let inner = simple(inner_sub(&s));
2078 assert_eq!(inner.words.len(), 3);
2079 assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
2080 }
2081 #[test]
2082 fn cmd_sub_literal_after_close_stays_in_outer_word() {
2083 let s = p("echo $(ls)tail");
2084 let w = &simple(&s).words[1];
2085 assert_eq!(w.0.len(), 2);
2086 assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
2087 assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
2088 }
2089 #[test]
2090 fn cmd_sub_heredoc_body_paren_does_not_close() {
2091 let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
2095 assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
2096 }
2097 #[test]
2098 fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
2099 assert!(parse("echo $(echo ')").is_none());
2101 }
2102 #[test]
2103 fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
2104 assert!(parse("cat <(grep ')").is_none());
2105 }
2106}