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 let result = script.parse(input).ok();
12 reset_heredoc_queue();
13 result
14}
15
16fn backtrack<T>() -> ModalResult<T> {
17 Err(ErrMode::Backtrack(ContextError::new()))
18}
19
20thread_local! {
21 static PARSE_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
22}
23
24const MAX_PARSE_DEPTH: u32 = 48;
34
35struct DepthGuard;
38
39impl DepthGuard {
40 fn enter() -> Option<Self> {
41 PARSE_DEPTH.with(|d| {
42 if d.get() >= MAX_PARSE_DEPTH {
43 None
44 } else {
45 d.set(d.get() + 1);
46 Some(DepthGuard)
47 }
48 })
49 }
50}
51
52impl Drop for DepthGuard {
53 fn drop(&mut self) {
54 PARSE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
55 }
56}
57
58fn comment(input: &mut &str) -> ModalResult<()> {
59 if input.starts_with('#') {
60 if let Some(pos) = input.find('\n') {
61 *input = &input[pos + 1..];
62 } else {
63 *input = "";
64 }
65 }
66 Ok(())
67}
68
69fn ws(input: &mut &str) -> ModalResult<()> {
70 loop {
71 take_while(0.., [' ', '\t']).void().parse_next(input)?;
72 if input.starts_with('#') {
73 comment(input)?;
74 } else {
75 break;
76 }
77 }
78 Ok(())
79}
80
81fn sep(input: &mut &str) -> ModalResult<()> {
82 loop {
83 take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
84 if input.starts_with('#') {
85 comment(input)?;
86 } else {
87 break;
88 }
89 }
90 Ok(())
91}
92
93fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
94 if !input.starts_with(kw) {
95 return backtrack();
96 }
97 if input
98 .as_bytes()
99 .get(kw.len())
100 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
101 {
102 return backtrack();
103 }
104 *input = &input[kw.len()..];
105 Ok(())
106}
107
108const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
109
110fn at_script_stop(input: &str) -> bool {
111 input.starts_with(')')
112 || input.starts_with('}')
113 || SCRIPT_STOPS.iter().any(|kw| {
114 input.starts_with(kw)
115 && !input
116 .as_bytes()
117 .get(kw.len())
118 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
119 })
120}
121
122fn is_word_boundary(c: char) -> bool {
123 matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
124}
125
126fn is_word_literal(c: char) -> bool {
127 !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
128}
129
130fn is_dq_literal(c: char) -> bool {
131 !matches!(c, '"' | '\\' | '`' | '$')
132}
133
134fn script(input: &mut &str) -> ModalResult<Script> {
137 let Some(_depth) = DepthGuard::enter() else {
140 return backtrack();
141 };
142 sep.parse_next(input)?;
143 let mut stmts = Vec::new();
144 while let Some(pl) = opt(pipeline).parse_next(input)? {
145 ws.parse_next(input)?;
146 let op = opt(list_op).parse_next(input)?;
147 stmts.push(Stmt { pipeline: pl, op });
148 drain_pending_heredocs(input);
153 if op.is_none() {
154 break;
155 }
156 sep.parse_next(input)?;
157 }
158 Ok(Script(stmts))
159}
160
161fn list_op(input: &mut &str) -> ModalResult<ListOp> {
162 ws.parse_next(input)?;
163 alt((
164 "&&".value(ListOp::And),
165 "||".value(ListOp::Or),
166 '\n'.value(ListOp::Semi),
167 ';'.value(ListOp::Semi),
168 ('&', not('>')).value(ListOp::Amp),
169 ))
170 .parse_next(input)
171}
172
173fn pipe_sep(input: &mut &str) -> ModalResult<()> {
174 (ws, '|', not('|'), ws).void().parse_next(input)
175}
176
177fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
180 ws.parse_next(input)?;
181 if at_script_stop(input) {
182 return backtrack();
183 }
184 let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
185 let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
186 Ok(Pipeline { bang, commands })
187}
188
189fn command(input: &mut &str) -> ModalResult<Cmd> {
192 ws.parse_next(input)?;
193 if at_script_stop(input) {
194 return backtrack();
195 }
196 alt((
197 subshell,
198 brace_group,
199 for_cmd,
200 while_cmd,
201 until_cmd,
202 if_cmd,
203 double_bracket_cmd,
204 simple_cmd.map(Cmd::Simple),
205 ))
206 .parse_next(input)
207}
208
209fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
210 let mut redirs = Vec::new();
211 loop {
212 ws.parse_next(input)?;
213 if let Some(r) = opt(redirect).parse_next(input)? {
214 redirs.push(r);
215 } else {
216 break;
217 }
218 }
219 Ok(redirs)
220}
221
222fn subshell(input: &mut &str) -> ModalResult<Cmd> {
223 let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
224 let redirs = trailing_redirs(input)?;
225 Ok(Cmd::Subshell { body, redirs })
226}
227
228fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
229 if !input.starts_with('{') {
230 return backtrack();
231 }
232 if !input
233 .as_bytes()
234 .get(1)
235 .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
236 {
237 return backtrack();
238 }
239 *input = &input[1..];
240 sep.parse_next(input)?;
241 let body = script.parse_next(input)?;
242 if body.0.is_empty() {
243 return backtrack();
244 }
245 sep.parse_next(input)?;
246 if !input.starts_with('}') {
247 return backtrack();
248 }
249 let last_op = body.0.last().and_then(|s| s.op);
250 if last_op.is_none() {
251 return backtrack();
252 }
253 *input = &input[1..];
254 let redirs = trailing_redirs(input)?;
255 Ok(Cmd::BraceGroup { body, redirs })
256}
257
258fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
261 let env: Vec<(String, Word)> =
262 repeat(0.., terminated(assignment, ws)).parse_next(input)?;
263 let mut words = Vec::new();
264 let mut redirs = Vec::new();
265
266 loop {
267 ws.parse_next(input)?;
268 if at_cmd_end(input) {
269 break;
270 }
271 if let Some(r) = opt(redirect).parse_next(input)? {
272 redirs.push(r);
273 } else if let Some(w) = opt(word).parse_next(input)? {
274 words.push(w);
275 } else {
276 break;
277 }
278 }
279
280 if env.is_empty() && words.is_empty() && redirs.is_empty() {
281 return backtrack();
282 }
283 Ok(SimpleCmd { env, words, redirs })
284}
285
286fn at_cmd_end(input: &str) -> bool {
287 input.is_empty()
288 || matches!(
289 input.as_bytes().first(),
290 Some(b'\n' | b';' | b'|' | b'&' | b')')
291 )
292}
293
294fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
295 let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
296 .parse_next(input)?;
297 '='.parse_next(input)?;
298 let value = opt(word)
299 .parse_next(input)?
300 .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
301 Ok((n.to_string(), value))
302}
303
304fn redirect(input: &mut &str) -> ModalResult<Redir> {
307 let fd = opt(fd_prefix).parse_next(input)?;
308 alt((
309 preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
310 heredoc,
311 preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
312 fd: fd.unwrap_or(1),
313 target,
314 append: true,
315 }),
316 preceded(">&", fd_target).map(move |dst| Redir::DupFd {
317 src: fd.unwrap_or(1),
318 dst,
319 }),
320 preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
321 fd: fd.unwrap_or(1),
322 target,
323 append: false,
324 }),
325 preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
326 fd: fd.unwrap_or(0),
327 target,
328 }),
329 ))
330 .parse_next(input)
331}
332
333fn heredoc(input: &mut &str) -> ModalResult<Redir> {
334 "<<".parse_next(input)?;
335 let strip_tabs = opt('-').parse_next(input)?.is_some();
336 ws.parse_next(input)?;
337 let delimiter = heredoc_delimiter.parse_next(input)?;
338 PENDING_HEREDOCS.with(|q| {
344 q.borrow_mut().push(PendingHeredoc {
345 delimiter: delimiter.clone(),
346 strip_tabs,
347 });
348 });
349 Ok(Redir::HereDoc { delimiter, strip_tabs })
350}
351
352#[derive(Debug, Clone)]
353struct PendingHeredoc {
354 delimiter: String,
355 strip_tabs: bool,
356}
357
358thread_local! {
359 static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
360 const { std::cell::RefCell::new(Vec::new()) };
361}
362
363fn drain_pending_heredocs(input: &mut &str) {
364 let pending: Vec<PendingHeredoc> =
365 PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
366 for h in pending {
367 if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
368 return;
372 }
373 }
374}
375
376fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
377 let s = *input;
378 let bytes = s.as_bytes();
379 let mut line_start = 0;
380 while line_start <= bytes.len() {
381 let line_end = match s[line_start..].find('\n') {
382 Some(rel) => line_start + rel,
383 None => bytes.len(),
384 };
385 let line_bytes = &bytes[line_start..line_end];
386 let line = if strip_tabs {
387 std::str::from_utf8(line_bytes)
388 .unwrap_or("")
389 .trim_start_matches('\t')
390 } else {
391 std::str::from_utf8(line_bytes).unwrap_or("")
392 };
393 if line == delimiter {
394 let advance = line_end + usize::from(line_end < bytes.len());
396 *input = &s[advance..];
397 return true;
398 }
399 if line_end >= bytes.len() {
400 return false;
401 }
402 line_start = line_end + 1;
403 }
404 false
405}
406
407fn reset_heredoc_queue() {
408 PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
409}
410
411fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
412 alt((
413 delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
414 delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
415 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
416 ))
417 .parse_next(input)
418}
419
420fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
421 let b = input.as_bytes();
422 if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
423 let d = (b[0] - b'0') as u32;
424 *input = &input[1..];
425 Ok(d)
426 } else {
427 backtrack()
428 }
429}
430
431fn fd_target(input: &mut &str) -> ModalResult<String> {
432 alt((
433 '-'.value("-".to_string()),
434 take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
435 ))
436 .parse_next(input)
437}
438
439fn word(input: &mut &str) -> ModalResult<Word> {
442 repeat(1.., word_part)
443 .map(Word)
444 .parse_next(input)
445}
446
447fn word_part(input: &mut &str) -> ModalResult<WordPart> {
448 if input.is_empty() {
449 return backtrack();
450 }
451 if input.starts_with("<(") || input.starts_with(">(") {
452 return proc_sub(input);
453 }
454 if is_word_boundary(input.as_bytes()[0] as char) {
455 return backtrack();
456 }
457 alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
458 .parse_next(input)
459}
460
461fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
462 delimited('\'', take_while(0.., |c| c != '\''), '\'')
463 .map(|s: &str| WordPart::SQuote(s.to_string()))
464 .parse_next(input)
465}
466
467fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
468 delimited('"', repeat(0.., dq_part).map(Word), '"')
469 .map(WordPart::DQuote)
470 .parse_next(input)
471}
472
473fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
474 delimited(("$(", ws), script, (ws, ')'))
475 .map(WordPart::CmdSub)
476 .parse_next(input)
477}
478
479fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
480 if !(input.starts_with("<(") || input.starts_with(">(")) {
481 return backtrack();
482 }
483 *input = &input[1..];
484 delimited(('(', ws), script, (ws, ')'))
485 .map(WordPart::ProcSub)
486 .parse_next(input)
487}
488
489fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
490 if !input.starts_with("$((") {
491 return backtrack();
492 }
493 let body_start = 3;
494 let bytes = input.as_bytes();
495 let mut depth: i32 = 1;
496 let mut i = body_start;
497 while i < bytes.len() {
498 match bytes[i] {
499 b'(' => depth += 1,
500 b')' => {
501 if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
502 let body = input[body_start..i].to_string();
503 if body.contains("$(") || body.contains('`') {
504 return backtrack();
505 }
506 *input = &input[i + 2..];
507 return Ok(WordPart::Arith(body));
508 }
509 depth -= 1;
510 if depth < 0 {
511 return backtrack();
512 }
513 }
514 _ => {}
515 }
516 i += 1;
517 }
518 backtrack()
519}
520
521fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
522 delimited('`', backtick_inner, '`')
523 .map(WordPart::Backtick)
524 .parse_next(input)
525}
526
527fn escaped(input: &mut &str) -> ModalResult<WordPart> {
528 preceded('\\', any).map(WordPart::Escape).parse_next(input)
529}
530
531fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
532 move |input: &mut &str| {
533 take_while(1.., pred)
534 .map(|s: &str| WordPart::Lit(s.to_string()))
535 .parse_next(input)
536 }
537}
538
539fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
540 move |input: &mut &str| {
541 ('$', not('(')).void().parse_next(input)?;
542 let rest: &str = take_while(0.., pred).parse_next(input)?;
543 Ok(WordPart::Lit(format!("${rest}")))
544 }
545}
546
547fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
550 if input.is_empty() || input.starts_with('"') {
551 return backtrack();
552 }
553 alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
554 .parse_next(input)
555}
556
557fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
558 preceded('\\', any)
559 .map(|c: char| match c {
560 '"' | '\\' | '$' | '`' => WordPart::Escape(c),
561 _ => WordPart::Lit(format!("\\{c}")),
562 })
563 .parse_next(input)
564}
565
566fn backtick_inner(input: &mut &str) -> ModalResult<String> {
569 repeat(0.., alt((bt_escape, bt_literal)))
570 .fold(String::new, |mut acc, chunk: &str| {
571 acc.push_str(chunk);
572 acc
573 })
574 .parse_next(input)
575}
576
577fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
578 ('\\', any).take().parse_next(input)
579}
580
581fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
582 take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
583}
584
585fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
588 eat_keyword(input, "for")?;
589 ws.parse_next(input)?;
590 let var = name.parse_next(input)?;
591 ws.parse_next(input)?;
592
593 let items = if eat_keyword(input, "in").is_ok() {
594 ws.parse_next(input)?;
595 repeat(0.., terminated(word, ws)).parse_next(input)?
596 } else {
597 vec![]
598 };
599
600 let body = do_done_body.parse_next(input)?;
601 let redirs = trailing_redirs(input)?;
602 Ok(Cmd::For { var, items, body, redirs })
603}
604
605fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
606 eat_keyword(input, "while")?;
607 ws.parse_next(input)?;
608 let cond = script.parse_next(input)?;
609 let body = do_done_body.parse_next(input)?;
610 let redirs = trailing_redirs(input)?;
611 Ok(Cmd::While { cond, body, redirs })
612}
613
614fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
615 eat_keyword(input, "until")?;
616 ws.parse_next(input)?;
617 let cond = script.parse_next(input)?;
618 let body = do_done_body.parse_next(input)?;
619 let redirs = trailing_redirs(input)?;
620 Ok(Cmd::Until { cond, body, redirs })
621}
622
623fn do_done_body(input: &mut &str) -> ModalResult<Script> {
624 sep.parse_next(input)?;
625 eat_keyword(input, "do")?;
626 sep.parse_next(input)?;
627 let body = script.parse_next(input)?;
628 sep.parse_next(input)?;
629 eat_keyword(input, "done")?;
630 Ok(body)
631}
632
633fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
634 eat_keyword(input, "if")?;
635 ws.parse_next(input)?;
636 let mut branches = vec![cond_then_body.parse_next(input)?];
637 let mut else_body = None;
638
639 loop {
640 sep.parse_next(input)?;
641 if eat_keyword(input, "elif").is_ok() {
642 ws.parse_next(input)?;
643 branches.push(cond_then_body.parse_next(input)?);
644 } else if eat_keyword(input, "else").is_ok() {
645 sep.parse_next(input)?;
646 else_body = Some(script.parse_next(input)?);
647 break;
648 } else {
649 break;
650 }
651 }
652
653 sep.parse_next(input)?;
654 eat_keyword(input, "fi")?;
655 let redirs = trailing_redirs(input)?;
656 Ok(Cmd::If { branches, else_body, redirs })
657}
658
659fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
660 let cond = script.parse_next(input)?;
661 sep.parse_next(input)?;
662 eat_keyword(input, "then")?;
663 sep.parse_next(input)?;
664 let body = script.parse_next(input)?;
665 Ok(Branch { cond, body })
666}
667
668fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
669 if !input.starts_with("[[") {
670 return backtrack();
671 }
672 let bytes = input.as_bytes();
673 if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
674 return backtrack();
675 }
676 *input = &input[2..];
677
678 let mut words: Vec<Word> = Vec::new();
679 loop {
680 ws.parse_next(input)?;
681 if at_double_bracket_end(input) {
682 *input = &input[2..];
683 let redirs = trailing_redirs(input)?;
684 return Ok(Cmd::DoubleBracket { words, redirs });
685 }
686 if input.is_empty() {
687 return backtrack();
688 }
689 let w = bracket_word.parse_next(input)?;
690 words.push(w);
691 }
692}
693
694fn at_double_bracket_end(input: &str) -> bool {
695 if !input.starts_with("]]") {
696 return false;
697 }
698 let after = &input[2..];
699 after.is_empty()
700 || after.starts_with(|c: char| {
701 matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
702 })
703}
704
705fn bracket_word(input: &mut &str) -> ModalResult<Word> {
706 repeat(1.., bracket_word_part).map(Word).parse_next(input)
707}
708
709fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
710 if input.is_empty() {
711 return backtrack();
712 }
713 if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
714 return backtrack();
715 }
716 if at_double_bracket_end(input) {
717 return backtrack();
718 }
719 alt((
720 single_quoted,
721 double_quoted,
722 arith_sub,
723 cmd_sub,
724 backtick_part,
725 escaped,
726 dollar_lit(is_bracket_literal),
727 bracket_lit,
728 ))
729 .parse_next(input)
730}
731
732fn is_bracket_literal(c: char) -> bool {
733 !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
734}
735
736fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
737 let bytes = input.as_bytes();
742 let mut end = 0;
743 while end < bytes.len() {
744 let c = bytes[end] as char;
745 if !is_bracket_literal(c) {
746 break;
747 }
748 if c == ']' && at_double_bracket_end(&input[end..]) {
749 break;
750 }
751 end += 1;
752 }
753 if end == 0 {
754 return backtrack();
755 }
756 let lit = input[..end].to_string();
757 *input = &input[end..];
758 Ok(WordPart::Lit(lit))
759}
760
761fn name(input: &mut &str) -> ModalResult<String> {
762 take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
763 .map(|s: &str| s.to_string())
764 .parse_next(input)
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 fn p(input: &str) -> Script {
772 parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
773 }
774
775 fn words(script: &Script) -> Vec<String> {
776 match &script.0[0].pipeline.commands[0] {
777 Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
778 _ => panic!("expected simple command"),
779 }
780 }
781
782 fn simple(script: &Script) -> &SimpleCmd {
783 match &script.0[0].pipeline.commands[0] {
784 Cmd::Simple(s) => s,
785 _ => panic!("expected simple command"),
786 }
787 }
788
789 #[test]
790 fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
791 #[test]
792 fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
793 #[test]
794 fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
795 #[test]
796 fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
797 #[test]
798 fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
799
800 #[test]
801 fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
802 #[test]
803 fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
804 #[test]
805 fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
806 #[test]
807 fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
808 #[test]
809 fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
810 #[test]
811 fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
812 #[test]
813 fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n \necho bar").0.len(), 2); }
814 #[test]
815 fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
816 #[test]
817 fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
818 #[test]
819 fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
820
821 #[test]
822 fn brace_group_simple() {
823 assert!(matches!(
824 &p("{ echo hello; }").0[0].pipeline.commands[0],
825 Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
826 ));
827 }
828 #[test]
829 fn brace_group_multiple_stmts() {
830 if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
831 assert_eq!(body.0.len(), 3);
832 } else { panic!("expected BraceGroup"); }
833 }
834 #[test]
835 fn brace_group_with_redirect() {
836 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
837 assert_eq!(redirs.len(), 1);
838 assert!(matches!(redirs[0], Redir::Write { .. }));
839 } else { panic!("expected BraceGroup"); }
840 }
841 #[test]
842 fn brace_group_with_append_redirect() {
843 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
844 assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
845 } else { panic!("expected BraceGroup"); }
846 }
847 #[test]
848 fn brace_group_with_stderr_redirect() {
849 if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
850 assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
851 } else { panic!("expected BraceGroup"); }
852 }
853 #[test]
854 fn brace_group_newline_separated() {
855 if let Cmd::BraceGroup { body, .. } = &p("{\n echo a\n echo b\n}").0[0].pipeline.commands[0] {
856 assert_eq!(body.0.len(), 2);
857 } else { panic!("expected BraceGroup"); }
858 }
859 #[test]
860 fn brace_group_in_pipeline() {
861 let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
862 assert_eq!(pl.commands.len(), 2);
863 assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
864 }
865 #[test]
866 fn brace_group_followed_by_other() {
867 let stmts = &p("{ echo a; }; echo b").0;
868 assert_eq!(stmts.len(), 2);
869 assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
870 }
871 #[test]
872 fn brace_group_nested() {
873 if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
874 assert_eq!(body.0.len(), 2);
875 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
876 } else { panic!("expected outer BraceGroup"); }
877 }
878 #[test]
879 fn brace_group_with_subshell_inside() {
880 if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
881 assert_eq!(body.0.len(), 2);
882 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
883 } else { panic!("expected BraceGroup"); }
884 }
885 #[test]
886 fn brace_open_requires_whitespace() {
887 let cmds = &p("{echo a}").0;
891 if !cmds.is_empty() {
894 assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
895 }
896 }
897 #[test]
898 fn subshell_with_redirect() {
899 if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
900 assert_eq!(redirs.len(), 1);
901 } else { panic!("expected Subshell with redir"); }
902 }
903 #[test]
904 fn for_loop_with_redirect() {
905 if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
906 assert_eq!(redirs.len(), 1);
907 } else { panic!("expected For with redir"); }
908 }
909 #[test]
910 fn for_loop_redirect_then_pipe() {
911 let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
913 assert_eq!(pl.commands.len(), 2);
914 assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
915 }
916 #[test]
917 fn while_and_if_with_redirect() {
918 assert!(matches!(
919 &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
920 Cmd::While { redirs, .. } if redirs.len() == 1
921 ));
922 assert!(matches!(
923 &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
924 Cmd::If { redirs, .. } if redirs.len() == 1
925 ));
926 }
927 #[test]
928 fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
929
930 #[test]
931 fn redirect_dev_null() {
932 let s = p("echo hello > /dev/null");
933 let cmd = simple(&s);
934 assert_eq!(cmd.words.len(), 2);
935 assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
936 }
937 #[test]
938 fn redirect_stderr() {
939 assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
940 }
941 #[test]
942 fn here_string() {
943 assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
944 }
945 #[test]
946 fn heredoc_bare() {
947 assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
948 }
949 #[test]
950 fn heredoc_with_content() {
951 let s = p("cat <<EOF\nhello world\nEOF");
952 assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
953 }
954 #[test]
955 fn heredoc_quoted_delimiter() {
956 assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
957 }
958 #[test]
959 fn heredoc_strip_tabs() {
960 assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
961 }
962 #[test]
963 fn heredoc_pipe_on_command_line() {
964 let s = p("cat <<EOF | grep hello\nhello\nEOF");
967 assert_eq!(s.0[0].pipeline.commands.len(), 2);
968 }
969 #[test]
970 fn heredoc_body_does_not_swallow_pipe() {
971 let s = p("cat <<EOF | bash\nrm\nEOF");
975 assert_eq!(
976 s.0[0].pipeline.commands.len(),
977 2,
978 "pipeline must keep `bash` as a second command"
979 );
980 }
981 #[test]
982 fn heredoc_followed_by_next_statement() {
983 let s = p("cat <<EOF\nhello\nEOF\nls");
986 assert_eq!(s.0.len(), 2);
987 }
988
989 #[test]
990 fn env_prefix() {
991 let s = p("FOO='bar baz' ls -la");
992 let cmd = simple(&s);
993 assert_eq!(cmd.env[0].0, "FOO");
994 assert_eq!(cmd.env[0].1.eval(), "bar baz");
995 }
996 #[test]
997 fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
998 #[test]
999 fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_CMDSUB__"); }
1000 #[test]
1001 fn nested_substitution() {
1002 if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
1003 assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
1004 } else { panic!("expected CmdSub"); }
1005 }
1006
1007 #[test]
1008 fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
1009 #[test]
1010 fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
1011
1012 #[test]
1013 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")); }
1014 #[test]
1015 fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
1016 #[test]
1017 fn if_then_fi() {
1018 if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
1019 assert_eq!(branches.len(), 1);
1020 assert!(else_body.is_none());
1021 } else { panic!("expected If"); }
1022 }
1023 #[test]
1024 fn if_elif_else() {
1025 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] {
1026 assert_eq!(branches.len(), 2);
1027 assert!(else_body.is_some());
1028 } else { panic!("expected If"); }
1029 }
1030
1031 #[test]
1032 fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
1033 #[test]
1034 fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
1035 #[test]
1036 fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
1037
1038 #[test]
1039 fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
1040 #[test]
1041 fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
1042 #[test]
1043 fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1044 #[test]
1045 fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1046 #[test]
1047 fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1048 #[test]
1049 fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1050
1051 #[test]
1052 fn subshell_for() {
1053 if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1054 assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1055 } else { panic!("expected Subshell"); }
1056 }
1057 #[test]
1058 fn proc_sub_input() {
1059 let s = p("diff <(sort a.txt) <(sort b.txt)");
1060 let cmd = simple(&s);
1061 assert_eq!(cmd.words.len(), 3);
1062 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1063 assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1064 }
1065 #[test]
1066 fn proc_sub_output() {
1067 let s = p("tee >(grep error > /dev/null)");
1068 let cmd = simple(&s);
1069 assert_eq!(cmd.words.len(), 2);
1070 assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1071 }
1072 #[test]
1073 fn comment_only() {
1074 let s = p("# just a comment");
1075 assert!(s.0.is_empty());
1076 }
1077 #[test]
1078 fn comment_before_command() {
1079 let s = p("# comment\necho hello");
1080 assert_eq!(words(&s), ["echo", "hello"]);
1081 }
1082 #[test]
1083 fn inline_comment() {
1084 let s = p("echo hello # this is a comment");
1085 assert_eq!(words(&s), ["echo", "hello"]);
1086 }
1087 #[test]
1088 fn comment_between_commands() {
1089 let s = p("echo hello\n# middle comment\necho world");
1090 assert_eq!(s.0.len(), 2);
1091 }
1092 #[test]
1093 fn comment_after_semicolon() {
1094 let s = p("echo hello; # comment\necho world");
1095 assert_eq!(s.0.len(), 2);
1096 }
1097 #[test]
1098 fn comment_in_for_loop() {
1099 assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1100 }
1101 #[test]
1102 fn quoted_redirect_in_echo() {
1103 let s = p("echo 'greater > than' test");
1104 let cmd = simple(&s);
1105 assert_eq!(cmd.words.len(), 3);
1106 assert_eq!(cmd.redirs.len(), 0);
1107 }
1108
1109 #[test]
1110 fn parses_all_safe_commands() {
1111 let cmds = [
1112 "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1113 "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1114 "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1115 "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1116 "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1117 "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1118 "((echo hello))", "(for x in 1 2; do echo $x; done)",
1119 "echo 'greater > than' test", "echo '$(safe)' arg",
1120 "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1121 "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1122 "grep foo file.txt | head -5", "cat file | sort | uniq",
1123 "ls && echo done", "ls; echo done", "ls & echo done",
1124 "grep -c , <<< 'hello,world,test'",
1125 "cat <<EOF\nhello world\nEOF",
1126 "cat <<'MARKER'\nsome text\nMARKER",
1127 "cat <<-EOF\n\thello\nEOF",
1128 "echo foo\necho bar", "ls\ncat file.txt",
1129 "git log --oneline -20 | head -5",
1130 "echo hello > /dev/null", "echo hello 2> /dev/null",
1131 "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1132 "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1133 "for x in 1 2 3; do echo $x; done",
1134 "for f in *.txt; do cat $f | grep pattern; done",
1135 "for x in 1 2 3; do; done",
1136 "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1137 "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1138 "for x in 1 2; do echo $x; done && echo finished",
1139 "for x in $(seq 1 5); do echo $x; done",
1140 "while test -f /tmp/foo; do sleep 1; done",
1141 "while ! test -f /tmp/done; do sleep 1; done",
1142 "until test -f /tmp/ready; do sleep 1; done",
1143 "if test -f foo; then echo exists; fi",
1144 "if test -f foo; then echo yes; else echo no; fi",
1145 "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1146 "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1147 "if true; then for x in 1 2; do echo $x; done; fi",
1148 "diff <(sort a.txt) <(sort b.txt)",
1149 "comm -23 file.txt <(sort other.txt)",
1150 "cat <(echo hello)",
1151 "# comment only",
1152 "# comment\necho hello",
1153 "echo hello # inline comment",
1154 "echo one\n# between\necho two",
1155 "! echo hello", "! test -f foo",
1156 "echo for; echo done; echo if; echo fi",
1157 ];
1158 let mut failures = Vec::new();
1159 for cmd in &cmds {
1160 if parse(cmd).is_none() { failures.push(*cmd); }
1161 }
1162 assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1163 }
1164}