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 last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
101}
102
103impl DiagnosticsStore {
104 pub fn new() -> Self {
105 Self::with_capacity(5000)
106 }
107
108 pub fn with_capacity(capacity: usize) -> Self {
109 Self {
110 entries: HashMap::new(),
111 order: Vec::new(),
112 capacity,
113 next_epoch: 0,
114 last_publish_at_for_file: HashMap::new(),
115 }
116 }
117
118 pub fn set_capacity(&mut self, capacity: usize) {
122 self.capacity = capacity;
123 if capacity > 0 {
124 while self.entries.len() > capacity {
125 self.evict_lru();
126 }
127 }
128 }
129
130 pub fn len(&self) -> usize {
132 self.entries.len()
133 }
134
135 #[cfg(test)]
138 pub fn capacity_for_test(&self) -> usize {
139 self.capacity
140 }
141
142 pub fn is_empty(&self) -> bool {
143 self.entries.is_empty()
144 }
145
146 pub fn has_any_fresh_report(&self) -> bool {
149 self.entries.values().any(|entry| !entry.stale)
150 }
151
152 pub fn publish(
161 &mut self,
162 server: ServerKey,
163 file: PathBuf,
164 diagnostics: Vec<StoredDiagnostic>,
165 ) {
166 self.publish_with_result_id(server, file, diagnostics, None);
167 }
168
169 pub fn publish_with_result_id(
172 &mut self,
173 server: ServerKey,
174 file: PathBuf,
175 diagnostics: Vec<StoredDiagnostic>,
176 result_id: Option<String>,
177 ) {
178 self.publish_full(server, file, diagnostics, result_id, None);
179 }
180
181 pub fn publish_full(
185 &mut self,
186 server: ServerKey,
187 file: PathBuf,
188 diagnostics: Vec<StoredDiagnostic>,
189 result_id: Option<String>,
190 version: Option<i32>,
191 ) {
192 let key = (server, file);
193 self.next_epoch = self.next_epoch.saturating_add(1);
194 let entry = DiagnosticEntry {
195 diagnostics,
196 epoch: self.next_epoch,
197 result_id,
198 version,
199 stale: false,
200 };
201
202 self.last_publish_at_for_file
203 .insert(key.clone(), Instant::now());
204
205 if self.entries.contains_key(&key) {
206 self.entries.insert(key.clone(), entry);
207 self.touch_existing(&key);
208 } else {
209 if self.capacity > 0 && self.entries.len() >= self.capacity {
211 self.evict_lru();
212 }
213 self.entries.insert(key.clone(), entry);
214 self.order.push(key);
215 }
216 }
217
218 pub fn publish_with_kind(
224 &mut self,
225 kind: ServerKind,
226 file: PathBuf,
227 diagnostics: Vec<StoredDiagnostic>,
228 ) {
229 let key = ServerKey {
230 kind,
231 root: PathBuf::new(),
232 };
233 self.publish(key, file, diagnostics);
234 }
235
236 pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
239 self.entries
240 .iter()
241 .filter(|((_, stored_file), entry)| stored_file == file && !entry.stale)
242 .flat_map(|(_, entry)| entry.diagnostics.iter())
243 .collect()
244 }
245
246 pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
249 self.entries
250 .iter()
251 .filter(|((_, stored_file), _)| stored_file == file)
252 .map(|((key, _), entry)| (key, entry))
253 .collect()
254 }
255
256 pub fn has_any_report_for_file(&self, file: &Path) -> bool {
258 self.entries.keys().any(|(_, f)| f == file)
259 }
260
261 pub fn has_any_fresh_report_for_file(&self, file: &Path) -> bool {
263 self.entries
264 .iter()
265 .any(|((_, stored_file), entry)| stored_file == file && !entry.stale)
266 }
267
268 pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
272 self.entries
273 .contains_key(&(server.clone(), file.to_path_buf()))
274 }
275
276 pub fn has_fresh_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
278 self.entries
279 .get(&(server.clone(), file.to_path_buf()))
280 .is_some_and(|entry| !entry.stale)
281 }
282
283 pub fn has_publish_for_file_after(
287 &self,
288 server: &ServerKey,
289 file: &Path,
290 since: Instant,
291 ) -> bool {
292 self.last_publish_at_for_file
293 .get(&(server.clone(), file.to_path_buf()))
294 .is_some_and(|published_at| {
295 *published_at >= since && self.has_fresh_report_for_server_file(server, file)
296 })
297 }
298
299 pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
301 self.entries
302 .iter()
303 .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
304 .flat_map(|(_, entry)| entry.diagnostics.iter())
305 .collect()
306 }
307
308 pub fn all(&self) -> Vec<&StoredDiagnostic> {
310 self.entries
311 .values()
312 .filter(|entry| !entry.stale)
313 .flat_map(|entry| entry.diagnostics.iter())
314 .collect()
315 }
316
317 pub fn error_warning_counts(&self) -> (usize, usize) {
323 let mut errors = 0usize;
324 let mut warnings = 0usize;
325 for entry in self.entries.values() {
326 if entry.stale {
327 continue;
328 }
329 for diagnostic in &entry.diagnostics {
330 match diagnostic.severity {
331 DiagnosticSeverity::Error => errors += 1,
332 DiagnosticSeverity::Warning => warnings += 1,
333 _ => {}
334 }
335 }
336 }
337 (errors, warnings)
338 }
339
340 pub fn filtered_error_warning_counts(
353 &self,
354 mut keep: impl FnMut(&Path) -> bool,
355 ) -> (usize, usize) {
356 let mut seen: std::collections::HashSet<(
360 &Path,
361 u32,
362 u32,
363 u32,
364 u32,
365 &str,
366 &str,
367 Option<&str>,
368 )> = std::collections::HashSet::new();
369 let mut errors = 0usize;
370 let mut warnings = 0usize;
371 for ((_, file), entry) in &self.entries {
372 if entry.stale {
373 continue;
374 }
375 if !keep(file) {
379 continue;
380 }
381 for diagnostic in &entry.diagnostics {
382 if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
383 continue;
384 }
385 let dedup_key = (
386 diagnostic.file.as_path(),
387 diagnostic.line,
388 diagnostic.column,
389 diagnostic.end_line,
390 diagnostic.end_column,
391 diagnostic.severity.as_str(),
392 diagnostic.message.as_str(),
393 diagnostic.source.as_deref(),
394 );
395 if !seen.insert(dedup_key) {
396 continue;
397 }
398 match diagnostic.severity {
399 DiagnosticSeverity::Error => errors += 1,
400 DiagnosticSeverity::Warning => warnings += 1,
401 _ => {}
402 }
403 }
404 }
405 (errors, warnings)
406 }
407
408 pub fn clear_server(&mut self, server: ServerKind) {
412 self.entries
413 .retain(|(stored_key, _), _| stored_key.kind != server);
414 self.order
415 .retain(|(stored_key, _)| stored_key.kind != server);
416 self.last_publish_at_for_file
417 .retain(|(stored_key, _), _| stored_key.kind != server);
418 }
419
420 pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
422 let cache_key = (key.clone(), file.to_path_buf());
423 self.entries.remove(&cache_key);
424 self.order.retain(|entry_key| entry_key != &cache_key);
425 self.last_publish_at_for_file.remove(&cache_key);
426 }
427
428 pub fn clear_for_file(&mut self, file: &Path) -> bool {
434 let before = self.entries.len();
435 self.entries
436 .retain(|(_, stored_file), _| stored_file != file);
437 let removed = self.entries.len() != before;
438 if removed {
439 self.order.retain(|(_, stored_file)| stored_file != file);
440 self.last_publish_at_for_file
441 .retain(|(_, stored_file), _| stored_file != file);
442 }
443 removed
444 }
445
446 pub fn mark_stale_for_file(&mut self, file: &Path) -> (bool, bool) {
454 let mut had_entries = false;
455 let mut changed = false;
456 for ((_, stored_file), entry) in &mut self.entries {
457 if stored_file != file {
458 continue;
459 }
460 had_entries = true;
461 if !entry.stale {
462 entry.stale = true;
463 changed = true;
464 }
465 }
466 (had_entries, changed)
467 }
468
469 pub fn mark_fresh_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
472 let cache_key = (key.clone(), file.to_path_buf());
473 let Some(entry) = self.entries.get_mut(&cache_key) else {
474 return false;
475 };
476 let changed = entry.stale;
477 entry.stale = false;
478 self.touch_existing(&cache_key);
479 changed
480 }
481
482 pub fn clear_for_server(&mut self, key: &ServerKey) {
484 self.entries.retain(|(k, _), _| k != key);
485 self.order.retain(|(k, _)| k != key);
486 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
487 }
488
489 pub fn clear_server_instance(&mut self, key: &ServerKey) {
492 self.clear_for_server(key);
493 }
494
495 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
497 if self.order.is_empty() {
498 return None;
499 }
500 let evicted = self.order.remove(0);
501 self.entries.remove(&evicted);
502 self.last_publish_at_for_file.remove(&evicted);
503 Some(evicted)
504 }
505
506 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
507 if let Some(idx) = self.order.iter().position(|k| k == key) {
508 let removed = self.order.remove(idx);
509 self.order.push(removed);
510 }
511 }
512}
513
514impl Default for DiagnosticsStore {
515 fn default() -> Self {
516 Self::new()
517 }
518}
519
520pub fn from_lsp_diagnostics(
523 file: PathBuf,
524 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
525) -> Vec<StoredDiagnostic> {
526 lsp_diagnostics
527 .into_iter()
528 .map(|diagnostic| StoredDiagnostic {
529 file: file.clone(),
530 line: diagnostic.range.start.line + 1,
531 column: diagnostic.range.start.character + 1,
532 end_line: diagnostic.range.end.line + 1,
533 end_column: diagnostic.range.end.character + 1,
534 severity: match diagnostic.severity {
535 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
536 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
537 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
538 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
539 _ => DiagnosticSeverity::Warning,
540 },
541 message: diagnostic.message,
542 code: diagnostic.code.map(|code| match code {
543 lsp_types::NumberOrString::Number(value) => value.to_string(),
544 lsp_types::NumberOrString::String(value) => value,
545 }),
546 source: diagnostic.source,
547 })
548 .collect()
549}
550
551#[cfg(test)]
552mod tests {
553 use std::path::{Path, PathBuf};
554
555 use lsp_types::{
556 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
557 };
558
559 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
560 use crate::lsp::registry::ServerKind;
561 use crate::lsp::roots::ServerKey;
562
563 fn server_key(kind: ServerKind) -> ServerKey {
564 ServerKey {
565 kind,
566 root: PathBuf::from("/tmp/repo"),
567 }
568 }
569
570 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
571 StoredDiagnostic {
572 file: PathBuf::from(file),
573 line,
574 column: 1,
575 end_line: line,
576 end_column: 2,
577 severity: sev,
578 message: msg.into(),
579 code: None,
580 source: None,
581 }
582 }
583
584 #[test]
585 fn converts_lsp_positions_to_one_based() {
586 let file = PathBuf::from("/tmp/demo.rs");
587 let diagnostics = from_lsp_diagnostics(
588 file.clone(),
589 vec![Diagnostic {
590 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
591 severity: Some(LspDiagnosticSeverity::ERROR),
592 code: Some(NumberOrString::String("E1".into())),
593 code_description: None,
594 source: Some("fake".into()),
595 message: "boom".into(),
596 related_information: None,
597 tags: None,
598 data: None,
599 }],
600 );
601
602 assert_eq!(diagnostics.len(), 1);
603 assert_eq!(diagnostics[0].file, file);
604 assert_eq!(diagnostics[0].line, 1);
605 assert_eq!(diagnostics[0].column, 1);
606 assert_eq!(diagnostics[0].end_line, 2);
607 assert_eq!(diagnostics[0].end_column, 5);
608 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
609 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
610 }
611
612 #[test]
613 fn publish_replaces_existing_file_diagnostics() {
614 let file = PathBuf::from("/tmp/demo.rs");
615 let mut store = DiagnosticsStore::new();
616 let key = server_key(ServerKind::Rust);
617
618 store.publish(
619 key.clone(),
620 file.clone(),
621 vec![diag(
622 "/tmp/demo.rs",
623 1,
624 "first",
625 DiagnosticSeverity::Warning,
626 )],
627 );
628 store.publish(
629 key.clone(),
630 file.clone(),
631 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
632 );
633
634 let stored = store.for_file(&file);
635 assert_eq!(stored.len(), 1);
636 assert_eq!(stored[0].message, "second");
637 }
638
639 #[test]
640 fn empty_publish_is_preserved_as_checked_clean() {
641 let file = PathBuf::from("/tmp/clean.rs");
645 let mut store = DiagnosticsStore::new();
646 let key = server_key(ServerKind::Rust);
647
648 store.publish(
650 key.clone(),
651 file.clone(),
652 vec![diag(
653 "/tmp/clean.rs",
654 5,
655 "fix me",
656 DiagnosticSeverity::Warning,
657 )],
658 );
659 assert!(store.has_any_report_for_file(&file));
660 assert_eq!(store.for_file(&file).len(), 1);
661
662 store.publish(key.clone(), file.clone(), Vec::new());
665 assert!(
666 store.has_any_report_for_file(&file),
667 "checked-clean must be distinguishable from never-checked"
668 );
669 assert_eq!(store.for_file(&file).len(), 0);
670
671 let entries = store.entries_for_file(&file);
672 assert_eq!(entries.len(), 1);
673 assert!(entries[0].1.epoch > 0);
674 }
675
676 #[test]
677 fn never_checked_returns_no_report() {
678 let store = DiagnosticsStore::new();
679 let file = PathBuf::from("/tmp/never.rs");
680 assert!(!store.has_any_report_for_file(&file));
681 assert!(store.for_file(&file).is_empty());
682 }
683
684 #[test]
685 fn stale_entries_are_hidden_but_preserved_for_refresh() {
686 let file = PathBuf::from("/tmp/stale.rs");
687 let mut store = DiagnosticsStore::new();
688 let key = server_key(ServerKind::Rust);
689 store.publish(
690 key.clone(),
691 file.clone(),
692 vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
693 );
694
695 let (had_entries, changed) = store.mark_stale_for_file(&file);
696
697 assert!(had_entries);
698 assert!(changed);
699 assert!(store.has_any_report_for_file(&file));
700 assert!(!store.has_any_fresh_report_for_file(&file));
701 assert!(store.for_file(&file).is_empty());
702 assert!(store.all().is_empty());
703 assert_eq!(store.error_warning_counts(), (0, 0));
704 assert_eq!(store.entries_for_file(&file).len(), 1);
705
706 assert!(store.mark_fresh_for_server_file(&key, &file));
707 assert!(store.has_any_fresh_report_for_file(&file));
708 assert_eq!(store.for_file(&file).len(), 1);
709 assert_eq!(store.error_warning_counts(), (1, 0));
710 }
711
712 #[test]
713 fn per_server_state_is_tracked_independently() {
714 let file = PathBuf::from("/tmp/multi.py");
715 let mut store = DiagnosticsStore::new();
716 let pyright_key = server_key(ServerKind::Python);
717 let ty_key = server_key(ServerKind::Ty);
718
719 store.publish(
720 pyright_key,
721 file.clone(),
722 vec![diag(
723 "/tmp/multi.py",
724 1,
725 "pyright says X",
726 DiagnosticSeverity::Error,
727 )],
728 );
729 store.publish(
730 ty_key,
731 file.clone(),
732 vec![diag(
733 "/tmp/multi.py",
734 2,
735 "ty says Y",
736 DiagnosticSeverity::Warning,
737 )],
738 );
739
740 let messages: Vec<&str> = store
741 .for_file(&file)
742 .into_iter()
743 .map(|d| d.message.as_str())
744 .collect();
745
746 assert_eq!(messages.len(), 2, "both servers' reports preserved");
747 assert!(messages.iter().any(|m| m == &"pyright says X"));
748 assert!(messages.iter().any(|m| m == &"ty says Y"));
749 }
750
751 #[test]
752 fn clear_for_server_file_removes_only_exact_entry() {
753 let file_a = PathBuf::from("/tmp/a.rs");
754 let file_b = PathBuf::from("/tmp/b.rs");
755 let mut store = DiagnosticsStore::new();
756 let rust_key = server_key(ServerKind::Rust);
757 let py_key = server_key(ServerKind::Python);
758
759 store.publish(
760 rust_key.clone(),
761 file_a.clone(),
762 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
763 );
764 store.publish(
765 rust_key.clone(),
766 file_b.clone(),
767 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
768 );
769 store.publish(
770 py_key.clone(),
771 file_a.clone(),
772 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
773 );
774
775 store.clear_for_server_file(&rust_key, &file_a);
776
777 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
778 assert!(store.has_report_for_server_file(&rust_key, &file_b));
779 assert!(store.has_report_for_server_file(&py_key, &file_a));
780 }
781
782 #[test]
783 fn lru_evicts_oldest_when_capacity_exceeded() {
784 let mut store = DiagnosticsStore::with_capacity(2);
785 let key = server_key(ServerKind::Rust);
786
787 store.publish(
788 key.clone(),
789 PathBuf::from("/a.rs"),
790 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
791 );
792 store.publish(
793 key.clone(),
794 PathBuf::from("/b.rs"),
795 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
796 );
797 assert_eq!(store.len(), 2);
798
799 store.publish(
801 key.clone(),
802 PathBuf::from("/c.rs"),
803 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
804 );
805 assert_eq!(store.len(), 2);
806 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
807 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
808 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
809 }
810
811 #[test]
812 fn touching_existing_entry_moves_it_to_end_of_lru() {
813 let mut store = DiagnosticsStore::with_capacity(2);
814 let key = server_key(ServerKind::Rust);
815
816 store.publish(
817 key.clone(),
818 PathBuf::from("/a.rs"),
819 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
820 );
821 store.publish(
822 key.clone(),
823 PathBuf::from("/b.rs"),
824 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
825 );
826
827 store.publish(
830 key.clone(),
831 PathBuf::from("/a.rs"),
832 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
833 );
834 store.publish(
835 key.clone(),
836 PathBuf::from("/c.rs"),
837 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
838 );
839
840 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
841 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
842 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
843 }
844
845 #[test]
846 fn capacity_zero_disables_eviction() {
847 let mut store = DiagnosticsStore::with_capacity(0);
848 let key = server_key(ServerKind::Rust);
849
850 for i in 0..50 {
851 store.publish(
852 key.clone(),
853 PathBuf::from(format!("/f{i}.rs")),
854 vec![diag(
855 &format!("/f{i}.rs"),
856 1,
857 "x",
858 DiagnosticSeverity::Warning,
859 )],
860 );
861 }
862 assert_eq!(store.len(), 50);
863 }
864
865 #[test]
866 fn set_capacity_evicts_on_shrink() {
867 let mut store = DiagnosticsStore::with_capacity(0);
868 let key = server_key(ServerKind::Rust);
869 for i in 0..10 {
870 store.publish(
871 key.clone(),
872 PathBuf::from(format!("/f{i}.rs")),
873 vec![diag(
874 &format!("/f{i}.rs"),
875 1,
876 "x",
877 DiagnosticSeverity::Warning,
878 )],
879 );
880 }
881 assert_eq!(store.len(), 10);
882
883 store.set_capacity(3);
884 assert_eq!(store.len(), 3);
885 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
887 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
888 }
889
890 #[test]
891 fn epoch_increments_monotonically() {
892 let mut store = DiagnosticsStore::new();
893 let key = server_key(ServerKind::Rust);
894 let file = PathBuf::from("/e.rs");
895
896 store.publish(key.clone(), file.clone(), Vec::new());
897 let e1 = store.entries_for_file(&file)[0].1.epoch;
898
899 store.publish(key.clone(), file.clone(), Vec::new());
900 let e2 = store.entries_for_file(&file)[0].1.epoch;
901
902 assert!(e2 > e1, "epoch must increase on republish");
903 }
904
905 #[test]
906 fn result_id_is_round_tripped() {
907 let mut store = DiagnosticsStore::new();
908 let key = server_key(ServerKind::Rust);
909 let file = PathBuf::from("/r.rs");
910
911 store.publish_with_result_id(
912 key.clone(),
913 file.clone(),
914 Vec::new(),
915 Some("rev-42".to_string()),
916 );
917
918 let entries = store.entries_for_file(&file);
919 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
920 }
921
922 #[test]
923 fn clear_server_drops_all_entries_for_kind() {
924 let mut store = DiagnosticsStore::new();
925 let py_key = server_key(ServerKind::Python);
926 let rust_key = server_key(ServerKind::Rust);
927
928 store.publish(
929 py_key.clone(),
930 PathBuf::from("/a.py"),
931 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
932 );
933 store.publish(
934 rust_key.clone(),
935 PathBuf::from("/b.rs"),
936 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
937 );
938
939 store.clear_server(ServerKind::Python);
940 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
941 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
942 }
943
944 #[test]
945 fn clear_for_file_drops_every_server_entry_and_updates_counts() {
946 let mut store = DiagnosticsStore::new();
947 let py_key = server_key(ServerKind::Python);
948 let biome_key = server_key(ServerKind::Biome);
949
950 store.publish(
953 py_key,
954 PathBuf::from("/gone.ts"),
955 vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
956 );
957 store.publish(
958 biome_key,
959 PathBuf::from("/gone.ts"),
960 vec![diag(
961 "/gone.ts",
962 7,
963 "lint warning",
964 DiagnosticSeverity::Warning,
965 )],
966 );
967 store.publish(
968 server_key(ServerKind::Rust),
969 PathBuf::from("/keep.rs"),
970 vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
971 );
972
973 assert_eq!(store.error_warning_counts(), (2, 1));
974
975 let removed = store.clear_for_file(Path::new("/gone.ts"));
977 assert!(removed);
978 assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
979 assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
981 assert_eq!(store.error_warning_counts(), (1, 0));
982
983 assert!(!store.clear_for_file(Path::new("/gone.ts")));
985 }
986
987 #[test]
988 fn filtered_counts_apply_keep_predicate() {
989 let mut store = DiagnosticsStore::new();
990 store.publish(
991 server_key(ServerKind::TypeScript),
992 PathBuf::from("/repo/src/app.ts"),
993 vec![diag(
994 "/repo/src/app.ts",
995 1,
996 "in build",
997 DiagnosticSeverity::Error,
998 )],
999 );
1000 store.publish(
1001 server_key(ServerKind::TypeScript),
1002 PathBuf::from("/repo/src/app.test.ts"),
1003 vec![diag(
1004 "/repo/src/app.test.ts",
1005 1,
1006 "excluded",
1007 DiagnosticSeverity::Error,
1008 )],
1009 );
1010
1011 assert_eq!(store.error_warning_counts(), (2, 0));
1013 let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1015 assert_eq!(counts, (1, 0));
1016 }
1017
1018 #[test]
1019 fn filtered_counts_dedup_across_servers() {
1020 let mut store = DiagnosticsStore::new();
1021 let file = "/repo/src/app.ts";
1022 store.publish(
1027 server_key(ServerKind::TypeScript),
1028 PathBuf::from(file),
1029 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1030 );
1031 store.publish(
1032 server_key(ServerKind::Biome),
1033 PathBuf::from(file),
1034 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1035 );
1036
1037 assert_eq!(store.error_warning_counts(), (2, 0));
1038 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1039 }
1040
1041 #[test]
1042 fn filtered_counts_keep_distinct_diagnostics_same_file() {
1043 let mut store = DiagnosticsStore::new();
1044 let file = "/repo/src/app.ts";
1045 store.publish(
1048 server_key(ServerKind::TypeScript),
1049 PathBuf::from(file),
1050 vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1051 );
1052 store.publish(
1053 server_key(ServerKind::Biome),
1054 PathBuf::from(file),
1055 vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1056 );
1057 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1058 }
1059
1060 #[test]
1061 fn filtered_counts_exclude_environmental_diagnostics() {
1062 let mut store = DiagnosticsStore::new();
1063 let file = "/repo/src/app.ts";
1064 store.publish(
1065 server_key(ServerKind::TypeScript),
1066 PathBuf::from(file),
1067 vec![
1068 diag(
1069 file,
1070 1,
1071 "Cannot find name 'foo'.",
1072 DiagnosticSeverity::Error,
1073 ),
1074 diag(
1075 file,
1076 2,
1077 "Failed to load schema from https://cdn.example/pkg/schema.json",
1078 DiagnosticSeverity::Error,
1079 ),
1080 ],
1081 );
1082 assert_eq!(store.error_warning_counts(), (2, 0));
1083 assert_eq!(
1084 store.filtered_error_warning_counts(|_| true),
1085 (1, 0),
1086 "environmental schema-fetch must not inflate E count"
1087 );
1088 }
1089
1090 #[test]
1091 fn environmental_flap_does_not_change_filtered_counts() {
1092 let mut store = DiagnosticsStore::new();
1093 let file = "/repo/package.json";
1094 let key = server_key(ServerKind::TypeScript);
1095 let env_msg =
1096 "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1097
1098 assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1099
1100 store.publish(
1101 key.clone(),
1102 PathBuf::from(file),
1103 vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1104 );
1105 assert_eq!(
1106 store.filtered_error_warning_counts(|_| true),
1107 (0, 0),
1108 "publish environmental diagnostic must not change filtered E/W"
1109 );
1110
1111 store.publish(key, PathBuf::from(file), vec![]);
1112 assert_eq!(
1113 store.filtered_error_warning_counts(|_| true),
1114 (0, 0),
1115 "removing environmental diagnostic must not change filtered E/W"
1116 );
1117 }
1118
1119 #[test]
1120 fn mixed_syntax_and_schema_fetch_counts_one_error() {
1121 let mut store = DiagnosticsStore::new();
1122 let file = "/repo/src/mixed.ts";
1123 store.publish(
1124 server_key(ServerKind::TypeScript),
1125 PathBuf::from(file),
1126 vec![
1127 diag(
1128 file,
1129 3,
1130 "Cannot find name 'bar'.",
1131 DiagnosticSeverity::Error,
1132 ),
1133 diag(
1134 file,
1135 1,
1136 "Failed to resolve schema https://example.com/x.json",
1137 DiagnosticSeverity::Error,
1138 ),
1139 ],
1140 );
1141 assert_eq!(
1142 store.filtered_error_warning_counts(|_| true),
1143 (1, 0),
1144 "classifier is per-diagnostic: one real syntax error => E1"
1145 );
1146 }
1147}