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(
318 &self,
319 mut keep: impl FnMut(&Path) -> bool,
320 ) -> (usize, usize) {
321 let mut seen: std::collections::HashSet<(
325 &Path,
326 u32,
327 u32,
328 u32,
329 u32,
330 &str,
331 &str,
332 Option<&str>,
333 )> = std::collections::HashSet::new();
334 let mut errors = 0usize;
335 let mut warnings = 0usize;
336 for ((_, file), entry) in &self.entries {
337 if !keep(file) {
341 continue;
342 }
343 for diagnostic in &entry.diagnostics {
344 let dedup_key = (
345 diagnostic.file.as_path(),
346 diagnostic.line,
347 diagnostic.column,
348 diagnostic.end_line,
349 diagnostic.end_column,
350 diagnostic.severity.as_str(),
351 diagnostic.message.as_str(),
352 diagnostic.source.as_deref(),
353 );
354 if !seen.insert(dedup_key) {
355 continue;
356 }
357 match diagnostic.severity {
358 DiagnosticSeverity::Error => errors += 1,
359 DiagnosticSeverity::Warning => warnings += 1,
360 _ => {}
361 }
362 }
363 }
364 (errors, warnings)
365 }
366
367 pub fn clear_server(&mut self, server: ServerKind) {
371 self.entries
372 .retain(|(stored_key, _), _| stored_key.kind != server);
373 self.order
374 .retain(|(stored_key, _)| stored_key.kind != server);
375 self.last_publish_at_for_file
376 .retain(|(stored_key, _), _| stored_key.kind != server);
377 }
378
379 pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
381 let cache_key = (key.clone(), file.to_path_buf());
382 self.entries.remove(&cache_key);
383 self.order.retain(|entry_key| entry_key != &cache_key);
384 self.last_publish_at_for_file.remove(&cache_key);
385 }
386
387 pub fn clear_for_file(&mut self, file: &Path) -> bool {
393 let before = self.entries.len();
394 self.entries
395 .retain(|(_, stored_file), _| stored_file != file);
396 let removed = self.entries.len() != before;
397 if removed {
398 self.order.retain(|(_, stored_file)| stored_file != file);
399 self.last_publish_at_for_file
400 .retain(|(_, stored_file), _| stored_file != file);
401 }
402 removed
403 }
404
405 pub fn clear_for_server(&mut self, key: &ServerKey) {
407 self.entries.retain(|(k, _), _| k != key);
408 self.order.retain(|(k, _)| k != key);
409 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
410 }
411
412 pub fn clear_server_instance(&mut self, key: &ServerKey) {
415 self.clear_for_server(key);
416 }
417
418 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
420 if self.order.is_empty() {
421 return None;
422 }
423 let evicted = self.order.remove(0);
424 self.entries.remove(&evicted);
425 self.last_publish_at_for_file.remove(&evicted);
426 Some(evicted)
427 }
428
429 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
430 if let Some(idx) = self.order.iter().position(|k| k == key) {
431 let removed = self.order.remove(idx);
432 self.order.push(removed);
433 }
434 }
435}
436
437impl Default for DiagnosticsStore {
438 fn default() -> Self {
439 Self::new()
440 }
441}
442
443pub fn from_lsp_diagnostics(
446 file: PathBuf,
447 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
448) -> Vec<StoredDiagnostic> {
449 lsp_diagnostics
450 .into_iter()
451 .map(|diagnostic| StoredDiagnostic {
452 file: file.clone(),
453 line: diagnostic.range.start.line + 1,
454 column: diagnostic.range.start.character + 1,
455 end_line: diagnostic.range.end.line + 1,
456 end_column: diagnostic.range.end.character + 1,
457 severity: match diagnostic.severity {
458 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
459 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
460 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
461 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
462 _ => DiagnosticSeverity::Warning,
463 },
464 message: diagnostic.message,
465 code: diagnostic.code.map(|code| match code {
466 lsp_types::NumberOrString::Number(value) => value.to_string(),
467 lsp_types::NumberOrString::String(value) => value,
468 }),
469 source: diagnostic.source,
470 })
471 .collect()
472}
473
474#[cfg(test)]
475mod tests {
476 use std::path::{Path, PathBuf};
477
478 use lsp_types::{
479 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
480 };
481
482 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
483 use crate::lsp::registry::ServerKind;
484 use crate::lsp::roots::ServerKey;
485
486 fn server_key(kind: ServerKind) -> ServerKey {
487 ServerKey {
488 kind,
489 root: PathBuf::from("/tmp/repo"),
490 }
491 }
492
493 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
494 StoredDiagnostic {
495 file: PathBuf::from(file),
496 line,
497 column: 1,
498 end_line: line,
499 end_column: 2,
500 severity: sev,
501 message: msg.into(),
502 code: None,
503 source: None,
504 }
505 }
506
507 #[test]
508 fn converts_lsp_positions_to_one_based() {
509 let file = PathBuf::from("/tmp/demo.rs");
510 let diagnostics = from_lsp_diagnostics(
511 file.clone(),
512 vec![Diagnostic {
513 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
514 severity: Some(LspDiagnosticSeverity::ERROR),
515 code: Some(NumberOrString::String("E1".into())),
516 code_description: None,
517 source: Some("fake".into()),
518 message: "boom".into(),
519 related_information: None,
520 tags: None,
521 data: None,
522 }],
523 );
524
525 assert_eq!(diagnostics.len(), 1);
526 assert_eq!(diagnostics[0].file, file);
527 assert_eq!(diagnostics[0].line, 1);
528 assert_eq!(diagnostics[0].column, 1);
529 assert_eq!(diagnostics[0].end_line, 2);
530 assert_eq!(diagnostics[0].end_column, 5);
531 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
532 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
533 }
534
535 #[test]
536 fn publish_replaces_existing_file_diagnostics() {
537 let file = PathBuf::from("/tmp/demo.rs");
538 let mut store = DiagnosticsStore::new();
539 let key = server_key(ServerKind::Rust);
540
541 store.publish(
542 key.clone(),
543 file.clone(),
544 vec![diag(
545 "/tmp/demo.rs",
546 1,
547 "first",
548 DiagnosticSeverity::Warning,
549 )],
550 );
551 store.publish(
552 key.clone(),
553 file.clone(),
554 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
555 );
556
557 let stored = store.for_file(&file);
558 assert_eq!(stored.len(), 1);
559 assert_eq!(stored[0].message, "second");
560 }
561
562 #[test]
563 fn empty_publish_is_preserved_as_checked_clean() {
564 let file = PathBuf::from("/tmp/clean.rs");
568 let mut store = DiagnosticsStore::new();
569 let key = server_key(ServerKind::Rust);
570
571 store.publish(
573 key.clone(),
574 file.clone(),
575 vec![diag(
576 "/tmp/clean.rs",
577 5,
578 "fix me",
579 DiagnosticSeverity::Warning,
580 )],
581 );
582 assert!(store.has_any_report_for_file(&file));
583 assert_eq!(store.for_file(&file).len(), 1);
584
585 store.publish(key.clone(), file.clone(), Vec::new());
588 assert!(
589 store.has_any_report_for_file(&file),
590 "checked-clean must be distinguishable from never-checked"
591 );
592 assert_eq!(store.for_file(&file).len(), 0);
593
594 let entries = store.entries_for_file(&file);
595 assert_eq!(entries.len(), 1);
596 assert!(entries[0].1.epoch > 0);
597 }
598
599 #[test]
600 fn never_checked_returns_no_report() {
601 let store = DiagnosticsStore::new();
602 let file = PathBuf::from("/tmp/never.rs");
603 assert!(!store.has_any_report_for_file(&file));
604 assert!(store.for_file(&file).is_empty());
605 }
606
607 #[test]
608 fn per_server_state_is_tracked_independently() {
609 let file = PathBuf::from("/tmp/multi.py");
610 let mut store = DiagnosticsStore::new();
611 let pyright_key = server_key(ServerKind::Python);
612 let ty_key = server_key(ServerKind::Ty);
613
614 store.publish(
615 pyright_key,
616 file.clone(),
617 vec![diag(
618 "/tmp/multi.py",
619 1,
620 "pyright says X",
621 DiagnosticSeverity::Error,
622 )],
623 );
624 store.publish(
625 ty_key,
626 file.clone(),
627 vec![diag(
628 "/tmp/multi.py",
629 2,
630 "ty says Y",
631 DiagnosticSeverity::Warning,
632 )],
633 );
634
635 let messages: Vec<&str> = store
636 .for_file(&file)
637 .into_iter()
638 .map(|d| d.message.as_str())
639 .collect();
640
641 assert_eq!(messages.len(), 2, "both servers' reports preserved");
642 assert!(messages.iter().any(|m| m == &"pyright says X"));
643 assert!(messages.iter().any(|m| m == &"ty says Y"));
644 }
645
646 #[test]
647 fn clear_for_server_file_removes_only_exact_entry() {
648 let file_a = PathBuf::from("/tmp/a.rs");
649 let file_b = PathBuf::from("/tmp/b.rs");
650 let mut store = DiagnosticsStore::new();
651 let rust_key = server_key(ServerKind::Rust);
652 let py_key = server_key(ServerKind::Python);
653
654 store.publish(
655 rust_key.clone(),
656 file_a.clone(),
657 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
658 );
659 store.publish(
660 rust_key.clone(),
661 file_b.clone(),
662 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
663 );
664 store.publish(
665 py_key.clone(),
666 file_a.clone(),
667 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
668 );
669
670 store.clear_for_server_file(&rust_key, &file_a);
671
672 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
673 assert!(store.has_report_for_server_file(&rust_key, &file_b));
674 assert!(store.has_report_for_server_file(&py_key, &file_a));
675 }
676
677 #[test]
678 fn lru_evicts_oldest_when_capacity_exceeded() {
679 let mut store = DiagnosticsStore::with_capacity(2);
680 let key = server_key(ServerKind::Rust);
681
682 store.publish(
683 key.clone(),
684 PathBuf::from("/a.rs"),
685 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
686 );
687 store.publish(
688 key.clone(),
689 PathBuf::from("/b.rs"),
690 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
691 );
692 assert_eq!(store.len(), 2);
693
694 store.publish(
696 key.clone(),
697 PathBuf::from("/c.rs"),
698 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
699 );
700 assert_eq!(store.len(), 2);
701 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
702 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
703 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
704 }
705
706 #[test]
707 fn touching_existing_entry_moves_it_to_end_of_lru() {
708 let mut store = DiagnosticsStore::with_capacity(2);
709 let key = server_key(ServerKind::Rust);
710
711 store.publish(
712 key.clone(),
713 PathBuf::from("/a.rs"),
714 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
715 );
716 store.publish(
717 key.clone(),
718 PathBuf::from("/b.rs"),
719 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
720 );
721
722 store.publish(
725 key.clone(),
726 PathBuf::from("/a.rs"),
727 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
728 );
729 store.publish(
730 key.clone(),
731 PathBuf::from("/c.rs"),
732 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
733 );
734
735 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
736 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
737 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
738 }
739
740 #[test]
741 fn capacity_zero_disables_eviction() {
742 let mut store = DiagnosticsStore::with_capacity(0);
743 let key = server_key(ServerKind::Rust);
744
745 for i in 0..50 {
746 store.publish(
747 key.clone(),
748 PathBuf::from(format!("/f{i}.rs")),
749 vec![diag(
750 &format!("/f{i}.rs"),
751 1,
752 "x",
753 DiagnosticSeverity::Warning,
754 )],
755 );
756 }
757 assert_eq!(store.len(), 50);
758 }
759
760 #[test]
761 fn set_capacity_evicts_on_shrink() {
762 let mut store = DiagnosticsStore::with_capacity(0);
763 let key = server_key(ServerKind::Rust);
764 for i in 0..10 {
765 store.publish(
766 key.clone(),
767 PathBuf::from(format!("/f{i}.rs")),
768 vec![diag(
769 &format!("/f{i}.rs"),
770 1,
771 "x",
772 DiagnosticSeverity::Warning,
773 )],
774 );
775 }
776 assert_eq!(store.len(), 10);
777
778 store.set_capacity(3);
779 assert_eq!(store.len(), 3);
780 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
782 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
783 }
784
785 #[test]
786 fn epoch_increments_monotonically() {
787 let mut store = DiagnosticsStore::new();
788 let key = server_key(ServerKind::Rust);
789 let file = PathBuf::from("/e.rs");
790
791 store.publish(key.clone(), file.clone(), Vec::new());
792 let e1 = store.entries_for_file(&file)[0].1.epoch;
793
794 store.publish(key.clone(), file.clone(), Vec::new());
795 let e2 = store.entries_for_file(&file)[0].1.epoch;
796
797 assert!(e2 > e1, "epoch must increase on republish");
798 }
799
800 #[test]
801 fn result_id_is_round_tripped() {
802 let mut store = DiagnosticsStore::new();
803 let key = server_key(ServerKind::Rust);
804 let file = PathBuf::from("/r.rs");
805
806 store.publish_with_result_id(
807 key.clone(),
808 file.clone(),
809 Vec::new(),
810 Some("rev-42".to_string()),
811 );
812
813 let entries = store.entries_for_file(&file);
814 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
815 }
816
817 #[test]
818 fn clear_server_drops_all_entries_for_kind() {
819 let mut store = DiagnosticsStore::new();
820 let py_key = server_key(ServerKind::Python);
821 let rust_key = server_key(ServerKind::Rust);
822
823 store.publish(
824 py_key.clone(),
825 PathBuf::from("/a.py"),
826 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
827 );
828 store.publish(
829 rust_key.clone(),
830 PathBuf::from("/b.rs"),
831 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
832 );
833
834 store.clear_server(ServerKind::Python);
835 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
836 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
837 }
838
839 #[test]
840 fn clear_for_file_drops_every_server_entry_and_updates_counts() {
841 let mut store = DiagnosticsStore::new();
842 let py_key = server_key(ServerKind::Python);
843 let biome_key = server_key(ServerKind::Biome);
844
845 store.publish(
848 py_key,
849 PathBuf::from("/gone.ts"),
850 vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
851 );
852 store.publish(
853 biome_key,
854 PathBuf::from("/gone.ts"),
855 vec![diag(
856 "/gone.ts",
857 7,
858 "lint warning",
859 DiagnosticSeverity::Warning,
860 )],
861 );
862 store.publish(
863 server_key(ServerKind::Rust),
864 PathBuf::from("/keep.rs"),
865 vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
866 );
867
868 assert_eq!(store.error_warning_counts(), (2, 1));
869
870 let removed = store.clear_for_file(Path::new("/gone.ts"));
872 assert!(removed);
873 assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
874 assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
876 assert_eq!(store.error_warning_counts(), (1, 0));
877
878 assert!(!store.clear_for_file(Path::new("/gone.ts")));
880 }
881
882 #[test]
883 fn filtered_counts_apply_keep_predicate() {
884 let mut store = DiagnosticsStore::new();
885 store.publish(
886 server_key(ServerKind::TypeScript),
887 PathBuf::from("/repo/src/app.ts"),
888 vec![diag(
889 "/repo/src/app.ts",
890 1,
891 "in build",
892 DiagnosticSeverity::Error,
893 )],
894 );
895 store.publish(
896 server_key(ServerKind::TypeScript),
897 PathBuf::from("/repo/src/app.test.ts"),
898 vec![diag(
899 "/repo/src/app.test.ts",
900 1,
901 "excluded",
902 DiagnosticSeverity::Error,
903 )],
904 );
905
906 assert_eq!(store.error_warning_counts(), (2, 0));
908 let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
910 assert_eq!(counts, (1, 0));
911 }
912
913 #[test]
914 fn filtered_counts_dedup_across_servers() {
915 let mut store = DiagnosticsStore::new();
916 let file = "/repo/src/app.ts";
917 store.publish(
922 server_key(ServerKind::TypeScript),
923 PathBuf::from(file),
924 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
925 );
926 store.publish(
927 server_key(ServerKind::Biome),
928 PathBuf::from(file),
929 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
930 );
931
932 assert_eq!(store.error_warning_counts(), (2, 0));
933 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
934 }
935
936 #[test]
937 fn filtered_counts_keep_distinct_diagnostics_same_file() {
938 let mut store = DiagnosticsStore::new();
939 let file = "/repo/src/app.ts";
940 store.publish(
943 server_key(ServerKind::TypeScript),
944 PathBuf::from(file),
945 vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
946 );
947 store.publish(
948 server_key(ServerKind::Biome),
949 PathBuf::from(file),
950 vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
951 );
952 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
953 }
954}