modcli/
parser.rs

1/// Parses a list of pre-split arguments into `(command, args)`.
2/// Provided for compatibility when arguments are already split by the caller.
3pub fn parse_args(args: &[String]) -> (String, Vec<String>) {
4    if args.is_empty() {
5        return (String::new(), Vec::new());
6    }
7    let cmd = args[0].clone();
8    let rest = args[1..].to_vec();
9    (cmd, rest)
10}
11
12/// Parse a single command line into `(command, args)` with shell-like rules:
13/// - Whitespace separates tokens
14/// - Double quotes ("...") and single quotes ('...') preserve whitespace within
15/// - Supports escaping of quotes and spaces with backslash (e.g., \" or \ )
16/// - Mixed quoting is supported; escapes are processed within quoted segments
17/// - Empty or whitespace-only input returns ("", vec![])
18///
19/// # Examples
20///
21/// Basic splitting:
22/// ```
23/// use modcli::parser::parse_line;
24/// let (cmd, args) = parse_line("hello world there");
25/// assert_eq!(cmd, "hello");
26/// assert_eq!(args, vec!["world", "there"]);
27/// ```
28///
29/// Quoted segments preserved:
30/// ```
31/// use modcli::parser::parse_line;
32/// let (cmd, args) = parse_line("say \"hello world\" 'and universe'");
33/// assert_eq!(cmd, "say");
34/// assert_eq!(args, vec!["hello world", "and universe"]);
35/// ```
36///
37/// Escaped spaces and quotes:
38/// ```
39/// use modcli::parser::parse_line;
40/// let (cmd, args) = parse_line("run path\\ with\\ spaces \"quote\"");
41/// assert_eq!(cmd, "run");
42/// assert_eq!(args, vec!["path with spaces", "quote"]);
43/// ```
44pub fn parse_line(input: &str) -> (String, Vec<String>) {
45    let tokens = tokenize(input);
46    parse_args_slice(&tokens)
47}
48
49#[inline(always)]
50fn parse_args_slice(tokens: &[String]) -> (String, Vec<String>) {
51    if tokens.is_empty() {
52        return (String::new(), Vec::new());
53    }
54    (tokens[0].clone(), tokens[1..].to_vec())
55}
56
57fn tokenize(input: &str) -> Vec<String> {
58    let mut tokens = Vec::with_capacity(8);
59    let mut cur = String::new();
60    let mut chars = input.chars().peekable();
61    let mut in_single = false;
62    let mut in_double = false;
63
64    while let Some(ch) = chars.next() {
65        match ch {
66            '\\' => {
67                // Backslash handling differs by context
68                if in_single {
69                    // Inside single quotes, treat backslash as escaping the next char (including quotes)
70                    if let Some(&next) = chars.peek() {
71                        cur.push(next);
72                        chars.next();
73                    } else {
74                        cur.push('\\');
75                    }
76                } else if in_double {
77                    if let Some(&next) = chars.peek() {
78                        if next == '"' {
79                            // escape double quote inside double quotes
80                            cur.push('"');
81                            chars.next();
82                        } else {
83                            // keep literal backslash, unless escaping whitespace or backslash
84                            if next.is_whitespace() {
85                                cur.push(next);
86                                chars.next();
87                            } else if next == '\\' {
88                                cur.push('\\');
89                                chars.next();
90                            } else {
91                                cur.push('\\');
92                            }
93                        }
94                    } else {
95                        cur.push('\\');
96                    }
97                } else {
98                    // outside quotes: support escapes
99                    if let Some(&next) = chars.peek() {
100                        if next.is_whitespace() {
101                            // escaped space becomes literal space in current token
102                            cur.push(next);
103                            chars.next();
104                        } else if next == '\\' {
105                            // escaped backslash becomes single backslash
106                            cur.push('\\');
107                            chars.next();
108                        } else if next == '"' {
109                            // treat \" outside as starting a double-quoted segment (do not include quote)
110                            in_double = true;
111                            chars.next();
112                        } else if next == '\'' {
113                            // treat \' outside as starting a single-quoted segment (do not include quote)
114                            in_single = true;
115                            chars.next();
116                        } else {
117                            // keep backslash literally for other cases
118                            cur.push('\\');
119                        }
120                    } else {
121                        cur.push('\\');
122                    }
123                }
124            }
125            '"' if !in_single => {
126                if in_double {
127                    // inside double quotes: allow doubled quotes as literal
128                    if let Some('"') = chars.peek().copied() {
129                        cur.push('"');
130                        chars.next();
131                    } else {
132                        // closing
133                        in_double = false;
134                        // if empty quoted segment and next is whitespace/end, push empty arg
135                        if cur.is_empty() {
136                            match chars.peek().copied() {
137                                Some(c) if c.is_whitespace() => tokens.push(String::new()),
138                                None => tokens.push(String::new()),
139                                _ => {}
140                            }
141                        }
142                    }
143                } else {
144                    // If inside a running token, treat a double-quote as literal
145                    if !cur.is_empty() {
146                        cur.push('"');
147                    } else {
148                        // opening quoted segment
149                        in_double = true;
150                    }
151                }
152            }
153            '\'' if !in_double => {
154                if in_single {
155                    in_single = false;
156                    if cur.is_empty() {
157                        match chars.peek().copied() {
158                            Some(c) if c.is_whitespace() => tokens.push(String::new()),
159                            None => tokens.push(String::new()),
160                            _ => {}
161                        }
162                    }
163                } else {
164                    in_single = true;
165                }
166            }
167            c if c.is_whitespace() && !in_single && !in_double => {
168                if !cur.is_empty() {
169                    tokens.push(std::mem::take(&mut cur));
170                }
171            }
172            c => cur.push(c),
173        }
174    }
175
176    if !cur.is_empty() {
177        tokens.push(cur);
178    }
179
180    tokens
181}