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 simple_cmd.map(Cmd::Simple),
232 ))
233 .parse_next(input)
234}
235
236fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
237 let mut redirs = Vec::new();
238 loop {
239 ws.parse_next(input)?;
240 if let Some(r) = opt(redirect).parse_next(input)? {
241 redirs.push(r);
242 } else {
243 break;
244 }
245 }
246 Ok(redirs)
247}
248
249fn subshell(input: &mut &str) -> ModalResult<Cmd> {
250 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
251 let redirs = trailing_redirs(input)?;
252 Ok(Cmd::Subshell { body, redirs })
253}
254
255fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
256 if !input.starts_with('{') {
257 return backtrack();
258 }
259 if !input
260 .as_bytes()
261 .get(1)
262 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
263 {
264 return backtrack();
265 }
266 *input = &input[1..];
267 sep.parse_next(input)?;
268 let body = script.parse_next(input)?;
269 if body.0.is_empty() {
270 return backtrack();
271 }
272 sep.parse_next(input)?;
273 if !input.starts_with('}') {
274 return backtrack();
275 }
276 let last_op = body.0.last().and_then(|s| s.op);
277 if last_op.is_none() {
278 return backtrack();
279 }
280 *input = &input[1..];
281 let redirs = trailing_redirs(input)?;
282 Ok(Cmd::BraceGroup { body, redirs })
283}
284
285fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
288 let env: Vec<(String, Word)> =
289 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
290 let mut words = Vec::new();
291 let mut redirs = Vec::new();
292
293 loop {
294 ws.parse_next(input)?;
295 if at_cmd_end(input) {
296 break;
297 }
298 if let Some(r) = opt(redirect).parse_next(input)? {
299 redirs.push(r);
300 } else if let Some(w) = opt(word).parse_next(input)? {
301 words.push(w);
302 } else {
303 break;
304 }
305 }
306
307 if env.is_empty() && words.is_empty() && redirs.is_empty() {
308 return backtrack();
309 }
310 Ok(SimpleCmd { env, words, redirs })
311}
312
313fn at_cmd_end(input: &str) -> bool {
314 input.is_empty()
315 || matches!(
316 input.as_bytes().first(),
317 Some(b'\n' | b';' | b'|' | b'&' | b')')
318 )
319}
320
321fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
322 let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
323 .parse_next(input)?;
324 '='.parse_next(input)?;
325 let value = opt(word)
326 .parse_next(input)?
327 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
328 Ok((n.to_string(), value))
329}
330
331fn redirect(input: &mut &str) -> ModalResult<Redir> {
334 let fd = opt(fd_prefix).parse_next(input)?;
335 alt((
336 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
337 heredoc,
338 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
339 fd: fd.unwrap_or(1),
340 target,
341 append: true,
342 }),
343 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
344 src: fd.unwrap_or(1),
345 dst,
346 }),
347 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
348 fd: fd.unwrap_or(1),
349 target,
350 append: false,
351 }),
352 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
353 fd: fd.unwrap_or(0),
354 target,
355 }),
356 ))
357 .parse_next(input)
358}
359
360fn heredoc(input: &mut &str) -> ModalResult<Redir> {
361 "<<".parse_next(input)?;
362 let strip_tabs = opt('-').parse_next(input)?.is_some();
363 ws.parse_next(input)?;
364 let delimiter = heredoc_delimiter.parse_next(input)?;
365 PENDING_HEREDOCS.with(|q| {
371 q.borrow_mut().push(PendingHeredoc {
372 delimiter: delimiter.clone(),
373 strip_tabs,
374 });
375 });
376 Ok(Redir::HereDoc { delimiter, strip_tabs })
377}
378
379#[derive(Debug, Clone)]
380struct PendingHeredoc {
381 delimiter: String,
382 strip_tabs: bool,
383}
384
385thread_local! {
386 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
387 const { std::cell::RefCell::new(Vec::new()) };
388}
389
390fn drain_pending_heredocs(input: &mut &str) {
391 let pending: Vec<PendingHeredoc> =
392 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
393 for h in pending {
394 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
395 return;
399 }
400 }
401}
402
403fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
404 let s = *input;
405 let bytes = s.as_bytes();
406 let mut line_start = 0;
407 while line_start <= bytes.len() {
408 let line_end = match s[line_start..].find('\n') {
409 Some(rel) => line_start + rel,
410 None => bytes.len(),
411 };
412 let line_bytes = &bytes[line_start..line_end];
413 let line = if strip_tabs {
414 std::str::from_utf8(line_bytes)
415 .unwrap_or("")
416 .trim_start_matches('\t')
417 } else {
418 std::str::from_utf8(line_bytes).unwrap_or("")
419 };
420 if line == delimiter {
421 let advance = line_end + usize::from(line_end < bytes.len());
423 *input = &s[advance..];
424 return true;
425 }
426 if line_end >= bytes.len() {
427 return false;
428 }
429 line_start = line_end + 1;
430 }
431 false
432}
433
434fn reset_heredoc_queue() {
435 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
436}
437
438fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
439 alt((
440 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
441 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
442 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
443 ))
444 .parse_next(input)
445}
446
447fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
448 let b = input.as_bytes();
449 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
450 let d = (b[0] - b'0') as u32;
451 *input = &input[1..];
452 Ok(d)
453 } else {
454 backtrack()
455 }
456}
457
458fn fd_target(input: &mut &str) -> ModalResult<String> {
459 alt((
460 '-'.value("-".to_string()),
461 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
462 ))
463 .parse_next(input)
464}
465
466fn word(input: &mut &str) -> ModalResult<Word> {
469 repeat(1.., word_part)
470 .map(Word)
471 .parse_next(input)
472}
473
474fn word_part(input: &mut &str) -> ModalResult<WordPart> {
475 if input.is_empty() {
476 return backtrack();
477 }
478 if input.starts_with("<(") || input.starts_with(">(") {
479 return proc_sub(input);
480 }
481 if is_word_boundary(input.as_bytes()[0] as char) {
482 return backtrack();
483 }
484 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
485 .parse_next(input)
486}
487
488fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
489 delimited('\'', take_while(0.., |c| c != '\''), '\'')
490 .map(|s: &str| WordPart::SQuote(s.to_string()))
491 .parse_next(input)
492}
493
494fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
495 delimited('"', repeat(0.., dq_part).map(Word), '"')
496 .map(WordPart::DQuote)
497 .parse_next(input)
498}
499
500fn find_sub_close(body: &str) -> Option<usize> {
508 let b = body.as_bytes();
509 let mut i = 0;
510 let mut depth: usize = 0;
511 while i < b.len() {
512 match b[i] {
513 b'\\' => i += 1, b'\'' => {
515 i += 1;
516 while i < b.len() && b[i] != b'\'' {
517 i += 1;
518 }
519 if i >= b.len() {
520 return None;
521 }
522 }
523 b'"' => {
524 i += 1;
525 while i < b.len() && b[i] != b'"' {
526 i += if b[i] == b'\\' { 2 } else { 1 };
527 }
528 if i >= b.len() {
529 return None;
530 }
531 }
532 b'`' => {
533 i += 1;
534 while i < b.len() && b[i] != b'`' {
535 i += if b[i] == b'\\' { 2 } else { 1 };
538 }
539 if i >= b.len() {
540 return None;
541 }
542 }
543 b'(' => depth += 1,
544 b')' => {
545 if depth == 0 {
546 return Some(i);
547 }
548 depth -= 1;
549 }
550 _ => {}
551 }
552 i += 1;
553 }
554 None
555}
556
557fn sub_body(input: &mut &str, open_len: usize) -> ModalResult<Script> {
571 let body = &input[open_len..];
572 let Some(rel) = find_sub_close(body) else {
573 return backtrack();
574 };
575 let interior = &body[..rel];
581 if !interior.contains("<<") {
582 let mut fast: &str = interior;
583 if let Ok(parsed) = script.parse_next(&mut fast) {
584 ws.parse_next(&mut fast)?;
585 if fast.is_empty() {
586 *input = &body[rel + 1..];
587 return Ok(parsed);
588 }
589 }
590 }
591 let mut rest: &str = body;
592 ws.parse_next(&mut rest)?;
593 let parsed = script.parse_next(&mut rest)?;
594 ws.parse_next(&mut rest)?;
595 if !rest.starts_with(')') {
596 return backtrack();
597 }
598 *input = &rest[1..];
599 Ok(parsed)
600}
601
602fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
603 if !input.starts_with("$(") {
604 return backtrack();
605 }
606 sub_body(input, 2).map(WordPart::CmdSub)
607}
608
609fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
610 if !(input.starts_with("<(") || input.starts_with(">(")) {
611 return backtrack();
612 }
613 sub_body(input, 2).map(WordPart::ProcSub)
614}
615
616fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
617 if !input.starts_with("$((") {
618 return backtrack();
619 }
620 let body_start = 3;
621 let bytes = input.as_bytes();
622 let mut depth: i32 = 1;
623 let mut i = body_start;
624 while i < bytes.len() {
625 match bytes[i] {
626 b'(' => depth += 1,
627 b')' => {
628 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
629 let body = input[body_start..i].to_string();
630 if body.contains("$(") || body.contains('`') {
631 return backtrack();
632 }
633 *input = &input[i + 2..];
634 return Ok(WordPart::Arith(body));
635 }
636 depth -= 1;
637 if depth < 0 {
638 return backtrack();
639 }
640 }
641 _ => {}
642 }
643 i += 1;
644 }
645 backtrack()
646}
647
648fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
649 delimited('`', backtick_inner, '`')
650 .map(WordPart::Backtick)
651 .parse_next(input)
652}
653
654fn escaped(input: &mut &str) -> ModalResult<WordPart> {
655 preceded('\\', any).map(WordPart::Escape).parse_next(input)
656}
657
658fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
659 move |input: &mut &str| {
660 take_while(1.., pred)
661 .map(|s: &str| WordPart::Lit(s.to_string()))
662 .parse_next(input)
663 }
664}
665
666fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
667 move |input: &mut &str| {
668 ('$', not('(')).void().parse_next(input)?;
669 let rest: &str = take_while(0.., pred).parse_next(input)?;
670 Ok(WordPart::Lit(format!("${rest}")))
671 }
672}
673
674fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
677 if input.is_empty() || input.starts_with('"') {
678 return backtrack();
679 }
680 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
681 .parse_next(input)
682}
683
684fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
685 preceded('\\', any)
686 .map(|c: char| match c {
687 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
688 _ => WordPart::Lit(format!("\\{c}")),
689 })
690 .parse_next(input)
691}
692
693fn backtick_inner(input: &mut &str) -> ModalResult<String> {
696 repeat(0.., alt((bt_escape, bt_literal)))
697 .fold(String::new, |mut acc, chunk: &str| {
698 acc.push_str(chunk);
699 acc
700 })
701 .parse_next(input)
702}
703
704fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
705 ('\\', any).take().parse_next(input)
706}
707
708fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
709 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
710}
711
712fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
715 eat_keyword(input, "for")?;
716 ws.parse_next(input)?;
717 let var = name.parse_next(input)?;
718 ws.parse_next(input)?;
719
720 let items = if eat_keyword(input, "in").is_ok() {
721 ws.parse_next(input)?;
722 repeat(0.., terminated(word, ws)).parse_next(input)?
723 } else {
724 vec![]
725 };
726
727 let body = do_done_body.parse_next(input)?;
728 let redirs = trailing_redirs(input)?;
729 Ok(Cmd::For { var, items, body, redirs })
730}
731
732fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
733 eat_keyword(input, "while")?;
734 ws.parse_next(input)?;
735 let cond = script.parse_next(input)?;
736 let body = do_done_body.parse_next(input)?;
737 let redirs = trailing_redirs(input)?;
738 Ok(Cmd::While { cond, body, redirs })
739}
740
741fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
742 eat_keyword(input, "until")?;
743 ws.parse_next(input)?;
744 let cond = script.parse_next(input)?;
745 let body = do_done_body.parse_next(input)?;
746 let redirs = trailing_redirs(input)?;
747 Ok(Cmd::Until { cond, body, redirs })
748}
749
750fn do_done_body(input: &mut &str) -> ModalResult<Script> {
751 sep.parse_next(input)?;
752 eat_keyword(input, "do")?;
753 sep.parse_next(input)?;
754 let body = script.parse_next(input)?;
755 sep.parse_next(input)?;
756 eat_keyword(input, "done")?;
757 Ok(body)
758}
759
760fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
761 eat_keyword(input, "if")?;
762 ws.parse_next(input)?;
763 let mut branches = vec![cond_then_body.parse_next(input)?];
764 let mut else_body = None;
765
766 loop {
767 sep.parse_next(input)?;
768 if eat_keyword(input, "elif").is_ok() {
769 ws.parse_next(input)?;
770 branches.push(cond_then_body.parse_next(input)?);
771 } else if eat_keyword(input, "else").is_ok() {
772 sep.parse_next(input)?;
773 else_body = Some(script.parse_next(input)?);
774 break;
775 } else {
776 break;
777 }
778 }
779
780 sep.parse_next(input)?;
781 eat_keyword(input, "fi")?;
782 let redirs = trailing_redirs(input)?;
783 Ok(Cmd::If { branches, else_body, redirs })
784}
785
786fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
787 let cond = script.parse_next(input)?;
788 sep.parse_next(input)?;
789 eat_keyword(input, "then")?;
790 sep.parse_next(input)?;
791 let body = script.parse_next(input)?;
792 Ok(Branch { cond, body })
793}
794
795fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
796 if !input.starts_with("[[") {
797 return backtrack();
798 }
799 let bytes = input.as_bytes();
800 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
801 return backtrack();
802 }
803 *input = &input[2..];
804
805 let mut words: Vec<Word> = Vec::new();
806 loop {
807 ws.parse_next(input)?;
808 if at_double_bracket_end(input) {
809 *input = &input[2..];
810 let redirs = trailing_redirs(input)?;
811 return Ok(Cmd::DoubleBracket { words, redirs });
812 }
813 if input.is_empty() {
814 return backtrack();
815 }
816 let w = bracket_word.parse_next(input)?;
817 words.push(w);
818 }
819}
820
821fn at_double_bracket_end(input: &str) -> bool {
822 if !input.starts_with("]]") {
823 return false;
824 }
825 let after = &input[2..];
826 after.is_empty()
827 || after.starts_with(|c: char| {
828 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
829 })
830}
831
832fn bracket_word(input: &mut &str) -> ModalResult<Word> {
833 repeat(1.., bracket_word_part).map(Word).parse_next(input)
834}
835
836fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
837 if input.is_empty() {
838 return backtrack();
839 }
840 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
841 return backtrack();
842 }
843 if at_double_bracket_end(input) {
844 return backtrack();
845 }
846 alt((
847 single_quoted,
848 double_quoted,
849 arith_sub,
850 cmd_sub,
851 backtick_part,
852 escaped,
853 dollar_lit(is_bracket_literal),
854 bracket_lit,
855 ))
856 .parse_next(input)
857}
858
859fn is_bracket_literal(c: char) -> bool {
860 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
861}
862
863fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
864 let bytes = input.as_bytes();
869 let mut end = 0;
870 while end < bytes.len() {
871 let c = bytes[end] as char;
872 if !is_bracket_literal(c) {
873 break;
874 }
875 if c == ']' && at_double_bracket_end(&input[end..]) {
876 break;
877 }
878 end += 1;
879 }
880 if end == 0 {
881 return backtrack();
882 }
883 let lit = input[..end].to_string();
884 *input = &input[end..];
885 Ok(WordPart::Lit(lit))
886}
887
888fn name(input: &mut &str) -> ModalResult<String> {
889 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
890 .map(|s: &str| s.to_string())
891 .parse_next(input)
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897
898 fn p(input: &str) -> Script {
899 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
900 }
901
902 fn words(script: &Script) -> Vec<String> {
903 match &script.0[0].pipeline.commands[0] {
904 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
905 _ => panic!("expected simple command"),
906 }
907 }
908
909 fn simple(script: &Script) -> &SimpleCmd {
910 match &script.0[0].pipeline.commands[0] {
911 Cmd::Simple(s) => s,
912 _ => panic!("expected simple command"),
913 }
914 }
915
916 #[test]
917 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
918 #[test]
919 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
920 #[test]
921 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
922 #[test]
923 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
924 #[test]
925 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
926
927 #[test]
928 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
929 #[test]
930 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
931 #[test]
932 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
933 #[test]
934 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
935 #[test]
936 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
937 #[test]
938 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
939 #[test]
940 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
941 #[test]
942 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
943 #[test]
944 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
945 #[test]
946 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
947
948 #[test]
949 fn brace_group_simple() {
950 assert!(matches!(
951 &p("{ echo hello; }").0[0].pipeline.commands[0],
952 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
953 ));
954 }
955 #[test]
956 fn brace_group_multiple_stmts() {
957 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
958 assert_eq!(body.0.len(), 3);
959 } else { panic!("expected BraceGroup"); }
960 }
961 #[test]
962 fn brace_group_with_redirect() {
963 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
964 assert_eq!(redirs.len(), 1);
965 assert!(matches!(redirs[0], Redir::Write { .. }));
966 } else { panic!("expected BraceGroup"); }
967 }
968 #[test]
969 fn brace_group_with_append_redirect() {
970 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
971 assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
972 } else { panic!("expected BraceGroup"); }
973 }
974 #[test]
975 fn brace_group_with_stderr_redirect() {
976 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
977 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
978 } else { panic!("expected BraceGroup"); }
979 }
980 #[test]
981 fn brace_group_newline_separated() {
982 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
983 assert_eq!(body.0.len(), 2);
984 } else { panic!("expected BraceGroup"); }
985 }
986 #[test]
987 fn brace_group_in_pipeline() {
988 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
989 assert_eq!(pl.commands.len(), 2);
990 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
991 }
992 #[test]
993 fn brace_group_followed_by_other() {
994 let stmts = &p("{ echo a; }; echo b").0;
995 assert_eq!(stmts.len(), 2);
996 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
997 }
998 #[test]
999 fn brace_group_nested() {
1000 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
1001 assert_eq!(body.0.len(), 2);
1002 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1003 } else { panic!("expected outer BraceGroup"); }
1004 }
1005 #[test]
1006 fn brace_group_with_subshell_inside() {
1007 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
1008 assert_eq!(body.0.len(), 2);
1009 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
1010 } else { panic!("expected BraceGroup"); }
1011 }
1012 #[test]
1013 fn brace_open_requires_whitespace() {
1014 let cmds = &p("{echo a}").0;
1018 if !cmds.is_empty() {
1021 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
1022 }
1023 }
1024 #[test]
1025 fn subshell_with_redirect() {
1026 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
1027 assert_eq!(redirs.len(), 1);
1028 } else { panic!("expected Subshell with redir"); }
1029 }
1030 #[test]
1031 fn for_loop_with_redirect() {
1032 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
1033 assert_eq!(redirs.len(), 1);
1034 } else { panic!("expected For with redir"); }
1035 }
1036 #[test]
1037 fn for_loop_redirect_then_pipe() {
1038 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
1040 assert_eq!(pl.commands.len(), 2);
1041 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
1042 }
1043 #[test]
1044 fn while_and_if_with_redirect() {
1045 assert!(matches!(
1046 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
1047 Cmd::While { redirs, .. } if redirs.len() == 1
1048 ));
1049 assert!(matches!(
1050 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
1051 Cmd::If { redirs, .. } if redirs.len() == 1
1052 ));
1053 }
1054 #[test]
1055 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
1056
1057 #[test]
1058 fn redirect_dev_null() {
1059 let s = p("echo hello > /dev/null");
1060 let cmd = simple(&s);
1061 assert_eq!(cmd.words.len(), 2);
1062 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
1063 }
1064 #[test]
1065 fn redirect_stderr() {
1066 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
1067 }
1068 #[test]
1069 fn here_string() {
1070 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
1071 }
1072 #[test]
1073 fn heredoc_bare() {
1074 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
1075 }
1076 #[test]
1077 fn heredoc_with_content() {
1078 let s = p("cat <<EOF\nhello world\nEOF");
1079 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1080 }
1081 #[test]
1082 fn heredoc_quoted_delimiter() {
1083 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
1084 }
1085 #[test]
1086 fn heredoc_strip_tabs() {
1087 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
1088 }
1089 #[test]
1090 fn heredoc_pipe_on_command_line() {
1091 let s = p("cat <<EOF | grep hello\nhello\nEOF");
1094 assert_eq!(s.0[0].pipeline.commands.len(), 2);
1095 }
1096 #[test]
1097 fn heredoc_body_does_not_swallow_pipe() {
1098 let s = p("cat <<EOF | bash\nrm\nEOF");
1102 assert_eq!(
1103 s.0[0].pipeline.commands.len(),
1104 2,
1105 "pipeline must keep `bash` as a second command"
1106 );
1107 }
1108 #[test]
1109 fn heredoc_followed_by_next_statement() {
1110 let s = p("cat <<EOF\nhello\nEOF\nls");
1113 assert_eq!(s.0.len(), 2);
1114 }
1115
1116 #[test]
1117 fn env_prefix() {
1118 let s = p("FOO='bar baz' ls -la");
1119 let cmd = simple(&s);
1120 assert_eq!(cmd.env[0].0, "FOO");
1121 assert_eq!(cmd.env[0].1.eval(), "bar baz");
1122 }
1123 #[test]
1124 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
1125 #[test]
1126 fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__"); }
1127 #[test]
1128 fn nested_substitution() {
1129 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1130 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1131 } else { panic!("expected CmdSub"); }
1132 }
1133
1134 #[test]
1135 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1136 #[test]
1137 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1138
1139 #[test]
1140 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")); }
1141 #[test]
1142 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1143 #[test]
1144 fn if_then_fi() {
1145 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1146 assert_eq!(branches.len(), 1);
1147 assert!(else_body.is_none());
1148 } else { panic!("expected If"); }
1149 }
1150 #[test]
1151 fn if_elif_else() {
1152 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] {
1153 assert_eq!(branches.len(), 2);
1154 assert!(else_body.is_some());
1155 } else { panic!("expected If"); }
1156 }
1157
1158 #[test]
1159 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1160 #[test]
1161 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1162 #[test]
1163 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1164
1165 #[test]
1166 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1167 #[test]
1168 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1169 #[test]
1170 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1171 #[test]
1172 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1173 #[test]
1174 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1175 #[test]
1176 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1177
1178 #[test]
1179 fn subshell_for() {
1180 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1181 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1182 } else { panic!("expected Subshell"); }
1183 }
1184 #[test]
1185 fn proc_sub_input() {
1186 let s = p("diff <(sort a.txt) <(sort b.txt)");
1187 let cmd = simple(&s);
1188 assert_eq!(cmd.words.len(), 3);
1189 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1190 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1191 }
1192 #[test]
1193 fn proc_sub_output() {
1194 let s = p("tee >(grep error > /dev/null)");
1195 let cmd = simple(&s);
1196 assert_eq!(cmd.words.len(), 2);
1197 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1198 }
1199 #[test]
1200 fn comment_only() {
1201 let s = p("# just a comment");
1202 assert!(s.0.is_empty());
1203 }
1204 #[test]
1205 fn comment_before_command() {
1206 let s = p("# comment\necho hello");
1207 assert_eq!(words(&s), ["echo", "hello"]);
1208 }
1209 #[test]
1210 fn inline_comment() {
1211 let s = p("echo hello # this is a comment");
1212 assert_eq!(words(&s), ["echo", "hello"]);
1213 }
1214 #[test]
1215 fn comment_between_commands() {
1216 let s = p("echo hello\n# middle comment\necho world");
1217 assert_eq!(s.0.len(), 2);
1218 }
1219 #[test]
1220 fn comment_after_semicolon() {
1221 let s = p("echo hello; # comment\necho world");
1222 assert_eq!(s.0.len(), 2);
1223 }
1224 #[test]
1225 fn comment_in_for_loop() {
1226 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1227 }
1228 #[test]
1229 fn quoted_redirect_in_echo() {
1230 let s = p("echo 'greater > than' test");
1231 let cmd = simple(&s);
1232 assert_eq!(cmd.words.len(), 3);
1233 assert_eq!(cmd.redirs.len(), 0);
1234 }
1235
1236 #[test]
1237 fn parses_all_safe_commands() {
1238 let cmds = [
1239 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1240 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1241 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1242 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1243 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1244 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1245 "((echo hello))", "(for x in 1 2; do echo $x; done)",
1246 "echo 'greater > than' test", "echo '$(safe)' arg",
1247 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1248 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1249 "grep foo file.txt | head -5", "cat file | sort | uniq",
1250 "ls && echo done", "ls; echo done", "ls & echo done",
1251 "grep -c , <<< 'hello,world,test'",
1252 "cat <<EOF\nhello world\nEOF",
1253 "cat <<'MARKER'\nsome text\nMARKER",
1254 "cat <<-EOF\n\thello\nEOF",
1255 "echo foo\necho bar", "ls\ncat file.txt",
1256 "git log --oneline -20 | head -5",
1257 "echo hello > /dev/null", "echo hello 2> /dev/null",
1258 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1259 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1260 "for x in 1 2 3; do echo $x; done",
1261 "for f in *.txt; do cat $f | grep pattern; done",
1262 "for x in 1 2 3; do; done",
1263 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1264 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1265 "for x in 1 2; do echo $x; done && echo finished",
1266 "for x in $(seq 1 5); do echo $x; done",
1267 "while test -f /tmp/foo; do sleep 1; done",
1268 "while ! test -f /tmp/done; do sleep 1; done",
1269 "until test -f /tmp/ready; do sleep 1; done",
1270 "if test -f foo; then echo exists; fi",
1271 "if test -f foo; then echo yes; else echo no; fi",
1272 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1273 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1274 "if true; then for x in 1 2; do echo $x; done; fi",
1275 "diff <(sort a.txt) <(sort b.txt)",
1276 "comm -23 file.txt <(sort other.txt)",
1277 "cat <(echo hello)",
1278 "# comment only",
1279 "# comment\necho hello",
1280 "echo hello # inline comment",
1281 "echo one\n# between\necho two",
1282 "! echo hello", "! test -f foo",
1283 "echo for; echo done; echo if; echo fi",
1284 ];
1285 let mut failures = Vec::new();
1286 for cmd in &cmds {
1287 if parse(cmd).is_none() { failures.push(*cmd); }
1288 }
1289 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1290 }
1291
1292 fn inner_sub(s: &Script) -> &Script {
1298 match &simple(s).words[1].0[0] {
1299 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => inner,
1300 other => panic!("expected a substitution, got {other:?}"),
1301 }
1302 }
1303
1304 #[test]
1305 fn cmd_sub_squote_paren_is_not_close() {
1306 assert_eq!(words(inner_sub(&p("echo $(echo ')')"))), ["echo", ")"]);
1307 }
1308 #[test]
1309 fn cmd_sub_dquote_paren_is_not_close() {
1310 assert_eq!(words(inner_sub(&p("echo $(echo \")\")"))), ["echo", ")"]);
1311 }
1312 #[test]
1313 fn cmd_sub_escaped_paren_is_not_close() {
1314 assert_eq!(words(inner_sub(&p("echo $(echo \\))"))), ["echo", ")"]);
1315 }
1316 #[test]
1317 fn cmd_sub_backtick_paren_is_not_close() {
1318 let s = p("echo $(x `)` y)");
1320 let inner = simple(inner_sub(&s));
1321 assert_eq!(inner.words.len(), 3);
1322 assert!(matches!(&inner.words[1].0[0], WordPart::Backtick(_)));
1323 }
1324 #[test]
1325 fn cmd_sub_escaped_backtick_does_not_end_span() {
1326 let s = p("echo $(`\\`)`)");
1330 assert!(matches!(&simple(&s).words[1].0[0], WordPart::CmdSub(_)));
1331 }
1332 #[test]
1333 fn proc_sub_squote_paren_is_not_close() {
1334 assert_eq!(words(inner_sub(&p("cat <(grep ')' f)"))), ["grep", ")", "f"]);
1335 }
1336 #[test]
1337 fn proc_sub_out_squote_paren_is_not_close() {
1338 assert_eq!(words(inner_sub(&p("tee >(grep ')' f)"))), ["grep", ")", "f"]);
1339 }
1340 #[test]
1341 fn cmd_sub_nested_picks_outer_close() {
1342 let s = p("echo $(a $(b) c)");
1343 let inner = simple(inner_sub(&s));
1344 assert_eq!(inner.words.len(), 3);
1345 assert!(matches!(&inner.words[1].0[0], WordPart::CmdSub(_)));
1346 }
1347 #[test]
1348 fn cmd_sub_literal_after_close_stays_in_outer_word() {
1349 let s = p("echo $(ls)tail");
1350 let w = &simple(&s).words[1];
1351 assert_eq!(w.0.len(), 2);
1352 assert!(matches!(&w.0[0], WordPart::CmdSub(_)));
1353 assert!(matches!(&w.0[1], WordPart::Lit(s) if s == "tail"));
1354 }
1355 #[test]
1356 fn cmd_sub_heredoc_body_paren_does_not_close() {
1357 let s = p("x=$(cat <<EOF\na)b\nEOF\n)");
1361 assert!(matches!(&simple(&s).env[0].1.0[0], WordPart::CmdSub(_)));
1362 }
1363 #[test]
1364 fn cmd_sub_with_only_a_quoted_paren_is_unclosed() {
1365 assert!(parse("echo $(echo ')").is_none());
1367 }
1368 #[test]
1369 fn proc_sub_with_only_a_quoted_paren_is_unclosed() {
1370 assert!(parse("cat <(grep ')").is_none());
1371 }
1372}