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 pub stale: bool,
68}
69
70pub struct DiagnosticsStore {
89 entries: HashMap<(ServerKey, PathBuf), DiagnosticEntry>,
91 order: Vec<(ServerKey, PathBuf)>,
94 capacity: usize,
96 next_epoch: u64,
98 generation: u64,
101 last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
104}
105
106impl DiagnosticsStore {
107 pub fn new() -> Self {
108 Self::with_capacity(5000)
109 }
110
111 pub fn with_capacity(capacity: usize) -> Self {
112 Self {
113 entries: HashMap::new(),
114 order: Vec::new(),
115 capacity,
116 next_epoch: 0,
117 generation: 0,
118 last_publish_at_for_file: HashMap::new(),
119 }
120 }
121
122 pub fn set_capacity(&mut self, capacity: usize) {
126 if self.capacity != capacity {
127 self.generation = self.generation.wrapping_add(1);
128 }
129 self.capacity = capacity;
130 if capacity > 0 {
131 while self.entries.len() > capacity {
132 self.evict_lru();
133 }
134 }
135 }
136
137 pub fn len(&self) -> usize {
139 self.entries.len()
140 }
141
142 pub fn generation(&self) -> u64 {
143 self.generation
144 }
145
146 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
147 let mut diagnostic_count = 0usize;
148 let entry_bytes = self
149 .entries
150 .iter()
151 .fold(0u64, |bytes, ((server, path), entry)| {
152 diagnostic_count = diagnostic_count.saturating_add(entry.diagnostics.len());
153 let diagnostics_bytes = entry.diagnostics.iter().fold(0u64, |bytes, diagnostic| {
154 bytes
155 .saturating_add(std::mem::size_of::<StoredDiagnostic>() as u64)
156 .saturating_add(crate::memory::path_bytes(&diagnostic.file))
157 .saturating_add(crate::memory::usize_to_u64(diagnostic.message.len()))
158 .saturating_add(
159 diagnostic
160 .code
161 .as_ref()
162 .map(|code| crate::memory::usize_to_u64(code.len()))
163 .unwrap_or(0),
164 )
165 .saturating_add(
166 diagnostic
167 .source
168 .as_ref()
169 .map(|source| crate::memory::usize_to_u64(source.len()))
170 .unwrap_or(0),
171 )
172 });
173 bytes
174 .saturating_add(std::mem::size_of_val(server) as u64)
175 .saturating_add(crate::memory::path_bytes(&server.root))
176 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
177 .saturating_add(crate::memory::path_bytes(path))
178 .saturating_add(std::mem::size_of::<DiagnosticEntry>() as u64)
179 .saturating_add(
180 entry
181 .result_id
182 .as_ref()
183 .map(|result_id| crate::memory::usize_to_u64(result_id.len()))
184 .unwrap_or(0),
185 )
186 .saturating_add(diagnostics_bytes)
187 });
188 let order_bytes = self.order.iter().fold(0u64, |bytes, (server, path)| {
189 bytes
190 .saturating_add(std::mem::size_of_val(server) as u64)
191 .saturating_add(crate::memory::path_bytes(&server.root))
192 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
193 .saturating_add(crate::memory::path_bytes(path))
194 });
195 let publish_bytes =
196 self.last_publish_at_for_file
197 .iter()
198 .fold(0u64, |bytes, ((server, path), _)| {
199 bytes
200 .saturating_add(std::mem::size_of_val(server) as u64)
201 .saturating_add(crate::memory::path_bytes(&server.root))
202 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
203 .saturating_add(crate::memory::path_bytes(path))
204 .saturating_add(std::mem::size_of::<Instant>() as u64)
205 });
206 crate::memory::MemoryEstimate::estimated(
207 entry_bytes
208 .saturating_add(order_bytes)
209 .saturating_add(publish_bytes),
210 )
211 .count("diagnostic_entries", self.entries.len())
212 .count("diagnostics", diagnostic_count)
213 }
214
215 #[cfg(test)]
218 pub fn capacity_for_test(&self) -> usize {
219 self.capacity
220 }
221
222 pub fn is_empty(&self) -> bool {
223 self.entries.is_empty()
224 }
225
226 pub fn has_any_fresh_report(&self) -> bool {
229 self.entries.values().any(|entry| !entry.stale)
230 }
231
232 pub fn publish(
241 &mut self,
242 server: ServerKey,
243 file: PathBuf,
244 diagnostics: Vec<StoredDiagnostic>,
245 ) {
246 self.publish_with_result_id(server, file, diagnostics, None);
247 }
248
249 pub fn publish_with_result_id(
252 &mut self,
253 server: ServerKey,
254 file: PathBuf,
255 diagnostics: Vec<StoredDiagnostic>,
256 result_id: Option<String>,
257 ) {
258 self.publish_full(server, file, diagnostics, result_id, None);
259 }
260
261 pub fn publish_full(
265 &mut self,
266 server: ServerKey,
267 file: PathBuf,
268 diagnostics: Vec<StoredDiagnostic>,
269 result_id: Option<String>,
270 version: Option<i32>,
271 ) {
272 let key = (server, file);
273 self.next_epoch = self.next_epoch.saturating_add(1);
274 self.generation = self.generation.wrapping_add(1);
275 let entry = DiagnosticEntry {
276 diagnostics,
277 epoch: self.next_epoch,
278 result_id,
279 version,
280 stale: false,
281 };
282
283 self.last_publish_at_for_file
284 .insert(key.clone(), Instant::now());
285
286 if self.entries.contains_key(&key) {
287 self.entries.insert(key.clone(), entry);
288 self.touch_existing(&key);
289 } else {
290 if self.capacity > 0 && self.entries.len() >= self.capacity {
292 self.evict_lru();
293 }
294 self.entries.insert(key.clone(), entry);
295 self.order.push(key);
296 }
297 }
298
299 pub fn publish_with_kind(
305 &mut self,
306 kind: ServerKind,
307 file: PathBuf,
308 diagnostics: Vec<StoredDiagnostic>,
309 ) {
310 let key = ServerKey {
311 kind,
312 root: PathBuf::new(),
313 };
314 self.publish(key, file, diagnostics);
315 }
316
317 pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
320 self.entries
321 .iter()
322 .filter(|((_, stored_file), entry)| stored_file == file && !entry.stale)
323 .flat_map(|(_, entry)| entry.diagnostics.iter())
324 .collect()
325 }
326
327 pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
330 self.entries
331 .iter()
332 .filter(|((_, stored_file), _)| stored_file == file)
333 .map(|((key, _), entry)| (key, entry))
334 .collect()
335 }
336
337 pub fn has_any_report_for_file(&self, file: &Path) -> bool {
339 self.entries.keys().any(|(_, f)| f == file)
340 }
341
342 pub fn has_any_fresh_report_for_file(&self, file: &Path) -> bool {
344 self.entries
345 .iter()
346 .any(|((_, stored_file), entry)| stored_file == file && !entry.stale)
347 }
348
349 pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
353 self.entries
354 .contains_key(&(server.clone(), file.to_path_buf()))
355 }
356
357 pub fn has_fresh_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
359 self.entries
360 .get(&(server.clone(), file.to_path_buf()))
361 .is_some_and(|entry| !entry.stale)
362 }
363
364 pub fn has_publish_for_file_after(
368 &self,
369 server: &ServerKey,
370 file: &Path,
371 since: Instant,
372 ) -> bool {
373 self.last_publish_at_for_file
374 .get(&(server.clone(), file.to_path_buf()))
375 .is_some_and(|published_at| {
376 *published_at >= since && self.has_fresh_report_for_server_file(server, file)
377 })
378 }
379
380 pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
382 self.entries
383 .iter()
384 .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
385 .flat_map(|(_, entry)| entry.diagnostics.iter())
386 .collect()
387 }
388
389 pub fn all(&self) -> Vec<&StoredDiagnostic> {
391 self.entries
392 .values()
393 .filter(|entry| !entry.stale)
394 .flat_map(|entry| entry.diagnostics.iter())
395 .collect()
396 }
397
398 pub fn error_warning_counts(&self) -> (usize, usize) {
404 let mut errors = 0usize;
405 let mut warnings = 0usize;
406 for entry in self.entries.values() {
407 if entry.stale {
408 continue;
409 }
410 for diagnostic in &entry.diagnostics {
411 match diagnostic.severity {
412 DiagnosticSeverity::Error => errors += 1,
413 DiagnosticSeverity::Warning => warnings += 1,
414 _ => {}
415 }
416 }
417 }
418 (errors, warnings)
419 }
420
421 pub fn filtered_error_warning_counts(
434 &self,
435 mut keep: impl FnMut(&Path) -> bool,
436 ) -> (usize, usize) {
437 let mut seen: std::collections::HashSet<(
441 &Path,
442 u32,
443 u32,
444 u32,
445 u32,
446 &str,
447 &str,
448 Option<&str>,
449 )> = std::collections::HashSet::new();
450 let mut errors = 0usize;
451 let mut warnings = 0usize;
452 for ((_, file), entry) in &self.entries {
453 if entry.stale {
454 continue;
455 }
456 if !keep(file) {
460 continue;
461 }
462 for diagnostic in &entry.diagnostics {
463 if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
464 continue;
465 }
466 let dedup_key = (
467 diagnostic.file.as_path(),
468 diagnostic.line,
469 diagnostic.column,
470 diagnostic.end_line,
471 diagnostic.end_column,
472 diagnostic.severity.as_str(),
473 diagnostic.message.as_str(),
474 diagnostic.source.as_deref(),
475 );
476 if !seen.insert(dedup_key) {
477 continue;
478 }
479 match diagnostic.severity {
480 DiagnosticSeverity::Error => errors += 1,
481 DiagnosticSeverity::Warning => warnings += 1,
482 _ => {}
483 }
484 }
485 }
486 (errors, warnings)
487 }
488
489 pub fn clear_server(&mut self, server: ServerKind) {
493 let before = self.entries.len();
494 self.entries
495 .retain(|(stored_key, _), _| stored_key.kind != server);
496 self.order
497 .retain(|(stored_key, _)| stored_key.kind != server);
498 self.last_publish_at_for_file
499 .retain(|(stored_key, _), _| stored_key.kind != server);
500 if self.entries.len() != before {
501 self.generation = self.generation.wrapping_add(1);
502 }
503 }
504
505 pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
507 let cache_key = (key.clone(), file.to_path_buf());
508 if self.entries.remove(&cache_key).is_some() {
509 self.generation = self.generation.wrapping_add(1);
510 }
511 self.order.retain(|entry_key| entry_key != &cache_key);
512 self.last_publish_at_for_file.remove(&cache_key);
513 }
514
515 pub fn clear_for_file(&mut self, file: &Path) -> bool {
521 let before = self.entries.len();
522 self.entries
523 .retain(|(_, stored_file), _| stored_file != file);
524 let removed = self.entries.len() != before;
525 if removed {
526 self.generation = self.generation.wrapping_add(1);
527 self.order.retain(|(_, stored_file)| stored_file != file);
528 self.last_publish_at_for_file
529 .retain(|(_, stored_file), _| stored_file != file);
530 }
531 removed
532 }
533
534 pub fn mark_stale_for_file(&mut self, file: &Path) -> (bool, bool) {
542 let mut had_entries = false;
543 let mut changed = false;
544 for ((_, stored_file), entry) in &mut self.entries {
545 if stored_file != file {
546 continue;
547 }
548 had_entries = true;
549 if !entry.stale {
550 entry.stale = true;
551 changed = true;
552 }
553 }
554 if changed {
555 self.generation = self.generation.wrapping_add(1);
556 }
557 (had_entries, changed)
558 }
559
560 pub fn mark_fresh_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
563 let cache_key = (key.clone(), file.to_path_buf());
564 let Some(entry) = self.entries.get_mut(&cache_key) else {
565 return false;
566 };
567 let changed = entry.stale;
568 entry.stale = false;
569 if changed {
570 self.generation = self.generation.wrapping_add(1);
571 }
572 self.touch_existing(&cache_key);
573 changed
574 }
575
576 pub fn clear_for_server(&mut self, key: &ServerKey) {
578 let before = self.entries.len();
579 self.entries.retain(|(k, _), _| k != key);
580 self.order.retain(|(k, _)| k != key);
581 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
582 if self.entries.len() != before {
583 self.generation = self.generation.wrapping_add(1);
584 }
585 }
586
587 pub fn clear_server_instance(&mut self, key: &ServerKey) {
590 self.clear_for_server(key);
591 }
592
593 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
595 if self.order.is_empty() {
596 return None;
597 }
598 let evicted = self.order.remove(0);
599 self.entries.remove(&evicted);
600 self.last_publish_at_for_file.remove(&evicted);
601 Some(evicted)
602 }
603
604 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
605 if let Some(idx) = self.order.iter().position(|k| k == key) {
606 let removed = self.order.remove(idx);
607 self.order.push(removed);
608 }
609 }
610}
611
612impl Default for DiagnosticsStore {
613 fn default() -> Self {
614 Self::new()
615 }
616}
617
618pub fn from_lsp_diagnostics(
621 file: PathBuf,
622 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
623) -> Vec<StoredDiagnostic> {
624 lsp_diagnostics
625 .into_iter()
626 .map(|diagnostic| StoredDiagnostic {
627 file: file.clone(),
628 line: diagnostic.range.start.line + 1,
629 column: diagnostic.range.start.character + 1,
630 end_line: diagnostic.range.end.line + 1,
631 end_column: diagnostic.range.end.character + 1,
632 severity: match diagnostic.severity {
633 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
634 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
635 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
636 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
637 _ => DiagnosticSeverity::Warning,
638 },
639 message: diagnostic.message,
640 code: diagnostic.code.map(|code| match code {
641 lsp_types::NumberOrString::Number(value) => value.to_string(),
642 lsp_types::NumberOrString::String(value) => value,
643 }),
644 source: diagnostic.source,
645 })
646 .collect()
647}
648
649#[cfg(test)]
650mod tests {
651 use std::path::{Path, PathBuf};
652
653 use lsp_types::{
654 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
655 };
656
657 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
658 use crate::lsp::registry::ServerKind;
659 use crate::lsp::roots::ServerKey;
660
661 fn server_key(kind: ServerKind) -> ServerKey {
662 ServerKey {
663 kind,
664 root: PathBuf::from("/tmp/repo"),
665 }
666 }
667
668 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
669 StoredDiagnostic {
670 file: PathBuf::from(file),
671 line,
672 column: 1,
673 end_line: line,
674 end_column: 2,
675 severity: sev,
676 message: msg.into(),
677 code: None,
678 source: None,
679 }
680 }
681
682 #[test]
683 fn diagnostics_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
684 let mut store = DiagnosticsStore::new();
685 assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
686 let file = PathBuf::from("/tmp/memory.rs");
687 store.publish(
688 server_key(ServerKind::Rust),
689 file.clone(),
690 vec![diag(
691 file.to_str().unwrap(),
692 1,
693 "resident diagnostic message",
694 DiagnosticSeverity::Warning,
695 )],
696 );
697 let estimate = store.estimated_memory();
698 assert!(estimate.estimated_bytes.unwrap() > 0);
699 assert_eq!(estimate.counts["diagnostic_entries"], 1);
700 assert_eq!(estimate.counts["diagnostics"], 1);
701 }
702
703 #[test]
704 fn converts_lsp_positions_to_one_based() {
705 let file = PathBuf::from("/tmp/demo.rs");
706 let diagnostics = from_lsp_diagnostics(
707 file.clone(),
708 vec![Diagnostic {
709 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
710 severity: Some(LspDiagnosticSeverity::ERROR),
711 code: Some(NumberOrString::String("E1".into())),
712 code_description: None,
713 source: Some("fake".into()),
714 message: "boom".into(),
715 related_information: None,
716 tags: None,
717 data: None,
718 }],
719 );
720
721 assert_eq!(diagnostics.len(), 1);
722 assert_eq!(diagnostics[0].file, file);
723 assert_eq!(diagnostics[0].line, 1);
724 assert_eq!(diagnostics[0].column, 1);
725 assert_eq!(diagnostics[0].end_line, 2);
726 assert_eq!(diagnostics[0].end_column, 5);
727 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
728 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
729 }
730
731 #[test]
732 fn publish_replaces_existing_file_diagnostics() {
733 let file = PathBuf::from("/tmp/demo.rs");
734 let mut store = DiagnosticsStore::new();
735 let key = server_key(ServerKind::Rust);
736
737 store.publish(
738 key.clone(),
739 file.clone(),
740 vec![diag(
741 "/tmp/demo.rs",
742 1,
743 "first",
744 DiagnosticSeverity::Warning,
745 )],
746 );
747 store.publish(
748 key.clone(),
749 file.clone(),
750 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
751 );
752
753 let stored = store.for_file(&file);
754 assert_eq!(stored.len(), 1);
755 assert_eq!(stored[0].message, "second");
756 }
757
758 #[test]
759 fn empty_publish_is_preserved_as_checked_clean() {
760 let file = PathBuf::from("/tmp/clean.rs");
764 let mut store = DiagnosticsStore::new();
765 let key = server_key(ServerKind::Rust);
766
767 store.publish(
769 key.clone(),
770 file.clone(),
771 vec![diag(
772 "/tmp/clean.rs",
773 5,
774 "fix me",
775 DiagnosticSeverity::Warning,
776 )],
777 );
778 assert!(store.has_any_report_for_file(&file));
779 assert_eq!(store.for_file(&file).len(), 1);
780
781 store.publish(key.clone(), file.clone(), Vec::new());
784 assert!(
785 store.has_any_report_for_file(&file),
786 "checked-clean must be distinguishable from never-checked"
787 );
788 assert_eq!(store.for_file(&file).len(), 0);
789
790 let entries = store.entries_for_file(&file);
791 assert_eq!(entries.len(), 1);
792 assert!(entries[0].1.epoch > 0);
793 }
794
795 #[test]
796 fn never_checked_returns_no_report() {
797 let store = DiagnosticsStore::new();
798 let file = PathBuf::from("/tmp/never.rs");
799 assert!(!store.has_any_report_for_file(&file));
800 assert!(store.for_file(&file).is_empty());
801 }
802
803 #[test]
804 fn stale_entries_are_hidden_but_preserved_for_refresh() {
805 let file = PathBuf::from("/tmp/stale.rs");
806 let mut store = DiagnosticsStore::new();
807 let key = server_key(ServerKind::Rust);
808 store.publish(
809 key.clone(),
810 file.clone(),
811 vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
812 );
813
814 let (had_entries, changed) = store.mark_stale_for_file(&file);
815
816 assert!(had_entries);
817 assert!(changed);
818 assert!(store.has_any_report_for_file(&file));
819 assert!(!store.has_any_fresh_report_for_file(&file));
820 assert!(store.for_file(&file).is_empty());
821 assert!(store.all().is_empty());
822 assert_eq!(store.error_warning_counts(), (0, 0));
823 assert_eq!(store.entries_for_file(&file).len(), 1);
824
825 assert!(store.mark_fresh_for_server_file(&key, &file));
826 assert!(store.has_any_fresh_report_for_file(&file));
827 assert_eq!(store.for_file(&file).len(), 1);
828 assert_eq!(store.error_warning_counts(), (1, 0));
829 }
830
831 #[test]
832 fn per_server_state_is_tracked_independently() {
833 let file = PathBuf::from("/tmp/multi.py");
834 let mut store = DiagnosticsStore::new();
835 let pyright_key = server_key(ServerKind::Python);
836 let ty_key = server_key(ServerKind::Ty);
837
838 store.publish(
839 pyright_key,
840 file.clone(),
841 vec![diag(
842 "/tmp/multi.py",
843 1,
844 "pyright says X",
845 DiagnosticSeverity::Error,
846 )],
847 );
848 store.publish(
849 ty_key,
850 file.clone(),
851 vec![diag(
852 "/tmp/multi.py",
853 2,
854 "ty says Y",
855 DiagnosticSeverity::Warning,
856 )],
857 );
858
859 let messages: Vec<&str> = store
860 .for_file(&file)
861 .into_iter()
862 .map(|d| d.message.as_str())
863 .collect();
864
865 assert_eq!(messages.len(), 2, "both servers' reports preserved");
866 assert!(messages.iter().any(|m| m == &"pyright says X"));
867 assert!(messages.iter().any(|m| m == &"ty says Y"));
868 }
869
870 #[test]
871 fn clear_for_server_file_removes_only_exact_entry() {
872 let file_a = PathBuf::from("/tmp/a.rs");
873 let file_b = PathBuf::from("/tmp/b.rs");
874 let mut store = DiagnosticsStore::new();
875 let rust_key = server_key(ServerKind::Rust);
876 let py_key = server_key(ServerKind::Python);
877
878 store.publish(
879 rust_key.clone(),
880 file_a.clone(),
881 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
882 );
883 store.publish(
884 rust_key.clone(),
885 file_b.clone(),
886 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
887 );
888 store.publish(
889 py_key.clone(),
890 file_a.clone(),
891 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
892 );
893
894 store.clear_for_server_file(&rust_key, &file_a);
895
896 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
897 assert!(store.has_report_for_server_file(&rust_key, &file_b));
898 assert!(store.has_report_for_server_file(&py_key, &file_a));
899 }
900
901 #[test]
902 fn lru_evicts_oldest_when_capacity_exceeded() {
903 let mut store = DiagnosticsStore::with_capacity(2);
904 let key = server_key(ServerKind::Rust);
905
906 store.publish(
907 key.clone(),
908 PathBuf::from("/a.rs"),
909 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
910 );
911 store.publish(
912 key.clone(),
913 PathBuf::from("/b.rs"),
914 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
915 );
916 assert_eq!(store.len(), 2);
917
918 store.publish(
920 key.clone(),
921 PathBuf::from("/c.rs"),
922 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
923 );
924 assert_eq!(store.len(), 2);
925 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
926 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
927 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
928 }
929
930 #[test]
931 fn touching_existing_entry_moves_it_to_end_of_lru() {
932 let mut store = DiagnosticsStore::with_capacity(2);
933 let key = server_key(ServerKind::Rust);
934
935 store.publish(
936 key.clone(),
937 PathBuf::from("/a.rs"),
938 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
939 );
940 store.publish(
941 key.clone(),
942 PathBuf::from("/b.rs"),
943 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
944 );
945
946 store.publish(
949 key.clone(),
950 PathBuf::from("/a.rs"),
951 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
952 );
953 store.publish(
954 key.clone(),
955 PathBuf::from("/c.rs"),
956 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
957 );
958
959 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
960 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
961 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
962 }
963
964 #[test]
965 fn capacity_zero_disables_eviction() {
966 let mut store = DiagnosticsStore::with_capacity(0);
967 let key = server_key(ServerKind::Rust);
968
969 for i in 0..50 {
970 store.publish(
971 key.clone(),
972 PathBuf::from(format!("/f{i}.rs")),
973 vec![diag(
974 &format!("/f{i}.rs"),
975 1,
976 "x",
977 DiagnosticSeverity::Warning,
978 )],
979 );
980 }
981 assert_eq!(store.len(), 50);
982 }
983
984 #[test]
985 fn set_capacity_evicts_on_shrink() {
986 let mut store = DiagnosticsStore::with_capacity(0);
987 let key = server_key(ServerKind::Rust);
988 for i in 0..10 {
989 store.publish(
990 key.clone(),
991 PathBuf::from(format!("/f{i}.rs")),
992 vec![diag(
993 &format!("/f{i}.rs"),
994 1,
995 "x",
996 DiagnosticSeverity::Warning,
997 )],
998 );
999 }
1000 assert_eq!(store.len(), 10);
1001
1002 store.set_capacity(3);
1003 assert_eq!(store.len(), 3);
1004 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
1006 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
1007 }
1008
1009 #[test]
1010 fn epoch_increments_monotonically() {
1011 let mut store = DiagnosticsStore::new();
1012 let key = server_key(ServerKind::Rust);
1013 let file = PathBuf::from("/e.rs");
1014
1015 store.publish(key.clone(), file.clone(), Vec::new());
1016 let e1 = store.entries_for_file(&file)[0].1.epoch;
1017
1018 store.publish(key.clone(), file.clone(), Vec::new());
1019 let e2 = store.entries_for_file(&file)[0].1.epoch;
1020
1021 assert!(e2 > e1, "epoch must increase on republish");
1022 }
1023
1024 #[test]
1025 fn result_id_is_round_tripped() {
1026 let mut store = DiagnosticsStore::new();
1027 let key = server_key(ServerKind::Rust);
1028 let file = PathBuf::from("/r.rs");
1029
1030 store.publish_with_result_id(
1031 key.clone(),
1032 file.clone(),
1033 Vec::new(),
1034 Some("rev-42".to_string()),
1035 );
1036
1037 let entries = store.entries_for_file(&file);
1038 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
1039 }
1040
1041 #[test]
1042 fn clear_server_drops_all_entries_for_kind() {
1043 let mut store = DiagnosticsStore::new();
1044 let py_key = server_key(ServerKind::Python);
1045 let rust_key = server_key(ServerKind::Rust);
1046
1047 store.publish(
1048 py_key.clone(),
1049 PathBuf::from("/a.py"),
1050 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
1051 );
1052 store.publish(
1053 rust_key.clone(),
1054 PathBuf::from("/b.rs"),
1055 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
1056 );
1057
1058 store.clear_server(ServerKind::Python);
1059 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
1060 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1061 }
1062
1063 #[test]
1064 fn clear_for_file_drops_every_server_entry_and_updates_counts() {
1065 let mut store = DiagnosticsStore::new();
1066 let py_key = server_key(ServerKind::Python);
1067 let biome_key = server_key(ServerKind::Biome);
1068
1069 store.publish(
1072 py_key,
1073 PathBuf::from("/gone.ts"),
1074 vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
1075 );
1076 store.publish(
1077 biome_key,
1078 PathBuf::from("/gone.ts"),
1079 vec![diag(
1080 "/gone.ts",
1081 7,
1082 "lint warning",
1083 DiagnosticSeverity::Warning,
1084 )],
1085 );
1086 store.publish(
1087 server_key(ServerKind::Rust),
1088 PathBuf::from("/keep.rs"),
1089 vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
1090 );
1091
1092 assert_eq!(store.error_warning_counts(), (2, 1));
1093
1094 let removed = store.clear_for_file(Path::new("/gone.ts"));
1096 assert!(removed);
1097 assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
1098 assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
1100 assert_eq!(store.error_warning_counts(), (1, 0));
1101
1102 assert!(!store.clear_for_file(Path::new("/gone.ts")));
1104 }
1105
1106 #[test]
1107 fn filtered_counts_apply_keep_predicate() {
1108 let mut store = DiagnosticsStore::new();
1109 store.publish(
1110 server_key(ServerKind::TypeScript),
1111 PathBuf::from("/repo/src/app.ts"),
1112 vec![diag(
1113 "/repo/src/app.ts",
1114 1,
1115 "in build",
1116 DiagnosticSeverity::Error,
1117 )],
1118 );
1119 store.publish(
1120 server_key(ServerKind::TypeScript),
1121 PathBuf::from("/repo/src/app.test.ts"),
1122 vec![diag(
1123 "/repo/src/app.test.ts",
1124 1,
1125 "excluded",
1126 DiagnosticSeverity::Error,
1127 )],
1128 );
1129
1130 assert_eq!(store.error_warning_counts(), (2, 0));
1132 let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1134 assert_eq!(counts, (1, 0));
1135 }
1136
1137 #[test]
1138 fn filtered_counts_dedup_across_servers() {
1139 let mut store = DiagnosticsStore::new();
1140 let file = "/repo/src/app.ts";
1141 store.publish(
1146 server_key(ServerKind::TypeScript),
1147 PathBuf::from(file),
1148 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1149 );
1150 store.publish(
1151 server_key(ServerKind::Biome),
1152 PathBuf::from(file),
1153 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1154 );
1155
1156 assert_eq!(store.error_warning_counts(), (2, 0));
1157 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1158 }
1159
1160 #[test]
1161 fn filtered_counts_keep_distinct_diagnostics_same_file() {
1162 let mut store = DiagnosticsStore::new();
1163 let file = "/repo/src/app.ts";
1164 store.publish(
1167 server_key(ServerKind::TypeScript),
1168 PathBuf::from(file),
1169 vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1170 );
1171 store.publish(
1172 server_key(ServerKind::Biome),
1173 PathBuf::from(file),
1174 vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1175 );
1176 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1177 }
1178
1179 #[test]
1180 fn filtered_counts_exclude_environmental_diagnostics() {
1181 let mut store = DiagnosticsStore::new();
1182 let file = "/repo/src/app.ts";
1183 store.publish(
1184 server_key(ServerKind::TypeScript),
1185 PathBuf::from(file),
1186 vec![
1187 diag(
1188 file,
1189 1,
1190 "Cannot find name 'foo'.",
1191 DiagnosticSeverity::Error,
1192 ),
1193 diag(
1194 file,
1195 2,
1196 "Failed to load schema from https://cdn.example/pkg/schema.json",
1197 DiagnosticSeverity::Error,
1198 ),
1199 ],
1200 );
1201 assert_eq!(store.error_warning_counts(), (2, 0));
1202 assert_eq!(
1203 store.filtered_error_warning_counts(|_| true),
1204 (1, 0),
1205 "environmental schema-fetch must not inflate E count"
1206 );
1207 }
1208
1209 #[test]
1210 fn environmental_flap_does_not_change_filtered_counts() {
1211 let mut store = DiagnosticsStore::new();
1212 let file = "/repo/package.json";
1213 let key = server_key(ServerKind::TypeScript);
1214 let env_msg =
1215 "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1216
1217 assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1218
1219 store.publish(
1220 key.clone(),
1221 PathBuf::from(file),
1222 vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1223 );
1224 assert_eq!(
1225 store.filtered_error_warning_counts(|_| true),
1226 (0, 0),
1227 "publish environmental diagnostic must not change filtered E/W"
1228 );
1229
1230 store.publish(key, PathBuf::from(file), vec![]);
1231 assert_eq!(
1232 store.filtered_error_warning_counts(|_| true),
1233 (0, 0),
1234 "removing environmental diagnostic must not change filtered E/W"
1235 );
1236 }
1237
1238 #[test]
1239 fn mixed_syntax_and_schema_fetch_counts_one_error() {
1240 let mut store = DiagnosticsStore::new();
1241 let file = "/repo/src/mixed.ts";
1242 store.publish(
1243 server_key(ServerKind::TypeScript),
1244 PathBuf::from(file),
1245 vec![
1246 diag(
1247 file,
1248 3,
1249 "Cannot find name 'bar'.",
1250 DiagnosticSeverity::Error,
1251 ),
1252 diag(
1253 file,
1254 1,
1255 "Failed to resolve schema https://example.com/x.json",
1256 DiagnosticSeverity::Error,
1257 ),
1258 ],
1259 );
1260 assert_eq!(
1261 store.filtered_error_warning_counts(|_| true),
1262 (1, 0),
1263 "classifier is per-diagnostic: one real syntax error => E1"
1264 );
1265 }
1266}