Skip to main content

lean_ctx/
compound_lexer.rs

1/// Splits a compound shell command into segments separated by `&&`, `||`, `;`.
2/// Pipes (`|`) are treated specially: only the left side of a pipe is eligible
3/// for rewriting (the right side consumes output format and must stay raw).
4///
5/// Respects single quotes, double quotes, backtick-quotes, and `$(...)` subshells
6/// so that operators inside quoted strings are not treated as separators.
7///
8/// Returns a `Vec<Segment>` where each entry is either a command segment or an
9/// operator token that should be emitted verbatim.
10
11#[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, &current);
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, &current);
107                current.clear();
108                segments.push(Segment::Operator("||".to_string()));
109                i += 2;
110            }
111            '|' => {
112                push_command(&mut segments, &current);
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, &current);
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, &current);
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}