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 '`', '$', '~', '*', '?', '[', '>', '<', ];
539
540 command.chars().any(|c| unsupported.contains(&c))
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn test_tokenize_simple_command() {
549 let tokens = tokenize("echo hello world");
550 assert_eq!(tokens.len(), 4); assert!(matches!(tokens[0].token_type, TokenType::Word(_)));
552 assert!(matches!(tokens[3].token_type, TokenType::Eof));
553 }
554
555 #[test]
556 fn test_tokenize_with_operators() {
557 let tokens = tokenize("cmd1 && cmd2 || cmd3");
558 assert_eq!(tokens.len(), 6); assert!(matches!(tokens[1].token_type, TokenType::And));
560 assert!(matches!(tokens[3].token_type, TokenType::Or));
561 }
562
563 #[test]
564 fn test_tokenize_with_pipe() {
565 let tokens = tokenize("ls | grep foo");
566 assert_eq!(tokens.len(), 5); assert!(matches!(tokens[1].token_type, TokenType::Pipe));
568 }
569
570 #[test]
571 fn test_tokenize_with_quotes() {
572 let tokens = tokenize("echo 'hello world'");
573 assert_eq!(tokens.len(), 3); if let TokenType::Word(w) = &tokens[1].token_type {
575 assert_eq!(w, "'hello world'");
576 } else {
577 panic!("Expected Word token");
578 }
579 }
580
581 #[test]
582 fn test_parse_simple_command() {
583 let cmd = parse_shell_command("echo hello world").unwrap();
584 match cmd {
585 ParsedCommand::Simple { cmd, args, .. } => {
586 assert_eq!(cmd, "echo");
587 assert_eq!(args.len(), 2);
588 assert_eq!(args[0].value, "hello");
589 assert_eq!(args[1].value, "world");
590 }
591 _ => panic!("Expected Simple command"),
592 }
593 }
594
595 #[test]
596 fn test_parse_pipeline() {
597 let cmd = parse_shell_command("ls | grep foo | wc -l").unwrap();
598 match cmd {
599 ParsedCommand::Pipeline { commands } => {
600 assert_eq!(commands.len(), 3);
601 }
602 _ => panic!("Expected Pipeline"),
603 }
604 }
605
606 #[test]
607 fn test_parse_sequence() {
608 let cmd = parse_shell_command("cmd1 && cmd2 || cmd3").unwrap();
609 match cmd {
610 ParsedCommand::Sequence {
611 commands,
612 operators,
613 } => {
614 assert_eq!(commands.len(), 3);
615 assert_eq!(operators.len(), 2);
616 assert!(matches!(operators[0], TokenType::And));
617 assert!(matches!(operators[1], TokenType::Or));
618 }
619 _ => panic!("Expected Sequence"),
620 }
621 }
622
623 #[test]
624 fn test_needs_real_shell() {
625 assert!(needs_real_shell("echo $(date)"));
626 assert!(needs_real_shell("ls *.txt"));
627 assert!(needs_real_shell("echo ${HOME}"));
628 assert!(!needs_real_shell("echo hello"));
629 assert!(!needs_real_shell("ls | grep foo"));
630 }
631
632 #[test]
633 fn test_parse_with_redirect() {
634 let cmd = parse_shell_command("echo hello > output.txt").unwrap();
635 match cmd {
636 ParsedCommand::Simple {
637 cmd,
638 args,
639 redirects,
640 } => {
641 assert_eq!(cmd, "echo");
642 assert_eq!(args.len(), 1);
643 assert_eq!(redirects.len(), 1);
644 assert!(matches!(redirects[0].redirect_type, TokenType::RedirectOut));
645 assert_eq!(redirects[0].target, "output.txt");
646 }
647 _ => panic!("Expected Simple command with redirect"),
648 }
649 }
650
651 #[test]
652 fn test_parse_subshell() {
653 let cmd = parse_shell_command("(echo hello) && echo world").unwrap();
654 match cmd {
655 ParsedCommand::Sequence { commands, .. } => {
656 assert_eq!(commands.len(), 2);
657 assert!(matches!(commands[0], ParsedCommand::Subshell { .. }));
658 }
659 _ => panic!("Expected Sequence with Subshell"),
660 }
661 }
662
663 #[test]
668 fn test_remove_shell_quotes_whole_word() {
669 assert_eq!(remove_shell_quotes("'help wanted'").0, "help wanted");
670 assert_eq!(remove_shell_quotes("\"help wanted\"").0, "help wanted");
671 }
672
673 #[test]
674 fn test_remove_shell_quotes_embedded() {
675 assert_eq!(
677 remove_shell_quotes("label:'help wanted'").0,
678 "label:help wanted"
679 );
680 assert_eq!(
681 remove_shell_quotes("label:\"help wanted\"").0,
682 "label:help wanted"
683 );
684 assert_eq!(
685 remove_shell_quotes("--label='help wanted'").0,
686 "--label=help wanted"
687 );
688 }
689
690 #[test]
691 fn test_remove_shell_quotes_concatenation() {
692 assert_eq!(remove_shell_quotes("a'b c'd").0, "ab cd");
693 assert_eq!(remove_shell_quotes("pre'post'").0, "prepost");
694 assert_eq!(remove_shell_quotes("'a''b'").0, "ab");
695 assert_eq!(remove_shell_quotes("a''b").0, "ab");
696 }
697
698 #[test]
699 fn test_remove_shell_quotes_escapes() {
700 assert_eq!(remove_shell_quotes("\"a\\\"b\"").0, "a\"b");
703 assert_eq!(remove_shell_quotes("\"a\\nb\"").0, "a\\nb");
704
705 #[cfg(not(windows))]
708 {
709 assert_eq!(remove_shell_quotes("'it'\\''s here'").0, "it's here");
711 assert_eq!(remove_shell_quotes("a\\ b").0, "a b");
713 }
714 }
715
716 #[cfg(windows)]
719 #[test]
720 fn test_remove_shell_quotes_windows_path() {
721 assert_eq!(
722 remove_shell_quotes("C:\\Users\\foo").0,
723 "C:\\Users\\foo".to_string()
724 );
725 assert_eq!(
727 remove_shell_quotes("\"C:\\Users\\foo\"").0,
728 "C:\\Users\\foo".to_string()
729 );
730 }
731
732 #[test]
733 fn test_remove_shell_quotes_flags() {
734 let (value, quoted, quote_char) = remove_shell_quotes("'x'");
735 assert_eq!(value, "x");
736 assert!(quoted);
737 assert_eq!(quote_char, Some('\''));
738
739 let (value, quoted, quote_char) = remove_shell_quotes("plain");
740 assert_eq!(value, "plain");
741 assert!(!quoted);
742 assert_eq!(quote_char, None);
743 }
744
745 #[test]
746 fn test_tokenize_terminates_on_lone_ampersand() {
747 let tokens = tokenize("git push origin HEAD 2>&1");
751 let words: Vec<String> = tokens
752 .into_iter()
753 .filter_map(|t| match t.token_type {
754 TokenType::Word(w) => Some(w),
755 _ => None,
756 })
757 .collect();
758 assert_eq!(words, vec!["git", "push", "origin", "HEAD", "2", "1"]);
759 }
760
761 #[test]
762 fn test_split_command_words_terminates_on_background() {
763 assert_eq!(
766 split_command_words("echo a & echo b"),
767 vec![
768 "echo".to_string(),
769 "a".to_string(),
770 "echo".to_string(),
771 "b".to_string()
772 ]
773 );
774 }
775
776 #[test]
777 fn test_split_command_words_quote_removal() {
778 assert_eq!(
779 split_command_words("echo label:'help wanted' is:open"),
780 vec![
781 "echo".to_string(),
782 "label:help wanted".to_string(),
783 "is:open".to_string()
784 ]
785 );
786 }
787
788 #[test]
789 fn test_parse_simple_command_embedded_quotes() {
790 let cmd = parse_shell_command("gh search issues label:'help wanted'").unwrap();
791 match cmd {
792 ParsedCommand::Simple { cmd, args, .. } => {
793 assert_eq!(cmd, "gh");
794 assert_eq!(args.last().unwrap().value, "label:help wanted");
795 assert!(args.last().unwrap().quoted);
796 assert_eq!(args.last().unwrap().raw, "label:'help wanted'");
798 }
799 _ => panic!("Expected Simple command"),
800 }
801 }
802}