Skip to main content

krait/lsp/
diagnostics.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use dashmap::DashMap;
5use serde::Serialize;
6use serde_json::Value;
7
8/// Severity of a diagnostic, ordered from most to least severe.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum DiagSeverity {
12    Error,
13    Warning,
14    Information,
15    Hint,
16}
17
18impl DiagSeverity {
19    fn from_lsp(raw: Option<u64>) -> Self {
20        match raw {
21            Some(1) => Self::Error,
22            Some(2) => Self::Warning,
23            Some(3) => Self::Information,
24            _ => Self::Hint,
25        }
26    }
27
28    /// Short label for compact output.
29    #[must_use]
30    pub fn label(&self) -> &'static str {
31        match self {
32            Self::Error => "error",
33            Self::Warning => "warn",
34            Self::Information => "info",
35            Self::Hint => "hint",
36        }
37    }
38}
39
40/// A single diagnostic from a language server.
41#[derive(Debug, Clone, Serialize)]
42pub struct DiagnosticEntry {
43    pub severity: DiagSeverity,
44    /// 0-indexed line number (from LSP).
45    pub line: u32,
46    /// 0-indexed column (from LSP).
47    pub col: u32,
48    pub code: Option<String>,
49    pub message: String,
50}
51
52/// Thread-safe per-file diagnostic store.
53///
54/// Receives `textDocument/publishDiagnostics` notifications and stores them
55/// by absolute file path. Each update replaces previous diagnostics for that file.
56#[derive(Debug, Clone, Default)]
57pub struct DiagnosticStore {
58    inner: Arc<DashMap<PathBuf, Vec<DiagnosticEntry>>>,
59}
60
61impl DiagnosticStore {
62    #[must_use]
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Replace all diagnostics for `path`. Passing an empty vec removes the entry.
68    pub fn update(&self, path: PathBuf, diags: Vec<DiagnosticEntry>) {
69        if diags.is_empty() {
70            self.inner.remove(&path);
71        } else {
72            self.inner.insert(path, diags);
73        }
74    }
75
76    /// All diagnostics for `path`, or empty vec if none.
77    #[must_use]
78    pub fn get(&self, path: &Path) -> Vec<DiagnosticEntry> {
79        self.inner.get(path).map(|v| v.clone()).unwrap_or_default()
80    }
81
82    /// All diagnostics across all files.
83    #[must_use]
84    pub fn get_all(&self) -> Vec<(PathBuf, Vec<DiagnosticEntry>)> {
85        self.inner
86            .iter()
87            .map(|entry| (entry.key().clone(), entry.value().clone()))
88            .collect()
89    }
90
91    /// Remove diagnostics for a file.
92    pub fn clear(&self, path: &Path) {
93        self.inner.remove(path);
94    }
95
96    /// Total number of diagnostic entries across all files.
97    #[must_use]
98    pub fn total_count(&self) -> usize {
99        self.inner.iter().map(|e| e.value().len()).sum()
100    }
101}
102
103/// Ingest a `textDocument/publishDiagnostics` notification params into `store`.
104pub fn ingest_publish_diagnostics(params: Option<Value>, store: &DiagnosticStore) {
105    let Some(params) = params else { return };
106    let Some(uri) = params.get("uri").and_then(|v| v.as_str()) else {
107        return;
108    };
109    let path = uri_to_path(uri);
110
111    let Some(diags_raw) = params.get("diagnostics").and_then(|v| v.as_array()) else {
112        store.update(path, vec![]);
113        return;
114    };
115
116    let entries: Vec<DiagnosticEntry> = diags_raw.iter().filter_map(parse_entry).collect();
117    store.update(path, entries);
118}
119
120fn parse_entry(v: &Value) -> Option<DiagnosticEntry> {
121    let message = v.get("message").and_then(|m| m.as_str())?.to_string();
122    let severity = DiagSeverity::from_lsp(v.get("severity").and_then(Value::as_u64));
123    let start = v.get("range").and_then(|r| r.get("start"));
124    let line = u32::try_from(
125        start
126            .and_then(|s| s.get("line"))
127            .and_then(Value::as_u64)
128            .unwrap_or(0),
129    )
130    .unwrap_or(0);
131    let col = u32::try_from(
132        start
133            .and_then(|s| s.get("character"))
134            .and_then(Value::as_u64)
135            .unwrap_or(0),
136    )
137    .unwrap_or(0);
138    let code = v.get("code").and_then(|c| {
139        if let Some(s) = c.as_str() {
140            Some(s.to_string())
141        } else {
142            c.as_u64().map(|n| n.to_string())
143        }
144    });
145    Some(DiagnosticEntry {
146        severity,
147        line,
148        col,
149        code,
150        message,
151    })
152}
153
154/// Strip `file://` prefix from a URI and return a `PathBuf`.
155fn uri_to_path(uri: &str) -> PathBuf {
156    PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri))
157}
158
159// ── Tests ─────────────────────────────────────────────────────────────────────
160
161#[cfg(test)]
162mod tests {
163    use serde_json::json;
164
165    use super::*;
166
167    fn err(msg: &str) -> DiagnosticEntry {
168        DiagnosticEntry {
169            severity: DiagSeverity::Error,
170            line: 1,
171            col: 0,
172            code: None,
173            message: msg.to_string(),
174        }
175    }
176
177    #[test]
178    fn store_and_retrieve_diagnostics() {
179        let store = DiagnosticStore::new();
180        let path = PathBuf::from("/project/src/lib.rs");
181        store.update(path.clone(), vec![err("oops")]);
182        let diags = store.get(&path);
183        assert_eq!(diags.len(), 1);
184        assert_eq!(diags[0].message, "oops");
185    }
186
187    #[test]
188    fn update_replaces_previous() {
189        let store = DiagnosticStore::new();
190        let path = PathBuf::from("/project/src/lib.rs");
191        store.update(path.clone(), vec![err("first")]);
192        store.update(
193            path.clone(),
194            vec![DiagnosticEntry {
195                severity: DiagSeverity::Warning,
196                line: 2,
197                col: 0,
198                code: None,
199                message: "second".to_string(),
200            }],
201        );
202        let diags = store.get(&path);
203        assert_eq!(diags.len(), 1);
204        assert_eq!(diags[0].message, "second");
205    }
206
207    #[test]
208    fn get_nonexistent_returns_empty() {
209        let store = DiagnosticStore::new();
210        assert!(store.get(&PathBuf::from("/nonexistent.rs")).is_empty());
211    }
212
213    #[test]
214    fn get_all_returns_everything() {
215        let store = DiagnosticStore::new();
216        store.update(PathBuf::from("/a.rs"), vec![err("a")]);
217        store.update(PathBuf::from("/b.rs"), vec![err("b")]);
218        assert_eq!(store.get_all().len(), 2);
219    }
220
221    #[test]
222    fn update_empty_clears_entry() {
223        let store = DiagnosticStore::new();
224        let path = PathBuf::from("/project/lib.rs");
225        store.update(path.clone(), vec![err("e")]);
226        store.update(path.clone(), vec![]);
227        assert!(store.get(&path).is_empty());
228    }
229
230    #[test]
231    fn ingest_publish_diagnostics_parses_notification() {
232        let store = DiagnosticStore::new();
233        let params = json!({
234            "uri": "file:///project/src/lib.rs",
235            "diagnostics": [{
236                "range": {"start": {"line": 41, "character": 9}, "end": {"line": 41, "character": 15}},
237                "severity": 1,
238                "code": "E0308",
239                "message": "mismatched types"
240            }]
241        });
242        ingest_publish_diagnostics(Some(params), &store);
243        let diags = store.get(&PathBuf::from("/project/src/lib.rs"));
244        assert_eq!(diags.len(), 1);
245        assert_eq!(diags[0].severity, DiagSeverity::Error);
246        assert_eq!(diags[0].line, 41);
247        assert_eq!(diags[0].code.as_deref(), Some("E0308"));
248        assert_eq!(diags[0].message, "mismatched types");
249    }
250
251    #[test]
252    fn ingest_clears_on_empty_array() {
253        let store = DiagnosticStore::new();
254        let path = PathBuf::from("/project/src/lib.rs");
255        store.update(path.clone(), vec![err("old")]);
256        let params = json!({
257            "uri": "file:///project/src/lib.rs",
258            "diagnostics": []
259        });
260        ingest_publish_diagnostics(Some(params), &store);
261        assert!(store.get(&path).is_empty());
262    }
263
264    #[test]
265    fn severity_ordering_errors_first() {
266        assert!(DiagSeverity::Error < DiagSeverity::Warning);
267        assert!(DiagSeverity::Warning < DiagSeverity::Information);
268        assert!(DiagSeverity::Information < DiagSeverity::Hint);
269    }
270}