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}