1use std::collections::{HashMap, HashSet};
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 by_file: HashMap<PathBuf, HashSet<ServerKey>>,
94 order: Vec<(ServerKey, PathBuf)>,
97 capacity: usize,
99 next_epoch: u64,
101 generation: u64,
104 last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
107}
108
109impl DiagnosticsStore {
110 pub fn new() -> Self {
111 Self::with_capacity(5000)
112 }
113
114 pub fn with_capacity(capacity: usize) -> Self {
115 Self {
116 entries: HashMap::new(),
117 by_file: HashMap::new(),
118 order: Vec::new(),
119 capacity,
120 next_epoch: 0,
121 generation: 0,
122 last_publish_at_for_file: HashMap::new(),
123 }
124 }
125
126 pub fn set_capacity(&mut self, capacity: usize) {
130 if self.capacity != capacity {
131 self.generation = self.generation.wrapping_add(1);
132 }
133 self.capacity = capacity;
134 if capacity > 0 {
135 while self.entries.len() > capacity {
136 self.evict_lru();
137 }
138 }
139 self.debug_assert_index_consistent();
140 }
141
142 pub fn len(&self) -> usize {
144 self.entries.len()
145 }
146
147 pub fn generation(&self) -> u64 {
148 self.generation
149 }
150
151 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
152 let mut diagnostic_count = 0usize;
153 let entry_bytes = self
154 .entries
155 .iter()
156 .fold(0u64, |bytes, ((server, path), entry)| {
157 diagnostic_count = diagnostic_count.saturating_add(entry.diagnostics.len());
158 let diagnostics_bytes = entry.diagnostics.iter().fold(0u64, |bytes, diagnostic| {
159 bytes
160 .saturating_add(std::mem::size_of::<StoredDiagnostic>() as u64)
161 .saturating_add(crate::memory::path_bytes(&diagnostic.file))
162 .saturating_add(crate::memory::usize_to_u64(diagnostic.message.len()))
163 .saturating_add(
164 diagnostic
165 .code
166 .as_ref()
167 .map(|code| crate::memory::usize_to_u64(code.len()))
168 .unwrap_or(0),
169 )
170 .saturating_add(
171 diagnostic
172 .source
173 .as_ref()
174 .map(|source| crate::memory::usize_to_u64(source.len()))
175 .unwrap_or(0),
176 )
177 });
178 bytes
179 .saturating_add(std::mem::size_of_val(server) as u64)
180 .saturating_add(crate::memory::path_bytes(&server.root))
181 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
182 .saturating_add(crate::memory::path_bytes(path))
183 .saturating_add(std::mem::size_of::<DiagnosticEntry>() as u64)
184 .saturating_add(
185 entry
186 .result_id
187 .as_ref()
188 .map(|result_id| crate::memory::usize_to_u64(result_id.len()))
189 .unwrap_or(0),
190 )
191 .saturating_add(diagnostics_bytes)
192 });
193 let order_bytes = self.order.iter().fold(0u64, |bytes, (server, path)| {
194 bytes
195 .saturating_add(std::mem::size_of_val(server) as u64)
196 .saturating_add(crate::memory::path_bytes(&server.root))
197 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
198 .saturating_add(crate::memory::path_bytes(path))
199 });
200 let publish_bytes =
201 self.last_publish_at_for_file
202 .iter()
203 .fold(0u64, |bytes, ((server, path), _)| {
204 bytes
205 .saturating_add(std::mem::size_of_val(server) as u64)
206 .saturating_add(crate::memory::path_bytes(&server.root))
207 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
208 .saturating_add(crate::memory::path_bytes(path))
209 .saturating_add(std::mem::size_of::<Instant>() as u64)
210 });
211 let by_file_bytes = self.by_file.iter().fold(0u64, |bytes, (path, servers)| {
212 let server_bytes = servers.iter().fold(0u64, |server_bytes, server| {
213 server_bytes
214 .saturating_add(std::mem::size_of_val(server) as u64)
215 .saturating_add(crate::memory::path_bytes(&server.root))
216 });
217 bytes
218 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
219 .saturating_add(crate::memory::path_bytes(path))
220 .saturating_add(server_bytes)
221 });
222 crate::memory::MemoryEstimate::estimated(
223 entry_bytes
224 .saturating_add(order_bytes)
225 .saturating_add(publish_bytes)
226 .saturating_add(by_file_bytes),
227 )
228 .count("diagnostic_entries", self.entries.len())
229 .count("diagnostics", diagnostic_count)
230 }
231
232 #[cfg(test)]
235 pub fn capacity_for_test(&self) -> usize {
236 self.capacity
237 }
238
239 pub fn is_empty(&self) -> bool {
240 self.entries.is_empty()
241 }
242
243 pub fn has_any_fresh_report(&self) -> bool {
246 self.entries.values().any(|entry| !entry.stale)
247 }
248
249 pub fn publish(
258 &mut self,
259 server: ServerKey,
260 file: PathBuf,
261 diagnostics: Vec<StoredDiagnostic>,
262 ) {
263 self.publish_with_result_id(server, file, diagnostics, None);
264 }
265
266 pub fn publish_with_result_id(
269 &mut self,
270 server: ServerKey,
271 file: PathBuf,
272 diagnostics: Vec<StoredDiagnostic>,
273 result_id: Option<String>,
274 ) {
275 self.publish_full(server, file, diagnostics, result_id, None);
276 }
277
278 pub fn publish_full(
282 &mut self,
283 server: ServerKey,
284 file: PathBuf,
285 diagnostics: Vec<StoredDiagnostic>,
286 result_id: Option<String>,
287 version: Option<i32>,
288 ) {
289 let key = (server, file);
290 self.next_epoch = self.next_epoch.saturating_add(1);
291 self.generation = self.generation.wrapping_add(1);
292 let entry = DiagnosticEntry {
293 diagnostics,
294 epoch: self.next_epoch,
295 result_id,
296 version,
297 stale: false,
298 };
299
300 self.last_publish_at_for_file
301 .insert(key.clone(), Instant::now());
302
303 if self.entries.contains_key(&key) {
304 self.entries.insert(key.clone(), entry);
305 self.index_entry(&key);
306 self.touch_existing(&key);
307 } else {
308 if self.capacity > 0 && self.entries.len() >= self.capacity {
310 self.evict_lru();
311 }
312 self.entries.insert(key.clone(), entry);
313 self.index_entry(&key);
314 self.order.push(key);
315 }
316 self.debug_assert_index_consistent();
317 }
318
319 pub fn publish_with_kind(
325 &mut self,
326 kind: ServerKind,
327 file: PathBuf,
328 diagnostics: Vec<StoredDiagnostic>,
329 ) {
330 let key = ServerKey {
331 kind,
332 root: PathBuf::new(),
333 };
334 self.publish(key, file, diagnostics);
335 }
336
337 pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
340 let Some(servers) = self.by_file.get(file) else {
341 return Vec::new();
342 };
343 let file = file.to_path_buf();
344 let mut diagnostics = Vec::new();
345 for server in servers {
346 if let Some(entry) = self.entries.get(&(server.clone(), file.clone())) {
347 if !entry.stale {
348 diagnostics.extend(&entry.diagnostics);
349 }
350 }
351 }
352 diagnostics
353 }
354
355 pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
358 let Some(servers) = self.by_file.get(file) else {
359 return Vec::new();
360 };
361 let file = file.to_path_buf();
362 servers
363 .iter()
364 .filter_map(|server| {
365 self.entries
366 .get_key_value(&(server.clone(), file.clone()))
367 .map(|((stored_server, _), entry)| (stored_server, entry))
368 })
369 .collect()
370 }
371
372 pub fn has_any_report_for_file(&self, file: &Path) -> bool {
374 self.by_file.contains_key(file)
375 }
376
377 pub fn has_any_fresh_report_for_file(&self, file: &Path) -> bool {
379 let Some(servers) = self.by_file.get(file) else {
380 return false;
381 };
382 let file = file.to_path_buf();
383 servers.iter().any(|server| {
384 self.entries
385 .get(&(server.clone(), file.clone()))
386 .is_some_and(|entry| !entry.stale)
387 })
388 }
389
390 pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
394 self.entries
395 .contains_key(&(server.clone(), file.to_path_buf()))
396 }
397
398 pub fn has_fresh_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
400 self.entries
401 .get(&(server.clone(), file.to_path_buf()))
402 .is_some_and(|entry| !entry.stale)
403 }
404
405 pub fn has_publish_for_file_after(
409 &self,
410 server: &ServerKey,
411 file: &Path,
412 since: Instant,
413 ) -> bool {
414 self.last_publish_at_for_file
415 .get(&(server.clone(), file.to_path_buf()))
416 .is_some_and(|published_at| {
417 *published_at >= since && self.has_fresh_report_for_server_file(server, file)
418 })
419 }
420
421 pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
423 self.entries
424 .iter()
425 .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
426 .flat_map(|(_, entry)| entry.diagnostics.iter())
427 .collect()
428 }
429
430 pub fn all(&self) -> Vec<&StoredDiagnostic> {
432 self.entries
433 .values()
434 .filter(|entry| !entry.stale)
435 .flat_map(|entry| entry.diagnostics.iter())
436 .collect()
437 }
438
439 pub fn error_warning_counts(&self) -> (usize, usize) {
445 let mut errors = 0usize;
446 let mut warnings = 0usize;
447 for entry in self.entries.values() {
448 if entry.stale {
449 continue;
450 }
451 for diagnostic in &entry.diagnostics {
452 match diagnostic.severity {
453 DiagnosticSeverity::Error => errors += 1,
454 DiagnosticSeverity::Warning => warnings += 1,
455 _ => {}
456 }
457 }
458 }
459 (errors, warnings)
460 }
461
462 pub fn filtered_error_warning_counts(
475 &self,
476 mut keep: impl FnMut(&Path) -> bool,
477 ) -> (usize, usize) {
478 let mut seen: std::collections::HashSet<(
482 &Path,
483 u32,
484 u32,
485 u32,
486 u32,
487 &str,
488 &str,
489 Option<&str>,
490 )> = std::collections::HashSet::new();
491 let mut errors = 0usize;
492 let mut warnings = 0usize;
493 for ((_, file), entry) in &self.entries {
494 if entry.stale {
495 continue;
496 }
497 if !keep(file) {
501 continue;
502 }
503 for diagnostic in &entry.diagnostics {
504 if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
505 continue;
506 }
507 let dedup_key = (
508 diagnostic.file.as_path(),
509 diagnostic.line,
510 diagnostic.column,
511 diagnostic.end_line,
512 diagnostic.end_column,
513 diagnostic.severity.as_str(),
514 diagnostic.message.as_str(),
515 diagnostic.source.as_deref(),
516 );
517 if !seen.insert(dedup_key) {
518 continue;
519 }
520 match diagnostic.severity {
521 DiagnosticSeverity::Error => errors += 1,
522 DiagnosticSeverity::Warning => warnings += 1,
523 _ => {}
524 }
525 }
526 }
527 (errors, warnings)
528 }
529
530 pub fn clear_server(&mut self, server: ServerKind) {
534 let before = self.entries.len();
535 self.entries
536 .retain(|(stored_key, _), _| stored_key.kind != server);
537 self.order
538 .retain(|(stored_key, _)| stored_key.kind != server);
539 self.last_publish_at_for_file
540 .retain(|(stored_key, _), _| stored_key.kind != server);
541 self.by_file.retain(|_, servers| {
542 servers.retain(|stored_key| stored_key.kind != server);
543 !servers.is_empty()
544 });
545 if self.entries.len() != before {
546 self.generation = self.generation.wrapping_add(1);
547 }
548 self.debug_assert_index_consistent();
549 }
550
551 pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
553 let cache_key = (key.clone(), file.to_path_buf());
554 if self.entries.remove(&cache_key).is_some() {
555 self.unindex_entry(&cache_key);
556 self.generation = self.generation.wrapping_add(1);
557 }
558 self.order.retain(|entry_key| entry_key != &cache_key);
559 self.last_publish_at_for_file.remove(&cache_key);
560 self.debug_assert_index_consistent();
561 }
562
563 pub fn clear_for_file(&mut self, file: &Path) -> bool {
569 let Some(servers) = self.by_file.remove(file) else {
570 self.debug_assert_index_consistent();
571 return false;
572 };
573 let mut removed = false;
574 for server in servers {
575 let cache_key = (server, file.to_path_buf());
576 removed |= self.entries.remove(&cache_key).is_some();
577 self.last_publish_at_for_file.remove(&cache_key);
578 }
579 if removed {
580 self.generation = self.generation.wrapping_add(1);
581 self.order.retain(|(_, stored_file)| stored_file != file);
582 }
583 self.debug_assert_index_consistent();
584 removed
585 }
586
587 pub fn mark_stale_for_file(&mut self, file: &Path) -> (bool, bool) {
595 let Some(servers) = self.by_file.get(file) else {
596 return (false, false);
597 };
598 let had_entries = !servers.is_empty();
599 let mut changed = false;
600 for server in servers {
601 let cache_key = (server.clone(), file.to_path_buf());
602 if let Some(entry) = self
603 .entries
604 .get_mut(&cache_key)
605 .filter(|entry| !entry.stale)
606 {
607 entry.stale = true;
608 changed = true;
609 }
610 }
611 if changed {
612 self.generation = self.generation.wrapping_add(1);
613 }
614 self.debug_assert_index_consistent();
615 (had_entries, changed)
616 }
617
618 pub fn mark_fresh_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
621 let cache_key = (key.clone(), file.to_path_buf());
622 let Some(entry) = self.entries.get_mut(&cache_key) else {
623 return false;
624 };
625 let changed = entry.stale;
626 entry.stale = false;
627 if changed {
628 self.generation = self.generation.wrapping_add(1);
629 }
630 self.touch_existing(&cache_key);
631 changed
632 }
633
634 pub fn clear_for_server(&mut self, key: &ServerKey) {
636 let before = self.entries.len();
637 self.entries.retain(|(k, _), _| k != key);
638 self.order.retain(|(k, _)| k != key);
639 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
640 self.by_file.retain(|_, servers| {
641 servers.remove(key);
642 !servers.is_empty()
643 });
644 if self.entries.len() != before {
645 self.generation = self.generation.wrapping_add(1);
646 }
647 self.debug_assert_index_consistent();
648 }
649
650 pub fn clear_server_instance(&mut self, key: &ServerKey) {
653 self.clear_for_server(key);
654 }
655
656 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
658 if self.order.is_empty() {
659 return None;
660 }
661 let evicted = self.order.remove(0);
662 self.entries.remove(&evicted);
663 self.unindex_entry(&evicted);
664 self.last_publish_at_for_file.remove(&evicted);
665 self.debug_assert_index_consistent();
666 Some(evicted)
667 }
668
669 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
670 if let Some(idx) = self.order.iter().position(|k| k == key) {
671 let removed = self.order.remove(idx);
672 self.order.push(removed);
673 }
674 }
675
676 fn index_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
677 self.by_file
678 .entry(file.clone())
679 .or_default()
680 .insert(server.clone());
681 }
682
683 fn unindex_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
684 let remove_file = self.by_file.get_mut(file).is_some_and(|servers| {
685 servers.remove(server);
686 servers.is_empty()
687 });
688 if remove_file {
689 self.by_file.remove(file);
690 }
691 }
692
693 fn debug_assert_index_consistent(&self) {
694 #[cfg(debug_assertions)]
695 {
696 let indexed_entries = self.by_file.values().map(HashSet::len).sum::<usize>();
697 debug_assert_eq!(indexed_entries, self.entries.len());
698 for (server, file) in self.entries.keys() {
699 debug_assert!(self
700 .by_file
701 .get(file)
702 .is_some_and(|servers| servers.contains(server)));
703 }
704 for (file, servers) in &self.by_file {
705 debug_assert!(!servers.is_empty());
706 for server in servers {
707 debug_assert!(self.entries.contains_key(&(server.clone(), file.clone())));
708 }
709 }
710 }
711 }
712
713 #[cfg(test)]
714 fn mark_stale_for_file_linear_reference(&self, file: &Path) -> (bool, bool) {
715 let mut had_entries = false;
716 let mut changed = false;
717 for ((_, stored_file), entry) in &self.entries {
718 if stored_file == file {
719 had_entries = true;
720 changed |= !entry.stale;
721 }
722 }
723 (had_entries, changed)
724 }
725}
726
727impl Default for DiagnosticsStore {
728 fn default() -> Self {
729 Self::new()
730 }
731}
732
733pub fn from_lsp_diagnostics(
736 file: PathBuf,
737 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
738) -> Vec<StoredDiagnostic> {
739 lsp_diagnostics
740 .into_iter()
741 .map(|diagnostic| StoredDiagnostic {
742 file: file.clone(),
743 line: diagnostic.range.start.line + 1,
744 column: diagnostic.range.start.character + 1,
745 end_line: diagnostic.range.end.line + 1,
746 end_column: diagnostic.range.end.character + 1,
747 severity: match diagnostic.severity {
748 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
749 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
750 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
751 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
752 _ => DiagnosticSeverity::Warning,
753 },
754 message: diagnostic.message,
755 code: diagnostic.code.map(|code| match code {
756 lsp_types::NumberOrString::Number(value) => value.to_string(),
757 lsp_types::NumberOrString::String(value) => value,
758 }),
759 source: diagnostic.source,
760 })
761 .collect()
762}
763
764#[cfg(test)]
765mod tests {
766 use std::path::{Path, PathBuf};
767
768 use lsp_types::{
769 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
770 };
771
772 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
773 use crate::lsp::registry::ServerKind;
774 use crate::lsp::roots::ServerKey;
775
776 fn server_key(kind: ServerKind) -> ServerKey {
777 ServerKey {
778 kind,
779 root: PathBuf::from("/tmp/repo"),
780 }
781 }
782
783 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
784 StoredDiagnostic {
785 file: PathBuf::from(file),
786 line,
787 column: 1,
788 end_line: line,
789 end_column: 2,
790 severity: sev,
791 message: msg.into(),
792 code: None,
793 source: None,
794 }
795 }
796
797 #[test]
798 fn diagnostics_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
799 let mut store = DiagnosticsStore::new();
800 assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
801 let file = PathBuf::from("/tmp/memory.rs");
802 store.publish(
803 server_key(ServerKind::Rust),
804 file.clone(),
805 vec![diag(
806 file.to_str().unwrap(),
807 1,
808 "resident diagnostic message",
809 DiagnosticSeverity::Warning,
810 )],
811 );
812 let estimate = store.estimated_memory();
813 assert!(estimate.estimated_bytes.unwrap() > 0);
814 assert_eq!(estimate.counts["diagnostic_entries"], 1);
815 assert_eq!(estimate.counts["diagnostics"], 1);
816 }
817
818 #[test]
819 fn converts_lsp_positions_to_one_based() {
820 let file = PathBuf::from("/tmp/demo.rs");
821 let diagnostics = from_lsp_diagnostics(
822 file.clone(),
823 vec![Diagnostic {
824 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
825 severity: Some(LspDiagnosticSeverity::ERROR),
826 code: Some(NumberOrString::String("E1".into())),
827 code_description: None,
828 source: Some("fake".into()),
829 message: "boom".into(),
830 related_information: None,
831 tags: None,
832 data: None,
833 }],
834 );
835
836 assert_eq!(diagnostics.len(), 1);
837 assert_eq!(diagnostics[0].file, file);
838 assert_eq!(diagnostics[0].line, 1);
839 assert_eq!(diagnostics[0].column, 1);
840 assert_eq!(diagnostics[0].end_line, 2);
841 assert_eq!(diagnostics[0].end_column, 5);
842 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
843 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
844 }
845
846 #[test]
847 fn publish_replaces_existing_file_diagnostics() {
848 let file = PathBuf::from("/tmp/demo.rs");
849 let mut store = DiagnosticsStore::new();
850 let key = server_key(ServerKind::Rust);
851
852 store.publish(
853 key.clone(),
854 file.clone(),
855 vec![diag(
856 "/tmp/demo.rs",
857 1,
858 "first",
859 DiagnosticSeverity::Warning,
860 )],
861 );
862 store.publish(
863 key.clone(),
864 file.clone(),
865 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
866 );
867
868 let stored = store.for_file(&file);
869 assert_eq!(stored.len(), 1);
870 assert_eq!(stored[0].message, "second");
871 }
872
873 #[test]
874 fn empty_publish_is_preserved_as_checked_clean() {
875 let file = PathBuf::from("/tmp/clean.rs");
879 let mut store = DiagnosticsStore::new();
880 let key = server_key(ServerKind::Rust);
881
882 store.publish(
884 key.clone(),
885 file.clone(),
886 vec![diag(
887 "/tmp/clean.rs",
888 5,
889 "fix me",
890 DiagnosticSeverity::Warning,
891 )],
892 );
893 assert!(store.has_any_report_for_file(&file));
894 assert_eq!(store.for_file(&file).len(), 1);
895
896 store.publish(key.clone(), file.clone(), Vec::new());
899 assert!(
900 store.has_any_report_for_file(&file),
901 "checked-clean must be distinguishable from never-checked"
902 );
903 assert_eq!(store.for_file(&file).len(), 0);
904
905 let entries = store.entries_for_file(&file);
906 assert_eq!(entries.len(), 1);
907 assert!(entries[0].1.epoch > 0);
908 }
909
910 #[test]
911 fn never_checked_returns_no_report() {
912 let store = DiagnosticsStore::new();
913 let file = PathBuf::from("/tmp/never.rs");
914 assert!(!store.has_any_report_for_file(&file));
915 assert!(store.for_file(&file).is_empty());
916 }
917
918 #[test]
919 fn stale_entries_are_hidden_but_preserved_for_refresh() {
920 let file = PathBuf::from("/tmp/stale.rs");
921 let mut store = DiagnosticsStore::new();
922 let key = server_key(ServerKind::Rust);
923 store.publish(
924 key.clone(),
925 file.clone(),
926 vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
927 );
928
929 let (had_entries, changed) = store.mark_stale_for_file(&file);
930
931 assert!(had_entries);
932 assert!(changed);
933 assert!(store.has_any_report_for_file(&file));
934 assert!(!store.has_any_fresh_report_for_file(&file));
935 assert!(store.for_file(&file).is_empty());
936 assert!(store.all().is_empty());
937 assert_eq!(store.error_warning_counts(), (0, 0));
938 assert_eq!(store.entries_for_file(&file).len(), 1);
939
940 assert!(store.mark_fresh_for_server_file(&key, &file));
941 assert!(store.has_any_fresh_report_for_file(&file));
942 assert_eq!(store.for_file(&file).len(), 1);
943 assert_eq!(store.error_warning_counts(), (1, 0));
944 }
945
946 #[test]
947 fn per_server_state_is_tracked_independently() {
948 let file = PathBuf::from("/tmp/multi.py");
949 let mut store = DiagnosticsStore::new();
950 let pyright_key = server_key(ServerKind::Python);
951 let ty_key = server_key(ServerKind::Ty);
952
953 store.publish(
954 pyright_key,
955 file.clone(),
956 vec![diag(
957 "/tmp/multi.py",
958 1,
959 "pyright says X",
960 DiagnosticSeverity::Error,
961 )],
962 );
963 store.publish(
964 ty_key,
965 file.clone(),
966 vec![diag(
967 "/tmp/multi.py",
968 2,
969 "ty says Y",
970 DiagnosticSeverity::Warning,
971 )],
972 );
973
974 let messages: Vec<&str> = store
975 .for_file(&file)
976 .into_iter()
977 .map(|d| d.message.as_str())
978 .collect();
979
980 assert_eq!(messages.len(), 2, "both servers' reports preserved");
981 assert!(messages.iter().any(|m| m == &"pyright says X"));
982 assert!(messages.iter().any(|m| m == &"ty says Y"));
983 }
984
985 #[test]
986 fn clear_for_server_file_removes_only_exact_entry() {
987 let file_a = PathBuf::from("/tmp/a.rs");
988 let file_b = PathBuf::from("/tmp/b.rs");
989 let mut store = DiagnosticsStore::new();
990 let rust_key = server_key(ServerKind::Rust);
991 let py_key = server_key(ServerKind::Python);
992
993 store.publish(
994 rust_key.clone(),
995 file_a.clone(),
996 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
997 );
998 store.publish(
999 rust_key.clone(),
1000 file_b.clone(),
1001 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
1002 );
1003 store.publish(
1004 py_key.clone(),
1005 file_a.clone(),
1006 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
1007 );
1008
1009 store.clear_for_server_file(&rust_key, &file_a);
1010
1011 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
1012 assert!(store.has_report_for_server_file(&rust_key, &file_b));
1013 assert!(store.has_report_for_server_file(&py_key, &file_a));
1014 }
1015
1016 #[test]
1017 fn lru_evicts_oldest_when_capacity_exceeded() {
1018 let mut store = DiagnosticsStore::with_capacity(2);
1019 let key = server_key(ServerKind::Rust);
1020
1021 store.publish(
1022 key.clone(),
1023 PathBuf::from("/a.rs"),
1024 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1025 );
1026 store.publish(
1027 key.clone(),
1028 PathBuf::from("/b.rs"),
1029 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1030 );
1031 assert_eq!(store.len(), 2);
1032
1033 store.publish(
1035 key.clone(),
1036 PathBuf::from("/c.rs"),
1037 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1038 );
1039 assert_eq!(store.len(), 2);
1040 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
1041 assert!(!store.by_file.contains_key(Path::new("/a.rs")));
1042 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1043 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1044 store.debug_assert_index_consistent();
1045 }
1046
1047 #[test]
1048 fn secondary_index_stays_consistent_through_seeded_mutation_storm() {
1049 fn next_random(seed: &mut u64) -> u64 {
1050 *seed = seed
1051 .wrapping_mul(6_364_136_223_846_793_005)
1052 .wrapping_add(1_442_695_040_888_963_407);
1053 *seed
1054 }
1055
1056 let servers = [
1057 server_key(ServerKind::Rust),
1058 server_key(ServerKind::TypeScript),
1059 server_key(ServerKind::Python),
1060 server_key(ServerKind::Biome),
1061 ];
1062 let files = (0..11)
1063 .map(|index| PathBuf::from(format!("/tmp/index-{index}.rs")))
1064 .collect::<Vec<_>>();
1065 let mut store = DiagnosticsStore::with_capacity(7);
1066 let mut seed = 0x05ee_dd1a_6005_71c5_u64;
1067 let mut operation_counts = [0usize; 7];
1068 let mut stale_hits = 0usize;
1069
1070 for step in 0..1_000 {
1071 let operation = (next_random(&mut seed) % operation_counts.len() as u64) as usize;
1072 operation_counts[operation] += 1;
1073 let server = servers[(next_random(&mut seed) % servers.len() as u64) as usize].clone();
1074 let file = files[(next_random(&mut seed) % files.len() as u64) as usize].clone();
1075
1076 match operation {
1077 0 | 1 => store.publish(
1078 server,
1079 file.clone(),
1080 vec![diag(
1081 file.to_str().unwrap(),
1082 step + 1,
1083 "seeded diagnostic",
1084 DiagnosticSeverity::Warning,
1085 )],
1086 ),
1087 2 => {
1088 let expected = store.mark_stale_for_file_linear_reference(&file);
1089 let actual = store.mark_stale_for_file(&file);
1090 assert_eq!(actual, expected);
1091 stale_hits += usize::from(actual.0);
1092 }
1093 3 => {
1094 store.clear_for_server_file(&server, &file);
1095 }
1096 4 => {
1097 store.clear_for_file(&file);
1098 }
1099 5 => {
1100 store.clear_for_server(&server);
1101 }
1102 6 => {
1103 store.clear_server(server.kind);
1104 }
1105 _ => unreachable!(),
1106 }
1107
1108 store.debug_assert_index_consistent();
1109 assert!(store.len() <= 7);
1110 }
1111
1112 assert!(operation_counts.into_iter().all(|count| count > 0));
1113 assert!(
1114 stale_hits > 0,
1115 "seeded sequence must stale existing entries"
1116 );
1117 }
1118
1119 #[test]
1120 fn touching_existing_entry_moves_it_to_end_of_lru() {
1121 let mut store = DiagnosticsStore::with_capacity(2);
1122 let key = server_key(ServerKind::Rust);
1123
1124 store.publish(
1125 key.clone(),
1126 PathBuf::from("/a.rs"),
1127 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1128 );
1129 store.publish(
1130 key.clone(),
1131 PathBuf::from("/b.rs"),
1132 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1133 );
1134
1135 store.publish(
1138 key.clone(),
1139 PathBuf::from("/a.rs"),
1140 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
1141 );
1142 store.publish(
1143 key.clone(),
1144 PathBuf::from("/c.rs"),
1145 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1146 );
1147
1148 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
1149 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
1150 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1151 }
1152
1153 #[test]
1154 fn capacity_zero_disables_eviction() {
1155 let mut store = DiagnosticsStore::with_capacity(0);
1156 let key = server_key(ServerKind::Rust);
1157
1158 for i in 0..50 {
1159 store.publish(
1160 key.clone(),
1161 PathBuf::from(format!("/f{i}.rs")),
1162 vec![diag(
1163 &format!("/f{i}.rs"),
1164 1,
1165 "x",
1166 DiagnosticSeverity::Warning,
1167 )],
1168 );
1169 }
1170 assert_eq!(store.len(), 50);
1171 }
1172
1173 #[test]
1174 fn set_capacity_evicts_on_shrink() {
1175 let mut store = DiagnosticsStore::with_capacity(0);
1176 let key = server_key(ServerKind::Rust);
1177 for i in 0..10 {
1178 store.publish(
1179 key.clone(),
1180 PathBuf::from(format!("/f{i}.rs")),
1181 vec![diag(
1182 &format!("/f{i}.rs"),
1183 1,
1184 "x",
1185 DiagnosticSeverity::Warning,
1186 )],
1187 );
1188 }
1189 assert_eq!(store.len(), 10);
1190
1191 store.set_capacity(3);
1192 assert_eq!(store.len(), 3);
1193 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
1195 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
1196 }
1197
1198 #[test]
1199 fn epoch_increments_monotonically() {
1200 let mut store = DiagnosticsStore::new();
1201 let key = server_key(ServerKind::Rust);
1202 let file = PathBuf::from("/e.rs");
1203
1204 store.publish(key.clone(), file.clone(), Vec::new());
1205 let e1 = store.entries_for_file(&file)[0].1.epoch;
1206
1207 store.publish(key.clone(), file.clone(), Vec::new());
1208 let e2 = store.entries_for_file(&file)[0].1.epoch;
1209
1210 assert!(e2 > e1, "epoch must increase on republish");
1211 }
1212
1213 #[test]
1214 fn result_id_is_round_tripped() {
1215 let mut store = DiagnosticsStore::new();
1216 let key = server_key(ServerKind::Rust);
1217 let file = PathBuf::from("/r.rs");
1218
1219 store.publish_with_result_id(
1220 key.clone(),
1221 file.clone(),
1222 Vec::new(),
1223 Some("rev-42".to_string()),
1224 );
1225
1226 let entries = store.entries_for_file(&file);
1227 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
1228 }
1229
1230 #[test]
1231 fn clear_server_drops_all_entries_for_kind() {
1232 let mut store = DiagnosticsStore::new();
1233 let py_key = server_key(ServerKind::Python);
1234 let rust_key = server_key(ServerKind::Rust);
1235
1236 store.publish(
1237 py_key.clone(),
1238 PathBuf::from("/a.py"),
1239 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
1240 );
1241 store.publish(
1242 rust_key.clone(),
1243 PathBuf::from("/b.rs"),
1244 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
1245 );
1246
1247 store.clear_server(ServerKind::Python);
1248 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
1249 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1250 }
1251
1252 #[test]
1253 fn clear_for_file_drops_every_server_entry_and_updates_counts() {
1254 let mut store = DiagnosticsStore::new();
1255 let py_key = server_key(ServerKind::Python);
1256 let biome_key = server_key(ServerKind::Biome);
1257
1258 store.publish(
1261 py_key,
1262 PathBuf::from("/gone.ts"),
1263 vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
1264 );
1265 store.publish(
1266 biome_key,
1267 PathBuf::from("/gone.ts"),
1268 vec![diag(
1269 "/gone.ts",
1270 7,
1271 "lint warning",
1272 DiagnosticSeverity::Warning,
1273 )],
1274 );
1275 store.publish(
1276 server_key(ServerKind::Rust),
1277 PathBuf::from("/keep.rs"),
1278 vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
1279 );
1280
1281 assert_eq!(store.error_warning_counts(), (2, 1));
1282
1283 let removed = store.clear_for_file(Path::new("/gone.ts"));
1285 assert!(removed);
1286 assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
1287 assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
1289 assert_eq!(store.error_warning_counts(), (1, 0));
1290
1291 assert!(!store.clear_for_file(Path::new("/gone.ts")));
1293 }
1294
1295 #[test]
1296 fn filtered_counts_apply_keep_predicate() {
1297 let mut store = DiagnosticsStore::new();
1298 store.publish(
1299 server_key(ServerKind::TypeScript),
1300 PathBuf::from("/repo/src/app.ts"),
1301 vec![diag(
1302 "/repo/src/app.ts",
1303 1,
1304 "in build",
1305 DiagnosticSeverity::Error,
1306 )],
1307 );
1308 store.publish(
1309 server_key(ServerKind::TypeScript),
1310 PathBuf::from("/repo/src/app.test.ts"),
1311 vec![diag(
1312 "/repo/src/app.test.ts",
1313 1,
1314 "excluded",
1315 DiagnosticSeverity::Error,
1316 )],
1317 );
1318
1319 assert_eq!(store.error_warning_counts(), (2, 0));
1321 let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1323 assert_eq!(counts, (1, 0));
1324 }
1325
1326 #[test]
1327 fn filtered_counts_dedup_across_servers() {
1328 let mut store = DiagnosticsStore::new();
1329 let file = "/repo/src/app.ts";
1330 store.publish(
1335 server_key(ServerKind::TypeScript),
1336 PathBuf::from(file),
1337 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1338 );
1339 store.publish(
1340 server_key(ServerKind::Biome),
1341 PathBuf::from(file),
1342 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1343 );
1344
1345 assert_eq!(store.error_warning_counts(), (2, 0));
1346 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1347 }
1348
1349 #[test]
1350 fn filtered_counts_keep_distinct_diagnostics_same_file() {
1351 let mut store = DiagnosticsStore::new();
1352 let file = "/repo/src/app.ts";
1353 store.publish(
1356 server_key(ServerKind::TypeScript),
1357 PathBuf::from(file),
1358 vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1359 );
1360 store.publish(
1361 server_key(ServerKind::Biome),
1362 PathBuf::from(file),
1363 vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1364 );
1365 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1366 }
1367
1368 #[test]
1369 fn filtered_counts_exclude_environmental_diagnostics() {
1370 let mut store = DiagnosticsStore::new();
1371 let file = "/repo/src/app.ts";
1372 store.publish(
1373 server_key(ServerKind::TypeScript),
1374 PathBuf::from(file),
1375 vec![
1376 diag(
1377 file,
1378 1,
1379 "Cannot find name 'foo'.",
1380 DiagnosticSeverity::Error,
1381 ),
1382 diag(
1383 file,
1384 2,
1385 "Failed to load schema from https://cdn.example/pkg/schema.json",
1386 DiagnosticSeverity::Error,
1387 ),
1388 ],
1389 );
1390 assert_eq!(store.error_warning_counts(), (2, 0));
1391 assert_eq!(
1392 store.filtered_error_warning_counts(|_| true),
1393 (1, 0),
1394 "environmental schema-fetch must not inflate E count"
1395 );
1396 }
1397
1398 #[test]
1399 fn environmental_flap_does_not_change_filtered_counts() {
1400 let mut store = DiagnosticsStore::new();
1401 let file = "/repo/package.json";
1402 let key = server_key(ServerKind::TypeScript);
1403 let env_msg =
1404 "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1405
1406 assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1407
1408 store.publish(
1409 key.clone(),
1410 PathBuf::from(file),
1411 vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1412 );
1413 assert_eq!(
1414 store.filtered_error_warning_counts(|_| true),
1415 (0, 0),
1416 "publish environmental diagnostic must not change filtered E/W"
1417 );
1418
1419 store.publish(key, PathBuf::from(file), vec![]);
1420 assert_eq!(
1421 store.filtered_error_warning_counts(|_| true),
1422 (0, 0),
1423 "removing environmental diagnostic must not change filtered E/W"
1424 );
1425 }
1426
1427 #[test]
1428 fn mixed_syntax_and_schema_fetch_counts_one_error() {
1429 let mut store = DiagnosticsStore::new();
1430 let file = "/repo/src/mixed.ts";
1431 store.publish(
1432 server_key(ServerKind::TypeScript),
1433 PathBuf::from(file),
1434 vec![
1435 diag(
1436 file,
1437 3,
1438 "Cannot find name 'bar'.",
1439 DiagnosticSeverity::Error,
1440 ),
1441 diag(
1442 file,
1443 1,
1444 "Failed to resolve schema https://example.com/x.json",
1445 DiagnosticSeverity::Error,
1446 ),
1447 ],
1448 );
1449 assert_eq!(
1450 store.filtered_error_warning_counts(|_| true),
1451 (1, 0),
1452 "classifier is per-diagnostic: one real syntax error => E1"
1453 );
1454 }
1455}