1use crate::model::{Config, Item, Line, LineKind};
2
3fn 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
32pub 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
97fn 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 "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}