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