vimdoc_language_server/
diagnostics.rs1use 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}