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        '|', // Pipelines and boolean OR
537        '&', // Boolean AND and backgrounding
538        ';', // Command sequences
539        '(', // Subshells
540        ')', // Subshells
541        '>', // Output redirection, in every form (>, >>, 2>, &>, >&)
542        '<', // Input redirection, in every form (<, <<, <<<)
543    ];
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); // 3 words + EOF
556        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); // 3 words + 2 operators + EOF
564        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); // 3 words + 1 pipe + EOF
572        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); // echo + quoted string + EOF
579        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    // ------------------------------------------------------------------------
669    // Quote removal (issue #48)
670    // ------------------------------------------------------------------------
671
672    #[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        // The shape from the issue: an interpolated label inside a search term.
681        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        // Inside double quotes, backslash only escapes a small set (this is the
706        // same on every platform).
707        assert_eq!(remove_shell_quotes("\"a\\\"b\"").0, "a\"b");
708        assert_eq!(remove_shell_quotes("\"a\\nb\"").0, "a\\nb");
709
710        // Unquoted backslash escaping is POSIX-only. On Windows the backslash is
711        // the path separator, so it stays literal (see the Windows path test).
712        #[cfg(not(windows))]
713        {
714            // POSIX single-quote idiom produced by quote() for a quoted value.
715            assert_eq!(remove_shell_quotes("'it'\\''s here'").0, "it's here");
716            // Backslash escapes a space outside quotes.
717            assert_eq!(remove_shell_quotes("a\\ b").0, "a b");
718        }
719    }
720
721    // On Windows an unquoted backslash must be preserved so that virtual
722    // commands like `cd C:\Users\foo` still receive a valid path.
723    #[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        // A quoted Windows path is likewise preserved.
731        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        // Regression: a lone `&` (fd duplication `2>&1`, or backgrounding) used
753        // to spin the tokenizer forever because it matched neither the `&&`
754        // operator nor advanced the word scanner. It must now terminate.
755        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        // `echo a & echo b` is not caught by needs_real_shell, so it can reach
769        // the tokenizer with a lone `&`; it must terminate rather than hang.
770        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                // `raw` keeps the original word for re-serialization.
802                assert_eq!(args.last().unwrap().raw, "label:'help wanted'");
803            }
804            _ => panic!("Expected Simple command"),
805        }
806    }
807}