lean_ctx/
compound_lexer.rs1#[derive(Debug, Clone, PartialEq)]
12pub enum Segment {
13 Command(String),
14 Operator(String),
15}
16
17pub fn split_compound(input: &str) -> Vec<Segment> {
18 let input = input.trim();
19 if input.is_empty() {
20 return vec![];
21 }
22
23 if contains_heredoc(input) {
24 return vec![Segment::Command(input.to_string())];
25 }
26
27 let chars: Vec<char> = input.chars().collect();
28 let mut segments: Vec<Segment> = Vec::new();
29 let mut current = String::new();
30 let mut i = 0;
31 let len = chars.len();
32
33 while i < len {
34 let ch = chars[i];
35
36 match ch {
37 '\'' => {
38 current.push(ch);
39 i += 1;
40 while i < len && chars[i] != '\'' {
41 current.push(chars[i]);
42 i += 1;
43 }
44 if i < len {
45 current.push('\'');
46 i += 1;
47 }
48 }
49 '"' => {
50 current.push(ch);
51 i += 1;
52 while i < len && chars[i] != '"' {
53 if chars[i] == '\\' && i + 1 < len {
54 current.push('\\');
55 current.push(chars[i + 1]);
56 i += 2;
57 continue;
58 }
59 current.push(chars[i]);
60 i += 1;
61 }
62 if i < len {
63 current.push('"');
64 i += 1;
65 }
66 }
67 '`' => {
68 current.push(ch);
69 i += 1;
70 while i < len && chars[i] != '`' {
71 current.push(chars[i]);
72 i += 1;
73 }
74 if i < len {
75 current.push('`');
76 i += 1;
77 }
78 }
79 '$' if i + 1 < len && chars[i + 1] == '(' => {
80 current.push('$');
81 current.push('(');
82 i += 2;
83 let mut depth = 1;
84 while i < len && depth > 0 {
85 if chars[i] == '(' {
86 depth += 1;
87 } else if chars[i] == ')' {
88 depth -= 1;
89 }
90 current.push(chars[i]);
91 i += 1;
92 }
93 }
94 '\\' if i + 1 < len => {
95 current.push('\\');
96 current.push(chars[i + 1]);
97 i += 2;
98 }
99 '&' if i + 1 < len && chars[i + 1] == '&' => {
100 push_command(&mut segments, ¤t);
101 current.clear();
102 segments.push(Segment::Operator("&&".to_string()));
103 i += 2;
104 }
105 '|' if i + 1 < len && chars[i + 1] == '|' => {
106 push_command(&mut segments, ¤t);
107 current.clear();
108 segments.push(Segment::Operator("||".to_string()));
109 i += 2;
110 }
111 '|' => {
112 push_command(&mut segments, ¤t);
113 current.clear();
114 segments.push(Segment::Operator("|".to_string()));
115 let rest: String = chars[i + 1..].iter().collect::<String>();
116 let rest = rest.trim().to_string();
117 if !rest.is_empty() {
118 segments.push(Segment::Command(rest));
119 }
120 return segments;
121 }
122 ';' => {
123 push_command(&mut segments, ¤t);
124 current.clear();
125 segments.push(Segment::Operator(";".to_string()));
126 i += 1;
127 }
128 _ => {
129 current.push(ch);
130 i += 1;
131 }
132 }
133 }
134
135 push_command(&mut segments, ¤t);
136 segments
137}
138
139fn push_command(segments: &mut Vec<Segment>, cmd: &str) {
140 let trimmed = cmd.trim();
141 if !trimmed.is_empty() {
142 segments.push(Segment::Command(trimmed.to_string()));
143 }
144}
145
146fn contains_heredoc(input: &str) -> bool {
147 input.contains("<<") || input.contains("$((")
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn simple_command() {
156 let segs = split_compound("git status");
157 assert_eq!(segs, vec![Segment::Command("git status".into())]);
158 }
159
160 #[test]
161 fn and_chain() {
162 let segs = split_compound("cd src && git status && echo done");
163 assert_eq!(
164 segs,
165 vec![
166 Segment::Command("cd src".into()),
167 Segment::Operator("&&".into()),
168 Segment::Command("git status".into()),
169 Segment::Operator("&&".into()),
170 Segment::Command("echo done".into()),
171 ]
172 );
173 }
174
175 #[test]
176 fn pipe_stops_at_right() {
177 let segs = split_compound("git log --oneline | grep fix");
178 assert_eq!(
179 segs,
180 vec![
181 Segment::Command("git log --oneline".into()),
182 Segment::Operator("|".into()),
183 Segment::Command("grep fix".into()),
184 ]
185 );
186 }
187
188 #[test]
189 fn pipe_in_chain() {
190 let segs = split_compound("cd src && git log | head -5");
191 assert_eq!(
192 segs,
193 vec![
194 Segment::Command("cd src".into()),
195 Segment::Operator("&&".into()),
196 Segment::Command("git log".into()),
197 Segment::Operator("|".into()),
198 Segment::Command("head -5".into()),
199 ]
200 );
201 }
202
203 #[test]
204 fn semicolons() {
205 let segs = split_compound("git add .; git commit -m 'fix'");
206 assert_eq!(
207 segs,
208 vec![
209 Segment::Command("git add .".into()),
210 Segment::Operator(";".into()),
211 Segment::Command("git commit -m 'fix'".into()),
212 ]
213 );
214 }
215
216 #[test]
217 fn or_chain() {
218 let segs = split_compound("git pull || echo failed");
219 assert_eq!(
220 segs,
221 vec![
222 Segment::Command("git pull".into()),
223 Segment::Operator("||".into()),
224 Segment::Command("echo failed".into()),
225 ]
226 );
227 }
228
229 #[test]
230 fn quoted_ampersand_not_split() {
231 let segs = split_compound("echo 'foo && bar'");
232 assert_eq!(segs, vec![Segment::Command("echo 'foo && bar'".into())]);
233 }
234
235 #[test]
236 fn double_quoted_pipe_not_split() {
237 let segs = split_compound(r#"echo "hello | world""#);
238 assert_eq!(
239 segs,
240 vec![Segment::Command(r#"echo "hello | world""#.into())]
241 );
242 }
243
244 #[test]
245 fn heredoc_kept_whole() {
246 let segs = split_compound("cat <<EOF\nhello\nEOF && echo done");
247 assert_eq!(
248 segs,
249 vec![Segment::Command(
250 "cat <<EOF\nhello\nEOF && echo done".into()
251 )]
252 );
253 }
254
255 #[test]
256 fn subshell_not_split() {
257 let segs = split_compound("echo $(git status && echo ok)");
258 assert_eq!(
259 segs,
260 vec![Segment::Command("echo $(git status && echo ok)".into())]
261 );
262 }
263}