1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use crate::lsp::registry::ServerKind;
6use crate::lsp::roots::ServerKey;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct StoredDiagnostic {
11 pub file: PathBuf,
12 pub line: u32,
13 pub column: u32,
14 pub end_line: u32,
15 pub end_column: u32,
16 pub severity: DiagnosticSeverity,
17 pub message: String,
18 pub code: Option<String>,
19 pub source: Option<String>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum DiagnosticSeverity {
24 Error,
25 Warning,
26 Information,
27 Hint,
28}
29
30impl DiagnosticSeverity {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Error => "error",
34 Self::Warning => "warning",
35 Self::Information => "information",
36 Self::Hint => "hint",
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
45pub struct DiagnosticEntry {
46 pub diagnostics: Vec<StoredDiagnostic>,
47 pub epoch: u64,
51 pub result_id: Option<String>,
54 pub version: Option<i32>,
62}
63
64pub struct DiagnosticsStore {
83 entries: HashMap<(ServerKey, PathBuf), DiagnosticEntry>,
85 order: Vec<(ServerKey, PathBuf)>,
88 capacity: usize,
90 next_epoch: u64,
92 last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
95}
96
97impl DiagnosticsStore {
98 pub fn new() -> Self {
99 Self::with_capacity(5000)
100 }
101
102 pub fn with_capacity(capacity: usize) -> Self {
103 Self {
104 entries: HashMap::new(),
105 order: Vec::new(),
106 capacity,
107 next_epoch: 0,
108 last_publish_at_for_file: HashMap::new(),
109 }
110 }
111
112 pub fn set_capacity(&mut self, capacity: usize) {
116 self.capacity = capacity;
117 if capacity > 0 {
118 while self.entries.len() > capacity {
119 self.evict_lru();
120 }
121 }
122 }
123
124 pub fn len(&self) -> usize {
126 self.entries.len()
127 }
128
129 pub fn is_empty(&self) -> bool {
130 self.entries.is_empty()
131 }
132
133 pub fn publish(
142 &mut self,
143 server: ServerKey,
144 file: PathBuf,
145 diagnostics: Vec<StoredDiagnostic>,
146 ) {
147 self.publish_with_result_id(server, file, diagnostics, None);
148 }
149
150 pub fn publish_with_result_id(
153 &mut self,
154 server: ServerKey,
155 file: PathBuf,
156 diagnostics: Vec<StoredDiagnostic>,
157 result_id: Option<String>,
158 ) {
159 self.publish_full(server, file, diagnostics, result_id, None);
160 }
161
162 pub fn publish_full(
166 &mut self,
167 server: ServerKey,
168 file: PathBuf,
169 diagnostics: Vec<StoredDiagnostic>,
170 result_id: Option<String>,
171 version: Option<i32>,
172 ) {
173 let key = (server, file);
174 self.next_epoch = self.next_epoch.saturating_add(1);
175 let entry = DiagnosticEntry {
176 diagnostics,
177 epoch: self.next_epoch,
178 result_id,
179 version,
180 };
181
182 self.last_publish_at_for_file
183 .insert(key.clone(), Instant::now());
184
185 if self.entries.contains_key(&key) {
186 self.entries.insert(key.clone(), entry);
187 self.touch_existing(&key);
188 } else {
189 if self.capacity > 0 && self.entries.len() >= self.capacity {
191 self.evict_lru();
192 }
193 self.entries.insert(key.clone(), entry);
194 self.order.push(key);
195 }
196 }
197
198 pub fn publish_with_kind(
204 &mut self,
205 kind: ServerKind,
206 file: PathBuf,
207 diagnostics: Vec<StoredDiagnostic>,
208 ) {
209 let key = ServerKey {
210 kind,
211 root: PathBuf::new(),
212 };
213 self.publish(key, file, diagnostics);
214 }
215
216 pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
219 self.entries
220 .iter()
221 .filter(|((_, stored_file), _)| stored_file == file)
222 .flat_map(|(_, entry)| entry.diagnostics.iter())
223 .collect()
224 }
225
226 pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
229 self.entries
230 .iter()
231 .filter(|((_, stored_file), _)| stored_file == file)
232 .map(|((key, _), entry)| (key, entry))
233 .collect()
234 }
235
236 pub fn has_any_report_for_file(&self, file: &Path) -> bool {
238 self.entries.keys().any(|(_, f)| f == file)
239 }
240
241 pub fn has_publish_for_file_after(
245 &self,
246 server: &ServerKey,
247 file: &Path,
248 since: Instant,
249 ) -> bool {
250 self.last_publish_at_for_file
251 .get(&(server.clone(), file.to_path_buf()))
252 .is_some_and(|published_at| *published_at >= since)
253 }
254
255 pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
257 self.entries
258 .iter()
259 .filter(|((_, stored_file), _)| stored_file.starts_with(dir))
260 .flat_map(|(_, entry)| entry.diagnostics.iter())
261 .collect()
262 }
263
264 pub fn all(&self) -> Vec<&StoredDiagnostic> {
266 self.entries
267 .values()
268 .flat_map(|entry| entry.diagnostics.iter())
269 .collect()
270 }
271
272 pub fn clear_server(&mut self, server: ServerKind) {
276 self.entries
277 .retain(|(stored_key, _), _| stored_key.kind != server);
278 self.order
279 .retain(|(stored_key, _)| stored_key.kind != server);
280 self.last_publish_at_for_file
281 .retain(|(stored_key, _), _| stored_key.kind != server);
282 }
283
284 pub fn clear_for_server(&mut self, key: &ServerKey) {
286 self.entries.retain(|(k, _), _| k != key);
287 self.order.retain(|(k, _)| k != key);
288 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
289 }
290
291 pub fn clear_server_instance(&mut self, key: &ServerKey) {
294 self.clear_for_server(key);
295 }
296
297 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
299 if self.order.is_empty() {
300 return None;
301 }
302 let evicted = self.order.remove(0);
303 self.entries.remove(&evicted);
304 self.last_publish_at_for_file.remove(&evicted);
305 Some(evicted)
306 }
307
308 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
309 if let Some(idx) = self.order.iter().position(|k| k == key) {
310 let removed = self.order.remove(idx);
311 self.order.push(removed);
312 }
313 }
314}
315
316impl Default for DiagnosticsStore {
317 fn default() -> Self {
318 Self::new()
319 }
320}
321
322pub fn from_lsp_diagnostics(
325 file: PathBuf,
326 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
327) -> Vec<StoredDiagnostic> {
328 lsp_diagnostics
329 .into_iter()
330 .map(|diagnostic| StoredDiagnostic {
331 file: file.clone(),
332 line: diagnostic.range.start.line + 1,
333 column: diagnostic.range.start.character + 1,
334 end_line: diagnostic.range.end.line + 1,
335 end_column: diagnostic.range.end.character + 1,
336 severity: match diagnostic.severity {
337 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
338 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
339 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
340 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
341 _ => DiagnosticSeverity::Warning,
342 },
343 message: diagnostic.message,
344 code: diagnostic.code.map(|code| match code {
345 lsp_types::NumberOrString::Number(value) => value.to_string(),
346 lsp_types::NumberOrString::String(value) => value,
347 }),
348 source: diagnostic.source,
349 })
350 .collect()
351}
352
353#[cfg(test)]
354mod tests {
355 use std::path::{Path, PathBuf};
356
357 use lsp_types::{
358 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
359 };
360
361 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
362 use crate::lsp::registry::ServerKind;
363 use crate::lsp::roots::ServerKey;
364
365 fn server_key(kind: ServerKind) -> ServerKey {
366 ServerKey {
367 kind,
368 root: PathBuf::from("/tmp/repo"),
369 }
370 }
371
372 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
373 StoredDiagnostic {
374 file: PathBuf::from(file),
375 line,
376 column: 1,
377 end_line: line,
378 end_column: 2,
379 severity: sev,
380 message: msg.into(),
381 code: None,
382 source: None,
383 }
384 }
385
386 #[test]
387 fn converts_lsp_positions_to_one_based() {
388 let file = PathBuf::from("/tmp/demo.rs");
389 let diagnostics = from_lsp_diagnostics(
390 file.clone(),
391 vec![Diagnostic {
392 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
393 severity: Some(LspDiagnosticSeverity::ERROR),
394 code: Some(NumberOrString::String("E1".into())),
395 code_description: None,
396 source: Some("fake".into()),
397 message: "boom".into(),
398 related_information: None,
399 tags: None,
400 data: None,
401 }],
402 );
403
404 assert_eq!(diagnostics.len(), 1);
405 assert_eq!(diagnostics[0].file, file);
406 assert_eq!(diagnostics[0].line, 1);
407 assert_eq!(diagnostics[0].column, 1);
408 assert_eq!(diagnostics[0].end_line, 2);
409 assert_eq!(diagnostics[0].end_column, 5);
410 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
411 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
412 }
413
414 #[test]
415 fn publish_replaces_existing_file_diagnostics() {
416 let file = PathBuf::from("/tmp/demo.rs");
417 let mut store = DiagnosticsStore::new();
418 let key = server_key(ServerKind::Rust);
419
420 store.publish(
421 key.clone(),
422 file.clone(),
423 vec![diag(
424 "/tmp/demo.rs",
425 1,
426 "first",
427 DiagnosticSeverity::Warning,
428 )],
429 );
430 store.publish(
431 key.clone(),
432 file.clone(),
433 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
434 );
435
436 let stored = store.for_file(&file);
437 assert_eq!(stored.len(), 1);
438 assert_eq!(stored[0].message, "second");
439 }
440
441 #[test]
442 fn empty_publish_is_preserved_as_checked_clean() {
443 let file = PathBuf::from("/tmp/clean.rs");
447 let mut store = DiagnosticsStore::new();
448 let key = server_key(ServerKind::Rust);
449
450 store.publish(
452 key.clone(),
453 file.clone(),
454 vec![diag(
455 "/tmp/clean.rs",
456 5,
457 "fix me",
458 DiagnosticSeverity::Warning,
459 )],
460 );
461 assert!(store.has_any_report_for_file(&file));
462 assert_eq!(store.for_file(&file).len(), 1);
463
464 store.publish(key.clone(), file.clone(), Vec::new());
467 assert!(
468 store.has_any_report_for_file(&file),
469 "checked-clean must be distinguishable from never-checked"
470 );
471 assert_eq!(store.for_file(&file).len(), 0);
472
473 let entries = store.entries_for_file(&file);
474 assert_eq!(entries.len(), 1);
475 assert!(entries[0].1.epoch > 0);
476 }
477
478 #[test]
479 fn never_checked_returns_no_report() {
480 let store = DiagnosticsStore::new();
481 let file = PathBuf::from("/tmp/never.rs");
482 assert!(!store.has_any_report_for_file(&file));
483 assert!(store.for_file(&file).is_empty());
484 }
485
486 #[test]
487 fn per_server_state_is_tracked_independently() {
488 let file = PathBuf::from("/tmp/multi.py");
489 let mut store = DiagnosticsStore::new();
490 let pyright_key = server_key(ServerKind::Python);
491 let ty_key = server_key(ServerKind::Ty);
492
493 store.publish(
494 pyright_key,
495 file.clone(),
496 vec![diag(
497 "/tmp/multi.py",
498 1,
499 "pyright says X",
500 DiagnosticSeverity::Error,
501 )],
502 );
503 store.publish(
504 ty_key,
505 file.clone(),
506 vec![diag(
507 "/tmp/multi.py",
508 2,
509 "ty says Y",
510 DiagnosticSeverity::Warning,
511 )],
512 );
513
514 let messages: Vec<&str> = store
515 .for_file(&file)
516 .into_iter()
517 .map(|d| d.message.as_str())
518 .collect();
519
520 assert_eq!(messages.len(), 2, "both servers' reports preserved");
521 assert!(messages.iter().any(|m| m == &"pyright says X"));
522 assert!(messages.iter().any(|m| m == &"ty says Y"));
523 }
524
525 #[test]
526 fn lru_evicts_oldest_when_capacity_exceeded() {
527 let mut store = DiagnosticsStore::with_capacity(2);
528 let key = server_key(ServerKind::Rust);
529
530 store.publish(
531 key.clone(),
532 PathBuf::from("/a.rs"),
533 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
534 );
535 store.publish(
536 key.clone(),
537 PathBuf::from("/b.rs"),
538 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
539 );
540 assert_eq!(store.len(), 2);
541
542 store.publish(
544 key.clone(),
545 PathBuf::from("/c.rs"),
546 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
547 );
548 assert_eq!(store.len(), 2);
549 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
550 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
551 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
552 }
553
554 #[test]
555 fn touching_existing_entry_moves_it_to_end_of_lru() {
556 let mut store = DiagnosticsStore::with_capacity(2);
557 let key = server_key(ServerKind::Rust);
558
559 store.publish(
560 key.clone(),
561 PathBuf::from("/a.rs"),
562 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
563 );
564 store.publish(
565 key.clone(),
566 PathBuf::from("/b.rs"),
567 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
568 );
569
570 store.publish(
573 key.clone(),
574 PathBuf::from("/a.rs"),
575 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
576 );
577 store.publish(
578 key.clone(),
579 PathBuf::from("/c.rs"),
580 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
581 );
582
583 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
584 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
585 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
586 }
587
588 #[test]
589 fn capacity_zero_disables_eviction() {
590 let mut store = DiagnosticsStore::with_capacity(0);
591 let key = server_key(ServerKind::Rust);
592
593 for i in 0..50 {
594 store.publish(
595 key.clone(),
596 PathBuf::from(format!("/f{i}.rs")),
597 vec![diag(
598 &format!("/f{i}.rs"),
599 1,
600 "x",
601 DiagnosticSeverity::Warning,
602 )],
603 );
604 }
605 assert_eq!(store.len(), 50);
606 }
607
608 #[test]
609 fn set_capacity_evicts_on_shrink() {
610 let mut store = DiagnosticsStore::with_capacity(0);
611 let key = server_key(ServerKind::Rust);
612 for i in 0..10 {
613 store.publish(
614 key.clone(),
615 PathBuf::from(format!("/f{i}.rs")),
616 vec![diag(
617 &format!("/f{i}.rs"),
618 1,
619 "x",
620 DiagnosticSeverity::Warning,
621 )],
622 );
623 }
624 assert_eq!(store.len(), 10);
625
626 store.set_capacity(3);
627 assert_eq!(store.len(), 3);
628 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
630 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
631 }
632
633 #[test]
634 fn epoch_increments_monotonically() {
635 let mut store = DiagnosticsStore::new();
636 let key = server_key(ServerKind::Rust);
637 let file = PathBuf::from("/e.rs");
638
639 store.publish(key.clone(), file.clone(), Vec::new());
640 let e1 = store.entries_for_file(&file)[0].1.epoch;
641
642 store.publish(key.clone(), file.clone(), Vec::new());
643 let e2 = store.entries_for_file(&file)[0].1.epoch;
644
645 assert!(e2 > e1, "epoch must increase on republish");
646 }
647
648 #[test]
649 fn result_id_is_round_tripped() {
650 let mut store = DiagnosticsStore::new();
651 let key = server_key(ServerKind::Rust);
652 let file = PathBuf::from("/r.rs");
653
654 store.publish_with_result_id(
655 key.clone(),
656 file.clone(),
657 Vec::new(),
658 Some("rev-42".to_string()),
659 );
660
661 let entries = store.entries_for_file(&file);
662 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
663 }
664
665 #[test]
666 fn clear_server_drops_all_entries_for_kind() {
667 let mut store = DiagnosticsStore::new();
668 let py_key = server_key(ServerKind::Python);
669 let rust_key = server_key(ServerKind::Rust);
670
671 store.publish(
672 py_key.clone(),
673 PathBuf::from("/a.py"),
674 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
675 );
676 store.publish(
677 rust_key.clone(),
678 PathBuf::from("/b.rs"),
679 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
680 );
681
682 store.clear_server(ServerKind::Python);
683 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
684 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
685 }
686}