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| l.set(MAX_PARSE_WORK_BASE + MAX_PARSE_WORK_PER_BYTE * input.len() as u64));
14 let result = script.parse(input).ok();
15 reset_heredoc_queue();
16 result
17}
18
19fn backtrack<T>() -> ModalResult<T> {
20 Err(ErrMode::Backtrack(ContextError::new()))
21}
22
23thread_local! {
24 static PARSE_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
25 static PARSE_WORK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
29 static PARSE_WORK_LIMIT: std::cell::Cell<u64> = const { std::cell::Cell::new(u64::MAX) };
30}
31
32const MAX_PARSE_DEPTH: u32 = 48;
42
43const MAX_PARSE_WORK_BASE: u64 = 16_384;
51const MAX_PARSE_WORK_PER_BYTE: u64 = 512;
52
53struct DepthGuard;
57
58impl DepthGuard {
59 fn enter() -> Option<Self> {
60 let over_budget = PARSE_WORK.with(|w| {
61 let n = w.get().saturating_add(1);
62 w.set(n);
63 n > PARSE_WORK_LIMIT.with(|l| l.get())
64 });
65 if over_budget {
66 return None;
67 }
68 PARSE_DEPTH.with(|d| {
69 if d.get() >= MAX_PARSE_DEPTH {
70 None
71 } else {
72 d.set(d.get() + 1);
73 Some(DepthGuard)
74 }
75 })
76 }
77}
78
79impl Drop for DepthGuard {
80 fn drop(&mut self) {
81 PARSE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
82 }
83}
84
85fn comment(input: &mut &str) -> ModalResult<()> {
86 if input.starts_with('#') {
87 if let Some(pos) = input.find('\n') {
88 *input = &input[pos + 1..];
89 } else {
90 *input = "";
91 }
92 }
93 Ok(())
94}
95
96fn ws(input: &mut &str) -> ModalResult<()> {
97 loop {
98 take_while(0.., [' ', '\t']).void().parse_next(input)?;
99 if input.starts_with('#') {
100 comment(input)?;
101 } else {
102 break;
103 }
104 }
105 Ok(())
106}
107
108fn sep(input: &mut &str) -> ModalResult<()> {
109 loop {
110 while let Some(c) = input.chars().next() {
113 if input.starts_with(";;") {
114 return Ok(());
115 }
116 if !matches!(c, ' ' | '\t' | ';' | '\n') {
117 break;
118 }
119 *input = &input[c.len_utf8()..];
120 }
121 if input.starts_with('#') {
122 comment(input)?;
123 } else {
124 break;
125 }
126 }
127 Ok(())
128}
129
130fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
131 if !input.starts_with(kw) {
132 return backtrack();
133 }
134 if input
135 .as_bytes()
136 .get(kw.len())
137 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
138 {
139 return backtrack();
140 }
141 *input = &input[kw.len()..];
142 Ok(())
143}
144
145const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "esac", "fi", "then"];
146
147fn at_script_stop(input: &str) -> bool {
148 input.starts_with(')')
149 || input.starts_with('}')
150 || input.starts_with(";;")
153 || SCRIPT_STOPS.iter().any(|kw| {
154 input.starts_with(kw)
155 && !input
156 .as_bytes()
157 .get(kw.len())
158 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
159 })
160}
161
162fn is_word_boundary(c: char) -> bool {
163 matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
164}
165
166fn is_word_literal(c: char) -> bool {
167 !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
168}
169
170fn is_dq_literal(c: char) -> bool {
171 !matches!(c, '"' | '\\' | '`' | '$')
172}
173
174fn script(input: &mut &str) -> ModalResult<Script> {
177 let Some(_depth) = DepthGuard::enter() else {
180 return backtrack();
181 };
182 sep.parse_next(input)?;
183 let mut stmts = Vec::new();
184 while let Some(pl) = opt(pipeline).parse_next(input)? {
185 ws.parse_next(input)?;
186 let op = opt(list_op).parse_next(input)?;
187 stmts.push(Stmt { pipeline: pl, op });
188 drain_pending_heredocs(input);
193 if op.is_none() {
194 break;
195 }
196 sep.parse_next(input)?;
197 }
198 Ok(Script(stmts))
199}
200
201fn list_op(input: &mut &str) -> ModalResult<ListOp> {
202 ws.parse_next(input)?;
203 alt((
204 "&&".value(ListOp::And),
205 "||".value(ListOp::Or),
206 '\n'.value(ListOp::Semi),
207 (';', not(';')).value(ListOp::Semi),
210 ('&', not('>')).value(ListOp::Amp),
211 ))
212 .parse_next(input)
213}
214
215fn pipe_sep(input: &mut &str) -> ModalResult<()> {
219 (ws, alt(("|&".void(), ('|', not('|')).void())), ws).void().parse_next(input)
220}
221
222fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
225 ws.parse_next(input)?;
226 if at_script_stop(input) {
227 return backtrack();
228 }
229 let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
230 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
231 Ok(Pipeline { bang, commands })
232}
233
234fn command(input: &mut &str) -> ModalResult<Cmd> {
237 ws.parse_next(input)?;
238 if at_script_stop(input) {
239 return backtrack();
240 }
241 alt((
242 subshell,
243 brace_group,
244 for_cmd,
245 while_cmd,
246 until_cmd,
247 if_cmd,
248 case_cmd,
249 double_bracket_cmd,
250 function_def,
251 simple_cmd.map(Cmd::Simple),
252 ))
253 .parse_next(input)
254}
255
256fn function_def(input: &mut &str) -> ModalResult<Cmd> {
260 let had_keyword = opt_function_keyword(input);
261 let name = function_name(input)?;
262 ws.parse_next(input)?;
263 let has_parens = opt_paren_pair(input);
264 if !had_keyword && !has_parens {
265 return backtrack();
266 }
267 take_while(0.., [' ', '\t', '\n']).void().parse_next(input)?;
269 let body = function_body(input)?;
270 Ok(Cmd::FunctionDef { name, body })
271}
272
273fn opt_function_keyword(input: &mut &str) -> bool {
276 let mut probe = *input;
277 if eat_keyword(&mut probe, "function").is_ok()
278 && probe.starts_with([' ', '\t', '\n'])
279 && ws.parse_next(&mut probe).is_ok()
280 {
281 *input = probe;
282 return true;
283 }
284 false
285}
286
287fn opt_paren_pair(input: &mut &str) -> bool {
289 let mut probe = *input;
290 if let Some(rest) = probe.strip_prefix('(') {
291 probe = rest;
292 if ws.parse_next(&mut probe).is_ok()
293 && let Some(rest) = probe.strip_prefix(')')
294 {
295 *input = rest;
296 return true;
297 }
298 }
299 false
300}
301
302fn function_name(input: &mut &str) -> ModalResult<String> {
303 take_while(1.., |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+'))
304 .map(|s: &str| s.to_string())
305 .parse_next(input)
306}
307
308fn function_body(input: &mut &str) -> ModalResult<Script> {
311 if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
312 return Ok(body);
313 }
314 if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
315 return Ok(body);
316 }
317 backtrack()
318}
319
320fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
321 let mut redirs = Vec::new();
322 loop {
323 ws.parse_next(input)?;
324 if let Some(r) = opt(redirect).parse_next(input)? {
325 redirs.push(r);
326 } else {
327 break;
328 }
329 }
330 Ok(redirs)
331}
332
333fn subshell(input: &mut &str) -> ModalResult<Cmd> {
334 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
335 let redirs = trailing_redirs(input)?;
336 Ok(Cmd::Subshell { body, redirs })
337}
338
339fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
340 if !input.starts_with('{') {
341 return backtrack();
342 }
343 if !input
344 .as_bytes()
345 .get(1)
346 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
347 {
348 return backtrack();
349 }
350 *input = &input[1..];
351 sep.parse_next(input)?;
352 let body = script.parse_next(input)?;
353 if body.0.is_empty() {
354 return backtrack();
355 }
356 sep.parse_next(input)?;
357 if !input.starts_with('}') {
358 return backtrack();
359 }
360 let last_op = body.0.last().and_then(|s| s.op);
361 if last_op.is_none() {
362 return backtrack();
363 }
364 *input = &input[1..];
365 let redirs = trailing_redirs(input)?;
366 Ok(Cmd::BraceGroup { body, redirs })
367}
368
369fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
372 let env: Vec<(String, Word)> =
373 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
374 let mut words = Vec::new();
375 let mut redirs = Vec::new();
376
377 loop {
378 ws.parse_next(input)?;
379 if at_cmd_end(input) {
380 break;
381 }
382 if let Some(r) = opt(redirect).parse_next(input)? {
383 redirs.push(r);
384 } else if let Some(w) = opt(word).parse_next(input)? {
385 words.push(w);
386 } else {
387 break;
388 }
389 }
390
391 if env.is_empty() && words.is_empty() && redirs.is_empty() {
392 return backtrack();
393 }
394 Ok(SimpleCmd { env, words, redirs })
395}
396
397fn at_cmd_end(input: &str) -> bool {
398 if input.starts_with("&>") {
401 return false;
402 }
403 input.is_empty()
404 || matches!(
405 input.as_bytes().first(),
406 Some(b'\n' | b';' | b'|' | b'&' | b')')
407 )
408}
409
410fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
411 let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
412 .parse_next(input)?;
413 '='.parse_next(input)?;
414 let value = opt(word)
415 .parse_next(input)?
416 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
417 Ok((n.to_string(), value))
418}
419
420fn redirect(input: &mut &str) -> ModalResult<Redir> {
423 let fd = opt(fd_prefix).parse_next(input)?;
424 alt((
425 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
426 heredoc,
427 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
428 fd: fd.unwrap_or(1),
429 target,
430 mode: WriteMode::Append,
431 }),
432 preceded("&>>", (ws, word)).map(|(_, target)| Redir::Write {
435 fd: 1,
436 target,
437 mode: WriteMode::AppendBoth,
438 }),
439 preceded("&>", (ws, word)).map(|(_, target)| Redir::Write {
440 fd: 1,
441 target,
442 mode: WriteMode::TruncateBoth,
443 }),
444 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
445 src: fd.unwrap_or(1),
446 dst,
447 }),
448 preceded(">&", (ws, word)).map(|(_, target)| Redir::Write {
452 fd: 1,
453 target,
454 mode: WriteMode::TruncateBoth,
455 }),
456 preceded(">|", (ws, word)).map(move |(_, target)| Redir::Write {
460 fd: fd.unwrap_or(1),
461 target,
462 mode: WriteMode::Clobber,
463 }),
464 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
465 fd: fd.unwrap_or(1),
466 target,
467 mode: WriteMode::Truncate,
468 }),
469 preceded("<>", (ws, word)).map(move |(_, target)| Redir::ReadWrite {
472 fd: fd.unwrap_or(0),
473 target,
474 }),
475 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
476 fd: fd.unwrap_or(0),
477 target,
478 }),
479 ))
480 .parse_next(input)
481}
482
483fn heredoc(input: &mut &str) -> ModalResult<Redir> {
484 "<<".parse_next(input)?;
485 let strip_tabs = opt('-').parse_next(input)?.is_some();
486 ws.parse_next(input)?;
487 let (delimiter, expands) = heredoc_delimiter.parse_next(input)?;
488 let body = if expands {
492 heredoc_body_word(input, &delimiter, strip_tabs)?
493 } else {
494 Word(Vec::new())
495 };
496 PENDING_HEREDOCS.with(|q| {
502 q.borrow_mut().push(PendingHeredoc {
503 delimiter: delimiter.clone(),
504 strip_tabs,
505 });
506 });
507 Ok(Redir::HereDoc { delimiter, strip_tabs, body })
508}
509
510fn heredoc_body_word(input: &str, delimiter: &str, strip_tabs: bool) -> ModalResult<Word> {
520 let Some(nl) = input.find('\n') else {
521 return Ok(Word(Vec::new())); };
523 let mut rest = &input[nl + 1..];
524 let priors: Vec<PendingHeredoc> = PENDING_HEREDOCS.with(|q| q.borrow().clone());
525 for prior in &priors {
526 let Some((_, after)) = split_heredoc_body(rest, &prior.delimiter, prior.strip_tabs) else {
527 return Ok(Word(Vec::new()));
528 };
529 rest = after;
530 }
531 let Some((body, _)) = split_heredoc_body(rest, delimiter, strip_tabs) else {
532 return Ok(Word(Vec::new()));
533 };
534 let mut text = body;
535 let parts: Vec<WordPart> = repeat(0.., heredoc_part).parse_next(&mut text)?;
536 if !text.is_empty() {
537 return backtrack();
538 }
539 Ok(Word(parts))
540}
541
542fn is_heredoc_literal(c: char) -> bool {
546 !matches!(c, '"' | '\\' | '`' | '$')
547}
548
549fn heredoc_part(input: &mut &str) -> ModalResult<WordPart> {
550 if input.is_empty() {
551 return backtrack();
552 }
553 if input.starts_with('"') {
554 *input = &input[1..];
555 return Ok(WordPart::Lit("\"".to_string()));
556 }
557 alt((
558 dq_escape,
559 arith_sub,
560 cmd_sub,
561 backtick_part,
562 dollar_lit(is_heredoc_literal),
563 lit(is_heredoc_literal),
564 ))
565 .parse_next(input)
566}
567
568#[derive(Debug, Clone)]
569struct PendingHeredoc {
570 delimiter: String,
571 strip_tabs: bool,
572}
573
574thread_local! {
575 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
576 const { std::cell::RefCell::new(Vec::new()) };
577}
578
579fn drain_pending_heredocs(input: &mut &str) {
580 let pending: Vec<PendingHeredoc> =
581 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
582 for h in pending {
583 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
584 return;
588 }
589 }
590}
591
592fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
593 match split_heredoc_body(input, delimiter, strip_tabs) {
594 Some((_, rest)) => {
595 *input = rest;
596 true
597 }
598 None => false,
599 }
600}
601
602fn split_heredoc_body<'a>(
606 s: &'a str,
607 delimiter: &str,
608 strip_tabs: bool,
609) -> Option<(&'a str, &'a str)> {
610 let bytes = s.as_bytes();
611 let mut line_start = 0;
612 while line_start <= bytes.len() {
613 let line_end = match s[line_start..].find('\n') {
614 Some(rel) => line_start + rel,
615 None => bytes.len(),
616 };
617 let line = &s[line_start..line_end];
618 let line = if strip_tabs { line.trim_start_matches('\t') } else { line };
619 if line == delimiter {
620 let advance = line_end + usize::from(line_end < bytes.len());
621 return Some((&s[..line_start], &s[advance..]));
622 }
623 if line_end >= bytes.len() {
624 return None;
625 }
626 line_start = line_end + 1;
627 }
628 None
629}
630
631fn reset_heredoc_queue() {
632 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
633}
634
635fn heredoc_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
640 alt((
641 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| (s.to_string(), false)),
642 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| (s.to_string(), false)),
643 escaped_delimiter,
644 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
645 .map(|s: &str| (s.to_string(), true)),
646 ))
647 .parse_next(input)
648}
649
650fn escaped_delimiter(input: &mut &str) -> ModalResult<(String, bool)> {
655 let mut rest = *input;
656 let mut name = String::new();
657 let mut quoted = false;
658 loop {
659 let mut chars = rest.chars();
660 match chars.next() {
661 Some('\\') => match chars.next() {
662 Some(c) => {
663 name.push(c);
664 quoted = true;
665 rest = &rest[1 + c.len_utf8()..];
666 }
667 None => break,
668 },
669 Some(q @ ('\'' | '"')) => {
670 let inner_end = rest[1..].find(q).map(|i| i + 1);
671 let Some(end) = inner_end else { break };
672 name.push_str(&rest[1..end]);
673 quoted = true;
674 rest = &rest[end + 1..];
675 }
676 Some(c) if c.is_ascii_alphanumeric() || c == '_' => {
677 name.push(c);
678 rest = &rest[c.len_utf8()..];
679 }
680 _ => break,
681 }
682 }
683 if !quoted || name.is_empty() {
684 return backtrack();
685 }
686 *input = rest;
687 Ok((name, false))
688}
689
690fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
691 let b = input.as_bytes();
692 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
693 let d = (b[0] - b'0') as u32;
694 *input = &input[1..];
695 Ok(d)
696 } else {
697 backtrack()
698 }
699}
700
701fn fd_target(input: &mut &str) -> ModalResult<String> {
702 alt((
703 '-'.value("-".to_string()),
704 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
705 ))
706 .parse_next(input)
707}
708
709fn word(input: &mut &str) -> ModalResult<Word> {
712 repeat(1.., word_part)
713 .map(Word)
714 .parse_next(input)
715}
716
717fn word_part(input: &mut &str) -> ModalResult<WordPart> {
718 if input.is_empty() {
719 return backtrack();
720 }
721 if input.starts_with("<(") || input.starts_with(">(") {
722 return proc_sub(input);
723 }
724 if is_word_boundary(input.as_bytes()[0] as char) {
725 return backtrack();
726 }
727 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
728 .parse_next(input)
729}
730
731fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
732 delimited('\'', take_while(0.., |c| c != '\''), '\'')
733 .map(|s: &str| WordPart::SQuote(s.to_string()))
734 .parse_next(input)
735}
736
737fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
738 delimited('"', repeat(0.., dq_part).map(Word), '"')
739 .map(WordPart::DQuote)
740 .parse_next(input)
741}
742
743fn find_sub_close(body: &str) -> Option<usize> {
751 let b = body.as_bytes();
752 let mut i = 0;
753 let mut depth: usize = 0;
754 while i < b.len() {
755 match b[i] {
756 b'\\' => i += 1, b'\'' => {
758 i += 1;
759 while i < b.len() && b[i] != b'\'' {
760 i += 1;
761 }
762 if i >= b.len() {
763 return None;
764 }
765 }
766 b'"' => {
767 i += 1;
768 while i < b.len() && b[i] != b'"' {
769 i += if b[i] == b'\\' { 2 } else { 1 };
770 }
771 if i >= b.len() {
772 return None;
773 }
774 }
775 b'`' => {
776 i += 1;
777 while i < b.len() && b[i] != b'`' {
778 i += if b[i] == b'\\' { 2 } else { 1 };
781 }
782 if i >= b.len() {
783 return None;
784 }
785 }
786 b'(' => depth += 1,
787 b')' => {
788 if depth == 0 {
789 return Some(i);
790 }
791 depth -= 1;
792 }
793 _ => {}
794 }
795 i += 1;
796 }
797 None
798}
799
800fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
818 let body = &input[open_len..];
819 let Some(rel) = find_sub_close(body) else {
820 return if body.contains("<<") {
821 sub_body_via_grammar(input, body)
822 } else {
823 backtrack()
824 };
825 };
826 let interior = &body[..rel];
832 if !interior.contains("<<") && !interior.contains("case") {
838 let mut fast: &str = interior;
839 if let Ok(parsed) = script.parse_next(&mut fast) {
840 ws.parse_next(&mut fast)?;
841 if fast.is_empty() {
842 *input = &body[rel + 1..];
843 return Ok(parsed);
844 }
845 }
846 }
847 sub_body_via_grammar(input, body)
848}
849
850fn sub_body_via_grammar<'a>(input: &mut &'a str, body: &'a str) -> ModalResult<Script> {
853 let mut rest: &str = body;
854 ws.parse_next(&mut rest)?;
855 let parsed = script.parse_next(&mut rest)?;
856 ws.parse_next(&mut rest)?;
857 if !rest.starts_with(')') {
858 return backtrack();
859 }
860 *input = &rest[1..];
861 Ok(parsed)
862}
863
864fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
865 if !input.starts_with("$(") {
866 return backtrack();
867 }
868 sub_body(input, 2).map(WordPart::CmdSub)
869}
870
871fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
872 if !(input.starts_with("<(") || input.starts_with(">(")) {
873 return backtrack();
874 }
875 sub_body(input, 2).map(WordPart::ProcSub)
876}
877
878fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
879 if !input.starts_with("$((") {
880 return backtrack();
881 }
882 let body_start = 3;
883 let bytes = input.as_bytes();
884 let mut depth: i32 = 1;
885 let mut i = body_start;
886 while i < bytes.len() {
887 match bytes[i] {
888 b'(' => depth += 1,
889 b')' => {
890 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
891 let body = input[body_start..i].to_string();
892 if body.contains("$(") || body.contains('`') {
893 return backtrack();
894 }
895 *input = &input[i + 2..];
896 return Ok(WordPart::Arith(body));
897 }
898 depth -= 1;
899 if depth < 0 {
900 return backtrack();
901 }
902 }
903 _ => {}
904 }
905 i += 1;
906 }
907 backtrack()
908}
909
910fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
911 delimited('`', backtick_inner, '`')
912 .map(WordPart::Backtick)
913 .parse_next(input)
914}
915
916fn escaped(input: &mut &str) -> ModalResult<WordPart> {
917 preceded('\\', any).map(WordPart::Escape).parse_next(input)
918}
919
920fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
921 move |input: &mut &str| {
922 take_while(1.., pred)
923 .map(|s: &str| WordPart::Lit(s.to_string()))
924 .parse_next(input)
925 }
926}
927
928fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
929 move |input: &mut &str| {
930 ('$', not('(')).void().parse_next(input)?;
931 let rest: &str = take_while(0.., pred).parse_next(input)?;
932 Ok(WordPart::Lit(format!("${rest}")))
933 }
934}
935
936fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
939 if input.is_empty() || input.starts_with('"') {
940 return backtrack();
941 }
942 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
943 .parse_next(input)
944}
945
946fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
947 preceded('\\', any)
948 .map(|c: char| match c {
949 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
950 _ => WordPart::Lit(format!("\\{c}")),
951 })
952 .parse_next(input)
953}
954
955fn backtick_inner(input: &mut &str) -> ModalResult<String> {
958 repeat(0.., alt((bt_escape, bt_literal)))
959 .fold(String::new, |mut acc, chunk: &str| {
960 acc.push_str(chunk);
961 acc
962 })
963 .parse_next(input)
964}
965
966fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
967 ('\\', any).take().parse_next(input)
968}
969
970fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
971 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
972}
973
974fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
977 eat_keyword(input, "for")?;
978 ws.parse_next(input)?;
979 let var = name.parse_next(input)?;
980 ws.parse_next(input)?;
981
982 let items = if eat_keyword(input, "in").is_ok() {
983 ws.parse_next(input)?;
984 repeat(0.., terminated(word, ws)).parse_next(input)?
985 } else {
986 vec![]
987 };
988
989 let body = do_done_body.parse_next(input)?;
990 let redirs = trailing_redirs(input)?;
991 Ok(Cmd::For { var, items, body, redirs })
992}
993
994fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
995 eat_keyword(input, "while")?;
996 ws.parse_next(input)?;
997 let cond = script.parse_next(input)?;
998 let body = do_done_body.parse_next(input)?;
999 let redirs = trailing_redirs(input)?;
1000 Ok(Cmd::While { cond, body, redirs })
1001}
1002
1003fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
1004 eat_keyword(input, "until")?;
1005 ws.parse_next(input)?;
1006 let cond = script.parse_next(input)?;
1007 let body = do_done_body.parse_next(input)?;
1008 let redirs = trailing_redirs(input)?;
1009 Ok(Cmd::Until { cond, body, redirs })
1010}
1011
1012fn do_done_body(input: &mut &str) -> ModalResult<Script> {
1013 sep.parse_next(input)?;
1014 eat_keyword(input, "do")?;
1015 sep.parse_next(input)?;
1016 let body = script.parse_next(input)?;
1017 sep.parse_next(input)?;
1018 eat_keyword(input, "done")?;
1019 Ok(body)
1020}
1021
1022fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
1023 eat_keyword(input, "if")?;
1024 ws.parse_next(input)?;
1025 let mut branches = vec![cond_then_body.parse_next(input)?];
1026 let mut else_body = None;
1027
1028 loop {
1029 sep.parse_next(input)?;
1030 if eat_keyword(input, "elif").is_ok() {
1031 ws.parse_next(input)?;
1032 branches.push(cond_then_body.parse_next(input)?);
1033 } else if eat_keyword(input, "else").is_ok() {
1034 sep.parse_next(input)?;
1035 else_body = Some(script.parse_next(input)?);
1036 break;
1037 } else {
1038 break;
1039 }
1040 }
1041
1042 sep.parse_next(input)?;
1043 eat_keyword(input, "fi")?;
1044 let redirs = trailing_redirs(input)?;
1045 Ok(Cmd::If { branches, else_body, redirs })
1046}
1047
1048fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
1049 let cond = script.parse_next(input)?;
1050 sep.parse_next(input)?;
1051 eat_keyword(input, "then")?;
1052 sep.parse_next(input)?;
1053 let body = script.parse_next(input)?;
1054 Ok(Branch { cond, body })
1055}
1056
1057fn case_cmd(input: &mut &str) -> ModalResult<Cmd> {
1059 eat_keyword(input, "case")?;
1060 ws.parse_next(input)?;
1061 let subject = word.parse_next(input)?;
1062 blank.parse_next(input)?;
1063 eat_keyword(input, "in")?;
1064
1065 let mut arms = Vec::new();
1066 loop {
1067 blank.parse_next(input)?;
1068 if eat_keyword(input, "esac").is_ok() {
1069 break;
1070 }
1071 let arm = case_arm.parse_next(input)?;
1072 let had_terminator = opt(";;").parse_next(input)?.is_some();
1073 arms.push(arm);
1074 if !had_terminator {
1077 blank.parse_next(input)?;
1078 eat_keyword(input, "esac")?;
1079 break;
1080 }
1081 }
1082
1083 let redirs = trailing_redirs(input)?;
1084 Ok(Cmd::Case { subject, arms, redirs })
1085}
1086
1087fn case_arm(input: &mut &str) -> ModalResult<CaseArm> {
1088 blank.parse_next(input)?;
1089 opt('(').parse_next(input)?;
1090 let mut patterns = Vec::new();
1091 loop {
1092 ws.parse_next(input)?;
1093 patterns.push(word.parse_next(input)?);
1094 ws.parse_next(input)?;
1095 if opt('|').parse_next(input)?.is_none() {
1096 break;
1097 }
1098 }
1099 ')'.parse_next(input)?;
1100 let body = script.parse_next(input)?;
1101 blank.parse_next(input)?;
1102 Ok(CaseArm { patterns, body })
1103}
1104
1105fn blank(input: &mut &str) -> ModalResult<()> {
1107 take_while(0.., [' ', '\t', '\n']).void().parse_next(input)
1108}
1109
1110fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
1111 if !input.starts_with("[[") {
1112 return backtrack();
1113 }
1114 let bytes = input.as_bytes();
1115 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
1116 return backtrack();
1117 }
1118 *input = &input[2..];
1119
1120 let mut words: Vec<Word> = Vec::new();
1121 loop {
1122 ws.parse_next(input)?;
1123 if at_double_bracket_end(input) {
1124 *input = &input[2..];
1125 let redirs = trailing_redirs(input)?;
1126 return Ok(Cmd::DoubleBracket { words, redirs });
1127 }
1128 if input.is_empty() {
1129 return backtrack();
1130 }
1131 let w = bracket_word.parse_next(input)?;
1132 words.push(w);
1133 }
1134}
1135
1136fn at_double_bracket_end(input: &str) -> bool {
1137 if !input.starts_with("]]") {
1138 return false;
1139 }
1140 let after = &input[2..];
1141 after.is_empty()
1142 || after.starts_with(|c: char| {
1143 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
1144 })
1145}
1146
1147fn bracket_word(input: &mut &str) -> ModalResult<Word> {
1148 repeat(1.., bracket_word_part).map(Word).parse_next(input)
1149}
1150
1151fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
1152 if input.is_empty() {
1153 return backtrack();
1154 }
1155 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
1156 return backtrack();
1157 }
1158 if at_double_bracket_end(input) {
1159 return backtrack();
1160 }
1161 alt((
1162 single_quoted,
1163 double_quoted,
1164 arith_sub,
1165 cmd_sub,
1166 backtick_part,
1167 escaped,
1168 dollar_lit(is_bracket_literal),
1169 bracket_lit,
1170 ))
1171 .parse_next(input)
1172}
1173
1174fn is_bracket_literal(c: char) -> bool {
1175 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
1176}
1177
1178fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
1179 let bytes = input.as_bytes();
1184 let mut end = 0;
1185 while end < bytes.len() {
1186 let c = bytes[end] as char;
1187 if !is_bracket_literal(c) {
1188 break;
1189 }
1190 if c == ']' && at_double_bracket_end(&input[end..]) {
1191 break;
1192 }
1193 end += 1;
1194 }
1195 if end == 0 {
1196 return backtrack();
1197 }
1198 let lit = input[..end].to_string();
1199 *input = &input[end..];
1200 Ok(WordPart::Lit(lit))
1201}
1202
1203fn name(input: &mut &str) -> ModalResult<String> {
1204 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
1205 .map(|s: &str| s.to_string())
1206 .parse_next(input)
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212
1213 fn p(input: &str) -> Script {
1214 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
1215 }
1216
1217 fn words(script: &Script) -> Vec<String> {
1218 match &script.0[0].pipeline.commands[0] {
1219 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
1220 _ => panic!("expected simple command"),
1221 }
1222 }
1223
1224 fn simple(script: &Script) -> &SimpleCmd {
1225 match &script.0[0].pipeline.commands[0] {
1226 Cmd::Simple(s) => s,
1227 _ => panic!("expected simple command"),
1228 }
1229 }
1230
1231 #[test]
1232 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
1233 #[test]
1234 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
1235 #[test]
1236 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
1237 #[test]
1238 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
1239 #[test]
1240 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
1241
1242 #[test]
1243 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
1244 #[test]
1245 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
1246 #[test]
1247 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
1248 #[test]
1249 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
1250 #[test]
1251 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
1252 #[test]
1253 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
1254 #[test]
1255 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
1256 #[test]
1257 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
1258 #[test]
1259 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
1260 #[test]
1261 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
1262
1263 #[test]
1264 fn brace_group_simple() {
1265 assert!(matches!(
1266 &p("{ echo hello; }").0[0].pipeline.commands[0],
1267 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1268 ));
1269 }
1270 #[test]
1271 fn brace_group_multiple_stmts() {
1272 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1273 assert_eq!(body.0.len(), 3);
1274 } else { panic!("expected BraceGroup"); }
1275 }
1276 #[test]
1277 fn brace_group_with_redirect() {
1278 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1279 assert_eq!(redirs.len(), 1);
1280 assert!(matches!(redirs[0], Redir::Write { .. }));
1281 } else { panic!("expected BraceGroup"); }
1282 }
1283 #[test]
1284 fn brace_group_with_append_redirect() {
1285 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1286 assert!(matches!(redirs[0], Redir::Write { mode: WriteMode::Append, .. }));
1287 } else { panic!("expected BraceGroup"); }
1288 }
1289 #[test]
1290 fn brace_group_with_stderr_redirect() {
1291 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1292 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1293 } else { panic!("expected BraceGroup"); }
1294 }
1295 #[test]
1296 fn brace_group_newline_separated() {
1297 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
1298 assert_eq!(body.0.len(), 2);
1299 } else { panic!("expected BraceGroup"); }
1300 }
1301 #[test]
1302 fn brace_group_in_pipeline() {
1303 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1304 assert_eq!(pl.commands.len(), 2);
1305 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1306 }
1307 #[test]
1308 fn brace_group_followed_by_other() {
1309 let stmts = &p("{ echo a; }; echo b").0;
1310 assert_eq!(stmts.len(), 2);
1311 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1312 }
1313 #[test]
1314 fn brace_group_nested() {
1315 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1316 assert_eq!(body.0.len(), 2);
1317 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1318 } else { panic!("expected outer BraceGroup"); }
1319 }
1320 #[test]
1321 fn brace_group_with_subshell_inside() {
1322 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1323 assert_eq!(body.0.len(), 2);
1324 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1325 } else { panic!("expected BraceGroup"); }
1326 }
1327 #[test]
1328 fn brace_open_requires_whitespace() {
1329 let cmds = &p("{echo a}").0;
1333 if !cmds.is_empty() {
1336 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1337 }
1338 }
1339 #[test]
1340 fn subshell_with_redirect() {
1341 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1342 assert_eq!(redirs.len(), 1);
1343 } else { panic!("expected Subshell with redir"); }
1344 }
1345 #[test]
1346 fn for_loop_with_redirect() {
1347 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1348 assert_eq!(redirs.len(), 1);
1349 } else { panic!("expected For with redir"); }
1350 }
1351 #[test]
1352 fn for_loop_redirect_then_pipe() {
1353 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1355 assert_eq!(pl.commands.len(), 2);
1356 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1357 }
1358 #[test]
1359 fn while_and_if_with_redirect() {
1360 assert!(matches!(
1361 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1362 Cmd::While { redirs, .. } if redirs.len() == 1
1363 ));
1364 assert!(matches!(
1365 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1366 Cmd::If { redirs, .. } if redirs.len() == 1
1367 ));
1368 }
1369 #[test]
1370 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1371
1372 #[test]
1373 fn redirect_dev_null() {
1374 let s = p("echo hello > /dev/null");
1375 let cmd = simple(&s);
1376 assert_eq!(cmd.words.len(), 2);
1377 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, mode: WriteMode::Truncate, .. }));
1378 }
1379 #[test]
1380 fn redirect_stderr() {
1381 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1382 }
1383 #[test]
1384 fn here_string() {
1385 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1386 }
1387 #[test]
1388 fn heredoc_bare() {
1389 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false, .. } if delimiter == "EOF"));
1390 }
1391 #[test]
1392 fn heredoc_with_content() {
1393 let s = p("cat <<EOF\nhello world\nEOF");
1394 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1395 }
1396 #[test]
1397 fn heredoc_quoted_delimiter() {
1398 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1399 }
1400 #[test]
1401 fn heredoc_strip_tabs() {
1402 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1403 }
1404 #[test]
1405 fn heredoc_pipe_on_command_line() {
1406 let s = p("cat <<EOF | grep hello\nhello\nEOF");
1409 assert_eq!(s.0[0].pipeline.commands.len(), 2);
1410 }
1411 #[test]
1412 fn heredoc_body_does_not_swallow_pipe() {
1413 let s = p("cat <<EOF | bash\nrm\nEOF");
1417 assert_eq!(
1418 s.0[0].pipeline.commands.len(),
1419 2,
1420 "pipeline must keep `bash` as a second command"
1421 );
1422 }
1423 #[test]
1424 fn heredoc_followed_by_next_statement() {
1425 let s = p("cat <<EOF\nhello\nEOF\nls");
1428 assert_eq!(s.0.len(), 2);
1429 }
1430
1431 #[test]
1432 fn env_prefix() {
1433 let s = p("FOO='bar baz' ls -la");
1434 let cmd = simple(&s);
1435 assert_eq!(cmd.env[0].0, "FOO");
1436 assert_eq!(cmd.env[0].1.eval(), "bar baz");
1437 }
1438 #[test]
1439 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1440 #[test]
1441 fn backtick_substitution() {
1442 assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB_WORKTREE__");
1445 assert_eq!(simple(&p("ls `hostname`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__");
1447 }
1448 #[test]
1449 fn nested_substitution() {
1450 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1451 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1452 } else { panic!("expected CmdSub"); }
1453 }
1454
1455 #[test]
1456 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1457 #[test]
1458 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1459
1460 #[test]
1461 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")); }
1462 #[test]
1463 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1464 #[test]
1465 fn if_then_fi() {
1466 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1467 assert_eq!(branches.len(), 1);
1468 assert!(else_body.is_none());
1469 } else { panic!("expected If"); }
1470 }
1471 #[test]
1472 fn if_elif_else() {
1473 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] {
1474 assert_eq!(branches.len(), 2);
1475 assert!(else_body.is_some());
1476 } else { panic!("expected If"); }
1477 }
1478
1479 #[test]
1480 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1481 #[test]
1482 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1483 #[test]
1484 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1485
1486 #[test]
1487 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1488 #[test]
1489 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1490 #[test]
1491 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1492 #[test]
1493 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1494 #[test]
1495 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1496 #[test]
1497 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1498
1499 #[test]
1500 fn subshell_for() {
1501 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1502 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1503 } else { panic!("expected Subshell"); }
1504 }
1505 #[test]
1506 fn proc_sub_input() {
1507 let s = p("diff <(sort a.txt) <(sort b.txt)");
1508 let cmd = simple(&s);
1509 assert_eq!(cmd.words.len(), 3);
1510 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1511 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1512 }
1513 #[test]
1514 fn proc_sub_output() {
1515 let s = p("tee >(grep error > /dev/null)");
1516 let cmd = simple(&s);
1517 assert_eq!(cmd.words.len(), 2);
1518 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1519 }
1520
1521 fn func_def(s: &Script) -> (&str, &Script) {
1523 match &s.0[0].pipeline.commands[0] {
1524 Cmd::FunctionDef { name, body } => (name.as_str(), body),
1525 other => panic!("expected FunctionDef, got {other:?}"),
1526 }
1527 }
1528 #[test]
1529 fn function_def_posix_form() {
1530 let s = p("probe(){ echo hi; }");
1531 let (name, body) = func_def(&s);
1532 assert_eq!(name, "probe");
1533 assert_eq!(words(body), ["echo", "hi"]);
1534 }
1535 #[test]
1536 fn function_def_spaced_and_keyword_forms() {
1537 assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1538 assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1539 assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1540 }
1541 #[test]
1542 fn function_def_subshell_body() {
1543 let s = p("foo() ( echo sub )");
1544 assert_eq!(func_def(&s).0, "foo");
1545 }
1546 #[test]
1547 fn function_def_body_on_next_line() {
1548 assert_eq!(func_def(&p("foo()\n{\n echo hi\n}")).0, "foo");
1549 }
1550 #[test]
1551 fn function_def_name_with_dashes_and_dots() {
1552 assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1553 }
1554 #[test]
1555 fn plain_command_is_not_a_function_def() {
1556 assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1558 assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1559 }
1560 #[test]
1561 fn function_def_roundtrips() {
1562 let rendered = p("greet(){ echo hi; }").to_string();
1563 assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1564 assert_eq!(func_def(&p(&rendered)).0, "greet");
1565 }
1566 #[test]
1567 fn comment_only() {
1568 let s = p("# just a comment");
1569 assert!(s.0.is_empty());
1570 }
1571 #[test]
1572 fn comment_before_command() {
1573 let s = p("# comment\necho hello");
1574 assert_eq!(words(&s), ["echo", "hello"]);
1575 }
1576 #[test]
1577 fn inline_comment() {
1578 let s = p("echo hello # this is a comment");
1579 assert_eq!(words(&s), ["echo", "hello"]);
1580 }
1581 #[test]
1582 fn comment_between_commands() {
1583 let s = p("echo hello\n# middle comment\necho world");
1584 assert_eq!(s.0.len(), 2);
1585 }
1586 #[test]
1587 fn comment_after_semicolon() {
1588 let s = p("echo hello; # comment\necho world");
1589 assert_eq!(s.0.len(), 2);
1590 }
1591 #[test]
1592 fn comment_in_for_loop() {
1593 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1594 }
1595 #[test]
1596 fn quoted_redirect_in_echo() {
1597 let s = p("echo 'greater > than' test");
1598 let cmd = simple(&s);
1599 assert_eq!(cmd.words.len(), 3);
1600 assert_eq!(cmd.redirs.len(), 0);
1601 }
1602
1603 #[test]
1604 fn parses_all_safe_commands() {
1605 let cmds = [
1606 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1607 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1608 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1609 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1610 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1611 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1612 "((echo hello))", "(for x in 1 2; do echo $x; done)",
1613 "echo 'greater > than' test", "echo '$(safe)' arg",
1614 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1615 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1616 "grep foo file.txt | head -5", "cat file | sort | uniq",
1617 "ls && echo done", "ls; echo done", "ls & echo done",
1618 "grep -c , <<< 'hello,world,test'",
1619 "cat <<EOF\nhello world\nEOF",
1620 "cat <<'MARKER'\nsome text\nMARKER",
1621 "cat <<-EOF\n\thello\nEOF",
1622 "echo foo\necho bar", "ls\ncat file.txt",
1623 "git log --oneline -20 | head -5",
1624 "echo hello > /dev/null", "echo hello 2> /dev/null",
1625 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1626 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1627 "for x in 1 2 3; do echo $x; done",
1628 "for f in *.txt; do cat $f | grep pattern; done",
1629 "for x in 1 2 3; do; done",
1630 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1631 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1632 "for x in 1 2; do echo $x; done && echo finished",
1633 "for x in $(seq 1 5); do echo $x; done",
1634 "while test -f /tmp/foo; do sleep 1; done",
1635 "while ! test -f /tmp/done; do sleep 1; done",
1636 "until test -f /tmp/ready; do sleep 1; done",
1637 "if test -f foo; then echo exists; fi",
1638 "if test -f foo; then echo yes; else echo no; fi",
1639 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1640 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1641 "if true; then for x in 1 2; do echo $x; done; fi",
1642 "diff <(sort a.txt) <(sort b.txt)",
1643 "comm -23 file.txt <(sort other.txt)",
1644 "cat <(echo hello)",
1645 "# comment only",
1646 "# comment\necho hello",
1647 "echo hello # inline comment",
1648 "echo one\n# between\necho two",
1649 "! echo hello", "! test -f foo",
1650 "echo for; echo done; echo if; echo fi",
1651 ];
1652 let mut failures = Vec::new();
1653 for cmd in &cmds {
1654 if parse(cmd).is_none() { failures.push(*cmd); }
1655 }
1656 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1657 }
1658
1659 fn inner_sub(s: &Script) -> &Script {
1665 match &simple(s).words[1].0[0] {
1666 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1667 other => panic!("expected a substitution, got {other:?}"),
1668 }
1669 }
1670
1671 #[test]
1672 fn cmd_sub_squote_paren_is_not_close() {
1673 assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1674 }
1675 #[test]
1676 fn cmd_sub_dquote_paren_is_not_close() {
1677 assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1678 }
1679 #[test]
1680 fn cmd_sub_escaped_paren_is_not_close() {
1681 assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1682 }
1683 #[test]
1684 fn cmd_sub_backtick_paren_is_not_close() {
1685 let s = p("echo $(x `)` y)");
1687 let inner = simple(inner_sub(&s));
1688 assert_eq!(inner.words.len(), 3);
1689 assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1690 }
1691 #[test]
1692 fn cmd_sub_escaped_backtick_does_not_end_span() {
1693 let s = p("echo $(`\\`)`)");
1697 assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1698 }
1699 #[test]
1700 fn proc_sub_squote_paren_is_not_close() {
1701 assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1702 }
1703 #[test]
1704 fn proc_sub_out_squote_paren_is_not_close() {
1705 assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1706 }
1707 #[test]
1708 fn cmd_sub_nested_picks_outer_close() {
1709 let s = p("echo $(a $(b) c)");
1710 let inner = simple(inner_sub(&s));
1711 assert_eq!(inner.words.len(), 3);
1712 assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1713 }
1714 #[test]
1715 fn cmd_sub_literal_after_close_stays_in_outer_word() {
1716 let s = p("echo $(ls)tail");
1717 let w = &simple(&s).words[1];
1718 assert_eq!(w.0.len(), 2);
1719 assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1720 assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1721 }
1722 #[test]
1723 fn cmd_sub_heredoc_body_paren_does_not_close() {
1724 let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1728 assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1729 }
1730 #[test]
1731 fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1732 assert!(parse("echo $(echo ')").is_none());
1734 }
1735 #[test]
1736 fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1737 assert!(parse("cat <(grep ')").is_none());
1738 }
1739}