1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use dashmap::DashMap;
5use serde::Serialize;
6use serde_json::Value;
7
8#[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 #[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#[derive(Debug, Clone, Serialize)]
42pub struct DiagnosticEntry {
43 pub severity: DiagSeverity,
44 pub line: u32,
46 pub col: u32,
48 pub code: Option<String>,
49 pub message: String,
50}
51
52#[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 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 #[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 #[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 pub fn clear(&self, path: &Path) {
93 self.inner.remove(path);
94 }
95
96 #[must_use]
98 pub fn total_count(&self) -> usize {
99 self.inner.iter().map(|e| e.value().len()).sum()
100 }
101}
102
103pub 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
154fn uri_to_path(uri: &str) -> PathBuf {
156 PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri))
157}
158
159#[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}