Skip to main content

sshconfig_lint/
parser.rs

1use crate::model::{Config, Item, Line, LineKind};
2
3/// Parse a space-separated list of patterns, respecting quoted values.
4fn parse_patterns(value: &str) -> Vec<String> {
5    let mut patterns = Vec::new();
6    let mut current = String::new();
7    let mut in_quote = false;
8
9    for ch in value.chars() {
10        match ch {
11            '"' => {
12                in_quote = !in_quote;
13                current.push(ch);
14            }
15            ' ' | '\t' if !in_quote => {
16                if !current.is_empty() {
17                    patterns.push(current.clone());
18                    current.clear();
19                }
20            }
21            _ => current.push(ch),
22        }
23    }
24
25    if !current.is_empty() {
26        patterns.push(current);
27    }
28
29    patterns
30}
31
32/// Parse lexed lines into a structured Config AST.
33pub fn parse(lines: Vec<Line>) -> Config {
34    let mut items = Vec::new();
35    let mut i = 0;
36
37    while i < lines.len() {
38        let line = &lines[i];
39        match &line.kind {
40            LineKind::Empty => {
41                i += 1;
42            }
43            LineKind::Comment(text) => {
44                items.push(Item::Comment {
45                    text: text.clone(),
46                    span: line.span.clone(),
47                });
48                i += 1;
49            }
50            LineKind::Directive { key, value } => {
51                let key_lower = key.to_lowercase();
52                match key_lower.as_str() {
53                    "host" => {
54                        let span = line.span.clone();
55                        let patterns = parse_patterns(value);
56                        let (block_items, next_i) = collect_block(&lines, i + 1);
57                        items.push(Item::HostBlock {
58                            patterns,
59                            span,
60                            items: block_items,
61                        });
62                        i = next_i;
63                    }
64                    "match" => {
65                        let span = line.span.clone();
66                        let criteria = value.clone();
67                        let (block_items, next_i) = collect_block(&lines, i + 1);
68                        items.push(Item::MatchBlock {
69                            criteria,
70                            span,
71                            items: block_items,
72                        });
73                        i = next_i;
74                    }
75                    "include" => {
76                        let span = line.span.clone();
77                        let patterns = parse_patterns(value);
78                        items.push(Item::Include { patterns, span });
79                        i += 1;
80                    }
81                    _ => {
82                        items.push(Item::Directive {
83                            key: key.clone(),
84                            value: value.clone(),
85                            span: line.span.clone(),
86                        });
87                        i += 1;
88                    }
89                }
90            }
91        }
92    }
93
94    Config { items }
95}
96
97/// Collect directives that belong inside a Host/Match block.
98/// A block ends when we hit another Host, Match, or end-of-input.
99fn collect_block(lines: &[Line], start: usize) -> (Vec<Item>, usize) {
100    let mut items = Vec::new();
101    let mut i = start;
102
103    while i < lines.len() {
104        let line = &lines[i];
105        match &line.kind {
106            LineKind::Empty => {
107                i += 1;
108            }
109            LineKind::Comment(text) => {
110                items.push(Item::Comment {
111                    text: text.clone(),
112                    span: line.span.clone(),
113                });
114                i += 1;
115            }
116            LineKind::Directive { key, value } => {
117                let key_lower = key.to_lowercase();
118                match key_lower.as_str() {
119                    // These start a new block, so we stop collecting.
120                    "host" | "match" => break,
121                    "include" => {
122                        let span = line.span.clone();
123                        let patterns = parse_patterns(value);
124                        items.push(Item::Include { patterns, span });
125                        i += 1;
126                    }
127                    _ => {
128                        items.push(Item::Directive {
129                            key: key.clone(),
130                            value: value.clone(),
131                            span: line.span.clone(),
132                        });
133                        i += 1;
134                    }
135                }
136            }
137        }
138    }
139
140    (items, i)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::lexer::lex;
147
148    #[test]
149    fn empty_config() {
150        let config = parse(lex(""));
151        assert!(
152            config.items.is_empty()
153                || config
154                    .items
155                    .iter()
156                    .all(|i| matches!(i, Item::Comment { .. }))
157        );
158    }
159
160    #[test]
161    fn single_root_directive() {
162        let config = parse(lex("ServerAliveInterval 60"));
163        assert_eq!(config.items.len(), 1);
164        match &config.items[0] {
165            Item::Directive { key, value, .. } => {
166                assert_eq!(key, "ServerAliveInterval");
167                assert_eq!(value, "60");
168            }
169            other => panic!("expected Directive, got {:?}", other),
170        }
171    }
172
173    #[test]
174    fn host_block_collects_directives() {
175        let input = "Host github.com\n  User git\n  IdentityFile ~/.ssh/gh";
176        let config = parse(lex(input));
177        assert_eq!(config.items.len(), 1);
178        match &config.items[0] {
179            Item::HostBlock {
180                patterns, items, ..
181            } => {
182                assert_eq!(patterns, &vec!["github.com".to_string()]);
183                assert_eq!(items.len(), 2);
184                match &items[0] {
185                    Item::Directive { key, value, .. } => {
186                        assert_eq!(key, "User");
187                        assert_eq!(value, "git");
188                    }
189                    other => panic!("expected Directive, got {:?}", other),
190                }
191            }
192            other => panic!("expected HostBlock, got {:?}", other),
193        }
194    }
195
196    #[test]
197    fn multiple_host_blocks() {
198        let input = "Host a\n  User alice\nHost b\n  User bob";
199        let config = parse(lex(input));
200        assert_eq!(config.items.len(), 2);
201        assert!(matches!(
202            &config.items[0],
203            Item::HostBlock { patterns, .. } if patterns == &vec!["a".to_string()]
204        ));
205        assert!(matches!(
206            &config.items[1],
207            Item::HostBlock { patterns, .. } if patterns == &vec!["b".to_string()]
208        ));
209    }
210
211    #[test]
212    fn match_block() {
213        let input = "Match host github.com\n  User git";
214        let config = parse(lex(input));
215        assert_eq!(config.items.len(), 1);
216        match &config.items[0] {
217            Item::MatchBlock {
218                criteria, items, ..
219            } => {
220                assert_eq!(criteria, "host github.com");
221                assert_eq!(items.len(), 1);
222            }
223            other => panic!("expected MatchBlock, got {:?}", other),
224        }
225    }
226
227    #[test]
228    fn include_becomes_item() {
229        let input = "Include config.d/*";
230        let config = parse(lex(input));
231        assert_eq!(config.items.len(), 1);
232        match &config.items[0] {
233            Item::Include { patterns, .. } => {
234                assert_eq!(patterns, &vec!["config.d/*".to_string()]);
235            }
236            other => panic!("expected Include, got {:?}", other),
237        }
238    }
239
240    #[test]
241    fn include_inside_host_block() {
242        let input = "Host a\n  Include extra.conf\n  User alice";
243        let config = parse(lex(input));
244        assert_eq!(config.items.len(), 1);
245        match &config.items[0] {
246            Item::HostBlock { items, .. } => {
247                assert_eq!(items.len(), 2);
248                assert!(matches!(
249                    &items[0],
250                    Item::Include { patterns, .. } if patterns == &vec!["extra.conf".to_string()]
251                ));
252                assert!(matches!(&items[1], Item::Directive { key, .. } if key == "User"));
253            }
254            other => panic!("expected HostBlock, got {:?}", other),
255        }
256    }
257
258    #[test]
259    fn root_directives_before_host() {
260        let input = "ServerAliveInterval 60\n\nHost a\n  User alice";
261        let config = parse(lex(input));
262        assert_eq!(config.items.len(), 2);
263        assert!(matches!(
264            &config.items[0],
265            Item::Directive { key, .. } if key == "ServerAliveInterval"
266        ));
267        assert!(matches!(
268            &config.items[1],
269            Item::HostBlock { patterns, .. } if patterns == &vec!["a".to_string()]
270        ));
271    }
272
273    #[test]
274    fn comments_preserved() {
275        let input = "# global comment\nHost a\n  # block comment\n  User alice";
276        let config = parse(lex(input));
277        assert_eq!(config.items.len(), 2);
278        assert!(matches!(&config.items[0], Item::Comment { .. }));
279        match &config.items[1] {
280            Item::HostBlock { items, .. } => {
281                assert_eq!(items.len(), 2);
282                assert!(matches!(&items[0], Item::Comment { .. }));
283            }
284            other => panic!("expected HostBlock, got {:?}", other),
285        }
286    }
287
288    #[test]
289    fn host_with_multiple_patterns() {
290        let input = "Host github.com gitlab.com *.corp";
291        let config = parse(lex(input));
292        assert_eq!(config.items.len(), 1);
293        match &config.items[0] {
294            Item::HostBlock { patterns, .. } => {
295                assert_eq!(
296                    patterns,
297                    &vec![
298                        "github.com".to_string(),
299                        "gitlab.com".to_string(),
300                        "*.corp".to_string()
301                    ]
302                );
303            }
304            other => panic!("expected HostBlock, got {:?}", other),
305        }
306    }
307
308    #[test]
309    fn include_with_multiple_patterns() {
310        let input = "Include ~/.ssh/conf.d/*.conf ~/.ssh/extra.conf";
311        let config = parse(lex(input));
312        assert_eq!(config.items.len(), 1);
313        match &config.items[0] {
314            Item::Include { patterns, .. } => {
315                assert_eq!(
316                    patterns,
317                    &vec![
318                        "~/.ssh/conf.d/*.conf".to_string(),
319                        "~/.ssh/extra.conf".to_string()
320                    ]
321                );
322            }
323            other => panic!("expected Include, got {:?}", other),
324        }
325    }
326}