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 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
242 Ok(Pipeline { bang, commands })
243}
244
245fn command(input: &mut &str) -> ModalResult<Cmd> {
248 ws.parse_next(input)?;
249 if at_script_stop(input) {
250 return backtrack();
251 }
252 alt((
253 subshell,
254 brace_group,
255 for_cmd,
256 while_cmd,
257 until_cmd,
258 if_cmd,
259 case_cmd,
260 double_bracket_cmd,
261 function_def,
262 simple_cmd.map(Cmd::Simple),
263 ))
264 .parse_next(input)
265}
266
267fn function_def(input: &mut &str) -> ModalResult<Cmd> {
271 let had_keyword = opt_function_keyword(input);
272 let name = function_name(input)?;
273 ws.parse_next(input)?;
274 let has_parens = opt_paren_pair(input);
275 if !had_keyword && !has_parens {
276 return backtrack();
277 }
278 take_while(0.., [' ', '\t', '\n']).void().parse_next(input)?;
280 let body = function_body(input)?;
281 Ok(Cmd::FunctionDef { name, body })
282}
283
284fn opt_function_keyword(input: &mut &str) -> bool {
287 let mut probe = *input;
288 if eat_keyword(&mut probe, "function").is_ok()
289 && probe.starts_with([' ', '\t', '\n'])
290 && ws.parse_next(&mut probe).is_ok()
291 {
292 *input = probe;
293 return true;
294 }
295 false
296}
297
298fn opt_paren_pair(input: &mut &str) -> bool {
300 let mut probe = *input;
301 if let Some(rest) = probe.strip_prefix('(') {
302 probe = rest;
303 if ws.parse_next(&mut probe).is_ok()
304 && let Some(rest) = probe.strip_prefix(')')
305 {
306 *input = rest;
307 return true;
308 }
309 }
310 false
311}
312
313fn function_name(input: &mut &str) -> ModalResult<String> {
314 take_while(1.., |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+'))
315 .map(|s: &str| s.to_string())
316 .parse_next(input)
317}
318
319fn function_body(input: &mut &str) -> ModalResult<Script> {
322 if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
323 return Ok(body);
324 }
325 if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
326 return Ok(body);
327 }
328 backtrack()
329}
330
331fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
332 let mut redirs = Vec::new();
333 loop {
334 ws.parse_next(input)?;
335 if let Some(r) = opt(redirect).parse_next(input)? {
336 redirs.push(r);
337 } else {
338 break;
339 }
340 }
341 Ok(redirs)
342}
343
344fn subshell(input: &mut &str) -> ModalResult<Cmd> {
345 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
346 let redirs = trailing_redirs(input)?;
347 Ok(Cmd::Subshell { body, redirs })
348}
349
350fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
351 if !input.starts_with('{') {
352 return backtrack();
353 }
354 if !input
355 .as_bytes()
356 .get(1)
357 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
358 {
359 return backtrack();
360 }
361 *input = &input[1..];
362 sep.parse_next(input)?;
363 let body = script.parse_next(input)?;
364 if body.0.is_empty() {
365 return backtrack();
366 }
367 sep.parse_next(input)?;
368 if !input.starts_with('}') {
369 return backtrack();
370 }
371 let last_op = body.0.last().and_then(|s| s.op);
372 if last_op.is_none() {
373 return backtrack();
374 }
375 *input = &input[1..];
376 let redirs = trailing_redirs(input)?;
377 Ok(Cmd::BraceGroup { body, redirs })
378}
379
380fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
383 let env: Vec<(String, Word)> =
384 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
385 let mut words = Vec::new();
386 let mut redirs = Vec::new();
387
388 loop {
389 ws.parse_next(input)?;
390 if at_cmd_end(input) {
391 break;
392 }
393 if let Some(r) = opt(redirect).parse_next(input)? {
394 redirs.push(r);
395 } else if let Some(w) = opt(word).parse_next(input)? {
396 words.push(w);
397 } else {
398 break;
399 }
400 }
401
402 if env.is_empty() && words.is_empty() && redirs.is_empty() {
403 return backtrack();
404 }
405 Ok(SimpleCmd { env, words, redirs })
406}
407
408fn at_cmd_end(input: &str) -> bool {
409 if input.starts_with("&>") {
412 return false;
413 }
414 input.is_empty()
415 || matches!(
416 input.as_bytes().first(),
417 Some(b'\n' | b';' | b'|' | b'&' | b')')
418 )
419}
420
421fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
422 let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
423 .parse_next(input)?;
424 '='.parse_next(input)?;
425 let value = opt(word)
426 .parse_next(input)?
427 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
428 Ok((n.to_string(), value))
429}
430
431fn redirect(input: &mut &str) -> ModalResult<Redir> {
434 let fd = opt(fd_prefix).parse_next(input)?;
435 alt((
436 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
437 heredoc,
438 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
439 fd: fd.unwrap_or(1),
440 target,
441 mode: WriteMode::Append,
442 }),
443 preceded("&>>", (ws, word)).map(|(_, target)| Redir::Write {
446 fd: 1,
447 target,
448 mode: WriteMode::AppendBoth,
449 }),
450 preceded("&>", (ws, word)).map(|(_, target)| Redir::Write {
451 fd: 1,
452 target,
453 mode: WriteMode::TruncateBoth,
454 }),
455 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
456 src: fd.unwrap_or(1),
457 dst,
458 }),
459 preceded(">&", (ws, word)).map(|(_, target)| Redir::Write {
463 fd: 1,
464 target,
465 mode: WriteMode::TruncateBoth,
466 }),
467 preceded(">|", (ws, word)).map(move |(_, target)| Redir::Write {
471 fd: fd.unwrap_or(1),
472 target,
473 mode: WriteMode::Clobber,
474 }),
475 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
476 fd: fd.unwrap_or(1),
477 target,
478 mode: WriteMode::Truncate,
479 }),
480 preceded("<>", (ws, word)).map(move |(_, target)| Redir::ReadWrite {
483 fd: fd.unwrap_or(0),
484 target,
485 }),
486 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
487 fd: fd.unwrap_or(0),
488 target,
489 }),
490 ))
491 .parse_next(input)
492}
493
494fn heredoc(input: &mut &str) -> ModalResult<Redir> {
495 "<<".parse_next(input)?;
496 let strip_tabs = opt('-').parse_next(input)?.is_some();
497 ws.parse_next(input)?;
498 let (delimiter, expands) = heredoc_delimiter.parse_next(input)?;
499 let body = if expands {
503 heredoc_body_word(input, &delimiter, strip_tabs)?
504 } else {
505 Word(Vec::new())
506 };
507 PENDING_HEREDOCS.with(|q| {
513 q.borrow_mut().push(PendingHeredoc {
514 delimiter: delimiter.clone(),
515 strip_tabs,
516 });
517 });
518 Ok(Redir::HereDoc { delimiter, strip_tabs, body })
519}
520
521fn heredoc_body_word(input: &str, delimiter: &str, strip_tabs: bool) -> ModalResult<Word> {
531 let Some(nl) = input.find('\n') else {
532 return Ok(Word(Vec::new())); };
534 let mut rest = &input[nl + 1..];
535 let priors: Vec<PendingHeredoc> = PENDING_HEREDOCS.with(|q| q.borrow().clone());
536 for prior in &priors {
537 let Some((_, after)) = split_heredoc_body(rest, &prior.delimiter, prior.strip_tabs) else {
538 return Ok(Word(Vec::new()));
539 };
540 rest = after;
541 }
542 let Some((body, _)) = split_heredoc_body(rest, delimiter, strip_tabs) else {
543 return Ok(Word(Vec::new()));
544 };
545 let mut text = body;
546 let parts: Vec<WordPart> = repeat(0.., heredoc_part).parse_next(&mut text)?;
547 if !text.is_empty() {
548 return backtrack();
549 }
550 Ok(Word(parts))
551}
552
553fn is_heredoc_literal(c: char) -> bool {
557 !matches!(c, '"' | '\\' | '`' | '$')
558}
559
560fn heredoc_part(input: &mut &str) -> ModalResult<WordPart> {
561 if input.is_empty() {
562 return backtrack();
563 }
564 if input.starts_with('"') {
565 *input = &input[1..];
566 return Ok(WordPart::Lit("\"".to_string()));
567 }
568 alt((
569 dq_escape,
570 arith_sub,
571 cmd_sub,
572 backtick_part,
573 dollar_lit(is_heredoc_literal),
574 lit(is_heredoc_literal),
575 ))
576 .parse_next(input)
577}
578
579#[derive(Debug, Clone)]
580struct PendingHeredoc {
581 delimiter: String,
582 strip_tabs: bool,
583}
584
585thread_local! {
586 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
587 const { std::cell::RefCell::new(Vec::new()) };
588}
589
590fn drain_pending_heredocs(input: &mut &str) {
591 let pending: Vec<PendingHeredoc> =
592 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
593 for h in pending {
594 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
595 return;
599 }
600 }
601}
602
603fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
604 match split_heredoc_body(input, delimiter, strip_tabs) {
605 Some((_, rest)) => {
606 *input = rest;
607 true
608 }
609 None => false,
610 }
611}
612
613fn split_heredoc_body<'a>(
617 s: &'a str,
618 delimiter: &str,
619 strip_tabs: bool,
620) -> Option<(&'a str, &'a str)> {
621 let bytes = s.as_bytes();
622 let mut line_start = 0;
623 while line_start <= bytes.len() {
624 let line_end = match s[line_start..].find('\n') {
625 Some(rel) => line_start + rel,
626 None => bytes.len(),
627 };
628 let line = &s[line_start..line_end];
629 let line = if strip_tabs { line.trim_start_matches('\t') } else { line };
630 if line == delimiter {
631 let advance = line_end + usize::from(line_end < bytes.len());
632 return Some((&s[..line_start], &s[advance..]));
633 }
634 if line_end >= bytes.len() {
635 return None;
636 }
637 line_start = line_end + 1;
638 }
639 None
640}
641
642fn reset_heredoc_queue() {
643 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
644}
645
646fn heredoc_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
651 alt((
652 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| (s.to_string(), false)),
653 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| (s.to_string(), false)),
654 escaped_delimiter,
655 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
656 .map(|s: &str| (s.to_string(), true)),
657 ))
658 .parse_next(input)
659}
660
661fn escaped_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
666 let mut rest = *input;
667 let mut name = String::new();
668 let mut quoted = false;
669 loop {
670 let mut chars = rest.chars();
671 match chars.next() {
672 Some('\\') => match chars.next() {
673 Some(c) => {
674 name.push(c);
675 quoted = true;
676 rest = &rest[1 + c.len_utf8()..];
677 }
678 None => break,
679 },
680 Some(q @ ('\'' | '"')) => {
681 let inner_end = rest[1..].find(q).map(|i| i + 1);
682 let Some(end) = inner_end else { break };
683 name.push_str(&rest[1..end]);
684 quoted = true;
685 rest = &rest[end + 1..];
686 }
687 Some(c) if c.is_ascii_alphanumeric() || c == '_' => {
688 name.push(c);
689 rest = &rest[c.len_utf8()..];
690 }
691 _ => break,
692 }
693 }
694 if !quoted || name.is_empty() {
695 return backtrack();
696 }
697 *input = rest;
698 Ok((name, false))
699}
700
701fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
702 let b = input.as_bytes();
703 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
704 let d = (b[0] - b'0') as u32;
705 *input = &input[1..];
706 Ok(d)
707 } else {
708 backtrack()
709 }
710}
711
712fn fd_target(input: &mut &str) -> ModalResult<String> {
713 alt((
714 '-'.value("-".to_string()),
715 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
716 ))
717 .parse_next(input)
718}
719
720fn word(input: &mut &str) -> ModalResult<Word> {
723 repeat(1.., word_part)
724 .map(Word)
725 .parse_next(input)
726}
727
728fn word_part(input: &mut &str) -> ModalResult<WordPart> {
729 if input.is_empty() {
730 return backtrack();
731 }
732 if input.starts_with("<(") || input.starts_with(">(") {
733 return proc_sub(input);
734 }
735 if is_word_boundary(input.as_bytes()[0] as char) {
736 return backtrack();
737 }
738 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
739 .parse_next(input)
740}
741
742fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
743 delimited('\'', take_while(0.., |c| c != '\''), '\'')
744 .map(|s: &str| WordPart::SQuote(s.to_string()))
745 .parse_next(input)
746}
747
748fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
749 delimited('"', repeat(0.., dq_part).map(Word), '"')
750 .map(WordPart::DQuote)
751 .parse_next(input)
752}
753
754fn find_sub_close(body: &str) -> Option<usize> {
762 let b = body.as_bytes();
763 let mut i = 0;
764 let mut depth: usize = 0;
765 while i < b.len() {
766 match b[i] {
767 b'\\' => i += 1, b'\'' => {
769 i += 1;
770 while i < b.len() && b[i] != b'\'' {
771 i += 1;
772 }
773 if i >= b.len() {
774 return None;
775 }
776 }
777 b'"' => {
778 i += 1;
779 while i < b.len() && b[i] != b'"' {
780 i += if b[i] == b'\\' { 2 } else { 1 };
781 }
782 if i >= b.len() {
783 return None;
784 }
785 }
786 b'`' => {
787 i += 1;
788 while i < b.len() && b[i] != b'`' {
789 i += if b[i] == b'\\' { 2 } else { 1 };
792 }
793 if i >= b.len() {
794 return None;
795 }
796 }
797 b'(' => depth += 1,
798 b')' => {
799 if depth == 0 {
800 return Some(i);
801 }
802 depth -= 1;
803 }
804 _ => {}
805 }
806 i += 1;
807 }
808 None
809}
810
811fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
829 let body = &input[open_len..];
830 let Some(rel) = find_sub_close(body) else {
831 return if body.contains("<<") {
832 sub_body_via_grammar(input, body)
833 } else {
834 backtrack()
835 };
836 };
837 let interior = &body[..rel];
843 if !interior.contains("<<") && !interior.contains("case") {
849 let mut fast: &str = interior;
850 if let Ok(parsed) = script.parse_next(&mut fast) {
851 ws.parse_next(&mut fast)?;
852 if fast.is_empty() {
853 *input = &body[rel + 1..];
854 return Ok(parsed);
855 }
856 }
857 }
858 sub_body_via_grammar(input, body)
859}
860
861fn sub_body_via_grammar<'a>(input: &mut &'a str, body: &'a str) -> ModalResult<Script> {
864 let mut rest: &str = body;
865 ws.parse_next(&mut rest)?;
866 let parsed = script.parse_next(&mut rest)?;
867 ws.parse_next(&mut rest)?;
868 if !rest.starts_with(')') {
869 return backtrack();
870 }
871 *input = &rest[1..];
872 Ok(parsed)
873}
874
875fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
876 if !input.starts_with("$(") {
877 return backtrack();
878 }
879 sub_body(input, 2).map(WordPart::CmdSub)
880}
881
882fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
883 if !(input.starts_with("<(") || input.starts_with(">(")) {
884 return backtrack();
885 }
886 sub_body(input, 2).map(WordPart::ProcSub)
887}
888
889fn arith_body_part(input: &mut &str) -> ModalResult<WordPart> {
891 if input.is_empty() {
892 return backtrack();
893 }
894 alt((
895 dq_escape,
896 arith_sub,
901 cmd_sub,
902 backtick_part,
903 dollar_lit(is_heredoc_literal),
904 lit(is_heredoc_literal),
905 ))
906 .parse_next(input)
907}
908
909fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
910 if !input.starts_with("$((") {
911 return backtrack();
912 }
913 let Some(_depth) = DepthGuard::enter() else {
920 return backtrack();
921 };
922 let body_start = 3;
923 let bytes = input.as_bytes();
924 let mut depth: i32 = 1;
925 let mut i = body_start;
926 while i < bytes.len() {
927 match bytes[i] {
928 b'(' => depth += 1,
929 b')' => {
930 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
931 let mut body = &input[body_start..i];
943 let parts: Vec<WordPart> = repeat(0.., arith_body_part).parse_next(&mut body)?;
944 if !body.is_empty() {
945 return backtrack();
946 }
947 *input = &input[i + 2..];
948 return Ok(WordPart::Arith(Word(parts)));
949 }
950 depth -= 1;
951 if depth < 0 {
952 return backtrack();
953 }
954 }
955 _ => {}
956 }
957 i += 1;
958 }
959 backtrack()
960}
961
962fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
963 delimited('`', backtick_inner, '`')
964 .map(WordPart::Backtick)
965 .parse_next(input)
966}
967
968fn escaped(input: &mut &str) -> ModalResult<WordPart> {
969 preceded('\\', any).map(WordPart::Escape).parse_next(input)
970}
971
972fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
973 move |input: &mut &str| {
974 take_while(1.., pred)
975 .map(|s: &str| WordPart::Lit(s.to_string()))
976 .parse_next(input)
977 }
978}
979
980fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
981 move |input: &mut &str| {
982 ('$', not('(')).void().parse_next(input)?;
983 let rest: &str = take_while(0.., pred).parse_next(input)?;
984 Ok(WordPart::Lit(format!("${rest}")))
985 }
986}
987
988fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
991 if input.is_empty() || input.starts_with('"') {
992 return backtrack();
993 }
994 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
995 .parse_next(input)
996}
997
998fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
999 preceded('\\', any)
1000 .map(|c: char| match c {
1001 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
1002 _ => WordPart::Lit(format!("\\{c}")),
1003 })
1004 .parse_next(input)
1005}
1006
1007fn backtick_inner(input: &mut &str) -> ModalResult<String> {
1010 repeat(0.., alt((bt_escape, bt_literal)))
1011 .fold(String::new, |mut acc, chunk: &str| {
1012 acc.push_str(chunk);
1013 acc
1014 })
1015 .parse_next(input)
1016}
1017
1018fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1019 ('\\', any).take().parse_next(input)
1020}
1021
1022fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
1023 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
1024}
1025
1026fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
1029 eat_keyword(input, "for")?;
1030 ws.parse_next(input)?;
1031 let var = name.parse_next(input)?;
1032 ws.parse_next(input)?;
1033
1034 let items = if eat_keyword(input, "in").is_ok() {
1035 ws.parse_next(input)?;
1036 repeat(0.., terminated(word, ws)).parse_next(input)?
1037 } else {
1038 vec![]
1039 };
1040
1041 let body = do_done_body.parse_next(input)?;
1042 let redirs = trailing_redirs(input)?;
1043 Ok(Cmd::For { var, items, body, redirs })
1044}
1045
1046fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
1047 eat_keyword(input, "while")?;
1048 ws.parse_next(input)?;
1049 let cond = script.parse_next(input)?;
1050 let body = do_done_body.parse_next(input)?;
1051 let redirs = trailing_redirs(input)?;
1052 Ok(Cmd::While { cond, body, redirs })
1053}
1054
1055fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
1056 eat_keyword(input, "until")?;
1057 ws.parse_next(input)?;
1058 let cond = script.parse_next(input)?;
1059 let body = do_done_body.parse_next(input)?;
1060 let redirs = trailing_redirs(input)?;
1061 Ok(Cmd::Until { cond, body, redirs })
1062}
1063
1064fn do_done_body(input: &mut &str) -> ModalResult<Script> {
1065 sep.parse_next(input)?;
1066 eat_keyword(input, "do")?;
1067 sep.parse_next(input)?;
1068 let body = script.parse_next(input)?;
1069 sep.parse_next(input)?;
1070 eat_keyword(input, "done")?;
1071 Ok(body)
1072}
1073
1074fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
1075 eat_keyword(input, "if")?;
1076 ws.parse_next(input)?;
1077 let mut branches = vec![cond_then_body.parse_next(input)?];
1078 let mut else_body = None;
1079
1080 loop {
1081 sep.parse_next(input)?;
1082 if eat_keyword(input, "elif").is_ok() {
1083 ws.parse_next(input)?;
1084 branches.push(cond_then_body.parse_next(input)?);
1085 } else if eat_keyword(input, "else").is_ok() {
1086 sep.parse_next(input)?;
1087 else_body = Some(script.parse_next(input)?);
1088 break;
1089 } else {
1090 break;
1091 }
1092 }
1093
1094 sep.parse_next(input)?;
1095 eat_keyword(input, "fi")?;
1096 let redirs = trailing_redirs(input)?;
1097 Ok(Cmd::If { branches, else_body, redirs })
1098}
1099
1100fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
1101 let cond = script.parse_next(input)?;
1102 sep.parse_next(input)?;
1103 eat_keyword(input, "then")?;
1104 sep.parse_next(input)?;
1105 let body = script.parse_next(input)?;
1106 Ok(Branch { cond, body })
1107}
1108
1109fn case_cmd(input: &mut &str) -> ModalResult<Cmd> {
1111 eat_keyword(input, "case")?;
1112 ws.parse_next(input)?;
1113 let subject = word.parse_next(input)?;
1114 blank.parse_next(input)?;
1115 eat_keyword(input, "in")?;
1116
1117 let mut arms = Vec::new();
1118 loop {
1119 blank.parse_next(input)?;
1120 if eat_keyword(input, "esac").is_ok() {
1121 break;
1122 }
1123 let arm = case_arm.parse_next(input)?;
1124 let had_terminator = opt(";;").parse_next(input)?.is_some();
1125 arms.push(arm);
1126 if !had_terminator {
1129 blank.parse_next(input)?;
1130 eat_keyword(input, "esac")?;
1131 break;
1132 }
1133 }
1134
1135 let redirs = trailing_redirs(input)?;
1136 Ok(Cmd::Case { subject, arms, redirs })
1137}
1138
1139fn case_arm(input: &mut &str) -> ModalResult<CaseArm> {
1140 blank.parse_next(input)?;
1141 opt('(').parse_next(input)?;
1142 let mut patterns = Vec::new();
1143 loop {
1144 ws.parse_next(input)?;
1145 patterns.push(word.parse_next(input)?);
1146 ws.parse_next(input)?;
1147 if opt('|').parse_next(input)?.is_none() {
1148 break;
1149 }
1150 }
1151 ')'.parse_next(input)?;
1152 let body = script.parse_next(input)?;
1153 blank.parse_next(input)?;
1154 Ok(CaseArm { patterns, body })
1155}
1156
1157fn blank(input: &mut &str) -> ModalResult<()> {
1159 take_while(0.., [' ', '\t', '\n']).void().parse_next(input)
1160}
1161
1162fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
1163 if !input.starts_with("[[") {
1164 return backtrack();
1165 }
1166 let bytes = input.as_bytes();
1167 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
1168 return backtrack();
1169 }
1170 *input = &input[2..];
1171
1172 let mut words: Vec<Word> = Vec::new();
1173 loop {
1174 ws.parse_next(input)?;
1175 if at_double_bracket_end(input) {
1176 *input = &input[2..];
1177 let redirs = trailing_redirs(input)?;
1178 return Ok(Cmd::DoubleBracket { words, redirs });
1179 }
1180 if input.is_empty() {
1181 return backtrack();
1182 }
1183 let w = bracket_word.parse_next(input)?;
1184 words.push(w);
1185 }
1186}
1187
1188fn at_double_bracket_end(input: &str) -> bool {
1189 if !input.starts_with("]]") {
1190 return false;
1191 }
1192 let after = &input[2..];
1193 after.is_empty()
1194 || after.starts_with(|c: char| {
1195 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
1196 })
1197}
1198
1199fn bracket_word(input: &mut &str) -> ModalResult<Word> {
1200 repeat(1.., bracket_word_part).map(Word).parse_next(input)
1201}
1202
1203fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
1204 if input.is_empty() {
1205 return backtrack();
1206 }
1207 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
1208 return backtrack();
1209 }
1210 if at_double_bracket_end(input) {
1211 return backtrack();
1212 }
1213 alt((
1214 single_quoted,
1215 double_quoted,
1216 arith_sub,
1217 cmd_sub,
1218 backtick_part,
1219 escaped,
1220 dollar_lit(is_bracket_literal),
1221 bracket_lit,
1222 ))
1223 .parse_next(input)
1224}
1225
1226fn is_bracket_literal(c: char) -> bool {
1227 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
1228}
1229
1230fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
1231 let bytes = input.as_bytes();
1236 let mut end = 0;
1237 while end < bytes.len() {
1238 let c = bytes[end] as char;
1239 if !is_bracket_literal(c) {
1240 break;
1241 }
1242 if c == ']' && at_double_bracket_end(&input[end..]) {
1243 break;
1244 }
1245 end += 1;
1246 }
1247 if end == 0 {
1248 return backtrack();
1249 }
1250 let lit = input[..end].to_string();
1251 *input = &input[end..];
1252 Ok(WordPart::Lit(lit))
1253}
1254
1255fn name(input: &mut &str) -> ModalResult<String> {
1256 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
1257 .map(|s: &str| s.to_string())
1258 .parse_next(input)
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 #[test]
1281 fn the_parse_work_budget_bounds_nesting_without_refusing_real_commands() {
1282 let worst_real = crate::registry::corpus_examples()
1283 .into_iter()
1284 .flat_map(|(_, safe, denied)| safe.iter().chain(denied.iter()).cloned().collect::<Vec<_>>())
1285 .map(|ex| {
1286 let _ = super::parse(&ex);
1287 super::PARSE_WORK.with(|w| w.get())
1288 })
1289 .max()
1290 .expect("the registry ships examples");
1291 assert!(
1292 worst_real * 100 < MAX_PARSE_WORK_CEILING,
1293 "a real example needs {worst_real} entries against a {MAX_PARSE_WORK_CEILING} ceiling; \
1294 the margin that makes this constant safe is gone"
1295 );
1296
1297 let flat = format!("echo {}", "$((1)) ".repeat(400));
1303 let _ = super::parse(&flat);
1304 let flat_work = super::PARSE_WORK.with(|w| w.get());
1305 assert!(
1306 flat_work < MAX_PARSE_WORK_CEILING / 10,
1307 "flat input cost {flat_work}, close to the {MAX_PARSE_WORK_CEILING} ceiling — a long \
1308 flat command is at risk of being refused"
1309 );
1310
1311 let work_at = |depth: usize| {
1317 let deep = format!("echo {}1{}", "$((1+".repeat(depth), "))".repeat(depth));
1318 let _ = super::parse(&deep);
1319 super::PARSE_WORK.with(|w| w.get())
1320 };
1321 let (w2k, w4k) = (work_at(2000), work_at(4000));
1322 assert!(w2k > 1000, "the nest should actually reach the bound, or this proves nothing");
1323 assert!(
1324 w4k <= w2k + w2k / 10,
1325 "work still scales with input length: depth 2000 used {w2k}, depth 4000 used {w4k}"
1326 );
1327 assert!(
1328 w4k < MAX_PARSE_WORK_CEILING + MAX_PARSE_WORK_CEILING / 10,
1329 "work {w4k} ran far past the {MAX_PARSE_WORK_CEILING} ceiling"
1330 );
1331 }
1332
1333
1334 use super::*;
1335
1336 fn p(input: &str) -> Script {
1337 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
1338 }
1339
1340 fn words(script: &Script) -> Vec<String> {
1341 match &script.0[0].pipeline.commands[0] {
1342 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
1343 _ => panic!("expected simple command"),
1344 }
1345 }
1346
1347 fn simple(script: &Script) -> &SimpleCmd {
1348 match &script.0[0].pipeline.commands[0] {
1349 Cmd::Simple(s) => s,
1350 _ => panic!("expected simple command"),
1351 }
1352 }
1353
1354 #[test]
1355 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
1356 #[test]
1357 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
1358 #[test]
1359 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
1360 #[test]
1361 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
1362 #[test]
1363 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
1364
1365 #[test]
1366 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
1367 #[test]
1368 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
1369 #[test]
1370 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
1371 #[test]
1372 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
1373 #[test]
1374 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
1375 #[test]
1376 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
1377 #[test]
1378 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
1379 #[test]
1380 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
1381 #[test]
1382 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
1383 #[test]
1384 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
1385
1386 #[test]
1387 fn brace_group_simple() {
1388 assert!(matches!(
1389 &p("{ echo hello; }").0[0].pipeline.commands[0],
1390 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1391 ));
1392 }
1393 #[test]
1394 fn brace_group_multiple_stmts() {
1395 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1396 assert_eq!(body.0.len(), 3);
1397 } else { panic!("expected BraceGroup"); }
1398 }
1399 #[test]
1400 fn brace_group_with_redirect() {
1401 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1402 assert_eq!(redirs.len(), 1);
1403 assert!(matches!(redirs[0], Redir::Write { .. }));
1404 } else { panic!("expected BraceGroup"); }
1405 }
1406 #[test]
1407 fn brace_group_with_append_redirect() {
1408 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1409 assert!(matches!(redirs[0], Redir::Write { mode: WriteMode::Append, .. }));
1410 } else { panic!("expected BraceGroup"); }
1411 }
1412 #[test]
1413 fn brace_group_with_stderr_redirect() {
1414 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1415 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1416 } else { panic!("expected BraceGroup"); }
1417 }
1418 #[test]
1419 fn brace_group_newline_separated() {
1420 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
1421 assert_eq!(body.0.len(), 2);
1422 } else { panic!("expected BraceGroup"); }
1423 }
1424 #[test]
1425 fn brace_group_in_pipeline() {
1426 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1427 assert_eq!(pl.commands.len(), 2);
1428 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1429 }
1430 #[test]
1431 fn brace_group_followed_by_other() {
1432 let stmts = &p("{ echo a; }; echo b").0;
1433 assert_eq!(stmts.len(), 2);
1434 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1435 }
1436 #[test]
1437 fn brace_group_nested() {
1438 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1439 assert_eq!(body.0.len(), 2);
1440 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1441 } else { panic!("expected outer BraceGroup"); }
1442 }
1443 #[test]
1444 fn brace_group_with_subshell_inside() {
1445 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1446 assert_eq!(body.0.len(), 2);
1447 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1448 } else { panic!("expected BraceGroup"); }
1449 }
1450 #[test]
1451 fn brace_open_requires_whitespace() {
1452 let cmds = &p("{echo a}").0;
1456 if !cmds.is_empty() {
1459 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1460 }
1461 }
1462 #[test]
1463 fn subshell_with_redirect() {
1464 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1465 assert_eq!(redirs.len(), 1);
1466 } else { panic!("expected Subshell with redir"); }
1467 }
1468 #[test]
1469 fn for_loop_with_redirect() {
1470 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1471 assert_eq!(redirs.len(), 1);
1472 } else { panic!("expected For with redir"); }
1473 }
1474 #[test]
1475 fn for_loop_redirect_then_pipe() {
1476 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1478 assert_eq!(pl.commands.len(), 2);
1479 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1480 }
1481 #[test]
1482 fn while_and_if_with_redirect() {
1483 assert!(matches!(
1484 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1485 Cmd::While { redirs, .. } if redirs.len() == 1
1486 ));
1487 assert!(matches!(
1488 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1489 Cmd::If { redirs, .. } if redirs.len() == 1
1490 ));
1491 }
1492 #[test]
1493 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1494
1495 #[test]
1496 fn redirect_dev_null() {
1497 let s = p("echo hello > /dev/null");
1498 let cmd = simple(&s);
1499 assert_eq!(cmd.words.len(), 2);
1500 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, mode: WriteMode::Truncate, .. }));
1501 }
1502 #[test]
1503 fn redirect_stderr() {
1504 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1505 }
1506 #[test]
1507 fn here_string() {
1508 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1509 }
1510 #[test]
1511 fn heredoc_bare() {
1512 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false, .. } if delimiter == "EOF"));
1513 }
1514 #[test]
1515 fn heredoc_with_content() {
1516 let s = p("cat <<EOF\nhello world\nEOF");
1517 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1518 }
1519 #[test]
1520 fn heredoc_quoted_delimiter() {
1521 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1522 }
1523 #[test]
1524 fn heredoc_strip_tabs() {
1525 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1526 }
1527 #[test]
1528 fn heredoc_pipe_on_command_line() {
1529 let s = p("cat <<EOF | grep hello\nhello\nEOF");
1532 assert_eq!(s.0[0].pipeline.commands.len(), 2);
1533 }
1534 #[test]
1535 fn heredoc_body_does_not_swallow_pipe() {
1536 let s = p("cat <<EOF | bash\nrm\nEOF");
1540 assert_eq!(
1541 s.0[0].pipeline.commands.len(),
1542 2,
1543 "pipeline must keep `bash` as a second command"
1544 );
1545 }
1546 #[test]
1547 fn heredoc_followed_by_next_statement() {
1548 let s = p("cat <<EOF\nhello\nEOF\nls");
1551 assert_eq!(s.0.len(), 2);
1552 }
1553
1554 #[test]
1555 fn env_prefix() {
1556 let s = p("FOO='bar baz' ls -la");
1557 let cmd = simple(&s);
1558 assert_eq!(cmd.env[0].0, "FOO");
1559 assert_eq!(cmd.env[0].1.eval(), "bar baz");
1560 }
1561 #[test]
1562 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1563 #[test]
1564 fn backtick_substitution() {
1565 assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB_WORKTREE__");
1568 assert_eq!(simple(&p("ls `hostname`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__");
1570 }
1571 #[test]
1572 fn nested_substitution() {
1573 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1574 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1575 } else { panic!("expected CmdSub"); }
1576 }
1577
1578 #[test]
1579 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1580 #[test]
1581 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1582
1583 #[test]
1584 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")); }
1585 #[test]
1586 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1587 #[test]
1588 fn if_then_fi() {
1589 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1590 assert_eq!(branches.len(), 1);
1591 assert!(else_body.is_none());
1592 } else { panic!("expected If"); }
1593 }
1594 #[test]
1595 fn if_elif_else() {
1596 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] {
1597 assert_eq!(branches.len(), 2);
1598 assert!(else_body.is_some());
1599 } else { panic!("expected If"); }
1600 }
1601
1602 #[test]
1603 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1604 #[test]
1605 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1606 #[test]
1607 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1608
1609 #[test]
1610 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1611 #[test]
1612 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1613 #[test]
1614 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1615 #[test]
1616 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1617 #[test]
1618 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1619 #[test]
1620 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1621
1622 #[test]
1623 fn subshell_for() {
1624 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1625 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1626 } else { panic!("expected Subshell"); }
1627 }
1628 #[test]
1629 fn proc_sub_input() {
1630 let s = p("diff <(sort a.txt) <(sort b.txt)");
1631 let cmd = simple(&s);
1632 assert_eq!(cmd.words.len(), 3);
1633 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1634 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1635 }
1636 #[test]
1637 fn proc_sub_output() {
1638 let s = p("tee >(grep error > /dev/null)");
1639 let cmd = simple(&s);
1640 assert_eq!(cmd.words.len(), 2);
1641 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1642 }
1643
1644 fn func_def(s: &Script) -> (&str, &Script) {
1646 match &s.0[0].pipeline.commands[0] {
1647 Cmd::FunctionDef { name, body } => (name.as_str(), body),
1648 other => panic!("expected FunctionDef, got {other:?}"),
1649 }
1650 }
1651 #[test]
1652 fn function_def_posix_form() {
1653 let s = p("probe(){ echo hi; }");
1654 let (name, body) = func_def(&s);
1655 assert_eq!(name, "probe");
1656 assert_eq!(words(body), ["echo", "hi"]);
1657 }
1658 #[test]
1659 fn function_def_spaced_and_keyword_forms() {
1660 assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1661 assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1662 assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1663 }
1664 #[test]
1665 fn function_def_subshell_body() {
1666 let s = p("foo() ( echo sub )");
1667 assert_eq!(func_def(&s).0, "foo");
1668 }
1669 #[test]
1670 fn function_def_body_on_next_line() {
1671 assert_eq!(func_def(&p("foo()\n{\n echo hi\n}")).0, "foo");
1672 }
1673 #[test]
1674 fn function_def_name_with_dashes_and_dots() {
1675 assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1676 }
1677 #[test]
1678 fn plain_command_is_not_a_function_def() {
1679 assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1681 assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1682 }
1683 #[test]
1684 fn function_def_roundtrips() {
1685 let rendered = p("greet(){ echo hi; }").to_string();
1686 assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1687 assert_eq!(func_def(&p(&rendered)).0, "greet");
1688 }
1689 #[test]
1690 fn comment_only() {
1691 let s = p("# just a comment");
1692 assert!(s.0.is_empty());
1693 }
1694 #[test]
1695 fn comment_before_command() {
1696 let s = p("# comment\necho hello");
1697 assert_eq!(words(&s), ["echo", "hello"]);
1698 }
1699 #[test]
1700 fn inline_comment() {
1701 let s = p("echo hello # this is a comment");
1702 assert_eq!(words(&s), ["echo", "hello"]);
1703 }
1704 #[test]
1705 fn comment_between_commands() {
1706 let s = p("echo hello\n# middle comment\necho world");
1707 assert_eq!(s.0.len(), 2);
1708 }
1709 #[test]
1710 fn comment_after_semicolon() {
1711 let s = p("echo hello; # comment\necho world");
1712 assert_eq!(s.0.len(), 2);
1713 }
1714 #[test]
1715 fn comment_in_for_loop() {
1716 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1717 }
1718 #[test]
1719 fn quoted_redirect_in_echo() {
1720 let s = p("echo 'greater > than' test");
1721 let cmd = simple(&s);
1722 assert_eq!(cmd.words.len(), 3);
1723 assert_eq!(cmd.redirs.len(), 0);
1724 }
1725
1726 #[test]
1727 fn parses_all_safe_commands() {
1728 let cmds = [
1729 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1730 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1731 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1732 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1733 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1734 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1735 "((echo hello))", "(for x in 1 2; do echo $x; done)",
1736 "echo 'greater > than' test", "echo '$(safe)' arg",
1737 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1738 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1739 "grep foo file.txt | head -5", "cat file | sort | uniq",
1740 "ls && echo done", "ls; echo done", "ls & echo done",
1741 "grep -c , <<< 'hello,world,test'",
1742 "cat <<EOF\nhello world\nEOF",
1743 "cat <<'MARKER'\nsome text\nMARKER",
1744 "cat <<-EOF\n\thello\nEOF",
1745 "echo foo\necho bar", "ls\ncat file.txt",
1746 "git log --oneline -20 | head -5",
1747 "echo hello > /dev/null", "echo hello 2> /dev/null",
1748 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1749 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1750 "for x in 1 2 3; do echo $x; done",
1751 "for f in *.txt; do cat $f | grep pattern; done",
1752 "for x in 1 2 3; do; done",
1753 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1754 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1755 "for x in 1 2; do echo $x; done && echo finished",
1756 "for x in $(seq 1 5); do echo $x; done",
1757 "while test -f /tmp/foo; do sleep 1; done",
1758 "while ! test -f /tmp/done; do sleep 1; done",
1759 "until test -f /tmp/ready; do sleep 1; done",
1760 "if test -f foo; then echo exists; fi",
1761 "if test -f foo; then echo yes; else echo no; fi",
1762 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1763 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1764 "if true; then for x in 1 2; do echo $x; done; fi",
1765 "diff <(sort a.txt) <(sort b.txt)",
1766 "comm -23 file.txt <(sort other.txt)",
1767 "cat <(echo hello)",
1768 "# comment only",
1769 "# comment\necho hello",
1770 "echo hello # inline comment",
1771 "echo one\n# between\necho two",
1772 "! echo hello", "! test -f foo",
1773 "echo for; echo done; echo if; echo fi",
1774 ];
1775 let mut failures = Vec::new();
1776 for cmd in &cmds {
1777 if parse(cmd).is_none() { failures.push(*cmd); }
1778 }
1779 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1780 }
1781
1782 fn inner_sub(s: &Script) -> &Script {
1788 match &simple(s).words[1].0[0] {
1789 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1790 other => panic!("expected a substitution, got {other:?}"),
1791 }
1792 }
1793
1794 #[test]
1795 fn cmd_sub_squote_paren_is_not_close() {
1796 assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1797 }
1798 #[test]
1799 fn cmd_sub_dquote_paren_is_not_close() {
1800 assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1801 }
1802 #[test]
1803 fn cmd_sub_escaped_paren_is_not_close() {
1804 assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1805 }
1806 #[test]
1807 fn cmd_sub_backtick_paren_is_not_close() {
1808 let s = p("echo $(x `)` y)");
1810 let inner = simple(inner_sub(&s));
1811 assert_eq!(inner.words.len(), 3);
1812 assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1813 }
1814 #[test]
1815 fn cmd_sub_escaped_backtick_does_not_end_span() {
1816 let s = p("echo $(`\\`)`)");
1820 assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1821 }
1822 #[test]
1823 fn proc_sub_squote_paren_is_not_close() {
1824 assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1825 }
1826 #[test]
1827 fn proc_sub_out_squote_paren_is_not_close() {
1828 assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1829 }
1830 #[test]
1831 fn cmd_sub_nested_picks_outer_close() {
1832 let s = p("echo $(a $(b) c)");
1833 let inner = simple(inner_sub(&s));
1834 assert_eq!(inner.words.len(), 3);
1835 assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1836 }
1837 #[test]
1838 fn cmd_sub_literal_after_close_stays_in_outer_word() {
1839 let s = p("echo $(ls)tail");
1840 let w = &simple(&s).words[1];
1841 assert_eq!(w.0.len(), 2);
1842 assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1843 assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1844 }
1845 #[test]
1846 fn cmd_sub_heredoc_body_paren_does_not_close() {
1847 let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1851 assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1852 }
1853 #[test]
1854 fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1855 assert!(parse("echo $(echo ')").is_none());
1857 }
1858 #[test]
1859 fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1860 assert!(parse("cat <(grep ')").is_none());
1861 }
1862}