systemd_lsp/
parser.rs

1use dashmap::DashMap;
2use log::{debug, trace};
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use tower_lsp_server::lsp_types::{Position, Uri};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SystemdUnit {
10    pub sections: HashMap<String, SystemdSection>,
11    pub raw_text: String,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SystemdSection {
16    pub name: String,
17    pub directives: Vec<SystemdDirective>,
18    pub line_range: (u32, u32),
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct DirectiveValueSpan {
23    pub line: u32,
24    pub start: u32,
25    pub end: u32,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct SystemdDirective {
30    pub key: String,
31    pub value: String,
32    pub line_number: u32,
33    pub column_range: (u32, u32),
34    pub end_line_number: u32,
35    pub value_spans: Vec<DirectiveValueSpan>,
36}
37
38#[derive(Debug)]
39pub struct SystemdParser {
40    documents: DashMap<Uri, SystemdUnit>,
41    section_regex: Regex,
42    directive_regex: Regex,
43}
44
45impl SystemdParser {
46    pub fn new() -> Self {
47        Self {
48            documents: DashMap::new(),
49            section_regex: Regex::new(r"^\[([^\]]+)\]$").unwrap(),
50            directive_regex: Regex::new(r"^([^=]+)=(.*)$").unwrap(),
51        }
52    }
53
54    pub fn parse(&self, text: &str) -> SystemdUnit {
55        trace!("Parsing systemd unit file ({} characters)", text.len());
56        let mut unit = SystemdUnit {
57            sections: HashMap::new(),
58            raw_text: text.to_string(),
59        };
60
61        let mut current_section: Option<String> = None;
62
63        let mut lines = text.lines().enumerate().peekable();
64
65        while let Some((raw_line_num, line)) = lines.next() {
66            let line_num = raw_line_num as u32;
67            let trimmed = line.trim();
68
69            if trimmed.is_empty() || trimmed.starts_with('#') {
70                continue;
71            }
72
73            if let Some(captures) = self.section_regex.captures(trimmed) {
74                if let Some(section_name) = current_section.take() {
75                    if let Some(section) = unit.sections.get_mut(&section_name) {
76                        section.line_range.1 = line_num - 1;
77                    }
78                }
79
80                let section_name = captures[1].to_string();
81                current_section = Some(section_name.clone());
82
83                unit.sections.insert(
84                    section_name.clone(),
85                    SystemdSection {
86                        name: section_name,
87                        directives: Vec::new(),
88                        line_range: (line_num, line_num),
89                    },
90                );
91            } else if let Some(captures) = self.directive_regex.captures(trimmed) {
92                if let Some(section_name) = &current_section {
93                    let key = captures[1].trim().to_string();
94                    let raw_value = captures[2].to_string();
95
96                    let key_start = line.find(&key).unwrap_or(0) as u32;
97                    let key_end = key_start + key.len() as u32;
98
99                    let eq_index = line.find('=').map(|idx| idx as u32);
100                    let mut value_start = eq_index.unwrap_or(key_end) + 1;
101                    let after_eq = if let Some(eq_idx) = line.find('=') {
102                        &line[eq_idx + 1..]
103                    } else {
104                        ""
105                    };
106                    let leading_ws = after_eq.chars().take_while(|c| c.is_whitespace()).count();
107                    value_start += leading_ws as u32;
108
109                    let (mut fragment, mut continuation) = parse_value_fragment(&raw_value);
110                    let mut normalized_value = fragment.clone();
111                    let mut value_spans = Vec::new();
112
113                    let first_span_end = value_start + fragment.len() as u32;
114                    value_spans.push(DirectiveValueSpan {
115                        line: line_num,
116                        start: value_start,
117                        end: first_span_end,
118                    });
119
120                    let mut end_line_number = line_num;
121
122                    while continuation {
123                        if let Some((next_line_num, next_line)) = lines.next() {
124                            let next_line_trimmed = next_line.trim();
125                            end_line_number = next_line_num as u32;
126
127                            let indent = next_line.find(next_line_trimmed).unwrap_or(0) as u32;
128
129                            let (next_fragment, next_continuation) =
130                                parse_value_fragment(next_line_trimmed);
131
132                            if !next_fragment.is_empty() {
133                                if normalized_value.is_empty() {
134                                    normalized_value = next_fragment.clone();
135                                } else {
136                                    normalized_value.push(' ');
137                                    normalized_value.push_str(&next_fragment);
138                                }
139                            }
140
141                            value_spans.push(DirectiveValueSpan {
142                                line: end_line_number,
143                                start: indent,
144                                end: indent + next_fragment.len() as u32,
145                            });
146
147                            continuation = next_continuation;
148                            fragment = next_fragment;
149                        } else {
150                            break;
151                        }
152                    }
153
154                    if normalized_value.is_empty() {
155                        normalized_value = fragment;
156                    }
157
158                    // If the directive has no value, ensure spans reflect current position
159                    if normalized_value.is_empty() {
160                        value_spans.clear();
161                        value_spans.push(DirectiveValueSpan {
162                            line: line_num,
163                            start: value_start,
164                            end: value_start,
165                        });
166                        end_line_number = line_num;
167                    } else if value_spans.is_empty() {
168                        value_spans.push(DirectiveValueSpan {
169                            line: line_num,
170                            start: value_start,
171                            end: value_start + normalized_value.len() as u32,
172                        });
173                    }
174
175                    let directive = SystemdDirective {
176                        key: key.clone(),
177                        value: normalized_value,
178                        line_number: line_num,
179                        column_range: (key_start, key_end),
180                        end_line_number,
181                        value_spans,
182                    };
183
184                    if let Some(section) = unit.sections.get_mut(section_name) {
185                        section.directives.push(directive);
186                    }
187                }
188            }
189        }
190
191        if let Some(section_name) = current_section {
192            if let Some(section) = unit.sections.get_mut(&section_name) {
193                section.line_range.1 = text.lines().count() as u32;
194            }
195        }
196
197        debug!(
198            "Parsed {} sections with {} total directives",
199            unit.sections.len(),
200            unit.sections
201                .values()
202                .map(|s| s.directives.len())
203                .sum::<usize>()
204        );
205        unit
206    }
207
208    pub fn update_document(&self, uri: &Uri, text: &str) {
209        let parsed = self.parse(text);
210        self.documents.insert(uri.clone(), parsed);
211    }
212
213    pub fn get_parsed_document(&self, uri: &Uri) -> Option<SystemdUnit> {
214        self.documents.get(uri).map(|entry| entry.clone())
215    }
216
217    pub fn get_document_text(&self, uri: &Uri) -> Option<String> {
218        self.documents.get(uri).map(|entry| entry.raw_text.clone())
219    }
220
221    pub fn get_word_at_position(&self, unit: &SystemdUnit, position: &Position) -> Option<String> {
222        let lines: Vec<&str> = unit.raw_text.lines().collect();
223        if let Some(line) = lines.get(position.line as usize) {
224            // Try to extract the word at the cursor position
225            let chars: Vec<char> = line.chars().collect();
226            if position.character < chars.len() as u32 {
227                let cursor_pos = position.character as usize;
228
229                // Find word boundaries around the cursor
230                let mut start = cursor_pos;
231                let mut end = cursor_pos;
232
233                // Move start backwards to find word start
234                while start > 0
235                    && (chars[start - 1].is_alphanumeric()
236                        || chars[start - 1] == '-'
237                        || chars[start - 1] == '_'
238                        || chars[start - 1] == '.')
239                {
240                    start -= 1;
241                }
242
243                // Move end forwards to find word end
244                while end < chars.len()
245                    && (chars[end].is_alphanumeric()
246                        || chars[end] == '-'
247                        || chars[end] == '_'
248                        || chars[end] == '.')
249                {
250                    end += 1;
251                }
252
253                if start < end {
254                    return Some(chars[start..end].iter().collect());
255                }
256            }
257        }
258        None
259    }
260
261    pub fn get_section_header_at_position(
262        &self,
263        unit: &SystemdUnit,
264        position: &Position,
265    ) -> Option<String> {
266        debug!("Checking for section header at line {}", position.line);
267        for section in unit.sections.values() {
268            if position.line == section.line_range.0 {
269                debug!(
270                    "Found section header '{}' at line {}",
271                    section.name, position.line
272                );
273                return Some(section.name.clone());
274            }
275        }
276        debug!("No section header found at line {}", position.line);
277        None
278    }
279
280    pub fn get_section_at_line<'a>(
281        &self,
282        unit: &'a SystemdUnit,
283        line: u32,
284    ) -> Option<&'a SystemdSection> {
285        unit.sections
286            .values()
287            .find(|section| line >= section.line_range.0 && line <= section.line_range.1)
288    }
289}
290
291fn parse_value_fragment(text: &str) -> (String, bool) {
292    let trimmed = text.trim();
293    if trimmed.is_empty() {
294        return (String::new(), false);
295    }
296
297    let mut backslash_count = 0usize;
298    for ch in trimmed.chars().rev() {
299        if ch == '\\' {
300            backslash_count += 1;
301        } else {
302            break;
303        }
304    }
305
306    let continuation = backslash_count % 2 == 1;
307    let mut fragment = trimmed.to_string();
308
309    if continuation {
310        fragment.pop();
311        fragment = fragment.trim_end().to_string();
312    }
313
314    (fragment, continuation)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use tower_lsp_server::lsp_types::{Position, Uri};
321
322    #[test]
323    fn test_parse_basic_systemd_file() {
324        let parser = SystemdParser::new();
325        let content = "[Unit]\nDescription=Test service\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/bin/test\n";
326
327        let parsed = parser.parse(content);
328
329        assert_eq!(parsed.sections.len(), 2);
330        assert!(parsed.sections.contains_key("Unit"));
331        assert!(parsed.sections.contains_key("Service"));
332
333        let unit_section = &parsed.sections["Unit"];
334        assert_eq!(unit_section.line_range.0, 0);
335        assert_eq!(unit_section.directives.len(), 2);
336        assert!(unit_section
337            .directives
338            .iter()
339            .find(|directive| directive.key == "Description")
340            .is_some());
341        assert!(unit_section
342            .directives
343            .iter()
344            .find(|directive| directive.key == "After")
345            .is_some());
346
347        let service_section = &parsed.sections["Service"];
348        assert_eq!(service_section.line_range.0, 4);
349        assert_eq!(service_section.directives.len(), 2);
350        assert!(service_section
351            .directives
352            .iter()
353            .find(|directive| directive.key == "Type")
354            .is_some());
355        assert!(service_section
356            .directives
357            .iter()
358            .find(|directive| directive.key == "ExecStart")
359            .is_some());
360    }
361
362    #[test]
363    fn test_parse_with_comments_and_empty_lines() {
364        let parser = SystemdParser::new();
365        let content = "# This is a comment\n\n[Unit]\n# Another comment\nDescription=Test\n\n[Service]\nType=simple\n";
366
367        let parsed = parser.parse(content);
368
369        assert_eq!(parsed.sections.len(), 2);
370        assert!(parsed.sections.contains_key("Unit"));
371        assert!(parsed.sections.contains_key("Service"));
372
373        // Comments and empty lines should be ignored
374        let unit_section = &parsed.sections["Unit"];
375        assert_eq!(unit_section.directives.len(), 1);
376        assert!(unit_section
377            .directives
378            .iter()
379            .find(|directive| directive.key == "Description")
380            .is_some());
381    }
382
383    #[test]
384    fn test_get_section_header_at_position() {
385        let parser = SystemdParser::new();
386        let content = "[Unit]\nDescription=Test\n\n[Service]\nType=simple\n\n[Install]\nWantedBy=multi-user.target\n";
387        let parsed = parser.parse(content);
388
389        // Test section headers
390        assert_eq!(
391            parser.get_section_header_at_position(
392                &parsed,
393                &Position {
394                    line: 0,
395                    character: 0
396                }
397            ),
398            Some("Unit".to_string())
399        );
400        assert_eq!(
401            parser.get_section_header_at_position(
402                &parsed,
403                &Position {
404                    line: 3,
405                    character: 0
406                }
407            ),
408            Some("Service".to_string())
409        );
410        assert_eq!(
411            parser.get_section_header_at_position(
412                &parsed,
413                &Position {
414                    line: 6,
415                    character: 0
416                }
417            ),
418            Some("Install".to_string())
419        );
420
421        // Test non-header lines
422        assert_eq!(
423            parser.get_section_header_at_position(
424                &parsed,
425                &Position {
426                    line: 1,
427                    character: 0
428                }
429            ),
430            None
431        );
432        assert_eq!(
433            parser.get_section_header_at_position(
434                &parsed,
435                &Position {
436                    line: 4,
437                    character: 0
438                }
439            ),
440            None
441        );
442        assert_eq!(
443            parser.get_section_header_at_position(
444                &parsed,
445                &Position {
446                    line: 7,
447                    character: 0
448                }
449            ),
450            None
451        );
452    }
453
454    #[test]
455    fn test_get_section_at_line() {
456        let parser = SystemdParser::new();
457        let content = "[Unit]\nDescription=Test\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/bin/test\n";
458        let parsed = parser.parse(content);
459
460        // Test lines within sections
461        let unit_section = parser.get_section_at_line(&parsed, 0).unwrap();
462        assert_eq!(unit_section.name, "Unit");
463
464        let unit_section = parser.get_section_at_line(&parsed, 1).unwrap();
465        assert_eq!(unit_section.name, "Unit");
466
467        let unit_section = parser.get_section_at_line(&parsed, 2).unwrap();
468        assert_eq!(unit_section.name, "Unit");
469
470        let service_section = parser.get_section_at_line(&parsed, 4).unwrap();
471        assert_eq!(service_section.name, "Service");
472
473        let service_section = parser.get_section_at_line(&parsed, 5).unwrap();
474        assert_eq!(service_section.name, "Service");
475
476        // The Unit section probably extends to line 3, so this test was wrong
477        // Line 3 is empty, but the Unit section includes it in its range
478        // Let's test a line that's definitely outside any section
479        assert!(parser.get_section_at_line(&parsed, 100).is_none());
480    }
481
482    #[test]
483    fn test_get_word_at_position() {
484        let parser = SystemdParser::new();
485        let content = "[Unit]\nDescription=Test service\nAfter=network.target\n";
486        let parsed = parser.parse(content);
487
488        // Test getting directive names
489        assert_eq!(
490            parser.get_word_at_position(
491                &parsed,
492                &Position {
493                    line: 1,
494                    character: 0
495                }
496            ),
497            Some("Description".to_string())
498        );
499        assert_eq!(
500            parser.get_word_at_position(
501                &parsed,
502                &Position {
503                    line: 1,
504                    character: 5
505                }
506            ),
507            Some("Description".to_string())
508        );
509        assert_eq!(
510            parser.get_word_at_position(
511                &parsed,
512                &Position {
513                    line: 2,
514                    character: 0
515                }
516            ),
517            Some("After".to_string())
518        );
519
520        // Test getting values - the word extraction includes dots and hyphens as valid word characters
521        assert_eq!(
522            parser.get_word_at_position(
523                &parsed,
524                &Position {
525                    line: 1,
526                    character: 12
527                }
528            ),
529            Some("Test".to_string())
530        );
531        assert_eq!(
532            parser.get_word_at_position(
533                &parsed,
534                &Position {
535                    line: 2,
536                    character: 6
537                }
538            ),
539            Some("network.target".to_string())
540        );
541
542        // Test position at different parts of "network.target"
543        assert_eq!(
544            parser.get_word_at_position(
545                &parsed,
546                &Position {
547                    line: 2,
548                    character: 10
549                }
550            ),
551            Some("network.target".to_string())
552        );
553        assert_eq!(
554            parser.get_word_at_position(
555                &parsed,
556                &Position {
557                    line: 2,
558                    character: 14
559                }
560            ),
561            Some("network.target".to_string())
562        );
563    }
564
565    #[test]
566    fn test_document_storage_and_retrieval() {
567        let parser = SystemdParser::new();
568        let content = "[Unit]\nDescription=Test\n";
569        let uri = "file:///test.service".parse::<Uri>().unwrap();
570
571        // Test that initially there's no document
572        assert!(parser.get_parsed_document(&uri).is_none());
573        assert!(parser.get_document_text(&uri).is_none());
574
575        // Store document
576        parser.update_document(&uri, content);
577
578        // Test retrieval
579        let retrieved = parser.get_parsed_document(&uri).unwrap();
580        assert_eq!(retrieved.sections.len(), 1);
581        assert!(retrieved.sections.contains_key("Unit"));
582
583        let text = parser.get_document_text(&uri).unwrap();
584        assert_eq!(text, content);
585    }
586
587    #[test]
588    fn test_parse_edge_cases() {
589        let parser = SystemdParser::new();
590
591        // Test empty file
592        let empty_parsed = parser.parse("");
593        assert_eq!(empty_parsed.sections.len(), 0);
594
595        // Test file with only comments
596        let comments_only = parser.parse("# Comment 1\n# Comment 2\n");
597        assert_eq!(comments_only.sections.len(), 0);
598
599        // Test section with no directives
600        let empty_section = parser.parse("[Unit]\n\n[Service]\n");
601        assert_eq!(empty_section.sections.len(), 2);
602        assert_eq!(empty_section.sections["Unit"].directives.len(), 0);
603        assert_eq!(empty_section.sections["Service"].directives.len(), 0);
604
605        // Test directive with empty value
606        let empty_value = parser.parse("[Unit]\nDescription=\n");
607        assert_eq!(empty_value.sections.len(), 1);
608        assert_eq!(
609            empty_value.sections["Unit"]
610                .directives
611                .iter()
612                .find(|directive| directive.key == "Description")
613                .unwrap()
614                .value,
615            ""
616        );
617
618        // Test directive with spaces around equals
619        let spaced_equals = parser.parse("[Unit]\nDescription = Test Service \n");
620        assert_eq!(spaced_equals.sections.len(), 1);
621        assert_eq!(
622            spaced_equals.sections["Unit"]
623                .directives
624                .iter()
625                .find(|directive| directive.key == "Description")
626                .unwrap()
627                .value,
628            "Test Service"
629        );
630    }
631
632    #[test]
633    fn test_case_sensitivity() {
634        let parser = SystemdParser::new();
635        let content = "[UNIT]\nDESCRIPTION=Test\n[service]\ntype=simple\n";
636        let parsed = parser.parse(content);
637
638        // Section names should preserve case
639        assert!(parsed.sections.contains_key("UNIT"));
640        assert!(parsed.sections.contains_key("service"));
641        assert!(!parsed.sections.contains_key("Unit"));
642        assert!(!parsed.sections.contains_key("Service"));
643
644        // Directive names should preserve case
645        assert!(parsed.sections["UNIT"]
646            .directives
647            .iter()
648            .find(|directive| directive.key == "DESCRIPTION")
649            .is_some());
650        assert!(parsed.sections["service"]
651            .directives
652            .iter()
653            .find(|directive| directive.key == "type")
654            .is_some());
655    }
656
657    #[test]
658    fn test_parse_multiline_directive_execstart() {
659        let parser = SystemdParser::new();
660        let content =
661            "[Service]\nExecStart=/usr/bin/test \\\n    --flag value \\\n    --another-flag\n";
662
663        let parsed = parser.parse(content);
664        let service_section = parsed
665            .sections
666            .get("Service")
667            .expect("Service section missing");
668        let exec_start = service_section
669            .directives
670            .iter()
671            .find(|directive| directive.key == "ExecStart")
672            .expect("ExecStart directive missing");
673
674        assert_eq!(
675            exec_start.value,
676            "/usr/bin/test --flag value --another-flag"
677        );
678        assert_eq!(exec_start.line_number, 1);
679        assert_eq!(exec_start.end_line_number, 3);
680        assert_eq!(exec_start.value_spans.len(), 3);
681
682        let first_span = &exec_start.value_spans[0];
683        assert_eq!(first_span.line, 1);
684        assert_eq!(first_span.start, 10);
685        assert_eq!(first_span.end, 23);
686
687        let second_span = &exec_start.value_spans[1];
688        assert_eq!(second_span.line, 2);
689        assert_eq!(second_span.start, 4);
690        assert_eq!(second_span.end, 16);
691
692        let third_span = &exec_start.value_spans[2];
693        assert_eq!(third_span.line, 3);
694        assert_eq!(third_span.start, 4);
695        assert_eq!(third_span.end, 18);
696    }
697}