1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use crate::lsp::registry::ServerKind;
6use crate::lsp::roots::ServerKey;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct StoredDiagnostic {
11 pub file: PathBuf,
12 pub line: u32,
13 pub column: u32,
14 pub end_line: u32,
15 pub end_column: u32,
16 pub severity: DiagnosticSeverity,
17 pub message: String,
18 pub code: Option<String>,
19 pub source: Option<String>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum DiagnosticSeverity {
24 Error,
25 Warning,
26 Information,
27 Hint,
28}
29
30impl DiagnosticSeverity {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Error => "error",
34 Self::Warning => "warning",
35 Self::Information => "information",
36 Self::Hint => "hint",
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
45pub struct DiagnosticEntry {
46 pub diagnostics: Vec<StoredDiagnostic>,
47 pub epoch: u64,
51 pub result_id: Option<String>,
54 pub version: Option<i32>,
62}
63
64pub struct DiagnosticsStore {
83 entries: HashMap<(ServerKey, PathBuf), DiagnosticEntry>,
85 order: Vec<(ServerKey, PathBuf)>,
88 capacity: usize,
90 next_epoch: u64,
92 last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
95}
96
97impl DiagnosticsStore {
98 pub fn new() -> Self {
99 Self::with_capacity(5000)
100 }
101
102 pub fn with_capacity(capacity: usize) -> Self {
103 Self {
104 entries: HashMap::new(),
105 order: Vec::new(),
106 capacity,
107 next_epoch: 0,
108 last_publish_at_for_file: HashMap::new(),
109 }
110 }
111
112 pub fn set_capacity(&mut self, capacity: usize) {
116 self.capacity = capacity;
117 if capacity > 0 {
118 while self.entries.len() > capacity {
119 self.evict_lru();
120 }
121 }
122 }
123
124 pub fn len(&self) -> usize {
126 self.entries.len()
127 }
128
129 pub fn is_empty(&self) -> bool {
130 self.entries.is_empty()
131 }
132
133 pub fn publish(
142 &mut self,
143 server: ServerKey,
144 file: PathBuf,
145 diagnostics: Vec<StoredDiagnostic>,
146 ) {
147 self.publish_with_result_id(server, file, diagnostics, None);
148 }
149
150 pub fn publish_with_result_id(
153 &mut self,
154 server: ServerKey,
155 file: PathBuf,
156 diagnostics: Vec<StoredDiagnostic>,
157 result_id: Option<String>,
158 ) {
159 self.publish_full(server, file, diagnostics, result_id, None);
160 }
161
162 pub fn publish_full(
166 &mut self,
167 server: ServerKey,
168 file: PathBuf,
169 diagnostics: Vec<StoredDiagnostic>,
170 result_id: Option<String>,
171 version: Option<i32>,
172 ) {
173 let key = (server, file);
174 self.next_epoch = self.next_epoch.saturating_add(1);
175 let entry = DiagnosticEntry {
176 diagnostics,
177 epoch: self.next_epoch,
178 result_id,
179 version,
180 };
181
182 self.last_publish_at_for_file
183 .insert(key.clone(), Instant::now());
184
185 if self.entries.contains_key(&key) {
186 self.entries.insert(key.clone(), entry);
187 self.touch_existing(&key);
188 } else {
189 if self.capacity > 0 && self.entries.len() >= self.capacity {
191 self.evict_lru();
192 }
193 self.entries.insert(key.clone(), entry);
194 self.order.push(key);
195 }
196 }
197
198 pub fn publish_with_kind(
204 &mut self,
205 kind: ServerKind,
206 file: PathBuf,
207 diagnostics: Vec<StoredDiagnostic>,
208 ) {
209 let key = ServerKey {
210 kind,
211 root: PathBuf::new(),
212 };
213 self.publish(key, file, diagnostics);
214 }
215
216 pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
219 self.entries
220 .iter()
221 .filter(|((_, stored_file), _)| stored_file == file)
222 .flat_map(|(_, entry)| entry.diagnostics.iter())
223 .collect()
224 }
225
226 pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
229 self.entries
230 .iter()
231 .filter(|((_, stored_file), _)| stored_file == file)
232 .map(|((key, _), entry)| (key, entry))
233 .collect()
234 }
235
236 pub fn has_any_report_for_file(&self, file: &Path) -> bool {
238 self.entries.keys().any(|(_, f)| f == file)
239 }
240
241 pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
244 self.entries
245 .contains_key(&(server.clone(), file.to_path_buf()))
246 }
247
248 pub fn has_publish_for_file_after(
252 &self,
253 server: &ServerKey,
254 file: &Path,
255 since: Instant,
256 ) -> bool {
257 self.last_publish_at_for_file
258 .get(&(server.clone(), file.to_path_buf()))
259 .is_some_and(|published_at| *published_at >= since)
260 }
261
262 pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
264 self.entries
265 .iter()
266 .filter(|((_, stored_file), _)| stored_file.starts_with(dir))
267 .flat_map(|(_, entry)| entry.diagnostics.iter())
268 .collect()
269 }
270
271 pub fn all(&self) -> Vec<&StoredDiagnostic> {
273 self.entries
274 .values()
275 .flat_map(|entry| entry.diagnostics.iter())
276 .collect()
277 }
278
279 pub fn clear_server(&mut self, server: ServerKind) {
283 self.entries
284 .retain(|(stored_key, _), _| stored_key.kind != server);
285 self.order
286 .retain(|(stored_key, _)| stored_key.kind != server);
287 self.last_publish_at_for_file
288 .retain(|(stored_key, _), _| stored_key.kind != server);
289 }
290
291 pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
293 let cache_key = (key.clone(), file.to_path_buf());
294 self.entries.remove(&cache_key);
295 self.order.retain(|entry_key| entry_key != &cache_key);
296 self.last_publish_at_for_file.remove(&cache_key);
297 }
298
299 pub fn clear_for_server(&mut self, key: &ServerKey) {
301 self.entries.retain(|(k, _), _| k != key);
302 self.order.retain(|(k, _)| k != key);
303 self.last_publish_at_for_file.retain(|(k, _), _| k != key);
304 }
305
306 pub fn clear_server_instance(&mut self, key: &ServerKey) {
309 self.clear_for_server(key);
310 }
311
312 fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
314 if self.order.is_empty() {
315 return None;
316 }
317 let evicted = self.order.remove(0);
318 self.entries.remove(&evicted);
319 self.last_publish_at_for_file.remove(&evicted);
320 Some(evicted)
321 }
322
323 fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
324 if let Some(idx) = self.order.iter().position(|k| k == key) {
325 let removed = self.order.remove(idx);
326 self.order.push(removed);
327 }
328 }
329}
330
331impl Default for DiagnosticsStore {
332 fn default() -> Self {
333 Self::new()
334 }
335}
336
337pub fn from_lsp_diagnostics(
340 file: PathBuf,
341 lsp_diagnostics: Vec<lsp_types::Diagnostic>,
342) -> Vec<StoredDiagnostic> {
343 lsp_diagnostics
344 .into_iter()
345 .map(|diagnostic| StoredDiagnostic {
346 file: file.clone(),
347 line: diagnostic.range.start.line + 1,
348 column: diagnostic.range.start.character + 1,
349 end_line: diagnostic.range.end.line + 1,
350 end_column: diagnostic.range.end.character + 1,
351 severity: match diagnostic.severity {
352 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
353 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
354 Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
355 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
356 _ => DiagnosticSeverity::Warning,
357 },
358 message: diagnostic.message,
359 code: diagnostic.code.map(|code| match code {
360 lsp_types::NumberOrString::Number(value) => value.to_string(),
361 lsp_types::NumberOrString::String(value) => value,
362 }),
363 source: diagnostic.source,
364 })
365 .collect()
366}
367
368#[cfg(test)]
369mod tests {
370 use std::path::{Path, PathBuf};
371
372 use lsp_types::{
373 Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
374 };
375
376 use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
377 use crate::lsp::registry::ServerKind;
378 use crate::lsp::roots::ServerKey;
379
380 fn server_key(kind: ServerKind) -> ServerKey {
381 ServerKey {
382 kind,
383 root: PathBuf::from("/tmp/repo"),
384 }
385 }
386
387 fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
388 StoredDiagnostic {
389 file: PathBuf::from(file),
390 line,
391 column: 1,
392 end_line: line,
393 end_column: 2,
394 severity: sev,
395 message: msg.into(),
396 code: None,
397 source: None,
398 }
399 }
400
401 #[test]
402 fn converts_lsp_positions_to_one_based() {
403 let file = PathBuf::from("/tmp/demo.rs");
404 let diagnostics = from_lsp_diagnostics(
405 file.clone(),
406 vec![Diagnostic {
407 range: Range::new(Position::new(0, 0), Position::new(1, 4)),
408 severity: Some(LspDiagnosticSeverity::ERROR),
409 code: Some(NumberOrString::String("E1".into())),
410 code_description: None,
411 source: Some("fake".into()),
412 message: "boom".into(),
413 related_information: None,
414 tags: None,
415 data: None,
416 }],
417 );
418
419 assert_eq!(diagnostics.len(), 1);
420 assert_eq!(diagnostics[0].file, file);
421 assert_eq!(diagnostics[0].line, 1);
422 assert_eq!(diagnostics[0].column, 1);
423 assert_eq!(diagnostics[0].end_line, 2);
424 assert_eq!(diagnostics[0].end_column, 5);
425 assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
426 assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
427 }
428
429 #[test]
430 fn publish_replaces_existing_file_diagnostics() {
431 let file = PathBuf::from("/tmp/demo.rs");
432 let mut store = DiagnosticsStore::new();
433 let key = server_key(ServerKind::Rust);
434
435 store.publish(
436 key.clone(),
437 file.clone(),
438 vec![diag(
439 "/tmp/demo.rs",
440 1,
441 "first",
442 DiagnosticSeverity::Warning,
443 )],
444 );
445 store.publish(
446 key.clone(),
447 file.clone(),
448 vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
449 );
450
451 let stored = store.for_file(&file);
452 assert_eq!(stored.len(), 1);
453 assert_eq!(stored[0].message, "second");
454 }
455
456 #[test]
457 fn empty_publish_is_preserved_as_checked_clean() {
458 let file = PathBuf::from("/tmp/clean.rs");
462 let mut store = DiagnosticsStore::new();
463 let key = server_key(ServerKind::Rust);
464
465 store.publish(
467 key.clone(),
468 file.clone(),
469 vec![diag(
470 "/tmp/clean.rs",
471 5,
472 "fix me",
473 DiagnosticSeverity::Warning,
474 )],
475 );
476 assert!(store.has_any_report_for_file(&file));
477 assert_eq!(store.for_file(&file).len(), 1);
478
479 store.publish(key.clone(), file.clone(), Vec::new());
482 assert!(
483 store.has_any_report_for_file(&file),
484 "checked-clean must be distinguishable from never-checked"
485 );
486 assert_eq!(store.for_file(&file).len(), 0);
487
488 let entries = store.entries_for_file(&file);
489 assert_eq!(entries.len(), 1);
490 assert!(entries[0].1.epoch > 0);
491 }
492
493 #[test]
494 fn never_checked_returns_no_report() {
495 let store = DiagnosticsStore::new();
496 let file = PathBuf::from("/tmp/never.rs");
497 assert!(!store.has_any_report_for_file(&file));
498 assert!(store.for_file(&file).is_empty());
499 }
500
501 #[test]
502 fn per_server_state_is_tracked_independently() {
503 let file = PathBuf::from("/tmp/multi.py");
504 let mut store = DiagnosticsStore::new();
505 let pyright_key = server_key(ServerKind::Python);
506 let ty_key = server_key(ServerKind::Ty);
507
508 store.publish(
509 pyright_key,
510 file.clone(),
511 vec![diag(
512 "/tmp/multi.py",
513 1,
514 "pyright says X",
515 DiagnosticSeverity::Error,
516 )],
517 );
518 store.publish(
519 ty_key,
520 file.clone(),
521 vec![diag(
522 "/tmp/multi.py",
523 2,
524 "ty says Y",
525 DiagnosticSeverity::Warning,
526 )],
527 );
528
529 let messages: Vec<&str> = store
530 .for_file(&file)
531 .into_iter()
532 .map(|d| d.message.as_str())
533 .collect();
534
535 assert_eq!(messages.len(), 2, "both servers' reports preserved");
536 assert!(messages.iter().any(|m| m == &"pyright says X"));
537 assert!(messages.iter().any(|m| m == &"ty says Y"));
538 }
539
540 #[test]
541 fn clear_for_server_file_removes_only_exact_entry() {
542 let file_a = PathBuf::from("/tmp/a.rs");
543 let file_b = PathBuf::from("/tmp/b.rs");
544 let mut store = DiagnosticsStore::new();
545 let rust_key = server_key(ServerKind::Rust);
546 let py_key = server_key(ServerKind::Python);
547
548 store.publish(
549 rust_key.clone(),
550 file_a.clone(),
551 vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
552 );
553 store.publish(
554 rust_key.clone(),
555 file_b.clone(),
556 vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
557 );
558 store.publish(
559 py_key.clone(),
560 file_a.clone(),
561 vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
562 );
563
564 store.clear_for_server_file(&rust_key, &file_a);
565
566 assert!(!store.has_report_for_server_file(&rust_key, &file_a));
567 assert!(store.has_report_for_server_file(&rust_key, &file_b));
568 assert!(store.has_report_for_server_file(&py_key, &file_a));
569 }
570
571 #[test]
572 fn lru_evicts_oldest_when_capacity_exceeded() {
573 let mut store = DiagnosticsStore::with_capacity(2);
574 let key = server_key(ServerKind::Rust);
575
576 store.publish(
577 key.clone(),
578 PathBuf::from("/a.rs"),
579 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
580 );
581 store.publish(
582 key.clone(),
583 PathBuf::from("/b.rs"),
584 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
585 );
586 assert_eq!(store.len(), 2);
587
588 store.publish(
590 key.clone(),
591 PathBuf::from("/c.rs"),
592 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
593 );
594 assert_eq!(store.len(), 2);
595 assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
596 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
597 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
598 }
599
600 #[test]
601 fn touching_existing_entry_moves_it_to_end_of_lru() {
602 let mut store = DiagnosticsStore::with_capacity(2);
603 let key = server_key(ServerKind::Rust);
604
605 store.publish(
606 key.clone(),
607 PathBuf::from("/a.rs"),
608 vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
609 );
610 store.publish(
611 key.clone(),
612 PathBuf::from("/b.rs"),
613 vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
614 );
615
616 store.publish(
619 key.clone(),
620 PathBuf::from("/a.rs"),
621 vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
622 );
623 store.publish(
624 key.clone(),
625 PathBuf::from("/c.rs"),
626 vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
627 );
628
629 assert!(store.has_any_report_for_file(Path::new("/a.rs")));
630 assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
631 assert!(store.has_any_report_for_file(Path::new("/c.rs")));
632 }
633
634 #[test]
635 fn capacity_zero_disables_eviction() {
636 let mut store = DiagnosticsStore::with_capacity(0);
637 let key = server_key(ServerKind::Rust);
638
639 for i in 0..50 {
640 store.publish(
641 key.clone(),
642 PathBuf::from(format!("/f{i}.rs")),
643 vec![diag(
644 &format!("/f{i}.rs"),
645 1,
646 "x",
647 DiagnosticSeverity::Warning,
648 )],
649 );
650 }
651 assert_eq!(store.len(), 50);
652 }
653
654 #[test]
655 fn set_capacity_evicts_on_shrink() {
656 let mut store = DiagnosticsStore::with_capacity(0);
657 let key = server_key(ServerKind::Rust);
658 for i in 0..10 {
659 store.publish(
660 key.clone(),
661 PathBuf::from(format!("/f{i}.rs")),
662 vec![diag(
663 &format!("/f{i}.rs"),
664 1,
665 "x",
666 DiagnosticSeverity::Warning,
667 )],
668 );
669 }
670 assert_eq!(store.len(), 10);
671
672 store.set_capacity(3);
673 assert_eq!(store.len(), 3);
674 assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
676 assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
677 }
678
679 #[test]
680 fn epoch_increments_monotonically() {
681 let mut store = DiagnosticsStore::new();
682 let key = server_key(ServerKind::Rust);
683 let file = PathBuf::from("/e.rs");
684
685 store.publish(key.clone(), file.clone(), Vec::new());
686 let e1 = store.entries_for_file(&file)[0].1.epoch;
687
688 store.publish(key.clone(), file.clone(), Vec::new());
689 let e2 = store.entries_for_file(&file)[0].1.epoch;
690
691 assert!(e2 > e1, "epoch must increase on republish");
692 }
693
694 #[test]
695 fn result_id_is_round_tripped() {
696 let mut store = DiagnosticsStore::new();
697 let key = server_key(ServerKind::Rust);
698 let file = PathBuf::from("/r.rs");
699
700 store.publish_with_result_id(
701 key.clone(),
702 file.clone(),
703 Vec::new(),
704 Some("rev-42".to_string()),
705 );
706
707 let entries = store.entries_for_file(&file);
708 assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
709 }
710
711 #[test]
712 fn clear_server_drops_all_entries_for_kind() {
713 let mut store = DiagnosticsStore::new();
714 let py_key = server_key(ServerKind::Python);
715 let rust_key = server_key(ServerKind::Rust);
716
717 store.publish(
718 py_key.clone(),
719 PathBuf::from("/a.py"),
720 vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
721 );
722 store.publish(
723 rust_key.clone(),
724 PathBuf::from("/b.rs"),
725 vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
726 );
727
728 store.clear_server(ServerKind::Python);
729 assert!(!store.has_any_report_for_file(Path::new("/a.py")));
730 assert!(store.has_any_report_for_file(Path::new("/b.rs")));
731 }
732}