Skip to main content

command_stream/
shell_parser.rs

1//! Enhanced shell command parser that handles &&, ||, ;, and () operators
2//! This allows virtual commands to work properly with shell operators
3
4use std::fmt;
5
6/// Token types for the parser
7#[derive(Debug, Clone, PartialEq)]
8pub enum TokenType {
9    Word(String),
10    And,            // &&
11    Or,             // ||
12    Semicolon,      // ;
13    Pipe,           // |
14    LParen,         // (
15    RParen,         // )
16    RedirectOut,    // >
17    RedirectAppend, // >>
18    RedirectIn,     // <
19    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/// A token with its type and original value
41#[derive(Debug, Clone)]
42pub struct Token {
43    pub token_type: TokenType,
44    pub value: String,
45}
46
47/// Redirect information
48#[derive(Debug, Clone)]
49pub struct Redirect {
50    pub redirect_type: TokenType,
51    pub target: String,
52}
53
54/// Parsed argument with quote information
55#[derive(Debug, Clone)]
56pub struct ParsedArg {
57    pub value: String,
58    pub quoted: bool,
59    pub quote_char: Option<char>,
60    /// The original word exactly as written, including any quote characters, so
61    /// callers that re-serialize the command back to a real shell round-trip it
62    /// without having to re-quote from `value`.
63    pub raw: String,
64}
65
66/// Perform POSIX quote removal on a single already-tokenized word.
67///
68/// A shell word may carry quotes anywhere inside it, not just wrapped around
69/// the whole thing: `label:'help wanted'`, `--flag="a b"` and `a'b c'd` are all
70/// one word each. The shell strips the quote characters and concatenates the
71/// quoted and unquoted pieces into a single argument. Our tokenizer keeps the
72/// quotes in the word (so it can split correctly and hand a valid command back
73/// to a real shell when needed); this function turns that raw word into the
74/// literal value a built-in command should receive, exactly as `/bin/sh` would
75/// (issue #48).
76///
77/// Rules mirrored from POSIX:
78///   - Outside quotes, a backslash escapes the next character (it becomes
79///     literal and loses any quoting role). On Windows the backslash is the
80///     path separator, so an unquoted backslash is kept literal there — eating
81///     it would corrupt paths such as `cd C:\Users\foo`.
82///   - Inside `'...'`, every character is literal, including backslash.
83///   - Inside `"..."`, a backslash only escapes `$`, `` ` ``, `"`, `\` and
84///     newline; before anything else it stays a literal backslash.
85///
86/// Returns the quote-removed value, whether any quoting/escaping was applied,
87/// and the first quote character seen.
88pub 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; // skip the closing quote (if any)
109            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; // skip the closing quote (if any)
131            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
148/// Split a simple command string into its words, respecting quotes and applying
149/// POSIX quote removal to each word. Operators are ignored, so this is meant for
150/// simple commands (the virtual-command dispatch path). Mirrors the JS parser's
151/// per-argument quote removal so `echo label:'help wanted'` yields
152/// `["echo", "label:help wanted"]` rather than splitting inside the quotes.
153pub 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/// Types of parsed commands
164#[derive(Debug, Clone)]
165pub enum ParsedCommand {
166    /// A simple command with command name, arguments, and optional redirects
167    Simple {
168        cmd: String,
169        args: Vec<ParsedArg>,
170        redirects: Vec<Redirect>,
171    },
172    /// A sequence of commands connected by &&, ||, or ;
173    Sequence {
174        commands: Vec<ParsedCommand>,
175        operators: Vec<TokenType>,
176    },
177    /// A pipeline of commands connected by |
178    Pipeline { commands: Vec<ParsedCommand> },
179    /// A subshell (commands in parentheses)
180    Subshell { command: Box<ParsedCommand> },
181}
182
183/// Tokenize a shell command string
184pub 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        // Skip whitespace
191        while i < chars.len() && chars[i].is_whitespace() {
192            i += 1;
193        }
194
195        if i >= chars.len() {
196            break;
197        }
198
199        // Check for operators
200        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            // A lone `&` (backgrounding, or the fd-duplication form in `2>&1`)
208            // is not modeled by this parser. Consume it so the tokenizer always
209            // makes progress: the word branch below lists `&` in its stop set,
210            // so without this arm it would break without advancing `i` and spin
211            // forever. Commands that truly rely on `&`/redirection are routed to
212            // a real shell (needs_real_shell) before we tokenize for virtual
213            // command dispatch, so dropping the token here is safe.
214            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            // Parse word (respecting quotes)
265            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                        // Handle escape sequences
282                        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                            // Handle escaped quotes and backslashes inside quotes
302                            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
336/// Shell command parser
337pub struct ShellParser {
338    tokens: Vec<Token>,
339    pos: usize,
340}
341
342impl ShellParser {
343    /// Create a new parser for the given command
344    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    /// Parse the main command sequence
365    pub fn parse(&mut self) -> Option<ParsedCommand> {
366        self.parse_sequence()
367    }
368
369    /// Parse a sequence of commands connected by &&, ||, ;
370    fn parse_sequence(&mut self) -> Option<ParsedCommand> {
371        let mut commands = Vec::new();
372        let mut operators = Vec::new();
373
374        // Parse first command
375        if let Some(cmd) = self.parse_pipeline() {
376            commands.push(cmd);
377        }
378
379        // Parse additional commands with operators
380        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    /// Parse a pipeline (commands connected by |)
410    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    /// Parse a single command or subshell
436    fn parse_command(&mut self) -> Option<ParsedCommand> {
437        // Check for subshell
438        if matches!(self.current().token_type, TokenType::LParen) {
439            self.consume(); // consume (
440            let subshell = self.parse_sequence();
441
442            if matches!(self.current().token_type, TokenType::RParen) {
443                self.consume(); // consume )
444            }
445
446            return subshell.map(|cmd| ParsedCommand::Subshell {
447                command: Box::new(cmd),
448            });
449        }
450
451        // Parse simple command
452        self.parse_simple_command()
453    }
454
455    /// Parse a simple command (command + args + redirections)
456    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                // POSIX quote removal: strip quotes wherever they appear in the
490                // word and concatenate the pieces, so `label:'help wanted'`
491                // becomes one argument `label:help wanted` (issue #48). `raw`
492                // keeps the original text for paths that re-serialize the
493                // command back to a real shell.
494                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
512/// Parse a shell command with support for &&, ||, ;, and ()
513pub fn parse_shell_command(command: &str) -> Option<ParsedCommand> {
514    let mut parser = ShellParser::new(command);
515    parser.parse()
516}
517
518/// Check if a command needs shell features we don't handle.
519///
520/// Redirection counts as such a feature. Virtual commands receive a plain
521/// argument list built by splitting on whitespace, so a redirection left in
522/// that list is passed through as a literal argument: `echo hello > out.txt`
523/// would print `hello > out.txt` and write no file, and `git push ... 2>&1`
524/// would report success while nothing was pushed. Handing the whole command to
525/// the system shell is the only way to get exactly the POSIX result
526/// (issue #46).
527pub fn needs_real_shell(command: &str) -> bool {
528    // `$fy` is a registered literal command name; expansion in its arguments
529    // still requires the real shell.
530    let trimmed = command.trim_start();
531    let command = trimmed
532        .strip_prefix("$fy")
533        .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
534        .unwrap_or(command);
535    // Check for features we don't handle yet
536    let unsupported = [
537        '`', // Command substitution
538        '$', // Command substitution and variable expansion
539        '~', // Home expansion (at start of word)
540        '*', // Glob patterns
541        '?', // Glob patterns
542        '[', // Glob patterns
543        '|', // Pipelines and boolean OR
544        '&', // Boolean AND and backgrounding
545        ';', // Command sequences
546        '(', // Subshells
547        ')', // Subshells
548        '>', // Output redirection, in every form (>, >>, 2>, &>, >&)
549        '<', // Input redirection, in every form (<, <<, <<<)
550    ];
551
552    command.chars().any(|c| unsupported.contains(&c))
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn test_tokenize_simple_command() {
561        let tokens = tokenize("echo hello world");
562        assert_eq!(tokens.len(), 4); // 3 words + EOF
563        assert!(matches!(tokens[0].token_type, TokenType::Word(_)));
564        assert!(matches!(tokens[3].token_type, TokenType::Eof));
565    }
566
567    #[test]
568    fn test_tokenize_with_operators() {
569        let tokens = tokenize("cmd1 && cmd2 || cmd3");
570        assert_eq!(tokens.len(), 6); // 3 words + 2 operators + EOF
571        assert!(matches!(tokens[1].token_type, TokenType::And));
572        assert!(matches!(tokens[3].token_type, TokenType::Or));
573    }
574
575    #[test]
576    fn test_tokenize_with_pipe() {
577        let tokens = tokenize("ls | grep foo");
578        assert_eq!(tokens.len(), 5); // 3 words + 1 pipe + EOF
579        assert!(matches!(tokens[1].token_type, TokenType::Pipe));
580    }
581
582    #[test]
583    fn test_tokenize_with_quotes() {
584        let tokens = tokenize("echo 'hello world'");
585        assert_eq!(tokens.len(), 3); // echo + quoted string + EOF
586        if let TokenType::Word(w) = &tokens[1].token_type {
587            assert_eq!(w, "'hello world'");
588        } else {
589            panic!("Expected Word token");
590        }
591    }
592
593    #[test]
594    fn test_parse_simple_command() {
595        let cmd = parse_shell_command("echo hello world").unwrap();
596        match cmd {
597            ParsedCommand::Simple { cmd, args, .. } => {
598                assert_eq!(cmd, "echo");
599                assert_eq!(args.len(), 2);
600                assert_eq!(args[0].value, "hello");
601                assert_eq!(args[1].value, "world");
602            }
603            _ => panic!("Expected Simple command"),
604        }
605    }
606
607    #[test]
608    fn test_parse_pipeline() {
609        let cmd = parse_shell_command("ls | grep foo | wc -l").unwrap();
610        match cmd {
611            ParsedCommand::Pipeline { commands } => {
612                assert_eq!(commands.len(), 3);
613            }
614            _ => panic!("Expected Pipeline"),
615        }
616    }
617
618    #[test]
619    fn test_parse_sequence() {
620        let cmd = parse_shell_command("cmd1 && cmd2 || cmd3").unwrap();
621        match cmd {
622            ParsedCommand::Sequence {
623                commands,
624                operators,
625            } => {
626                assert_eq!(commands.len(), 3);
627                assert_eq!(operators.len(), 2);
628                assert!(matches!(operators[0], TokenType::And));
629                assert!(matches!(operators[1], TokenType::Or));
630            }
631            _ => panic!("Expected Sequence"),
632        }
633    }
634
635    #[test]
636    fn test_needs_real_shell() {
637        assert!(needs_real_shell("echo $(date)"));
638        assert!(needs_real_shell("ls *.txt"));
639        assert!(needs_real_shell("echo ${HOME}"));
640        assert!(needs_real_shell("ls | grep foo"));
641        assert!(!needs_real_shell("echo hello"));
642    }
643
644    #[test]
645    fn test_parse_with_redirect() {
646        let cmd = parse_shell_command("echo hello > output.txt").unwrap();
647        match cmd {
648            ParsedCommand::Simple {
649                cmd,
650                args,
651                redirects,
652            } => {
653                assert_eq!(cmd, "echo");
654                assert_eq!(args.len(), 1);
655                assert_eq!(redirects.len(), 1);
656                assert!(matches!(redirects[0].redirect_type, TokenType::RedirectOut));
657                assert_eq!(redirects[0].target, "output.txt");
658            }
659            _ => panic!("Expected Simple command with redirect"),
660        }
661    }
662
663    #[test]
664    fn test_parse_subshell() {
665        let cmd = parse_shell_command("(echo hello) && echo world").unwrap();
666        match cmd {
667            ParsedCommand::Sequence { commands, .. } => {
668                assert_eq!(commands.len(), 2);
669                assert!(matches!(commands[0], ParsedCommand::Subshell { .. }));
670            }
671            _ => panic!("Expected Sequence with Subshell"),
672        }
673    }
674
675    // ------------------------------------------------------------------------
676    // Quote removal (issue #48)
677    // ------------------------------------------------------------------------
678
679    #[test]
680    fn test_remove_shell_quotes_whole_word() {
681        assert_eq!(remove_shell_quotes("'help wanted'").0, "help wanted");
682        assert_eq!(remove_shell_quotes("\"help wanted\"").0, "help wanted");
683    }
684
685    #[test]
686    fn test_remove_shell_quotes_embedded() {
687        // The shape from the issue: an interpolated label inside a search term.
688        assert_eq!(
689            remove_shell_quotes("label:'help wanted'").0,
690            "label:help wanted"
691        );
692        assert_eq!(
693            remove_shell_quotes("label:\"help wanted\"").0,
694            "label:help wanted"
695        );
696        assert_eq!(
697            remove_shell_quotes("--label='help wanted'").0,
698            "--label=help wanted"
699        );
700    }
701
702    #[test]
703    fn test_remove_shell_quotes_concatenation() {
704        assert_eq!(remove_shell_quotes("a'b c'd").0, "ab cd");
705        assert_eq!(remove_shell_quotes("pre'post'").0, "prepost");
706        assert_eq!(remove_shell_quotes("'a''b'").0, "ab");
707        assert_eq!(remove_shell_quotes("a''b").0, "ab");
708    }
709
710    #[test]
711    fn test_remove_shell_quotes_escapes() {
712        // Inside double quotes, backslash only escapes a small set (this is the
713        // same on every platform).
714        assert_eq!(remove_shell_quotes("\"a\\\"b\"").0, "a\"b");
715        assert_eq!(remove_shell_quotes("\"a\\nb\"").0, "a\\nb");
716
717        // Unquoted backslash escaping is POSIX-only. On Windows the backslash is
718        // the path separator, so it stays literal (see the Windows path test).
719        #[cfg(not(windows))]
720        {
721            // POSIX single-quote idiom produced by quote() for a quoted value.
722            assert_eq!(remove_shell_quotes("'it'\\''s here'").0, "it's here");
723            // Backslash escapes a space outside quotes.
724            assert_eq!(remove_shell_quotes("a\\ b").0, "a b");
725        }
726    }
727
728    // On Windows an unquoted backslash must be preserved so that virtual
729    // commands like `cd C:\Users\foo` still receive a valid path.
730    #[cfg(windows)]
731    #[test]
732    fn test_remove_shell_quotes_windows_path() {
733        assert_eq!(
734            remove_shell_quotes("C:\\Users\\foo").0,
735            "C:\\Users\\foo".to_string()
736        );
737        // A quoted Windows path is likewise preserved.
738        assert_eq!(
739            remove_shell_quotes("\"C:\\Users\\foo\"").0,
740            "C:\\Users\\foo".to_string()
741        );
742    }
743
744    #[test]
745    fn test_remove_shell_quotes_flags() {
746        let (value, quoted, quote_char) = remove_shell_quotes("'x'");
747        assert_eq!(value, "x");
748        assert!(quoted);
749        assert_eq!(quote_char, Some('\''));
750
751        let (value, quoted, quote_char) = remove_shell_quotes("plain");
752        assert_eq!(value, "plain");
753        assert!(!quoted);
754        assert_eq!(quote_char, None);
755    }
756
757    #[test]
758    fn test_tokenize_terminates_on_lone_ampersand() {
759        // Regression: a lone `&` (fd duplication `2>&1`, or backgrounding) used
760        // to spin the tokenizer forever because it matched neither the `&&`
761        // operator nor advanced the word scanner. It must now terminate.
762        let tokens = tokenize("git push origin HEAD 2>&1");
763        let words: Vec<String> = tokens
764            .into_iter()
765            .filter_map(|t| match t.token_type {
766                TokenType::Word(w) => Some(w),
767                _ => None,
768            })
769            .collect();
770        assert_eq!(words, vec!["git", "push", "origin", "HEAD", "2", "1"]);
771    }
772
773    #[test]
774    fn test_split_command_words_terminates_on_background() {
775        // `echo a & echo b` is not caught by needs_real_shell, so it can reach
776        // the tokenizer with a lone `&`; it must terminate rather than hang.
777        assert_eq!(
778            split_command_words("echo a & echo b"),
779            vec![
780                "echo".to_string(),
781                "a".to_string(),
782                "echo".to_string(),
783                "b".to_string()
784            ]
785        );
786    }
787
788    #[test]
789    fn test_split_command_words_quote_removal() {
790        assert_eq!(
791            split_command_words("echo label:'help wanted' is:open"),
792            vec![
793                "echo".to_string(),
794                "label:help wanted".to_string(),
795                "is:open".to_string()
796            ]
797        );
798    }
799
800    #[test]
801    fn test_parse_simple_command_embedded_quotes() {
802        let cmd = parse_shell_command("gh search issues label:'help wanted'").unwrap();
803        match cmd {
804            ParsedCommand::Simple { cmd, args, .. } => {
805                assert_eq!(cmd, "gh");
806                assert_eq!(args.last().unwrap().value, "label:help wanted");
807                assert!(args.last().unwrap().quoted);
808                // `raw` keeps the original word for re-serialization.
809                assert_eq!(args.last().unwrap().raw, "label:'help wanted'");
810            }
811            _ => panic!("Expected Simple command"),
812        }
813    }
814}