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 take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
111 if input.starts_with('#') {
112 comment(input)?;
113 } else {
114 break;
115 }
116 }
117 Ok(())
118}
119
120fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
121 if !input.starts_with(kw) {
122 return backtrack();
123 }
124 if input
125 .as_bytes()
126 .get(kw.len())
127 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
128 {
129 return backtrack();
130 }
131 *input = &input[kw.len()..];
132 Ok(())
133}
134
135const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
136
137fn at_script_stop(input: &str) -> bool {
138 input.starts_with(')')
139 || input.starts_with('}')
140 || SCRIPT_STOPS.iter().any(|kw| {
141 input.starts_with(kw)
142 && !input
143 .as_bytes()
144 .get(kw.len())
145 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
146 })
147}
148
149fn is_word_boundary(c: char) -> bool {
150 matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
151}
152
153fn is_word_literal(c: char) -> bool {
154 !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
155}
156
157fn is_dq_literal(c: char) -> bool {
158 !matches!(c, '"' | '\\' | '`' | '$')
159}
160
161fn script(input: &mut &str) -> ModalResult<Script> {
164 let Some(_depth) = DepthGuard::enter() else {
167 return backtrack();
168 };
169 sep.parse_next(input)?;
170 let mut stmts = Vec::new();
171 while let Some(pl) = opt(pipeline).parse_next(input)? {
172 ws.parse_next(input)?;
173 let op = opt(list_op).parse_next(input)?;
174 stmts.push(Stmt { pipeline: pl, op });
175 drain_pending_heredocs(input);
180 if op.is_none() {
181 break;
182 }
183 sep.parse_next(input)?;
184 }
185 Ok(Script(stmts))
186}
187
188fn list_op(input: &mut &str) -> ModalResult<ListOp> {
189 ws.parse_next(input)?;
190 alt((
191 "&&".value(ListOp::And),
192 "||".value(ListOp::Or),
193 '\n'.value(ListOp::Semi),
194 ';'.value(ListOp::Semi),
195 ('&', not('>')).value(ListOp::Amp),
196 ))
197 .parse_next(input)
198}
199
200fn pipe_sep(input: &mut &str) -> ModalResult<()> {
201 (ws, '|', not('|'), ws).void().parse_next(input)
202}
203
204fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
207 ws.parse_next(input)?;
208 if at_script_stop(input) {
209 return backtrack();
210 }
211 let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
212 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
213 Ok(Pipeline { bang, commands })
214}
215
216fn command(input: &mut &str) -> ModalResult<Cmd> {
219 ws.parse_next(input)?;
220 if at_script_stop(input) {
221 return backtrack();
222 }
223 alt((
224 subshell,
225 brace_group,
226 for_cmd,
227 while_cmd,
228 until_cmd,
229 if_cmd,
230 double_bracket_cmd,
231 function_def,
232 simple_cmd.map(Cmd::Simple),
233 ))
234 .parse_next(input)
235}
236
237fn function_def(input: &mut &str) -> ModalResult<Cmd> {
241 let had_keyword = opt_function_keyword(input);
242 let name = function_name(input)?;
243 ws.parse_next(input)?;
244 let has_parens = opt_paren_pair(input);
245 if !had_keyword && !has_parens {
246 return backtrack();
247 }
248 take_while(0.., [' ', '\t', '\n']).void().parse_next(input)?;
250 let body = function_body(input)?;
251 Ok(Cmd::FunctionDef { name, body })
252}
253
254fn opt_function_keyword(input: &mut &str) -> bool {
257 let mut probe = *input;
258 if eat_keyword(&mut probe, "function").is_ok()
259 && probe.starts_with([' ', '\t', '\n'])
260 && ws.parse_next(&mut probe).is_ok()
261 {
262 *input = probe;
263 return true;
264 }
265 false
266}
267
268fn opt_paren_pair(input: &mut &str) -> bool {
270 let mut probe = *input;
271 if let Some(rest) = probe.strip_prefix('(') {
272 probe = rest;
273 if ws.parse_next(&mut probe).is_ok()
274 && let Some(rest) = probe.strip_prefix(')')
275 {
276 *input = rest;
277 return true;
278 }
279 }
280 false
281}
282
283fn function_name(input: &mut &str) -> ModalResult<String> {
284 take_while(1.., |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '+'))
285 .map(|s: &str| s.to_string())
286 .parse_next(input)
287}
288
289fn function_body(input: &mut &str) -> ModalResult<Script> {
292 if let Some(Cmd::BraceGroup { body, .. }) = opt(brace_group).parse_next(input)? {
293 return Ok(body);
294 }
295 if let Some(Cmd::Subshell { body, .. }) = opt(subshell).parse_next(input)? {
296 return Ok(body);
297 }
298 backtrack()
299}
300
301fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
302 let mut redirs = Vec::new();
303 loop {
304 ws.parse_next(input)?;
305 if let Some(r) = opt(redirect).parse_next(input)? {
306 redirs.push(r);
307 } else {
308 break;
309 }
310 }
311 Ok(redirs)
312}
313
314fn subshell(input: &mut &str) -> ModalResult<Cmd> {
315 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
316 let redirs = trailing_redirs(input)?;
317 Ok(Cmd::Subshell { body, redirs })
318}
319
320fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
321 if !input.starts_with('{') {
322 return backtrack();
323 }
324 if !input
325 .as_bytes()
326 .get(1)
327 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
328 {
329 return backtrack();
330 }
331 *input = &input[1..];
332 sep.parse_next(input)?;
333 let body = script.parse_next(input)?;
334 if body.0.is_empty() {
335 return backtrack();
336 }
337 sep.parse_next(input)?;
338 if !input.starts_with('}') {
339 return backtrack();
340 }
341 let last_op = body.0.last().and_then(|s| s.op);
342 if last_op.is_none() {
343 return backtrack();
344 }
345 *input = &input[1..];
346 let redirs = trailing_redirs(input)?;
347 Ok(Cmd::BraceGroup { body, redirs })
348}
349
350fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
353 let env: Vec<(String, Word)> =
354 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
355 let mut words = Vec::new();
356 let mut redirs = Vec::new();
357
358 loop {
359 ws.parse_next(input)?;
360 if at_cmd_end(input) {
361 break;
362 }
363 if let Some(r) = opt(redirect).parse_next(input)? {
364 redirs.push(r);
365 } else if let Some(w) = opt(word).parse_next(input)? {
366 words.push(w);
367 } else {
368 break;
369 }
370 }
371
372 if env.is_empty() && words.is_empty() && redirs.is_empty() {
373 return backtrack();
374 }
375 Ok(SimpleCmd { env, words, redirs })
376}
377
378fn at_cmd_end(input: &str) -> bool {
379 input.is_empty()
380 || matches!(
381 input.as_bytes().first(),
382 Some(b'\n' | b';' | b'|' | b'&' | b')')
383 )
384}
385
386fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
387 let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
388 .parse_next(input)?;
389 '='.parse_next(input)?;
390 let value = opt(word)
391 .parse_next(input)?
392 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
393 Ok((n.to_string(), value))
394}
395
396fn redirect(input: &mut &str) -> ModalResult<Redir> {
399 let fd = opt(fd_prefix).parse_next(input)?;
400 alt((
401 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
402 heredoc,
403 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
404 fd: fd.unwrap_or(1),
405 target,
406 append: true,
407 }),
408 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
409 src: fd.unwrap_or(1),
410 dst,
411 }),
412 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
413 fd: fd.unwrap_or(1),
414 target,
415 append: false,
416 }),
417 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
418 fd: fd.unwrap_or(0),
419 target,
420 }),
421 ))
422 .parse_next(input)
423}
424
425fn heredoc(input: &mut &str) -> ModalResult<Redir> {
426 "<<".parse_next(input)?;
427 let strip_tabs = opt('-').parse_next(input)?.is_some();
428 ws.parse_next(input)?;
429 let delimiter = heredoc_delimiter.parse_next(input)?;
430 PENDING_HEREDOCS.with(|q| {
436 q.borrow_mut().push(PendingHeredoc {
437 delimiter: delimiter.clone(),
438 strip_tabs,
439 });
440 });
441 Ok(Redir::HereDoc { delimiter, strip_tabs })
442}
443
444#[derive(Debug, Clone)]
445struct PendingHeredoc {
446 delimiter: String,
447 strip_tabs: bool,
448}
449
450thread_local! {
451 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
452 const { std::cell::RefCell::new(Vec::new()) };
453}
454
455fn drain_pending_heredocs(input: &mut &str) {
456 let pending: Vec<PendingHeredoc> =
457 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
458 for h in pending {
459 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
460 return;
464 }
465 }
466}
467
468fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
469 let s = *input;
470 let bytes = s.as_bytes();
471 let mut line_start = 0;
472 while line_start <= bytes.len() {
473 let line_end = match s[line_start..].find('\n') {
474 Some(rel) => line_start + rel,
475 None => bytes.len(),
476 };
477 let line_bytes = &bytes[line_start..line_end];
478 let line = if strip_tabs {
479 std::str::from_utf8(line_bytes)
480 .unwrap_or("")
481 .trim_start_matches('\t')
482 } else {
483 std::str::from_utf8(line_bytes).unwrap_or("")
484 };
485 if line == delimiter {
486 let advance = line_end + usize::from(line_end < bytes.len());
488 *input = &s[advance..];
489 return true;
490 }
491 if line_end >= bytes.len() {
492 return false;
493 }
494 line_start = line_end + 1;
495 }
496 false
497}
498
499fn reset_heredoc_queue() {
500 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
501}
502
503fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
504 alt((
505 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
506 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
507 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
508 ))
509 .parse_next(input)
510}
511
512fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
513 let b = input.as_bytes();
514 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
515 let d = (b[0] - b'0') as u32;
516 *input = &input[1..];
517 Ok(d)
518 } else {
519 backtrack()
520 }
521}
522
523fn fd_target(input: &mut &str) -> ModalResult<String> {
524 alt((
525 '-'.value("-".to_string()),
526 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
527 ))
528 .parse_next(input)
529}
530
531fn word(input: &mut &str) -> ModalResult<Word> {
534 repeat(1.., word_part)
535 .map(Word)
536 .parse_next(input)
537}
538
539fn word_part(input: &mut &str) -> ModalResult<WordPart> {
540 if input.is_empty() {
541 return backtrack();
542 }
543 if input.starts_with("<(") || input.starts_with(">(") {
544 return proc_sub(input);
545 }
546 if is_word_boundary(input.as_bytes()[0] as char) {
547 return backtrack();
548 }
549 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
550 .parse_next(input)
551}
552
553fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
554 delimited('\'', take_while(0.., |c| c != '\''), '\'')
555 .map(|s: &str| WordPart::SQuote(s.to_string()))
556 .parse_next(input)
557}
558
559fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
560 delimited('"', repeat(0.., dq_part).map(Word), '"')
561 .map(WordPart::DQuote)
562 .parse_next(input)
563}
564
565fn find_sub_close(body: &str) -> Option<usize> {
573 let b = body.as_bytes();
574 let mut i = 0;
575 let mut depth: usize = 0;
576 while i < b.len() {
577 match b[i] {
578 b'\\' => i += 1, b'\'' => {
580 i += 1;
581 while i < b.len() && b[i] != b'\'' {
582 i += 1;
583 }
584 if i >= b.len() {
585 return None;
586 }
587 }
588 b'"' => {
589 i += 1;
590 while i < b.len() && b[i] != b'"' {
591 i += if b[i] == b'\\' { 2 } else { 1 };
592 }
593 if i >= b.len() {
594 return None;
595 }
596 }
597 b'`' => {
598 i += 1;
599 while i < b.len() && b[i] != b'`' {
600 i += if b[i] == b'\\' { 2 } else { 1 };
603 }
604 if i >= b.len() {
605 return None;
606 }
607 }
608 b'(' => depth += 1,
609 b')' => {
610 if depth == 0 {
611 return Some(i);
612 }
613 depth -= 1;
614 }
615 _ => {}
616 }
617 i += 1;
618 }
619 None
620}
621
622fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
636 let body = &input[open_len..];
637 let Some(rel) = find_sub_close(body) else {
638 return backtrack();
639 };
640 let interior = &body[..rel];
646 if !interior.contains("<<") {
647 let mut fast: &str = interior;
648 if let Ok(parsed) = script.parse_next(&mut fast) {
649 ws.parse_next(&mut fast)?;
650 if fast.is_empty() {
651 *input = &body[rel + 1..];
652 return Ok(parsed);
653 }
654 }
655 }
656 let mut rest: &str = body;
657 ws.parse_next(&mut rest)?;
658 let parsed = script.parse_next(&mut rest)?;
659 ws.parse_next(&mut rest)?;
660 if !rest.starts_with(')') {
661 return backtrack();
662 }
663 *input = &rest[1..];
664 Ok(parsed)
665}
666
667fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
668 if !input.starts_with("$(") {
669 return backtrack();
670 }
671 sub_body(input, 2).map(WordPart::CmdSub)
672}
673
674fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
675 if !(input.starts_with("<(") || input.starts_with(">(")) {
676 return backtrack();
677 }
678 sub_body(input, 2).map(WordPart::ProcSub)
679}
680
681fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
682 if !input.starts_with("$((") {
683 return backtrack();
684 }
685 let body_start = 3;
686 let bytes = input.as_bytes();
687 let mut depth: i32 = 1;
688 let mut i = body_start;
689 while i < bytes.len() {
690 match bytes[i] {
691 b'(' => depth += 1,
692 b')' => {
693 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
694 let body = input[body_start..i].to_string();
695 if body.contains("$(") || body.contains('`') {
696 return backtrack();
697 }
698 *input = &input[i + 2..];
699 return Ok(WordPart::Arith(body));
700 }
701 depth -= 1;
702 if depth < 0 {
703 return backtrack();
704 }
705 }
706 _ => {}
707 }
708 i += 1;
709 }
710 backtrack()
711}
712
713fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
714 delimited('`', backtick_inner, '`')
715 .map(WordPart::Backtick)
716 .parse_next(input)
717}
718
719fn escaped(input: &mut &str) -> ModalResult<WordPart> {
720 preceded('\\', any).map(WordPart::Escape).parse_next(input)
721}
722
723fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
724 move |input: &mut &str| {
725 take_while(1.., pred)
726 .map(|s: &str| WordPart::Lit(s.to_string()))
727 .parse_next(input)
728 }
729}
730
731fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
732 move |input: &mut &str| {
733 ('$', not('(')).void().parse_next(input)?;
734 let rest: &str = take_while(0.., pred).parse_next(input)?;
735 Ok(WordPart::Lit(format!("${rest}")))
736 }
737}
738
739fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
742 if input.is_empty() || input.starts_with('"') {
743 return backtrack();
744 }
745 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
746 .parse_next(input)
747}
748
749fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
750 preceded('\\', any)
751 .map(|c: char| match c {
752 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
753 _ => WordPart::Lit(format!("\\{c}")),
754 })
755 .parse_next(input)
756}
757
758fn backtick_inner(input: &mut &str) -> ModalResult<String> {
761 repeat(0.., alt((bt_escape, bt_literal)))
762 .fold(String::new, |mut acc, chunk: &str| {
763 acc.push_str(chunk);
764 acc
765 })
766 .parse_next(input)
767}
768
769fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
770 ('\\', any).take().parse_next(input)
771}
772
773fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
774 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
775}
776
777fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
780 eat_keyword(input, "for")?;
781 ws.parse_next(input)?;
782 let var = name.parse_next(input)?;
783 ws.parse_next(input)?;
784
785 let items = if eat_keyword(input, "in").is_ok() {
786 ws.parse_next(input)?;
787 repeat(0.., terminated(word, ws)).parse_next(input)?
788 } else {
789 vec![]
790 };
791
792 let body = do_done_body.parse_next(input)?;
793 let redirs = trailing_redirs(input)?;
794 Ok(Cmd::For { var, items, body, redirs })
795}
796
797fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
798 eat_keyword(input, "while")?;
799 ws.parse_next(input)?;
800 let cond = script.parse_next(input)?;
801 let body = do_done_body.parse_next(input)?;
802 let redirs = trailing_redirs(input)?;
803 Ok(Cmd::While { cond, body, redirs })
804}
805
806fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
807 eat_keyword(input, "until")?;
808 ws.parse_next(input)?;
809 let cond = script.parse_next(input)?;
810 let body = do_done_body.parse_next(input)?;
811 let redirs = trailing_redirs(input)?;
812 Ok(Cmd::Until { cond, body, redirs })
813}
814
815fn do_done_body(input: &mut &str) -> ModalResult<Script> {
816 sep.parse_next(input)?;
817 eat_keyword(input, "do")?;
818 sep.parse_next(input)?;
819 let body = script.parse_next(input)?;
820 sep.parse_next(input)?;
821 eat_keyword(input, "done")?;
822 Ok(body)
823}
824
825fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
826 eat_keyword(input, "if")?;
827 ws.parse_next(input)?;
828 let mut branches = vec![cond_then_body.parse_next(input)?];
829 let mut else_body = None;
830
831 loop {
832 sep.parse_next(input)?;
833 if eat_keyword(input, "elif").is_ok() {
834 ws.parse_next(input)?;
835 branches.push(cond_then_body.parse_next(input)?);
836 } else if eat_keyword(input, "else").is_ok() {
837 sep.parse_next(input)?;
838 else_body = Some(script.parse_next(input)?);
839 break;
840 } else {
841 break;
842 }
843 }
844
845 sep.parse_next(input)?;
846 eat_keyword(input, "fi")?;
847 let redirs = trailing_redirs(input)?;
848 Ok(Cmd::If { branches, else_body, redirs })
849}
850
851fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
852 let cond = script.parse_next(input)?;
853 sep.parse_next(input)?;
854 eat_keyword(input, "then")?;
855 sep.parse_next(input)?;
856 let body = script.parse_next(input)?;
857 Ok(Branch { cond, body })
858}
859
860fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
861 if !input.starts_with("[[") {
862 return backtrack();
863 }
864 let bytes = input.as_bytes();
865 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
866 return backtrack();
867 }
868 *input = &input[2..];
869
870 let mut words: Vec<Word> = Vec::new();
871 loop {
872 ws.parse_next(input)?;
873 if at_double_bracket_end(input) {
874 *input = &input[2..];
875 let redirs = trailing_redirs(input)?;
876 return Ok(Cmd::DoubleBracket { words, redirs });
877 }
878 if input.is_empty() {
879 return backtrack();
880 }
881 let w = bracket_word.parse_next(input)?;
882 words.push(w);
883 }
884}
885
886fn at_double_bracket_end(input: &str) -> bool {
887 if !input.starts_with("]]") {
888 return false;
889 }
890 let after = &input[2..];
891 after.is_empty()
892 || after.starts_with(|c: char| {
893 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
894 })
895}
896
897fn bracket_word(input: &mut &str) -> ModalResult<Word> {
898 repeat(1.., bracket_word_part).map(Word).parse_next(input)
899}
900
901fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
902 if input.is_empty() {
903 return backtrack();
904 }
905 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
906 return backtrack();
907 }
908 if at_double_bracket_end(input) {
909 return backtrack();
910 }
911 alt((
912 single_quoted,
913 double_quoted,
914 arith_sub,
915 cmd_sub,
916 backtick_part,
917 escaped,
918 dollar_lit(is_bracket_literal),
919 bracket_lit,
920 ))
921 .parse_next(input)
922}
923
924fn is_bracket_literal(c: char) -> bool {
925 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
926}
927
928fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
929 let bytes = input.as_bytes();
934 let mut end = 0;
935 while end < bytes.len() {
936 let c = bytes[end] as char;
937 if !is_bracket_literal(c) {
938 break;
939 }
940 if c == ']' && at_double_bracket_end(&input[end..]) {
941 break;
942 }
943 end += 1;
944 }
945 if end == 0 {
946 return backtrack();
947 }
948 let lit = input[..end].to_string();
949 *input = &input[end..];
950 Ok(WordPart::Lit(lit))
951}
952
953fn name(input: &mut &str) -> ModalResult<String> {
954 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
955 .map(|s: &str| s.to_string())
956 .parse_next(input)
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962
963 fn p(input: &str) -> Script {
964 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
965 }
966
967 fn words(script: &Script) -> Vec<String> {
968 match &script.0[0].pipeline.commands[0] {
969 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
970 _ => panic!("expected simple command"),
971 }
972 }
973
974 fn simple(script: &Script) -> &SimpleCmd {
975 match &script.0[0].pipeline.commands[0] {
976 Cmd::Simple(s) => s,
977 _ => panic!("expected simple command"),
978 }
979 }
980
981 #[test]
982 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
983 #[test]
984 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
985 #[test]
986 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
987 #[test]
988 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
989 #[test]
990 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
991
992 #[test]
993 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
994 #[test]
995 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
996 #[test]
997 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
998 #[test]
999 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
1000 #[test]
1001 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
1002 #[test]
1003 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
1004 #[test]
1005 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
1006 #[test]
1007 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
1008 #[test]
1009 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
1010 #[test]
1011 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
1012
1013 #[test]
1014 fn brace_group_simple() {
1015 assert!(matches!(
1016 &p("{ echo hello; }").0[0].pipeline.commands[0],
1017 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
1018 ));
1019 }
1020 #[test]
1021 fn brace_group_multiple_stmts() {
1022 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
1023 assert_eq!(body.0.len(), 3);
1024 } else { panic!("expected BraceGroup"); }
1025 }
1026 #[test]
1027 fn brace_group_with_redirect() {
1028 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
1029 assert_eq!(redirs.len(), 1);
1030 assert!(matches!(redirs[0], Redir::Write { .. }));
1031 } else { panic!("expected BraceGroup"); }
1032 }
1033 #[test]
1034 fn brace_group_with_append_redirect() {
1035 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
1036 assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
1037 } else { panic!("expected BraceGroup"); }
1038 }
1039 #[test]
1040 fn brace_group_with_stderr_redirect() {
1041 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
1042 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
1043 } else { panic!("expected BraceGroup"); }
1044 }
1045 #[test]
1046 fn brace_group_newline_separated() {
1047 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
1048 assert_eq!(body.0.len(), 2);
1049 } else { panic!("expected BraceGroup"); }
1050 }
1051 #[test]
1052 fn brace_group_in_pipeline() {
1053 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
1054 assert_eq!(pl.commands.len(), 2);
1055 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
1056 }
1057 #[test]
1058 fn brace_group_followed_by_other() {
1059 let stmts = &p("{ echo a; }; echo b").0;
1060 assert_eq!(stmts.len(), 2);
1061 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1062 }
1063 #[test]
1064 fn brace_group_nested() {
1065 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1066 assert_eq!(body.0.len(), 2);
1067 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1068 } else { panic!("expected outer BraceGroup"); }
1069 }
1070 #[test]
1071 fn brace_group_with_subshell_inside() {
1072 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1073 assert_eq!(body.0.len(), 2);
1074 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1075 } else { panic!("expected BraceGroup"); }
1076 }
1077 #[test]
1078 fn brace_open_requires_whitespace() {
1079 let cmds = &p("{echo a}").0;
1083 if !cmds.is_empty() {
1086 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1087 }
1088 }
1089 #[test]
1090 fn subshell_with_redirect() {
1091 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1092 assert_eq!(redirs.len(), 1);
1093 } else { panic!("expected Subshell with redir"); }
1094 }
1095 #[test]
1096 fn for_loop_with_redirect() {
1097 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1098 assert_eq!(redirs.len(), 1);
1099 } else { panic!("expected For with redir"); }
1100 }
1101 #[test]
1102 fn for_loop_redirect_then_pipe() {
1103 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1105 assert_eq!(pl.commands.len(), 2);
1106 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1107 }
1108 #[test]
1109 fn while_and_if_with_redirect() {
1110 assert!(matches!(
1111 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1112 Cmd::While { redirs, .. } if redirs.len() == 1
1113 ));
1114 assert!(matches!(
1115 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1116 Cmd::If { redirs, .. } if redirs.len() == 1
1117 ));
1118 }
1119 #[test]
1120 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1121
1122 #[test]
1123 fn redirect_dev_null() {
1124 let s = p("echo hello > /dev/null");
1125 let cmd = simple(&s);
1126 assert_eq!(cmd.words.len(), 2);
1127 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
1128 }
1129 #[test]
1130 fn redirect_stderr() {
1131 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1132 }
1133 #[test]
1134 fn here_string() {
1135 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1136 }
1137 #[test]
1138 fn heredoc_bare() {
1139 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
1140 }
1141 #[test]
1142 fn heredoc_with_content() {
1143 let s = p("cat <<EOF\nhello world\nEOF");
1144 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1145 }
1146 #[test]
1147 fn heredoc_quoted_delimiter() {
1148 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1149 }
1150 #[test]
1151 fn heredoc_strip_tabs() {
1152 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1153 }
1154 #[test]
1155 fn heredoc_pipe_on_command_line() {
1156 let s = p("cat <<EOF | grep hello\nhello\nEOF");
1159 assert_eq!(s.0[0].pipeline.commands.len(), 2);
1160 }
1161 #[test]
1162 fn heredoc_body_does_not_swallow_pipe() {
1163 let s = p("cat <<EOF | bash\nrm\nEOF");
1167 assert_eq!(
1168 s.0[0].pipeline.commands.len(),
1169 2,
1170 "pipeline must keep `bash` as a second command"
1171 );
1172 }
1173 #[test]
1174 fn heredoc_followed_by_next_statement() {
1175 let s = p("cat <<EOF\nhello\nEOF\nls");
1178 assert_eq!(s.0.len(), 2);
1179 }
1180
1181 #[test]
1182 fn env_prefix() {
1183 let s = p("FOO='bar baz' ls -la");
1184 let cmd = simple(&s);
1185 assert_eq!(cmd.env[0].0, "FOO");
1186 assert_eq!(cmd.env[0].1.eval(), "bar baz");
1187 }
1188 #[test]
1189 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1190 #[test]
1191 fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__"); }
1192 #[test]
1193 fn nested_substitution() {
1194 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1195 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1196 } else { panic!("expected CmdSub"); }
1197 }
1198
1199 #[test]
1200 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1201 #[test]
1202 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1203
1204 #[test]
1205 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")); }
1206 #[test]
1207 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1208 #[test]
1209 fn if_then_fi() {
1210 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1211 assert_eq!(branches.len(), 1);
1212 assert!(else_body.is_none());
1213 } else { panic!("expected If"); }
1214 }
1215 #[test]
1216 fn if_elif_else() {
1217 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] {
1218 assert_eq!(branches.len(), 2);
1219 assert!(else_body.is_some());
1220 } else { panic!("expected If"); }
1221 }
1222
1223 #[test]
1224 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1225 #[test]
1226 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1227 #[test]
1228 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1229
1230 #[test]
1231 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1232 #[test]
1233 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1234 #[test]
1235 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1236 #[test]
1237 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1238 #[test]
1239 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1240 #[test]
1241 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1242
1243 #[test]
1244 fn subshell_for() {
1245 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1246 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1247 } else { panic!("expected Subshell"); }
1248 }
1249 #[test]
1250 fn proc_sub_input() {
1251 let s = p("diff <(sort a.txt) <(sort b.txt)");
1252 let cmd = simple(&s);
1253 assert_eq!(cmd.words.len(), 3);
1254 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1255 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1256 }
1257 #[test]
1258 fn proc_sub_output() {
1259 let s = p("tee >(grep error > /dev/null)");
1260 let cmd = simple(&s);
1261 assert_eq!(cmd.words.len(), 2);
1262 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1263 }
1264
1265 fn func_def(s: &Script) -> (&str, &Script) {
1267 match &s.0[0].pipeline.commands[0] {
1268 Cmd::FunctionDef { name, body } => (name.as_str(), body),
1269 other => panic!("expected FunctionDef, got {other:?}"),
1270 }
1271 }
1272 #[test]
1273 fn function_def_posix_form() {
1274 let s = p("probe(){ echo hi; }");
1275 let (name, body) = func_def(&s);
1276 assert_eq!(name, "probe");
1277 assert_eq!(words(body), ["echo", "hi"]);
1278 }
1279 #[test]
1280 fn function_def_spaced_and_keyword_forms() {
1281 assert_eq!(func_def(&p("foo () { echo hi; }")).0, "foo");
1282 assert_eq!(func_def(&p("function foo { echo hi; }")).0, "foo");
1283 assert_eq!(func_def(&p("function foo () { echo hi; }")).0, "foo");
1284 }
1285 #[test]
1286 fn function_def_subshell_body() {
1287 let s = p("foo() ( echo sub )");
1288 assert_eq!(func_def(&s).0, "foo");
1289 }
1290 #[test]
1291 fn function_def_body_on_next_line() {
1292 assert_eq!(func_def(&p("foo()\n{\n echo hi\n}")).0, "foo");
1293 }
1294 #[test]
1295 fn function_def_name_with_dashes_and_dots() {
1296 assert_eq!(func_def(&p("my-func.v2(){ echo hi; }")).0, "my-func.v2");
1297 }
1298 #[test]
1299 fn plain_command_is_not_a_function_def() {
1300 assert!(matches!(&p("ls -la").0[0].pipeline.commands[0], Cmd::Simple(_)));
1302 assert!(matches!(&p("echo foo bar").0[0].pipeline.commands[0], Cmd::Simple(_)));
1303 }
1304 #[test]
1305 fn function_def_roundtrips() {
1306 let rendered = p("greet(){ echo hi; }").to_string();
1307 assert!(parse(&rendered).is_some(), "did not reparse: {rendered}");
1308 assert_eq!(func_def(&p(&rendered)).0, "greet");
1309 }
1310 #[test]
1311 fn comment_only() {
1312 let s = p("# just a comment");
1313 assert!(s.0.is_empty());
1314 }
1315 #[test]
1316 fn comment_before_command() {
1317 let s = p("# comment\necho hello");
1318 assert_eq!(words(&s), ["echo", "hello"]);
1319 }
1320 #[test]
1321 fn inline_comment() {
1322 let s = p("echo hello # this is a comment");
1323 assert_eq!(words(&s), ["echo", "hello"]);
1324 }
1325 #[test]
1326 fn comment_between_commands() {
1327 let s = p("echo hello\n# middle comment\necho world");
1328 assert_eq!(s.0.len(), 2);
1329 }
1330 #[test]
1331 fn comment_after_semicolon() {
1332 let s = p("echo hello; # comment\necho world");
1333 assert_eq!(s.0.len(), 2);
1334 }
1335 #[test]
1336 fn comment_in_for_loop() {
1337 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1338 }
1339 #[test]
1340 fn quoted_redirect_in_echo() {
1341 let s = p("echo 'greater > than' test");
1342 let cmd = simple(&s);
1343 assert_eq!(cmd.words.len(), 3);
1344 assert_eq!(cmd.redirs.len(), 0);
1345 }
1346
1347 #[test]
1348 fn parses_all_safe_commands() {
1349 let cmds = [
1350 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1351 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1352 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1353 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1354 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1355 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1356 "((echo hello))", "(for x in 1 2; do echo $x; done)",
1357 "echo 'greater > than' test", "echo '$(safe)' arg",
1358 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1359 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1360 "grep foo file.txt | head -5", "cat file | sort | uniq",
1361 "ls && echo done", "ls; echo done", "ls & echo done",
1362 "grep -c , <<< 'hello,world,test'",
1363 "cat <<EOF\nhello world\nEOF",
1364 "cat <<'MARKER'\nsome text\nMARKER",
1365 "cat <<-EOF\n\thello\nEOF",
1366 "echo foo\necho bar", "ls\ncat file.txt",
1367 "git log --oneline -20 | head -5",
1368 "echo hello > /dev/null", "echo hello 2> /dev/null",
1369 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1370 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1371 "for x in 1 2 3; do echo $x; done",
1372 "for f in *.txt; do cat $f | grep pattern; done",
1373 "for x in 1 2 3; do; done",
1374 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1375 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1376 "for x in 1 2; do echo $x; done && echo finished",
1377 "for x in $(seq 1 5); do echo $x; done",
1378 "while test -f /tmp/foo; do sleep 1; done",
1379 "while ! test -f /tmp/done; do sleep 1; done",
1380 "until test -f /tmp/ready; do sleep 1; done",
1381 "if test -f foo; then echo exists; fi",
1382 "if test -f foo; then echo yes; else echo no; fi",
1383 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1384 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1385 "if true; then for x in 1 2; do echo $x; done; fi",
1386 "diff <(sort a.txt) <(sort b.txt)",
1387 "comm -23 file.txt <(sort other.txt)",
1388 "cat <(echo hello)",
1389 "# comment only",
1390 "# comment\necho hello",
1391 "echo hello # inline comment",
1392 "echo one\n# between\necho two",
1393 "! echo hello", "! test -f foo",
1394 "echo for; echo done; echo if; echo fi",
1395 ];
1396 let mut failures = Vec::new();
1397 for cmd in &cmds {
1398 if parse(cmd).is_none() { failures.push(*cmd); }
1399 }
1400 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1401 }
1402
1403 fn inner_sub(s: &Script) -> &Script {
1409 match &simple(s).words[1].0[0] {
1410 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1411 other => panic!("expected a substitution, got {other:?}"),
1412 }
1413 }
1414
1415 #[test]
1416 fn cmd_sub_squote_paren_is_not_close() {
1417 assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1418 }
1419 #[test]
1420 fn cmd_sub_dquote_paren_is_not_close() {
1421 assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1422 }
1423 #[test]
1424 fn cmd_sub_escaped_paren_is_not_close() {
1425 assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1426 }
1427 #[test]
1428 fn cmd_sub_backtick_paren_is_not_close() {
1429 let s = p("echo $(x `)` y)");
1431 let inner = simple(inner_sub(&s));
1432 assert_eq!(inner.words.len(), 3);
1433 assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1434 }
1435 #[test]
1436 fn cmd_sub_escaped_backtick_does_not_end_span() {
1437 let s = p("echo $(`\\`)`)");
1441 assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1442 }
1443 #[test]
1444 fn proc_sub_squote_paren_is_not_close() {
1445 assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1446 }
1447 #[test]
1448 fn proc_sub_out_squote_paren_is_not_close() {
1449 assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1450 }
1451 #[test]
1452 fn cmd_sub_nested_picks_outer_close() {
1453 let s = p("echo $(a $(b) c)");
1454 let inner = simple(inner_sub(&s));
1455 assert_eq!(inner.words.len(), 3);
1456 assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1457 }
1458 #[test]
1459 fn cmd_sub_literal_after_close_stays_in_outer_word() {
1460 let s = p("echo $(ls)tail");
1461 let w = &simple(&s).words[1];
1462 assert_eq!(w.0.len(), 2);
1463 assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1464 assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1465 }
1466 #[test]
1467 fn cmd_sub_heredoc_body_paren_does_not_close() {
1468 let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1472 assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1473 }
1474 #[test]
1475 fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1476 assert!(parse("echo $(echo ')").is_none());
1478 }
1479 #[test]
1480 fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1481 assert!(parse("cat <(grep ')").is_none());
1482 }
1483}