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_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
244 self.entries
245 .contains_key(&(server.clone(), file.to_path_buf()))
246 }
247
248 pub fn has_publish_for_file_after(
252 &self,
253 server: &ServerKey,
254 file: &Path,
255 since: Instant,
256 ) -> bool {
257 self.last_publish_at_for_file
258 .get(&(server.clone(), file.to_path_buf()))
259 .is_some_and(|published_at| *published_at >= since)
260 }
261
262 pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
264 self.entries
265 .iter()
266 .filter(|((_, stored_file), _)| stored_file.starts_with(dir))
267 .flat_map(|(_, entry)| entry.diagnostics.iter())
268 .collect()
269 }
270
271 pub fn all(&self) -> Vec<&StoredDiagnostic> {
273 self.entries
274 .values()
275 .flat_map(|entry| entry.diagnostics.iter())
276 .collect()
277 }
278
279 pub fn error_warning_counts(&self) -> (usize, usize) {
283 let mut errors = 0usize;
284 let mut warnings = 0usize;
285 for entry in self.entries.values() {
286 for diagnostic in &entry.diagnostics {
287 match diagnostic.severity {
288 DiagnosticSeverity::Error => errors += 1,
289 DiagnosticSeverity::Warning => warnings += 1,
290 _ => {}
291 }
292 }
293 }
294 (errors, warnings)
295 }
296
297 pub fn clear_server(&mut self, server: ServerKind) {
301 self.entries
302 .retain(|(stored_key, _), _| stored_key.kind != server);
303 self.order
304 .retain(|(stored_key, _)| stored_key.kind != server);
305 self.last_publish_at_for_file
306 .retain(|(stored_key, _), _| stored_key.kind != server);
307 }
308
309 pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
311 let cache_key = (key.clone(), file.to_path_buf());
312 self.entries.remove(&cache_key);
313 self.order.retain(|entry_key| entry_key != &cache_key);
314 self.last_publish_at_for_file.remove(&cache_key);
315 }
316
317 pub fn clear_for_file(&mut self, file: &Path) -> bool {
323 let before = self.entries.len();
324 self.entries
325 .retain(|(_, stored_file), _| stored_file != file);
326 let removed = self.entries.len() != before;
327 if removed {
328 self.order.retain(|(_, stored_file)| stored_file != file);
329 self.last_publish_at_for_file
330 .retain(|(_, stored_file), _| stored_file != file);
331 }
332 removed
333 }
334
335 pub fn clear_for_server(&mut self, key: &ServerKey) {
337 self.entries.retain(|(k, _), _| k != key);
338 self.order.retain(|(k, _)| k != key);
339 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
340 }
341
342 pub fn clear_server_instance(&mut self, key: &ServerKey) {
345 self.clear_for_server(key);
346 }
347
348 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
350 if self.order.is_empty() {
351 return None;
352 }
353 let evicted = self.order.remove(0);
354 self.entries.remove(&evicted);
355 self.last_publish_at_for_file.remove(&evicted);
356 Some(evicted)
357 }
358
359 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
360 if let Some(idx) = self.order.iter().position(|k| k == key) {
361 let removed = self.order.remove(idx);
362 self.order.push(removed);
363 }
364 }
365}
366
367impl Default for DiagnosticsStore {
368 fn default() -> Self {
369 Self::new()
370 }
371}
372
373pub fn from_lsp_diagnostics(
376 file: PathBuf,
377 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
378) -> Vec<StoredDiagnostic> {
379 lsp_diagnostics
380 .into_iter()
381 .map(|diagnostic| StoredDiagnostic {
382 file: file.clone(),
383 line: diagnostic.range.start.line + 1,
384 column: diagnostic.range.start.character + 1,
385 end_line: diagnostic.range.end.line + 1,
386 end_column: diagnostic.range.end.character + 1,
387 severity: match diagnostic.severity {
388 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
389 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
390 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
391 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
392 _ => DiagnosticSeverity::Warning,
393 },
394 message: diagnostic.message,
395 code: diagnostic.code.map(|code| match code {
396 lsp_types::NumberOrString::Number(value) => value.to_string(),
397 lsp_types::NumberOrString::String(value) => value,
398 }),
399 source: diagnostic.source,
400 })
401 .collect()
402}
403
404#[cfg(test)]
405mod tests {
406 use std::path::{Path, PathBuf};
407
408 use lsp_types::{
409 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
410 };
411
412 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
413 use crate::lsp::registry::ServerKind;
414 use crate::lsp::roots::ServerKey;
415
416 fn server_key(kind: ServerKind) -> ServerKey {
417 ServerKey {
418 kind,
419 root: PathBuf::from("/tmp/repo"),
420 }
421 }
422
423 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
424 StoredDiagnostic {
425 file: PathBuf::from(file),
426 line,
427 column: 1,
428 end_line: line,
429 end_column: 2,
430 severity: sev,
431 message: msg.into(),
432 code: None,
433 source: None,
434 }
435 }
436
437 #[test]
438 fn converts_lsp_positions_to_one_based() {
439 let file = PathBuf::from("/tmp/demo.rs");
440 let diagnostics = from_lsp_diagnostics(
441 file.clone(),
442 vec![Diagnostic {
443 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
444 severity: Some(LspDiagnosticSeverity::ERROR),
445 code: Some(NumberOrString::String("E1".into())),
446 code_description: None,
447 source: Some("fake".into()),
448 message: "boom".into(),
449 related_information: None,
450 tags: None,
451 data: None,
452 }],
453 );
454
455 assert_eq!(diagnostics.len(), 1);
456 assert_eq!(diagnostics[0].file, file);
457 assert_eq!(diagnostics[0].line, 1);
458 assert_eq!(diagnostics[0].column, 1);
459 assert_eq!(diagnostics[0].end_line, 2);
460 assert_eq!(diagnostics[0].end_column, 5);
461 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
462 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
463 }
464
465 #[test]
466 fn publish_replaces_existing_file_diagnostics() {
467 let file = PathBuf::from("/tmp/demo.rs");
468 let mut store = DiagnosticsStore::new();
469 let key = server_key(ServerKind::Rust);
470
471 store.publish(
472 key.clone(),
473 file.clone(),
474 vec![diag(
475 "/tmp/demo.rs",
476 1,
477 "first",
478 DiagnosticSeverity::Warning,
479 )],
480 );
481 store.publish(
482 key.clone(),
483 file.clone(),
484 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
485 );
486
487 let stored = store.for_file(&file);
488 assert_eq!(stored.len(), 1);
489 assert_eq!(stored[0].message, "second");
490 }
491
492 #[test]
493 fn empty_publish_is_preserved_as_checked_clean() {
494 let file = PathBuf::from("/tmp/clean.rs");
498 let mut store = DiagnosticsStore::new();
499 let key = server_key(ServerKind::Rust);
500
501 store.publish(
503 key.clone(),
504 file.clone(),
505 vec![diag(
506 "/tmp/clean.rs",
507 5,
508 "fix me",
509 DiagnosticSeverity::Warning,
510 )],
511 );
512 assert!(store.has_any_report_for_file(&file));
513 assert_eq!(store.for_file(&file).len(), 1);
514
515 store.publish(key.clone(), file.clone(), Vec::new());
518 assert!(
519 store.has_any_report_for_file(&file),
520 "checked-clean must be distinguishable from never-checked"
521 );
522 assert_eq!(store.for_file(&file).len(), 0);
523
524 let entries = store.entries_for_file(&file);
525 assert_eq!(entries.len(), 1);
526 assert!(entries[0].1.epoch > 0);
527 }
528
529 #[test]
530 fn never_checked_returns_no_report() {
531 let store = DiagnosticsStore::new();
532 let file = PathBuf::from("/tmp/never.rs");
533 assert!(!store.has_any_report_for_file(&file));
534 assert!(store.for_file(&file).is_empty());
535 }
536
537 #[test]
538 fn per_server_state_is_tracked_independently() {
539 let file = PathBuf::from("/tmp/multi.py");
540 let mut store = DiagnosticsStore::new();
541 let pyright_key = server_key(ServerKind::Python);
542 let ty_key = server_key(ServerKind::Ty);
543
544 store.publish(
545 pyright_key,
546 file.clone(),
547 vec![diag(
548 "/tmp/multi.py",
549 1,
550 "pyright says X",
551 DiagnosticSeverity::Error,
552 )],
553 );
554 store.publish(
555 ty_key,
556 file.clone(),
557 vec![diag(
558 "/tmp/multi.py",
559 2,
560 "ty says Y",
561 DiagnosticSeverity::Warning,
562 )],
563 );
564
565 let messages: Vec<&str> = store
566 .for_file(&file)
567 .into_iter()
568 .map(|d| d.message.as_str())
569 .collect();
570
571 assert_eq!(messages.len(), 2, "both servers' reports preserved");
572 assert!(messages.iter().any(|m| m == &"pyright says X"));
573 assert!(messages.iter().any(|m| m == &"ty says Y"));
574 }
575
576 #[test]
577 fn clear_for_server_file_removes_only_exact_entry() {
578 let file_a = PathBuf::from("/tmp/a.rs");
579 let file_b = PathBuf::from("/tmp/b.rs");
580 let mut store = DiagnosticsStore::new();
581 let rust_key = server_key(ServerKind::Rust);
582 let py_key = server_key(ServerKind::Python);
583
584 store.publish(
585 rust_key.clone(),
586 file_a.clone(),
587 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
588 );
589 store.publish(
590 rust_key.clone(),
591 file_b.clone(),
592 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
593 );
594 store.publish(
595 py_key.clone(),
596 file_a.clone(),
597 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
598 );
599
600 store.clear_for_server_file(&rust_key, &file_a);
601
602 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
603 assert!(store.has_report_for_server_file(&rust_key, &file_b));
604 assert!(store.has_report_for_server_file(&py_key, &file_a));
605 }
606
607 #[test]
608 fn lru_evicts_oldest_when_capacity_exceeded() {
609 let mut store = DiagnosticsStore::with_capacity(2);
610 let key = server_key(ServerKind::Rust);
611
612 store.publish(
613 key.clone(),
614 PathBuf::from("/a.rs"),
615 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
616 );
617 store.publish(
618 key.clone(),
619 PathBuf::from("/b.rs"),
620 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
621 );
622 assert_eq!(store.len(), 2);
623
624 store.publish(
626 key.clone(),
627 PathBuf::from("/c.rs"),
628 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
629 );
630 assert_eq!(store.len(), 2);
631 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
632 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
633 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
634 }
635
636 #[test]
637 fn touching_existing_entry_moves_it_to_end_of_lru() {
638 let mut store = DiagnosticsStore::with_capacity(2);
639 let key = server_key(ServerKind::Rust);
640
641 store.publish(
642 key.clone(),
643 PathBuf::from("/a.rs"),
644 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
645 );
646 store.publish(
647 key.clone(),
648 PathBuf::from("/b.rs"),
649 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
650 );
651
652 store.publish(
655 key.clone(),
656 PathBuf::from("/a.rs"),
657 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
658 );
659 store.publish(
660 key.clone(),
661 PathBuf::from("/c.rs"),
662 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
663 );
664
665 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
666 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
667 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
668 }
669
670 #[test]
671 fn capacity_zero_disables_eviction() {
672 let mut store = DiagnosticsStore::with_capacity(0);
673 let key = server_key(ServerKind::Rust);
674
675 for i in 0..50 {
676 store.publish(
677 key.clone(),
678 PathBuf::from(format!("/f{i}.rs")),
679 vec![diag(
680 &format!("/f{i}.rs"),
681 1,
682 "x",
683 DiagnosticSeverity::Warning,
684 )],
685 );
686 }
687 assert_eq!(store.len(), 50);
688 }
689
690 #[test]
691 fn set_capacity_evicts_on_shrink() {
692 let mut store = DiagnosticsStore::with_capacity(0);
693 let key = server_key(ServerKind::Rust);
694 for i in 0..10 {
695 store.publish(
696 key.clone(),
697 PathBuf::from(format!("/f{i}.rs")),
698 vec![diag(
699 &format!("/f{i}.rs"),
700 1,
701 "x",
702 DiagnosticSeverity::Warning,
703 )],
704 );
705 }
706 assert_eq!(store.len(), 10);
707
708 store.set_capacity(3);
709 assert_eq!(store.len(), 3);
710 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
712 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
713 }
714
715 #[test]
716 fn epoch_increments_monotonically() {
717 let mut store = DiagnosticsStore::new();
718 let key = server_key(ServerKind::Rust);
719 let file = PathBuf::from("/e.rs");
720
721 store.publish(key.clone(), file.clone(), Vec::new());
722 let e1 = store.entries_for_file(&file)[0].1.epoch;
723
724 store.publish(key.clone(), file.clone(), Vec::new());
725 let e2 = store.entries_for_file(&file)[0].1.epoch;
726
727 assert!(e2 > e1, "epoch must increase on republish");
728 }
729
730 #[test]
731 fn result_id_is_round_tripped() {
732 let mut store = DiagnosticsStore::new();
733 let key = server_key(ServerKind::Rust);
734 let file = PathBuf::from("/r.rs");
735
736 store.publish_with_result_id(
737 key.clone(),
738 file.clone(),
739 Vec::new(),
740 Some("rev-42".to_string()),
741 );
742
743 let entries = store.entries_for_file(&file);
744 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
745 }
746
747 #[test]
748 fn clear_server_drops_all_entries_for_kind() {
749 let mut store = DiagnosticsStore::new();
750 let py_key = server_key(ServerKind::Python);
751 let rust_key = server_key(ServerKind::Rust);
752
753 store.publish(
754 py_key.clone(),
755 PathBuf::from("/a.py"),
756 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
757 );
758 store.publish(
759 rust_key.clone(),
760 PathBuf::from("/b.rs"),
761 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
762 );
763
764 store.clear_server(ServerKind::Python);
765 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
766 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
767 }
768
769 #[test]
770 fn clear_for_file_drops_every_server_entry_and_updates_counts() {
771 let mut store = DiagnosticsStore::new();
772 let py_key = server_key(ServerKind::Python);
773 let biome_key = server_key(ServerKind::Biome);
774
775 store.publish(
778 py_key,
779 PathBuf::from("/gone.ts"),
780 vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
781 );
782 store.publish(
783 biome_key,
784 PathBuf::from("/gone.ts"),
785 vec![diag(
786 "/gone.ts",
787 7,
788 "lint warning",
789 DiagnosticSeverity::Warning,
790 )],
791 );
792 store.publish(
793 server_key(ServerKind::Rust),
794 PathBuf::from("/keep.rs"),
795 vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
796 );
797
798 assert_eq!(store.error_warning_counts(), (2, 1));
799
800 let removed = store.clear_for_file(Path::new("/gone.ts"));
802 assert!(removed);
803 assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
804 assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
806 assert_eq!(store.error_warning_counts(), (1, 0));
807
808 assert!(!store.clear_for_file(Path::new("/gone.ts")));
810 }
811}