1use std::fmt;
5
6#[derive(Debug, Clone, PartialEq)]
8pub enum TokenType {
9 Word(String),
10 And, Or, Semicolon, Pipe, LParen, RParen, RedirectOut, RedirectAppend, RedirectIn, Eof,
20}
21
22impl fmt::Display for TokenType {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 match self {
25 TokenType::Word(s) => write!(f, "Word({})", s),
26 TokenType::And => write!(f, "&&"),
27 TokenType::Or => write!(f, "||"),
28 TokenType::Semicolon => write!(f, ";"),
29 TokenType::Pipe => write!(f, "|"),
30 TokenType::LParen => write!(f, "("),
31 TokenType::RParen => write!(f, ")"),
32 TokenType::RedirectOut => write!(f, ">"),
33 TokenType::RedirectAppend => write!(f, ">>"),
34 TokenType::RedirectIn => write!(f, "<"),
35 TokenType::Eof => write!(f, "EOF"),
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct Token {
43 pub token_type: TokenType,
44 pub value: String,
45}
46
47#[derive(Debug, Clone)]
49pub struct Redirect {
50 pub redirect_type: TokenType,
51 pub target: String,
52}
53
54#[derive(Debug, Clone)]
56pub struct ParsedArg {
57 pub value: String,
58 pub quoted: bool,
59 pub quote_char: Option<char>,
60 pub raw: String,
64}
65
66pub fn remove_shell_quotes(word: &str) -> (String, bool, Option<char>) {
89 let chars: Vec<char> = word.chars().collect();
90 let mut value = String::new();
91 let mut quoted = false;
92 let mut quote_char: Option<char> = None;
93 let mut i = 0;
94
95 while i < chars.len() {
96 let c = chars[i];
97
98 if c == '\'' {
99 quoted = true;
100 if quote_char.is_none() {
101 quote_char = Some('\'');
102 }
103 i += 1;
104 while i < chars.len() && chars[i] != '\'' {
105 value.push(chars[i]);
106 i += 1;
107 }
108 i += 1; continue;
110 }
111
112 if c == '"' {
113 quoted = true;
114 if quote_char.is_none() {
115 quote_char = Some('"');
116 }
117 i += 1;
118 while i < chars.len() && chars[i] != '"' {
119 if chars[i] == '\\'
120 && i + 1 < chars.len()
121 && matches!(chars[i + 1], '$' | '`' | '"' | '\\' | '\n')
122 {
123 value.push(chars[i + 1]);
124 i += 2;
125 continue;
126 }
127 value.push(chars[i]);
128 i += 1;
129 }
130 i += 1; continue;
132 }
133
134 if c == '\\' && i + 1 < chars.len() && !cfg!(windows) {
135 quoted = true;
136 value.push(chars[i + 1]);
137 i += 2;
138 continue;
139 }
140
141 value.push(c);
142 i += 1;
143 }
144
145 (value, quoted, quote_char)
146}
147
148pub fn split_command_words(command: &str) -> Vec<String> {
154 tokenize(command)
155 .into_iter()
156 .filter_map(|token| match token.token_type {
157 TokenType::Word(w) => Some(remove_shell_quotes(&w).0),
158 _ => None,
159 })
160 .collect()
161}
162
163#[derive(Debug, Clone)]
165pub enum ParsedCommand {
166 Simple {
168 cmd: String,
169 args: Vec<ParsedArg>,
170 redirects: Vec<Redirect>,
171 },
172 Sequence {
174 commands: Vec<ParsedCommand>,
175 operators: Vec<TokenType>,
176 },
177 Pipeline { commands: Vec<ParsedCommand> },
179 Subshell { command: Box<ParsedCommand> },
181}
182
183pub fn tokenize(command: &str) -> Vec<Token> {
185 let mut tokens = Vec::new();
186 let chars: Vec<char> = command.chars().collect();
187 let mut i = 0;
188
189 while i < chars.len() {
190 while i < chars.len() && chars[i].is_whitespace() {
192 i += 1;
193 }
194
195 if i >= chars.len() {
196 break;
197 }
198
199 if chars[i] == '&' && i + 1 < chars.len() && chars[i + 1] == '&' {
201 tokens.push(Token {
202 token_type: TokenType::And,
203 value: "&&".to_string(),
204 });
205 i += 2;
206 } else if chars[i] == '&' {
207 i += 1;
215 } else if chars[i] == '|' && i + 1 < chars.len() && chars[i + 1] == '|' {
216 tokens.push(Token {
217 token_type: TokenType::Or,
218 value: "||".to_string(),
219 });
220 i += 2;
221 } else if chars[i] == '|' {
222 tokens.push(Token {
223 token_type: TokenType::Pipe,
224 value: "|".to_string(),
225 });
226 i += 1;
227 } else if chars[i] == ';' {
228 tokens.push(Token {
229 token_type: TokenType::Semicolon,
230 value: ";".to_string(),
231 });
232 i += 1;
233 } else if chars[i] == '(' {
234 tokens.push(Token {
235 token_type: TokenType::LParen,
236 value: "(".to_string(),
237 });
238 i += 1;
239 } else if chars[i] == ')' {
240 tokens.push(Token {
241 token_type: TokenType::RParen,
242 value: ")".to_string(),
243 });
244 i += 1;
245 } else if chars[i] == '>' && i + 1 < chars.len() && chars[i + 1] == '>' {
246 tokens.push(Token {
247 token_type: TokenType::RedirectAppend,
248 value: ">>".to_string(),
249 });
250 i += 2;
251 } else if chars[i] == '>' {
252 tokens.push(Token {
253 token_type: TokenType::RedirectOut,
254 value: ">".to_string(),
255 });
256 i += 1;
257 } else if chars[i] == '<' {
258 tokens.push(Token {
259 token_type: TokenType::RedirectIn,
260 value: "<".to_string(),
261 });
262 i += 1;
263 } else {
264 let mut word = String::new();
266 let mut in_quote = false;
267 let mut quote_char = ' ';
268
269 while i < chars.len() {
270 let c = chars[i];
271
272 if !in_quote {
273 if c == '"' || c == '\'' {
274 in_quote = true;
275 quote_char = c;
276 word.push(c);
277 i += 1;
278 } else if c.is_whitespace() || "&|;()<>".contains(c) {
279 break;
280 } else if c == '\\' && i + 1 < chars.len() {
281 word.push(c);
283 i += 1;
284 if i < chars.len() {
285 word.push(chars[i]);
286 i += 1;
287 }
288 } else {
289 word.push(c);
290 i += 1;
291 }
292 } else {
293 let prev_char = if i > 0 { Some(chars[i - 1]) } else { None };
294 if c == quote_char && prev_char != Some('\\') {
295 in_quote = false;
296 word.push(c);
297 i += 1;
298 } else if c == '\\' && i + 1 < chars.len() {
299 let next_char = chars[i + 1];
300 if next_char == quote_char || next_char == '\\' {
301 word.push(c);
303 i += 1;
304 if i < chars.len() {
305 word.push(chars[i]);
306 i += 1;
307 }
308 } else {
309 word.push(c);
310 i += 1;
311 }
312 } else {
313 word.push(c);
314 i += 1;
315 }
316 }
317 }
318
319 if !word.is_empty() {
320 tokens.push(Token {
321 token_type: TokenType::Word(word.clone()),
322 value: word,
323 });
324 }
325 }
326 }
327
328 tokens.push(Token {
329 token_type: TokenType::Eof,
330 value: String::new(),
331 });
332
333 tokens
334}
335
336pub struct ShellParser {
338 tokens: Vec<Token>,
339 pos: usize,
340}
341
342impl ShellParser {
343 pub fn new(command: &str) -> Self {
345 ShellParser {
346 tokens: tokenize(command),
347 pos: 0,
348 }
349 }
350
351 fn current(&self) -> Token {
352 self.tokens.get(self.pos).cloned().unwrap_or(Token {
353 token_type: TokenType::Eof,
354 value: String::new(),
355 })
356 }
357
358 fn consume(&mut self) -> Token {
359 let token = self.current().clone();
360 self.pos += 1;
361 token
362 }
363
364 pub fn parse(&mut self) -> Option<ParsedCommand> {
366 self.parse_sequence()
367 }
368
369 fn parse_sequence(&mut self) -> Option<ParsedCommand> {
371 let mut commands = Vec::new();
372 let mut operators = Vec::new();
373
374 if let Some(cmd) = self.parse_pipeline() {
376 commands.push(cmd);
377 }
378
379 loop {
381 match &self.current().token_type {
382 TokenType::Eof | TokenType::RParen => break,
383 TokenType::And | TokenType::Or | TokenType::Semicolon => {
384 let op = self.consume().token_type;
385 operators.push(op);
386
387 if let Some(cmd) = self.parse_pipeline() {
388 commands.push(cmd);
389 }
390 }
391 _ => break,
392 }
393 }
394
395 if commands.len() == 1 && operators.is_empty() {
396 return commands.into_iter().next();
397 }
398
399 if commands.is_empty() {
400 return None;
401 }
402
403 Some(ParsedCommand::Sequence {
404 commands,
405 operators,
406 })
407 }
408
409 fn parse_pipeline(&mut self) -> Option<ParsedCommand> {
411 let mut commands = Vec::new();
412
413 if let Some(cmd) = self.parse_command() {
414 commands.push(cmd);
415 }
416
417 while matches!(self.current().token_type, TokenType::Pipe) {
418 self.consume();
419 if let Some(cmd) = self.parse_command() {
420 commands.push(cmd);
421 }
422 }
423
424 if commands.len() == 1 {
425 return commands.into_iter().next();
426 }
427
428 if commands.is_empty() {
429 return None;
430 }
431
432 Some(ParsedCommand::Pipeline { commands })
433 }
434
435 fn parse_command(&mut self) -> Option<ParsedCommand> {
437 if matches!(self.current().token_type, TokenType::LParen) {
439 self.consume(); let subshell = self.parse_sequence();
441
442 if matches!(self.current().token_type, TokenType::RParen) {
443 self.consume(); }
445
446 return subshell.map(|cmd| ParsedCommand::Subshell {
447 command: Box::new(cmd),
448 });
449 }
450
451 self.parse_simple_command()
453 }
454
455 fn parse_simple_command(&mut self) -> Option<ParsedCommand> {
457 let mut words = Vec::new();
458 let mut redirects = Vec::new();
459
460 loop {
461 match &self.current().token_type {
462 TokenType::Eof => break,
463 TokenType::Word(w) => {
464 words.push(w.clone());
465 self.consume();
466 }
467 TokenType::RedirectOut | TokenType::RedirectAppend | TokenType::RedirectIn => {
468 let redirect_type = self.consume().token_type;
469 if let TokenType::Word(target) = &self.current().token_type {
470 redirects.push(Redirect {
471 redirect_type,
472 target: target.clone(),
473 });
474 self.consume();
475 }
476 }
477 _ => break,
478 }
479 }
480
481 if words.is_empty() {
482 return None;
483 }
484
485 let cmd = words.remove(0);
486 let args: Vec<ParsedArg> = words
487 .into_iter()
488 .map(|word| {
489 let (value, quoted, quote_char) = remove_shell_quotes(&word);
495 ParsedArg {
496 value,
497 quoted,
498 quote_char,
499 raw: word,
500 }
501 })
502 .collect();
503
504 Some(ParsedCommand::Simple {
505 cmd,
506 args,
507 redirects,
508 })
509 }
510}
511
512pub fn parse_shell_command(command: &str) -> Option<ParsedCommand> {
514 let mut parser = ShellParser::new(command);
515 parser.parse()
516}
517
518pub fn needs_real_shell(command: &str) -> bool {
528 let unsupported = [
530 '`', '$', '~', '*', '?', '[', '|', '&', ';', '(', ')', '>', '<', ];
544
545 command.chars().any(|c| unsupported.contains(&c))
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn test_tokenize_simple_command() {
554 let tokens = tokenize("echo hello world");
555 assert_eq!(tokens.len(), 4); assert!(matches!(tokens[0].token_type, TokenType::Word(_)));
557 assert!(matches!(tokens[3].token_type, TokenType::Eof));
558 }
559
560 #[test]
561 fn test_tokenize_with_operators() {
562 let tokens = tokenize("cmd1 && cmd2 || cmd3");
563 assert_eq!(tokens.len(), 6); assert!(matches!(tokens[1].token_type, TokenType::And));
565 assert!(matches!(tokens[3].token_type, TokenType::Or));
566 }
567
568 #[test]
569 fn test_tokenize_with_pipe() {
570 let tokens = tokenize("ls | grep foo");
571 assert_eq!(tokens.len(), 5); assert!(matches!(tokens[1].token_type, TokenType::Pipe));
573 }
574
575 #[test]
576 fn test_tokenize_with_quotes() {
577 let tokens = tokenize("echo 'hello world'");
578 assert_eq!(tokens.len(), 3); if let TokenType::Word(w) = &tokens[1].token_type {
580 assert_eq!(w, "'hello world'");
581 } else {
582 panic!("Expected Word token");
583 }
584 }
585
586 #[test]
587 fn test_parse_simple_command() {
588 let cmd = parse_shell_command("echo hello world").unwrap();
589 match cmd {
590 ParsedCommand::Simple { cmd, args, .. } => {
591 assert_eq!(cmd, "echo");
592 assert_eq!(args.len(), 2);
593 assert_eq!(args[0].value, "hello");
594 assert_eq!(args[1].value, "world");
595 }
596 _ => panic!("Expected Simple command"),
597 }
598 }
599
600 #[test]
601 fn test_parse_pipeline() {
602 let cmd = parse_shell_command("ls | grep foo | wc -l").unwrap();
603 match cmd {
604 ParsedCommand::Pipeline { commands } => {
605 assert_eq!(commands.len(), 3);
606 }
607 _ => panic!("Expected Pipeline"),
608 }
609 }
610
611 #[test]
612 fn test_parse_sequence() {
613 let cmd = parse_shell_command("cmd1 && cmd2 || cmd3").unwrap();
614 match cmd {
615 ParsedCommand::Sequence {
616 commands,
617 operators,
618 } => {
619 assert_eq!(commands.len(), 3);
620 assert_eq!(operators.len(), 2);
621 assert!(matches!(operators[0], TokenType::And));
622 assert!(matches!(operators[1], TokenType::Or));
623 }
624 _ => panic!("Expected Sequence"),
625 }
626 }
627
628 #[test]
629 fn test_needs_real_shell() {
630 assert!(needs_real_shell("echo $(date)"));
631 assert!(needs_real_shell("ls *.txt"));
632 assert!(needs_real_shell("echo ${HOME}"));
633 assert!(needs_real_shell("ls | grep foo"));
634 assert!(!needs_real_shell("echo hello"));
635 }
636
637 #[test]
638 fn test_parse_with_redirect() {
639 let cmd = parse_shell_command("echo hello > output.txt").unwrap();
640 match cmd {
641 ParsedCommand::Simple {
642 cmd,
643 args,
644 redirects,
645 } => {
646 assert_eq!(cmd, "echo");
647 assert_eq!(args.len(), 1);
648 assert_eq!(redirects.len(), 1);
649 assert!(matches!(redirects[0].redirect_type, TokenType::RedirectOut));
650 assert_eq!(redirects[0].target, "output.txt");
651 }
652 _ => panic!("Expected Simple command with redirect"),
653 }
654 }
655
656 #[test]
657 fn test_parse_subshell() {
658 let cmd = parse_shell_command("(echo hello) && echo world").unwrap();
659 match cmd {
660 ParsedCommand::Sequence { commands, .. } => {
661 assert_eq!(commands.len(), 2);
662 assert!(matches!(commands[0], ParsedCommand::Subshell { .. }));
663 }
664 _ => panic!("Expected Sequence with Subshell"),
665 }
666 }
667
668 #[test]
673 fn test_remove_shell_quotes_whole_word() {
674 assert_eq!(remove_shell_quotes("'help wanted'").0, "help wanted");
675 assert_eq!(remove_shell_quotes("\"help wanted\"").0, "help wanted");
676 }
677
678 #[test]
679 fn test_remove_shell_quotes_embedded() {
680 assert_eq!(
682 remove_shell_quotes("label:'help wanted'").0,
683 "label:help wanted"
684 );
685 assert_eq!(
686 remove_shell_quotes("label:\"help wanted\"").0,
687 "label:help wanted"
688 );
689 assert_eq!(
690 remove_shell_quotes("--label='help wanted'").0,
691 "--label=help wanted"
692 );
693 }
694
695 #[test]
696 fn test_remove_shell_quotes_concatenation() {
697 assert_eq!(remove_shell_quotes("a'b c'd").0, "ab cd");
698 assert_eq!(remove_shell_quotes("pre'post'").0, "prepost");
699 assert_eq!(remove_shell_quotes("'a''b'").0, "ab");
700 assert_eq!(remove_shell_quotes("a''b").0, "ab");
701 }
702
703 #[test]
704 fn test_remove_shell_quotes_escapes() {
705 assert_eq!(remove_shell_quotes("\"a\\\"b\"").0, "a\"b");
708 assert_eq!(remove_shell_quotes("\"a\\nb\"").0, "a\\nb");
709
710 #[cfg(not(windows))]
713 {
714 assert_eq!(remove_shell_quotes("'it'\\''s here'").0, "it's here");
716 assert_eq!(remove_shell_quotes("a\\ b").0, "a b");
718 }
719 }
720
721 #[cfg(windows)]
724 #[test]
725 fn test_remove_shell_quotes_windows_path() {
726 assert_eq!(
727 remove_shell_quotes("C:\\Users\\foo").0,
728 "C:\\Users\\foo".to_string()
729 );
730 assert_eq!(
732 remove_shell_quotes("\"C:\\Users\\foo\"").0,
733 "C:\\Users\\foo".to_string()
734 );
735 }
736
737 #[test]
738 fn test_remove_shell_quotes_flags() {
739 let (value, quoted, quote_char) = remove_shell_quotes("'x'");
740 assert_eq!(value, "x");
741 assert!(quoted);
742 assert_eq!(quote_char, Some('\''));
743
744 let (value, quoted, quote_char) = remove_shell_quotes("plain");
745 assert_eq!(value, "plain");
746 assert!(!quoted);
747 assert_eq!(quote_char, None);
748 }
749
750 #[test]
751 fn test_tokenize_terminates_on_lone_ampersand() {
752 let tokens = tokenize("git push origin HEAD 2>&1");
756 let words: Vec<String> = tokens
757 .into_iter()
758 .filter_map(|t| match t.token_type {
759 TokenType::Word(w) => Some(w),
760 _ => None,
761 })
762 .collect();
763 assert_eq!(words, vec!["git", "push", "origin", "HEAD", "2", "1"]);
764 }
765
766 #[test]
767 fn test_split_command_words_terminates_on_background() {
768 assert_eq!(
771 split_command_words("echo a & echo b"),
772 vec![
773 "echo".to_string(),
774 "a".to_string(),
775 "echo".to_string(),
776 "b".to_string()
777 ]
778 );
779 }
780
781 #[test]
782 fn test_split_command_words_quote_removal() {
783 assert_eq!(
784 split_command_words("echo label:'help wanted' is:open"),
785 vec![
786 "echo".to_string(),
787 "label:help wanted".to_string(),
788 "is:open".to_string()
789 ]
790 );
791 }
792
793 #[test]
794 fn test_parse_simple_command_embedded_quotes() {
795 let cmd = parse_shell_command("gh search issues label:'help wanted'").unwrap();
796 match cmd {
797 ParsedCommand::Simple { cmd, args, .. } => {
798 assert_eq!(cmd, "gh");
799 assert_eq!(args.last().unwrap().value, "label:help wanted");
800 assert!(args.last().unwrap().quoted);
801 assert_eq!(args.last().unwrap().raw, "label:'help wanted'");
803 }
804 _ => panic!("Expected Simple command"),
805 }
806 }
807}