Skip to main content

vimdoc_language_server/
diagnostics.rs

1use std::collections::HashMap;
2
3use lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Uri};
4use serde::Deserialize;
5
6use crate::parser::Document;
7use crate::tags::TagIndex;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum DiagnosticLevel {
12    Error,
13    Warning,
14    Information,
15    Hint,
16    Off,
17}
18
19fn default_level(code: &str) -> DiagnosticLevel {
20    match code {
21        "duplicate-tag" => DiagnosticLevel::Error,
22        "missing-modeline" => DiagnosticLevel::Hint,
23        _ => DiagnosticLevel::Warning,
24    }
25}
26
27fn apply_level(
28    mut diag: Diagnostic,
29    levels: &HashMap<String, DiagnosticLevel>,
30) -> Option<Diagnostic> {
31    let code = match &diag.code {
32        Some(NumberOrString::String(s)) => s.clone(),
33        _ => return Some(diag),
34    };
35    let level = levels
36        .get(&code)
37        .copied()
38        .unwrap_or_else(|| default_level(&code));
39    diag.severity = Some(match level {
40        DiagnosticLevel::Off => return None,
41        DiagnosticLevel::Error => DiagnosticSeverity::ERROR,
42        DiagnosticLevel::Warning => DiagnosticSeverity::WARNING,
43        DiagnosticLevel::Information => DiagnosticSeverity::INFORMATION,
44        DiagnosticLevel::Hint => DiagnosticSeverity::HINT,
45    });
46    Some(diag)
47}
48
49#[must_use]
50#[allow(clippy::cast_possible_truncation, clippy::implicit_hasher)]
51pub fn compute(
52    doc: &Document,
53    tag_index: &TagIndex,
54    uri: &Uri,
55    levels: &HashMap<String, DiagnosticLevel>,
56) -> Vec<Diagnostic> {
57    let mut raw_diags: Vec<Diagnostic> = Vec::new();
58    let mut defined: HashMap<&str, Vec<Range>> = HashMap::new();
59
60    for span in doc.tag_defs() {
61        defined
62            .entry(span.name.as_str())
63            .or_default()
64            .push(span.range);
65    }
66
67    for (name, ranges) in &defined {
68        if ranges.len() > 1 {
69            for &range in ranges {
70                raw_diags.push(Diagnostic {
71                    range,
72                    severity: Some(DiagnosticSeverity::ERROR),
73                    code: Some(NumberOrString::String("duplicate-tag".into())),
74                    message: format!("duplicate tag definition: *{name}*"),
75                    source: Some("vimdoc".into()),
76                    ..Default::default()
77                });
78            }
79        }
80        if tag_index.has_definition_in_other_file(name, uri) {
81            for &range in ranges {
82                raw_diags.push(Diagnostic {
83                    range,
84                    severity: Some(DiagnosticSeverity::ERROR),
85                    code: Some(NumberOrString::String("duplicate-tag".into())),
86                    message: format!("tag `*{name}*` is also defined in another file"),
87                    source: Some("vimdoc".into()),
88                    ..Default::default()
89                });
90            }
91        }
92    }
93
94    for span in doc.tag_refs() {
95        if !defined.contains_key(span.name.as_str()) && !tag_index.contains(&span.name) {
96            raw_diags.push(Diagnostic {
97                range: span.range,
98                severity: Some(DiagnosticSeverity::WARNING),
99                code: Some(NumberOrString::String("unresolved-tag".into())),
100                message: format!("unresolved tag reference: |{}|", span.name),
101                source: Some("vimdoc".into()),
102                ..Default::default()
103            });
104        }
105    }
106
107    if !doc.lines.is_empty() && !doc.has_modeline {
108        let last_line = doc.lines.len().saturating_sub(1) as u32;
109        raw_diags.push(Diagnostic {
110            range: Range {
111                start: Position {
112                    line: last_line,
113                    character: 0,
114                },
115                end: Position {
116                    line: last_line,
117                    character: 0,
118                },
119            },
120            severity: Some(DiagnosticSeverity::HINT),
121            code: Some(NumberOrString::String("missing-modeline".into())),
122            message: "missing modeline; add ' vim:tw=78:ts=8:ft=help:norl:' to the last line"
123                .into(),
124            source: Some("vimdoc".into()),
125            ..Default::default()
126        });
127    }
128
129    raw_diags
130        .into_iter()
131        .filter_map(|d| apply_level(d, levels))
132        .collect()
133}