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    // Check for features we don't handle yet
529    let unsupported = [
530        '`', // Command substitution
531        '$', // Command substitution and variable expansion
532        '~', // Home expansion (at start of word)
533        '*', // Glob patterns
534        '?', // Glob patterns
535        '[', // Glob patterns
536        '>', // Output redirection, in every form (>, >>, 2>, &>, >&)
537        '<', // Input redirection, in every form (<, <<, <<<)
538    ];
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); // 3 words + EOF
551        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); // 3 words + 2 operators + EOF
559        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); // 3 words + 1 pipe + EOF
567        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); // echo + quoted string + EOF
574        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    // ------------------------------------------------------------------------
664    // Quote removal (issue #48)
665    // ------------------------------------------------------------------------
666
667    #[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        // The shape from the issue: an interpolated label inside a search term.
676        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        // Inside double quotes, backslash only escapes a small set (this is the
701        // same on every platform).
702        assert_eq!(remove_shell_quotes("\"a\\\"b\"").0, "a\"b");
703        assert_eq!(remove_shell_quotes("\"a\\nb\"").0, "a\\nb");
704
705        // Unquoted backslash escaping is POSIX-only. On Windows the backslash is
706        // the path separator, so it stays literal (see the Windows path test).
707        #[cfg(not(windows))]
708        {
709            // POSIX single-quote idiom produced by quote() for a quoted value.
710            assert_eq!(remove_shell_quotes("'it'\\''s here'").0, "it's here");
711            // Backslash escapes a space outside quotes.
712            assert_eq!(remove_shell_quotes("a\\ b").0, "a b");
713        }
714    }
715
716    // On Windows an unquoted backslash must be preserved so that virtual
717    // commands like `cd C:\Users\foo` still receive a valid path.
718    #[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        // A quoted Windows path is likewise preserved.
726        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        // Regression: a lone `&` (fd duplication `2>&1`, or backgrounding) used
748        // to spin the tokenizer forever because it matched neither the `&&`
749        // operator nor advanced the word scanner. It must now terminate.
750        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        // `echo a & echo b` is not caught by needs_real_shell, so it can reach
764        // the tokenizer with a lone `&`; it must terminate rather than hang.
765        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                // `raw` keeps the original word for re-serialization.
797                assert_eq!(args.last().unwrap().raw, "label:'help wanted'");
798            }
799            _ => panic!("Expected Simple command"),
800        }
801    }
802}