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 #[cfg(test)]
132 pub fn capacity_for_test(&self) -> usize {
133 self.capacity
134 }
135
136 pub fn is_empty(&self) -> bool {
137 self.entries.is_empty()
138 }
139
140 pub fn publish(
149 &mut self,
150 server: ServerKey,
151 file: PathBuf,
152 diagnostics: Vec<StoredDiagnostic>,
153 ) {
154 self.publish_with_result_id(server, file, diagnostics, None);
155 }
156
157 pub fn publish_with_result_id(
160 &mut self,
161 server: ServerKey,
162 file: PathBuf,
163 diagnostics: Vec<StoredDiagnostic>,
164 result_id: Option<String>,
165 ) {
166 self.publish_full(server, file, diagnostics, result_id, None);
167 }
168
169 pub fn publish_full(
173 &mut self,
174 server: ServerKey,
175 file: PathBuf,
176 diagnostics: Vec<StoredDiagnostic>,
177 result_id: Option<String>,
178 version: Option<i32>,
179 ) {
180 let key = (server, file);
181 self.next_epoch = self.next_epoch.saturating_add(1);
182 let entry = DiagnosticEntry {
183 diagnostics,
184 epoch: self.next_epoch,
185 result_id,
186 version,
187 };
188
189 self.last_publish_at_for_file
190 .insert(key.clone(), Instant::now());
191
192 if self.entries.contains_key(&key) {
193 self.entries.insert(key.clone(), entry);
194 self.touch_existing(&key);
195 } else {
196 if self.capacity > 0 && self.entries.len() >= self.capacity {
198 self.evict_lru();
199 }
200 self.entries.insert(key.clone(), entry);
201 self.order.push(key);
202 }
203 }
204
205 pub fn publish_with_kind(
211 &mut self,
212 kind: ServerKind,
213 file: PathBuf,
214 diagnostics: Vec<StoredDiagnostic>,
215 ) {
216 let key = ServerKey {
217 kind,
218 root: PathBuf::new(),
219 };
220 self.publish(key, file, diagnostics);
221 }
222
223 pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
226 self.entries
227 .iter()
228 .filter(|((_, stored_file), _)| stored_file == file)
229 .flat_map(|(_, entry)| entry.diagnostics.iter())
230 .collect()
231 }
232
233 pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
236 self.entries
237 .iter()
238 .filter(|((_, stored_file), _)| stored_file == file)
239 .map(|((key, _), entry)| (key, entry))
240 .collect()
241 }
242
243 pub fn has_any_report_for_file(&self, file: &Path) -> bool {
245 self.entries.keys().any(|(_, f)| f == file)
246 }
247
248 pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
251 self.entries
252 .contains_key(&(server.clone(), file.to_path_buf()))
253 }
254
255 pub fn has_publish_for_file_after(
259 &self,
260 server: &ServerKey,
261 file: &Path,
262 since: Instant,
263 ) -> bool {
264 self.last_publish_at_for_file
265 .get(&(server.clone(), file.to_path_buf()))
266 .is_some_and(|published_at| *published_at >= since)
267 }
268
269 pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
271 self.entries
272 .iter()
273 .filter(|((_, stored_file), _)| stored_file.starts_with(dir))
274 .flat_map(|(_, entry)| entry.diagnostics.iter())
275 .collect()
276 }
277
278 pub fn all(&self) -> Vec<&StoredDiagnostic> {
280 self.entries
281 .values()
282 .flat_map(|entry| entry.diagnostics.iter())
283 .collect()
284 }
285
286 pub fn error_warning_counts(&self) -> (usize, usize) {
292 let mut errors = 0usize;
293 let mut warnings = 0usize;
294 for entry in self.entries.values() {
295 for diagnostic in &entry.diagnostics {
296 match diagnostic.severity {
297 DiagnosticSeverity::Error => errors += 1,
298 DiagnosticSeverity::Warning => warnings += 1,
299 _ => {}
300 }
301 }
302 }
303 (errors, warnings)
304 }
305
306 pub fn filtered_error_warning_counts(
319 &self,
320 mut keep: impl FnMut(&Path) -> bool,
321 ) -> (usize, usize) {
322 let mut seen: std::collections::HashSet<(
326 &Path,
327 u32,
328 u32,
329 u32,
330 u32,
331 &str,
332 &str,
333 Option<&str>,
334 )> = std::collections::HashSet::new();
335 let mut errors = 0usize;
336 let mut warnings = 0usize;
337 for ((_, file), entry) in &self.entries {
338 if !keep(file) {
342 continue;
343 }
344 for diagnostic in &entry.diagnostics {
345 if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
346 continue;
347 }
348 let dedup_key = (
349 diagnostic.file.as_path(),
350 diagnostic.line,
351 diagnostic.column,
352 diagnostic.end_line,
353 diagnostic.end_column,
354 diagnostic.severity.as_str(),
355 diagnostic.message.as_str(),
356 diagnostic.source.as_deref(),
357 );
358 if !seen.insert(dedup_key) {
359 continue;
360 }
361 match diagnostic.severity {
362 DiagnosticSeverity::Error => errors += 1,
363 DiagnosticSeverity::Warning => warnings += 1,
364 _ => {}
365 }
366 }
367 }
368 (errors, warnings)
369 }
370
371 pub fn clear_server(&mut self, server: ServerKind) {
375 self.entries
376 .retain(|(stored_key, _), _| stored_key.kind != server);
377 self.order
378 .retain(|(stored_key, _)| stored_key.kind != server);
379 self.last_publish_at_for_file
380 .retain(|(stored_key, _), _| stored_key.kind != server);
381 }
382
383 pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
385 let cache_key = (key.clone(), file.to_path_buf());
386 self.entries.remove(&cache_key);
387 self.order.retain(|entry_key| entry_key != &cache_key);
388 self.last_publish_at_for_file.remove(&cache_key);
389 }
390
391 pub fn clear_for_file(&mut self, file: &Path) -> bool {
397 let before = self.entries.len();
398 self.entries
399 .retain(|(_, stored_file), _| stored_file != file);
400 let removed = self.entries.len() != before;
401 if removed {
402 self.order.retain(|(_, stored_file)| stored_file != file);
403 self.last_publish_at_for_file
404 .retain(|(_, stored_file), _| stored_file != file);
405 }
406 removed
407 }
408
409 pub fn clear_for_server(&mut self, key: &ServerKey) {
411 self.entries.retain(|(k, _), _| k != key);
412 self.order.retain(|(k, _)| k != key);
413 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
414 }
415
416 pub fn clear_server_instance(&mut self, key: &ServerKey) {
419 self.clear_for_server(key);
420 }
421
422 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
424 if self.order.is_empty() {
425 return None;
426 }
427 let evicted = self.order.remove(0);
428 self.entries.remove(&evicted);
429 self.last_publish_at_for_file.remove(&evicted);
430 Some(evicted)
431 }
432
433 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
434 if let Some(idx) = self.order.iter().position(|k| k == key) {
435 let removed = self.order.remove(idx);
436 self.order.push(removed);
437 }
438 }
439}
440
441impl Default for DiagnosticsStore {
442 fn default() -> Self {
443 Self::new()
444 }
445}
446
447pub fn from_lsp_diagnostics(
450 file: PathBuf,
451 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
452) -> Vec<StoredDiagnostic> {
453 lsp_diagnostics
454 .into_iter()
455 .map(|diagnostic| StoredDiagnostic {
456 file: file.clone(),
457 line: diagnostic.range.start.line + 1,
458 column: diagnostic.range.start.character + 1,
459 end_line: diagnostic.range.end.line + 1,
460 end_column: diagnostic.range.end.character + 1,
461 severity: match diagnostic.severity {
462 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
463 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
464 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
465 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
466 _ => DiagnosticSeverity::Warning,
467 },
468 message: diagnostic.message,
469 code: diagnostic.code.map(|code| match code {
470 lsp_types::NumberOrString::Number(value) => value.to_string(),
471 lsp_types::NumberOrString::String(value) => value,
472 }),
473 source: diagnostic.source,
474 })
475 .collect()
476}
477
478#[cfg(test)]
479mod tests {
480 use std::path::{Path, PathBuf};
481
482 use lsp_types::{
483 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
484 };
485
486 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
487 use crate::lsp::registry::ServerKind;
488 use crate::lsp::roots::ServerKey;
489
490 fn server_key(kind: ServerKind) -> ServerKey {
491 ServerKey {
492 kind,
493 root: PathBuf::from("/tmp/repo"),
494 }
495 }
496
497 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
498 StoredDiagnostic {
499 file: PathBuf::from(file),
500 line,
501 column: 1,
502 end_line: line,
503 end_column: 2,
504 severity: sev,
505 message: msg.into(),
506 code: None,
507 source: None,
508 }
509 }
510
511 #[test]
512 fn converts_lsp_positions_to_one_based() {
513 let file = PathBuf::from("/tmp/demo.rs");
514 let diagnostics = from_lsp_diagnostics(
515 file.clone(),
516 vec![Diagnostic {
517 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
518 severity: Some(LspDiagnosticSeverity::ERROR),
519 code: Some(NumberOrString::String("E1".into())),
520 code_description: None,
521 source: Some("fake".into()),
522 message: "boom".into(),
523 related_information: None,
524 tags: None,
525 data: None,
526 }],
527 );
528
529 assert_eq!(diagnostics.len(), 1);
530 assert_eq!(diagnostics[0].file, file);
531 assert_eq!(diagnostics[0].line, 1);
532 assert_eq!(diagnostics[0].column, 1);
533 assert_eq!(diagnostics[0].end_line, 2);
534 assert_eq!(diagnostics[0].end_column, 5);
535 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
536 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
537 }
538
539 #[test]
540 fn publish_replaces_existing_file_diagnostics() {
541 let file = PathBuf::from("/tmp/demo.rs");
542 let mut store = DiagnosticsStore::new();
543 let key = server_key(ServerKind::Rust);
544
545 store.publish(
546 key.clone(),
547 file.clone(),
548 vec![diag(
549 "/tmp/demo.rs",
550 1,
551 "first",
552 DiagnosticSeverity::Warning,
553 )],
554 );
555 store.publish(
556 key.clone(),
557 file.clone(),
558 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
559 );
560
561 let stored = store.for_file(&file);
562 assert_eq!(stored.len(), 1);
563 assert_eq!(stored[0].message, "second");
564 }
565
566 #[test]
567 fn empty_publish_is_preserved_as_checked_clean() {
568 let file = PathBuf::from("/tmp/clean.rs");
572 let mut store = DiagnosticsStore::new();
573 let key = server_key(ServerKind::Rust);
574
575 store.publish(
577 key.clone(),
578 file.clone(),
579 vec![diag(
580 "/tmp/clean.rs",
581 5,
582 "fix me",
583 DiagnosticSeverity::Warning,
584 )],
585 );
586 assert!(store.has_any_report_for_file(&file));
587 assert_eq!(store.for_file(&file).len(), 1);
588
589 store.publish(key.clone(), file.clone(), Vec::new());
592 assert!(
593 store.has_any_report_for_file(&file),
594 "checked-clean must be distinguishable from never-checked"
595 );
596 assert_eq!(store.for_file(&file).len(), 0);
597
598 let entries = store.entries_for_file(&file);
599 assert_eq!(entries.len(), 1);
600 assert!(entries[0].1.epoch > 0);
601 }
602
603 #[test]
604 fn never_checked_returns_no_report() {
605 let store = DiagnosticsStore::new();
606 let file = PathBuf::from("/tmp/never.rs");
607 assert!(!store.has_any_report_for_file(&file));
608 assert!(store.for_file(&file).is_empty());
609 }
610
611 #[test]
612 fn per_server_state_is_tracked_independently() {
613 let file = PathBuf::from("/tmp/multi.py");
614 let mut store = DiagnosticsStore::new();
615 let pyright_key = server_key(ServerKind::Python);
616 let ty_key = server_key(ServerKind::Ty);
617
618 store.publish(
619 pyright_key,
620 file.clone(),
621 vec![diag(
622 "/tmp/multi.py",
623 1,
624 "pyright says X",
625 DiagnosticSeverity::Error,
626 )],
627 );
628 store.publish(
629 ty_key,
630 file.clone(),
631 vec![diag(
632 "/tmp/multi.py",
633 2,
634 "ty says Y",
635 DiagnosticSeverity::Warning,
636 )],
637 );
638
639 let messages: Vec<&str> = store
640 .for_file(&file)
641 .into_iter()
642 .map(|d| d.message.as_str())
643 .collect();
644
645 assert_eq!(messages.len(), 2, "both servers' reports preserved");
646 assert!(messages.iter().any(|m| m == &"pyright says X"));
647 assert!(messages.iter().any(|m| m == &"ty says Y"));
648 }
649
650 #[test]
651 fn clear_for_server_file_removes_only_exact_entry() {
652 let file_a = PathBuf::from("/tmp/a.rs");
653 let file_b = PathBuf::from("/tmp/b.rs");
654 let mut store = DiagnosticsStore::new();
655 let rust_key = server_key(ServerKind::Rust);
656 let py_key = server_key(ServerKind::Python);
657
658 store.publish(
659 rust_key.clone(),
660 file_a.clone(),
661 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
662 );
663 store.publish(
664 rust_key.clone(),
665 file_b.clone(),
666 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
667 );
668 store.publish(
669 py_key.clone(),
670 file_a.clone(),
671 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
672 );
673
674 store.clear_for_server_file(&rust_key, &file_a);
675
676 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
677 assert!(store.has_report_for_server_file(&rust_key, &file_b));
678 assert!(store.has_report_for_server_file(&py_key, &file_a));
679 }
680
681 #[test]
682 fn lru_evicts_oldest_when_capacity_exceeded() {
683 let mut store = DiagnosticsStore::with_capacity(2);
684 let key = server_key(ServerKind::Rust);
685
686 store.publish(
687 key.clone(),
688 PathBuf::from("/a.rs"),
689 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
690 );
691 store.publish(
692 key.clone(),
693 PathBuf::from("/b.rs"),
694 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
695 );
696 assert_eq!(store.len(), 2);
697
698 store.publish(
700 key.clone(),
701 PathBuf::from("/c.rs"),
702 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
703 );
704 assert_eq!(store.len(), 2);
705 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
706 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
707 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
708 }
709
710 #[test]
711 fn touching_existing_entry_moves_it_to_end_of_lru() {
712 let mut store = DiagnosticsStore::with_capacity(2);
713 let key = server_key(ServerKind::Rust);
714
715 store.publish(
716 key.clone(),
717 PathBuf::from("/a.rs"),
718 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
719 );
720 store.publish(
721 key.clone(),
722 PathBuf::from("/b.rs"),
723 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
724 );
725
726 store.publish(
729 key.clone(),
730 PathBuf::from("/a.rs"),
731 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
732 );
733 store.publish(
734 key.clone(),
735 PathBuf::from("/c.rs"),
736 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
737 );
738
739 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
740 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
741 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
742 }
743
744 #[test]
745 fn capacity_zero_disables_eviction() {
746 let mut store = DiagnosticsStore::with_capacity(0);
747 let key = server_key(ServerKind::Rust);
748
749 for i in 0..50 {
750 store.publish(
751 key.clone(),
752 PathBuf::from(format!("/f{i}.rs")),
753 vec![diag(
754 &format!("/f{i}.rs"),
755 1,
756 "x",
757 DiagnosticSeverity::Warning,
758 )],
759 );
760 }
761 assert_eq!(store.len(), 50);
762 }
763
764 #[test]
765 fn set_capacity_evicts_on_shrink() {
766 let mut store = DiagnosticsStore::with_capacity(0);
767 let key = server_key(ServerKind::Rust);
768 for i in 0..10 {
769 store.publish(
770 key.clone(),
771 PathBuf::from(format!("/f{i}.rs")),
772 vec![diag(
773 &format!("/f{i}.rs"),
774 1,
775 "x",
776 DiagnosticSeverity::Warning,
777 )],
778 );
779 }
780 assert_eq!(store.len(), 10);
781
782 store.set_capacity(3);
783 assert_eq!(store.len(), 3);
784 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
786 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
787 }
788
789 #[test]
790 fn epoch_increments_monotonically() {
791 let mut store = DiagnosticsStore::new();
792 let key = server_key(ServerKind::Rust);
793 let file = PathBuf::from("/e.rs");
794
795 store.publish(key.clone(), file.clone(), Vec::new());
796 let e1 = store.entries_for_file(&file)[0].1.epoch;
797
798 store.publish(key.clone(), file.clone(), Vec::new());
799 let e2 = store.entries_for_file(&file)[0].1.epoch;
800
801 assert!(e2 > e1, "epoch must increase on republish");
802 }
803
804 #[test]
805 fn result_id_is_round_tripped() {
806 let mut store = DiagnosticsStore::new();
807 let key = server_key(ServerKind::Rust);
808 let file = PathBuf::from("/r.rs");
809
810 store.publish_with_result_id(
811 key.clone(),
812 file.clone(),
813 Vec::new(),
814 Some("rev-42".to_string()),
815 );
816
817 let entries = store.entries_for_file(&file);
818 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
819 }
820
821 #[test]
822 fn clear_server_drops_all_entries_for_kind() {
823 let mut store = DiagnosticsStore::new();
824 let py_key = server_key(ServerKind::Python);
825 let rust_key = server_key(ServerKind::Rust);
826
827 store.publish(
828 py_key.clone(),
829 PathBuf::from("/a.py"),
830 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
831 );
832 store.publish(
833 rust_key.clone(),
834 PathBuf::from("/b.rs"),
835 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
836 );
837
838 store.clear_server(ServerKind::Python);
839 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
840 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
841 }
842
843 #[test]
844 fn clear_for_file_drops_every_server_entry_and_updates_counts() {
845 let mut store = DiagnosticsStore::new();
846 let py_key = server_key(ServerKind::Python);
847 let biome_key = server_key(ServerKind::Biome);
848
849 store.publish(
852 py_key,
853 PathBuf::from("/gone.ts"),
854 vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
855 );
856 store.publish(
857 biome_key,
858 PathBuf::from("/gone.ts"),
859 vec![diag(
860 "/gone.ts",
861 7,
862 "lint warning",
863 DiagnosticSeverity::Warning,
864 )],
865 );
866 store.publish(
867 server_key(ServerKind::Rust),
868 PathBuf::from("/keep.rs"),
869 vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
870 );
871
872 assert_eq!(store.error_warning_counts(), (2, 1));
873
874 let removed = store.clear_for_file(Path::new("/gone.ts"));
876 assert!(removed);
877 assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
878 assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
880 assert_eq!(store.error_warning_counts(), (1, 0));
881
882 assert!(!store.clear_for_file(Path::new("/gone.ts")));
884 }
885
886 #[test]
887 fn filtered_counts_apply_keep_predicate() {
888 let mut store = DiagnosticsStore::new();
889 store.publish(
890 server_key(ServerKind::TypeScript),
891 PathBuf::from("/repo/src/app.ts"),
892 vec![diag(
893 "/repo/src/app.ts",
894 1,
895 "in build",
896 DiagnosticSeverity::Error,
897 )],
898 );
899 store.publish(
900 server_key(ServerKind::TypeScript),
901 PathBuf::from("/repo/src/app.test.ts"),
902 vec![diag(
903 "/repo/src/app.test.ts",
904 1,
905 "excluded",
906 DiagnosticSeverity::Error,
907 )],
908 );
909
910 assert_eq!(store.error_warning_counts(), (2, 0));
912 let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
914 assert_eq!(counts, (1, 0));
915 }
916
917 #[test]
918 fn filtered_counts_dedup_across_servers() {
919 let mut store = DiagnosticsStore::new();
920 let file = "/repo/src/app.ts";
921 store.publish(
926 server_key(ServerKind::TypeScript),
927 PathBuf::from(file),
928 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
929 );
930 store.publish(
931 server_key(ServerKind::Biome),
932 PathBuf::from(file),
933 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
934 );
935
936 assert_eq!(store.error_warning_counts(), (2, 0));
937 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
938 }
939
940 #[test]
941 fn filtered_counts_keep_distinct_diagnostics_same_file() {
942 let mut store = DiagnosticsStore::new();
943 let file = "/repo/src/app.ts";
944 store.publish(
947 server_key(ServerKind::TypeScript),
948 PathBuf::from(file),
949 vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
950 );
951 store.publish(
952 server_key(ServerKind::Biome),
953 PathBuf::from(file),
954 vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
955 );
956 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
957 }
958
959 #[test]
960 fn filtered_counts_exclude_environmental_diagnostics() {
961 let mut store = DiagnosticsStore::new();
962 let file = "/repo/src/app.ts";
963 store.publish(
964 server_key(ServerKind::TypeScript),
965 PathBuf::from(file),
966 vec![
967 diag(
968 file,
969 1,
970 "Cannot find name 'foo'.",
971 DiagnosticSeverity::Error,
972 ),
973 diag(
974 file,
975 2,
976 "Failed to load schema from https://cdn.example/pkg/schema.json",
977 DiagnosticSeverity::Error,
978 ),
979 ],
980 );
981 assert_eq!(store.error_warning_counts(), (2, 0));
982 assert_eq!(
983 store.filtered_error_warning_counts(|_| true),
984 (1, 0),
985 "environmental schema-fetch must not inflate E count"
986 );
987 }
988
989 #[test]
990 fn environmental_flap_does_not_change_filtered_counts() {
991 let mut store = DiagnosticsStore::new();
992 let file = "/repo/package.json";
993 let key = server_key(ServerKind::TypeScript);
994 let env_msg =
995 "Failed to fetch schema from https://json.schemastore.org/package.json: network";
996
997 assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
998
999 store.publish(
1000 key.clone(),
1001 PathBuf::from(file),
1002 vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1003 );
1004 assert_eq!(
1005 store.filtered_error_warning_counts(|_| true),
1006 (0, 0),
1007 "publish environmental diagnostic must not change filtered E/W"
1008 );
1009
1010 store.publish(key, PathBuf::from(file), vec![]);
1011 assert_eq!(
1012 store.filtered_error_warning_counts(|_| true),
1013 (0, 0),
1014 "removing environmental diagnostic must not change filtered E/W"
1015 );
1016 }
1017
1018 #[test]
1019 fn mixed_syntax_and_schema_fetch_counts_one_error() {
1020 let mut store = DiagnosticsStore::new();
1021 let file = "/repo/src/mixed.ts";
1022 store.publish(
1023 server_key(ServerKind::TypeScript),
1024 PathBuf::from(file),
1025 vec![
1026 diag(
1027 file,
1028 3,
1029 "Cannot find name 'bar'.",
1030 DiagnosticSeverity::Error,
1031 ),
1032 diag(
1033 file,
1034 1,
1035 "Failed to resolve schema https://example.com/x.json",
1036 DiagnosticSeverity::Error,
1037 ),
1038 ],
1039 );
1040 assert_eq!(
1041 store.filtered_error_warning_counts(|_| true),
1042 (1, 0),
1043 "classifier is per-diagnostic: one real syntax error => E1"
1044 );
1045 }
1046}