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 promote_provisional_for_server(&mut self, key: &ServerKey) -> bool {
731 let mut changed = false;
732 for ((stored_key, _), entry) in &mut self.entries {
733 if stored_key == key && entry.provisional {
734 entry.provisional = false;
735 changed = true;
736 }
737 }
738 if changed {
739 self.generation = self.generation.wrapping_add(1);
740 }
741 self.debug_assert_index_consistent();
742 changed
743 }
744
745 pub fn clear_provisional_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
749 let cache_key = (key.clone(), file.to_path_buf());
750 let Some(entry) = self.entries.get_mut(&cache_key) else {
751 return false;
752 };
753 if !entry.provisional {
754 return false;
755 }
756 entry.provisional = false;
757 self.generation = self.generation.wrapping_add(1);
758 true
759 }
760
761 pub fn clear_for_server(&mut self, key: &ServerKey) {
763 let before = self.entries.len();
764 self.entries.retain(|(k, _), _| k != key);
765 self.order.retain(|(k, _)| k != key);
766 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
767 self.by_file.retain(|_, servers| {
768 servers.remove(key);
769 !servers.is_empty()
770 });
771 if self.entries.len() != before {
772 self.generation = self.generation.wrapping_add(1);
773 }
774 self.debug_assert_index_consistent();
775 }
776
777 pub fn clear_server_instance(&mut self, key: &ServerKey) {
780 self.clear_for_server(key);
781 }
782
783 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
785 if self.order.is_empty() {
786 return None;
787 }
788 let evicted = self.order.remove(0);
789 self.entries.remove(&evicted);
790 self.unindex_entry(&evicted);
791 self.last_publish_at_for_file.remove(&evicted);
792 self.debug_assert_index_consistent();
793 Some(evicted)
794 }
795
796 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
797 if let Some(idx) = self.order.iter().position(|k| k == key) {
798 let removed = self.order.remove(idx);
799 self.order.push(removed);
800 }
801 }
802
803 fn index_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
804 self.by_file
805 .entry(file.clone())
806 .or_default()
807 .insert(server.clone());
808 }
809
810 fn unindex_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
811 let remove_file = self.by_file.get_mut(file).is_some_and(|servers| {
812 servers.remove(server);
813 servers.is_empty()
814 });
815 if remove_file {
816 self.by_file.remove(file);
817 }
818 }
819
820 fn debug_assert_index_consistent(&self) {
821 #[cfg(debug_assertions)]
822 {
823 let indexed_entries = self.by_file.values().map(HashSet::len).sum::<usize>();
824 debug_assert_eq!(indexed_entries, self.entries.len());
825 for (server, file) in self.entries.keys() {
826 debug_assert!(self
827 .by_file
828 .get(file)
829 .is_some_and(|servers| servers.contains(server)));
830 }
831 for (file, servers) in &self.by_file {
832 debug_assert!(!servers.is_empty());
833 for server in servers {
834 debug_assert!(self.entries.contains_key(&(server.clone(), file.clone())));
835 }
836 }
837 }
838 }
839
840 #[cfg(test)]
841 fn mark_stale_for_file_linear_reference(&self, file: &Path) -> (bool, bool) {
842 let mut had_entries = false;
843 let mut changed = false;
844 for ((_, stored_file), entry) in &self.entries {
845 if stored_file == file {
846 had_entries = true;
847 changed |= !entry.stale;
848 }
849 }
850 (had_entries, changed)
851 }
852}
853
854impl Default for DiagnosticsStore {
855 fn default() -> Self {
856 Self::new()
857 }
858}
859
860pub fn from_lsp_diagnostics(
863 file: PathBuf,
864 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
865) -> Vec<StoredDiagnostic> {
866 lsp_diagnostics
867 .into_iter()
868 .map(|diagnostic| StoredDiagnostic {
869 file: file.clone(),
870 line: diagnostic.range.start.line + 1,
871 column: diagnostic.range.start.character + 1,
872 end_line: diagnostic.range.end.line + 1,
873 end_column: diagnostic.range.end.character + 1,
874 severity: match diagnostic.severity {
875 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
876 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
877 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
878 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
879 _ => DiagnosticSeverity::Warning,
880 },
881 message: diagnostic.message,
882 code: diagnostic.code.map(|code| match code {
883 lsp_types::NumberOrString::Number(value) => value.to_string(),
884 lsp_types::NumberOrString::String(value) => value,
885 }),
886 source: diagnostic.source,
887 })
888 .collect()
889}
890
891#[cfg(test)]
892mod tests {
893 use std::path::{Path, PathBuf};
894
895 use lsp_types::{
896 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
897 };
898
899 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
900 use crate::lsp::registry::ServerKind;
901 use crate::lsp::roots::ServerKey;
902
903 fn server_key(kind: ServerKind) -> ServerKey {
904 ServerKey {
905 kind,
906 root: PathBuf::from("/tmp/repo"),
907 }
908 }
909
910 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
911 StoredDiagnostic {
912 file: PathBuf::from(file),
913 line,
914 column: 1,
915 end_line: line,
916 end_column: 2,
917 severity: sev,
918 message: msg.into(),
919 code: None,
920 source: None,
921 }
922 }
923
924 #[test]
925 fn diagnostics_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
926 let mut store = DiagnosticsStore::new();
927 assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
928 let file = PathBuf::from("/tmp/memory.rs");
929 store.publish(
930 server_key(ServerKind::Rust),
931 file.clone(),
932 vec![diag(
933 file.to_str().unwrap(),
934 1,
935 "resident diagnostic message",
936 DiagnosticSeverity::Warning,
937 )],
938 );
939 let estimate = store.estimated_memory();
940 assert!(estimate.estimated_bytes.unwrap() > 0);
941 assert_eq!(estimate.counts["diagnostic_entries"], 1);
942 assert_eq!(estimate.counts["diagnostics"], 1);
943 }
944
945 #[test]
946 fn converts_lsp_positions_to_one_based() {
947 let file = PathBuf::from("/tmp/demo.rs");
948 let diagnostics = from_lsp_diagnostics(
949 file.clone(),
950 vec![Diagnostic {
951 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
952 severity: Some(LspDiagnosticSeverity::ERROR),
953 code: Some(NumberOrString::String("E1".into())),
954 code_description: None,
955 source: Some("fake".into()),
956 message: "boom".into(),
957 related_information: None,
958 tags: None,
959 data: None,
960 }],
961 );
962
963 assert_eq!(diagnostics.len(), 1);
964 assert_eq!(diagnostics[0].file, file);
965 assert_eq!(diagnostics[0].line, 1);
966 assert_eq!(diagnostics[0].column, 1);
967 assert_eq!(diagnostics[0].end_line, 2);
968 assert_eq!(diagnostics[0].end_column, 5);
969 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
970 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
971 }
972
973 #[test]
974 fn publish_replaces_existing_file_diagnostics() {
975 let file = PathBuf::from("/tmp/demo.rs");
976 let mut store = DiagnosticsStore::new();
977 let key = server_key(ServerKind::Rust);
978
979 store.publish(
980 key.clone(),
981 file.clone(),
982 vec![diag(
983 "/tmp/demo.rs",
984 1,
985 "first",
986 DiagnosticSeverity::Warning,
987 )],
988 );
989 store.publish(
990 key.clone(),
991 file.clone(),
992 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
993 );
994
995 let stored = store.for_file(&file);
996 assert_eq!(stored.len(), 1);
997 assert_eq!(stored[0].message, "second");
998 }
999
1000 #[test]
1001 fn empty_publish_is_preserved_as_checked_clean() {
1002 let file = PathBuf::from("/tmp/clean.rs");
1006 let mut store = DiagnosticsStore::new();
1007 let key = server_key(ServerKind::Rust);
1008
1009 store.publish(
1011 key.clone(),
1012 file.clone(),
1013 vec![diag(
1014 "/tmp/clean.rs",
1015 5,
1016 "fix me",
1017 DiagnosticSeverity::Warning,
1018 )],
1019 );
1020 assert!(store.has_any_report_for_file(&file));
1021 assert_eq!(store.for_file(&file).len(), 1);
1022
1023 store.publish(key.clone(), file.clone(), Vec::new());
1026 assert!(
1027 store.has_any_report_for_file(&file),
1028 "checked-clean must be distinguishable from never-checked"
1029 );
1030 assert_eq!(store.for_file(&file).len(), 0);
1031
1032 let entries = store.entries_for_file(&file);
1033 assert_eq!(entries.len(), 1);
1034 assert!(entries[0].1.epoch > 0);
1035 }
1036
1037 #[test]
1038 fn never_checked_returns_no_report() {
1039 let store = DiagnosticsStore::new();
1040 let file = PathBuf::from("/tmp/never.rs");
1041 assert!(!store.has_any_report_for_file(&file));
1042 assert!(store.for_file(&file).is_empty());
1043 }
1044
1045 #[test]
1046 fn stale_entries_are_hidden_but_preserved_for_refresh() {
1047 let file = PathBuf::from("/tmp/stale.rs");
1048 let mut store = DiagnosticsStore::new();
1049 let key = server_key(ServerKind::Rust);
1050 store.publish(
1051 key.clone(),
1052 file.clone(),
1053 vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
1054 );
1055
1056 let (had_entries, changed) = store.mark_stale_for_file(&file);
1057
1058 assert!(had_entries);
1059 assert!(changed);
1060 assert!(store.has_any_report_for_file(&file));
1061 assert!(!store.has_any_fresh_report_for_file(&file));
1062 assert!(store.for_file(&file).is_empty());
1063 assert!(store.all().is_empty());
1064 assert_eq!(store.error_warning_counts(), (0, 0));
1065 assert_eq!(store.entries_for_file(&file).len(), 1);
1066
1067 assert!(store.mark_fresh_for_server_file(&key, &file));
1068 assert!(store.has_any_fresh_report_for_file(&file));
1069 assert_eq!(store.for_file(&file).len(), 1);
1070 assert_eq!(store.error_warning_counts(), (1, 0));
1071 }
1072
1073 #[test]
1074 fn per_server_state_is_tracked_independently() {
1075 let file = PathBuf::from("/tmp/multi.py");
1076 let mut store = DiagnosticsStore::new();
1077 let pyright_key = server_key(ServerKind::Python);
1078 let ty_key = server_key(ServerKind::Ty);
1079
1080 store.publish(
1081 pyright_key,
1082 file.clone(),
1083 vec![diag(
1084 "/tmp/multi.py",
1085 1,
1086 "pyright says X",
1087 DiagnosticSeverity::Error,
1088 )],
1089 );
1090 store.publish(
1091 ty_key,
1092 file.clone(),
1093 vec![diag(
1094 "/tmp/multi.py",
1095 2,
1096 "ty says Y",
1097 DiagnosticSeverity::Warning,
1098 )],
1099 );
1100
1101 let messages: Vec<&str> = store
1102 .for_file(&file)
1103 .into_iter()
1104 .map(|d| d.message.as_str())
1105 .collect();
1106
1107 assert_eq!(messages.len(), 2, "both servers' reports preserved");
1108 assert!(messages.iter().any(|m| m == &"pyright says X"));
1109 assert!(messages.iter().any(|m| m == &"ty says Y"));
1110 }
1111
1112 #[test]
1113 fn clear_for_server_file_removes_only_exact_entry() {
1114 let file_a = PathBuf::from("/tmp/a.rs");
1115 let file_b = PathBuf::from("/tmp/b.rs");
1116 let mut store = DiagnosticsStore::new();
1117 let rust_key = server_key(ServerKind::Rust);
1118 let py_key = server_key(ServerKind::Python);
1119
1120 store.publish(
1121 rust_key.clone(),
1122 file_a.clone(),
1123 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
1124 );
1125 store.publish(
1126 rust_key.clone(),
1127 file_b.clone(),
1128 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
1129 );
1130 store.publish(
1131 py_key.clone(),
1132 file_a.clone(),
1133 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
1134 );
1135
1136 store.clear_for_server_file(&rust_key, &file_a);
1137
1138 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
1139 assert!(store.has_report_for_server_file(&rust_key, &file_b));
1140 assert!(store.has_report_for_server_file(&py_key, &file_a));
1141 }
1142
1143 #[test]
1144 fn lru_evicts_oldest_when_capacity_exceeded() {
1145 let mut store = DiagnosticsStore::with_capacity(2);
1146 let key = server_key(ServerKind::Rust);
1147
1148 store.publish(
1149 key.clone(),
1150 PathBuf::from("/a.rs"),
1151 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1152 );
1153 store.publish(
1154 key.clone(),
1155 PathBuf::from("/b.rs"),
1156 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1157 );
1158 assert_eq!(store.len(), 2);
1159
1160 store.publish(
1162 key.clone(),
1163 PathBuf::from("/c.rs"),
1164 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1165 );
1166 assert_eq!(store.len(), 2);
1167 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
1168 assert!(!store.by_file.contains_key(Path::new("/a.rs")));
1169 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1170 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1171 store.debug_assert_index_consistent();
1172 }
1173
1174 #[test]
1175 fn secondary_index_stays_consistent_through_seeded_mutation_storm() {
1176 fn next_random(seed: &mut u64) -> u64 {
1177 *seed = seed
1178 .wrapping_mul(6_364_136_223_846_793_005)
1179 .wrapping_add(1_442_695_040_888_963_407);
1180 *seed
1181 }
1182
1183 let servers = [
1184 server_key(ServerKind::Rust),
1185 server_key(ServerKind::TypeScript),
1186 server_key(ServerKind::Python),
1187 server_key(ServerKind::Biome),
1188 ];
1189 let files = (0..11)
1190 .map(|index| PathBuf::from(format!("/tmp/index-{index}.rs")))
1191 .collect::<Vec<_>>();
1192 let mut store = DiagnosticsStore::with_capacity(7);
1193 let mut seed = 0x05ee_dd1a_6005_71c5_u64;
1194 let mut operation_counts = [0usize; 7];
1195 let mut stale_hits = 0usize;
1196
1197 for step in 0..1_000 {
1198 let operation = (next_random(&mut seed) % operation_counts.len() as u64) as usize;
1199 operation_counts[operation] += 1;
1200 let server = servers[(next_random(&mut seed) % servers.len() as u64) as usize].clone();
1201 let file = files[(next_random(&mut seed) % files.len() as u64) as usize].clone();
1202
1203 match operation {
1204 0 | 1 => store.publish(
1205 server,
1206 file.clone(),
1207 vec![diag(
1208 file.to_str().unwrap(),
1209 step + 1,
1210 "seeded diagnostic",
1211 DiagnosticSeverity::Warning,
1212 )],
1213 ),
1214 2 => {
1215 let expected = store.mark_stale_for_file_linear_reference(&file);
1216 let actual = store.mark_stale_for_file(&file);
1217 assert_eq!(actual, expected);
1218 stale_hits += usize::from(actual.0);
1219 }
1220 3 => {
1221 store.clear_for_server_file(&server, &file);
1222 }
1223 4 => {
1224 store.clear_for_file(&file);
1225 }
1226 5 => {
1227 store.clear_for_server(&server);
1228 }
1229 6 => {
1230 store.clear_server(server.kind);
1231 }
1232 _ => unreachable!(),
1233 }
1234
1235 store.debug_assert_index_consistent();
1236 assert!(store.len() <= 7);
1237 }
1238
1239 assert!(operation_counts.into_iter().all(|count| count > 0));
1240 assert!(
1241 stale_hits > 0,
1242 "seeded sequence must stale existing entries"
1243 );
1244 }
1245
1246 #[test]
1247 fn touching_existing_entry_moves_it_to_end_of_lru() {
1248 let mut store = DiagnosticsStore::with_capacity(2);
1249 let key = server_key(ServerKind::Rust);
1250
1251 store.publish(
1252 key.clone(),
1253 PathBuf::from("/a.rs"),
1254 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1255 );
1256 store.publish(
1257 key.clone(),
1258 PathBuf::from("/b.rs"),
1259 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1260 );
1261
1262 store.publish(
1265 key.clone(),
1266 PathBuf::from("/a.rs"),
1267 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
1268 );
1269 store.publish(
1270 key.clone(),
1271 PathBuf::from("/c.rs"),
1272 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1273 );
1274
1275 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
1276 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
1277 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1278 }
1279
1280 #[test]
1281 fn capacity_zero_disables_eviction() {
1282 let mut store = DiagnosticsStore::with_capacity(0);
1283 let key = server_key(ServerKind::Rust);
1284
1285 for i in 0..50 {
1286 store.publish(
1287 key.clone(),
1288 PathBuf::from(format!("/f{i}.rs")),
1289 vec![diag(
1290 &format!("/f{i}.rs"),
1291 1,
1292 "x",
1293 DiagnosticSeverity::Warning,
1294 )],
1295 );
1296 }
1297 assert_eq!(store.len(), 50);
1298 }
1299
1300 #[test]
1301 fn set_capacity_evicts_on_shrink() {
1302 let mut store = DiagnosticsStore::with_capacity(0);
1303 let key = server_key(ServerKind::Rust);
1304 for i in 0..10 {
1305 store.publish(
1306 key.clone(),
1307 PathBuf::from(format!("/f{i}.rs")),
1308 vec![diag(
1309 &format!("/f{i}.rs"),
1310 1,
1311 "x",
1312 DiagnosticSeverity::Warning,
1313 )],
1314 );
1315 }
1316 assert_eq!(store.len(), 10);
1317
1318 store.set_capacity(3);
1319 assert_eq!(store.len(), 3);
1320 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
1322 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
1323 }
1324
1325 #[test]
1326 fn epoch_increments_monotonically() {
1327 let mut store = DiagnosticsStore::new();
1328 let key = server_key(ServerKind::Rust);
1329 let file = PathBuf::from("/e.rs");
1330
1331 store.publish(key.clone(), file.clone(), Vec::new());
1332 let e1 = store.entries_for_file(&file)[0].1.epoch;
1333
1334 store.publish(key.clone(), file.clone(), Vec::new());
1335 let e2 = store.entries_for_file(&file)[0].1.epoch;
1336
1337 assert!(e2 > e1, "epoch must increase on republish");
1338 }
1339
1340 #[test]
1341 fn result_id_is_round_tripped() {
1342 let mut store = DiagnosticsStore::new();
1343 let key = server_key(ServerKind::Rust);
1344 let file = PathBuf::from("/r.rs");
1345
1346 store.publish_with_result_id(
1347 key.clone(),
1348 file.clone(),
1349 Vec::new(),
1350 Some("rev-42".to_string()),
1351 );
1352
1353 let entries = store.entries_for_file(&file);
1354 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
1355 }
1356
1357 #[test]
1358 fn clear_server_drops_all_entries_for_kind() {
1359 let mut store = DiagnosticsStore::new();
1360 let py_key = server_key(ServerKind::Python);
1361 let rust_key = server_key(ServerKind::Rust);
1362
1363 store.publish(
1364 py_key.clone(),
1365 PathBuf::from("/a.py"),
1366 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
1367 );
1368 store.publish(
1369 rust_key.clone(),
1370 PathBuf::from("/b.rs"),
1371 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
1372 );
1373
1374 store.clear_server(ServerKind::Python);
1375 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
1376 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1377 }
1378
1379 #[test]
1380 fn clear_for_file_drops_every_server_entry_and_updates_counts() {
1381 let mut store = DiagnosticsStore::new();
1382 let py_key = server_key(ServerKind::Python);
1383 let biome_key = server_key(ServerKind::Biome);
1384
1385 store.publish(
1388 py_key,
1389 PathBuf::from("/gone.ts"),
1390 vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
1391 );
1392 store.publish(
1393 biome_key,
1394 PathBuf::from("/gone.ts"),
1395 vec![diag(
1396 "/gone.ts",
1397 7,
1398 "lint warning",
1399 DiagnosticSeverity::Warning,
1400 )],
1401 );
1402 store.publish(
1403 server_key(ServerKind::Rust),
1404 PathBuf::from("/keep.rs"),
1405 vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
1406 );
1407
1408 assert_eq!(store.error_warning_counts(), (2, 1));
1409
1410 let removed = store.clear_for_file(Path::new("/gone.ts"));
1412 assert!(removed);
1413 assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
1414 assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
1416 assert_eq!(store.error_warning_counts(), (1, 0));
1417
1418 assert!(!store.clear_for_file(Path::new("/gone.ts")));
1420 }
1421
1422 #[test]
1423 fn filtered_counts_apply_keep_predicate() {
1424 let mut store = DiagnosticsStore::new();
1425 store.publish(
1426 server_key(ServerKind::TypeScript),
1427 PathBuf::from("/repo/src/app.ts"),
1428 vec![diag(
1429 "/repo/src/app.ts",
1430 1,
1431 "in build",
1432 DiagnosticSeverity::Error,
1433 )],
1434 );
1435 store.publish(
1436 server_key(ServerKind::TypeScript),
1437 PathBuf::from("/repo/src/app.test.ts"),
1438 vec![diag(
1439 "/repo/src/app.test.ts",
1440 1,
1441 "excluded",
1442 DiagnosticSeverity::Error,
1443 )],
1444 );
1445
1446 assert_eq!(store.error_warning_counts(), (2, 0));
1448 let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1450 assert_eq!(counts, (1, 0));
1451 }
1452
1453 #[test]
1454 fn filtered_counts_dedup_across_servers() {
1455 let mut store = DiagnosticsStore::new();
1456 let file = "/repo/src/app.ts";
1457 store.publish(
1462 server_key(ServerKind::TypeScript),
1463 PathBuf::from(file),
1464 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1465 );
1466 store.publish(
1467 server_key(ServerKind::Biome),
1468 PathBuf::from(file),
1469 vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1470 );
1471
1472 assert_eq!(store.error_warning_counts(), (2, 0));
1473 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1474 }
1475
1476 #[test]
1477 fn filtered_counts_keep_distinct_diagnostics_same_file() {
1478 let mut store = DiagnosticsStore::new();
1479 let file = "/repo/src/app.ts";
1480 store.publish(
1483 server_key(ServerKind::TypeScript),
1484 PathBuf::from(file),
1485 vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1486 );
1487 store.publish(
1488 server_key(ServerKind::Biome),
1489 PathBuf::from(file),
1490 vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1491 );
1492 assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1493 }
1494
1495 #[test]
1496 fn filtered_counts_exclude_environmental_diagnostics() {
1497 let mut store = DiagnosticsStore::new();
1498 let file = "/repo/src/app.ts";
1499 store.publish(
1500 server_key(ServerKind::TypeScript),
1501 PathBuf::from(file),
1502 vec![
1503 diag(
1504 file,
1505 1,
1506 "Cannot find name 'foo'.",
1507 DiagnosticSeverity::Error,
1508 ),
1509 diag(
1510 file,
1511 2,
1512 "Failed to load schema from https://cdn.example/pkg/schema.json",
1513 DiagnosticSeverity::Error,
1514 ),
1515 ],
1516 );
1517 assert_eq!(store.error_warning_counts(), (2, 0));
1518 assert_eq!(
1519 store.filtered_error_warning_counts(|_| true),
1520 (1, 0),
1521 "environmental schema-fetch must not inflate E count"
1522 );
1523 }
1524
1525 #[test]
1526 fn environmental_flap_does_not_change_filtered_counts() {
1527 let mut store = DiagnosticsStore::new();
1528 let file = "/repo/package.json";
1529 let key = server_key(ServerKind::TypeScript);
1530 let env_msg =
1531 "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1532
1533 assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1534
1535 store.publish(
1536 key.clone(),
1537 PathBuf::from(file),
1538 vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1539 );
1540 assert_eq!(
1541 store.filtered_error_warning_counts(|_| true),
1542 (0, 0),
1543 "publish environmental diagnostic must not change filtered E/W"
1544 );
1545
1546 store.publish(key, PathBuf::from(file), vec![]);
1547 assert_eq!(
1548 store.filtered_error_warning_counts(|_| true),
1549 (0, 0),
1550 "removing environmental diagnostic must not change filtered E/W"
1551 );
1552 }
1553
1554 #[test]
1555 fn mixed_syntax_and_schema_fetch_counts_one_error() {
1556 let mut store = DiagnosticsStore::new();
1557 let file = "/repo/src/mixed.ts";
1558 store.publish(
1559 server_key(ServerKind::TypeScript),
1560 PathBuf::from(file),
1561 vec![
1562 diag(
1563 file,
1564 3,
1565 "Cannot find name 'bar'.",
1566 DiagnosticSeverity::Error,
1567 ),
1568 diag(
1569 file,
1570 1,
1571 "Failed to resolve schema https://example.com/x.json",
1572 DiagnosticSeverity::Error,
1573 ),
1574 ],
1575 );
1576 assert_eq!(
1577 store.filtered_error_warning_counts(|_| true),
1578 (1, 0),
1579 "classifier is per-diagnostic: one real syntax error => E1"
1580 );
1581 }
1582}