Skip to main content

aft/lsp/
diagnostics.rs

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/// A single diagnostic from an LSP server.
9#[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/// One server's published diagnostics for one file, plus bookkeeping that
42/// distinguishes "checked clean" (`diagnostics.is_empty()` AND
43/// `epoch.is_some()`) from "never checked" (entry not present).
44#[derive(Debug, Clone)]
45pub struct DiagnosticEntry {
46    pub diagnostics: Vec<StoredDiagnostic>,
47    /// Monotonic epoch when this entry was last replaced by a publish or
48    /// pull response. Used by callers to tell "fresh" results apart from
49    /// stale cache contents.
50    pub epoch: u64,
51    /// Optional resultId from a pull response. Sent back as `previousResultId`
52    /// on the next pull request to enable `kind: "unchanged"` short-circuiting.
53    pub result_id: Option<String>,
54    /// Document version this publish/pull was tagged against, when the
55    /// server provided one. Servers that participate in versioned text
56    /// document sync echo `version` on `publishDiagnostics`; we store it
57    /// so post-edit waiters can reject stale publishes deterministically
58    /// (`version == target_version`) instead of relying on epoch ordering
59    /// alone, which has a race when an old-version publish arrives after
60    /// the pre-edit drain. `None` = server didn't tag the publish.
61    pub version: Option<i32>,
62    /// True after the filesystem watcher sees this file change outside AFT's
63    /// text sync path and before a publish or pull response proves the cached
64    /// diagnostics still describe the current file contents. Stale entries stay
65    /// in the store so resultIds and server coverage are not lost, but warm
66    /// readers must not count or display them as current diagnostics.
67    pub stale: bool,
68}
69
70/// Stores diagnostics from all LSP servers, keyed per `(ServerKey, file)`.
71///
72/// Key design points (driven by the v0.16 LSP audit):
73///
74/// 1. **Per-server state.** A single file can be served by multiple LSP
75///    servers (e.g., pyright + ty, or tsserver + ESLint). The cache key is
76///    `(ServerKey, PathBuf)` so each server's view is tracked independently.
77///
78/// 2. **Empty publishes are kept.** Earlier the store deleted entries on
79///    empty publishes, making "checked clean" indistinguishable from "never
80///    checked". Now we preserve the entry with `epoch = ...` so callers can
81///    answer the question honestly.
82///
83/// 3. **LRU cap.** `capacity` (default 5000, configurable via
84///    `Config::diagnostic_cache_size`) bounds memory. Set to 0 to disable.
85///    On insert when at capacity, the least-recently-touched entry is
86///    evicted. Eviction is tracked so directory-mode callers can list
87///    those files as `unchecked` rather than silently lose them.
88pub struct DiagnosticsStore {
89    /// Primary store keyed by `(ServerKey, canonical file path)`.
90    entries: HashMap<(ServerKey, PathBuf), DiagnosticEntry>,
91    /// Insertion/access order for LRU eviction. Most-recently-touched
92    /// entries are at the END of the vector.
93    order: Vec<(ServerKey, PathBuf)>,
94    /// Maximum number of entries before LRU eviction kicks in. 0 = no cap.
95    capacity: usize,
96    /// Monotonic epoch counter. Incremented on every publish.
97    next_epoch: u64,
98    /// Monotonic identity for any diagnostics-store mutation. Status-bar
99    /// aggregation uses it to skip unchanged project filtering work.
100    generation: u64,
101    /// Last time a server published/replaced diagnostics for a specific file.
102    /// Used as a per-file freshness proof for push-only servers.
103    last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
104}
105
106impl DiagnosticsStore {
107    pub fn new() -> Self {
108        Self::with_capacity(5000)
109    }
110
111    pub fn with_capacity(capacity: usize) -> Self {
112        Self {
113            entries: HashMap::new(),
114            order: Vec::new(),
115            capacity,
116            next_epoch: 0,
117            generation: 0,
118            last_publish_at_for_file: HashMap::new(),
119        }
120    }
121
122    /// Set or change the LRU cap. If the new cap is smaller than the
123    /// current entry count, the oldest entries are evicted immediately
124    /// to fit.
125    pub fn set_capacity(&mut self, capacity: usize) {
126        if self.capacity != capacity {
127            self.generation = self.generation.wrapping_add(1);
128        }
129        self.capacity = capacity;
130        if capacity > 0 {
131            while self.entries.len() > capacity {
132                self.evict_lru();
133            }
134        }
135    }
136
137    /// Number of currently-tracked entries.
138    pub fn len(&self) -> usize {
139        self.entries.len()
140    }
141
142    pub fn generation(&self) -> u64 {
143        self.generation
144    }
145
146    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
147        let mut diagnostic_count = 0usize;
148        let entry_bytes = self
149            .entries
150            .iter()
151            .fold(0u64, |bytes, ((server, path), entry)| {
152                diagnostic_count = diagnostic_count.saturating_add(entry.diagnostics.len());
153                let diagnostics_bytes = entry.diagnostics.iter().fold(0u64, |bytes, diagnostic| {
154                    bytes
155                        .saturating_add(std::mem::size_of::<StoredDiagnostic>() as u64)
156                        .saturating_add(crate::memory::path_bytes(&diagnostic.file))
157                        .saturating_add(crate::memory::usize_to_u64(diagnostic.message.len()))
158                        .saturating_add(
159                            diagnostic
160                                .code
161                                .as_ref()
162                                .map(|code| crate::memory::usize_to_u64(code.len()))
163                                .unwrap_or(0),
164                        )
165                        .saturating_add(
166                            diagnostic
167                                .source
168                                .as_ref()
169                                .map(|source| crate::memory::usize_to_u64(source.len()))
170                                .unwrap_or(0),
171                        )
172                });
173                bytes
174                    .saturating_add(std::mem::size_of_val(server) as u64)
175                    .saturating_add(crate::memory::path_bytes(&server.root))
176                    .saturating_add(std::mem::size_of::<PathBuf>() as u64)
177                    .saturating_add(crate::memory::path_bytes(path))
178                    .saturating_add(std::mem::size_of::<DiagnosticEntry>() as u64)
179                    .saturating_add(
180                        entry
181                            .result_id
182                            .as_ref()
183                            .map(|result_id| crate::memory::usize_to_u64(result_id.len()))
184                            .unwrap_or(0),
185                    )
186                    .saturating_add(diagnostics_bytes)
187            });
188        let order_bytes = self.order.iter().fold(0u64, |bytes, (server, path)| {
189            bytes
190                .saturating_add(std::mem::size_of_val(server) as u64)
191                .saturating_add(crate::memory::path_bytes(&server.root))
192                .saturating_add(std::mem::size_of::<PathBuf>() as u64)
193                .saturating_add(crate::memory::path_bytes(path))
194        });
195        let publish_bytes =
196            self.last_publish_at_for_file
197                .iter()
198                .fold(0u64, |bytes, ((server, path), _)| {
199                    bytes
200                        .saturating_add(std::mem::size_of_val(server) as u64)
201                        .saturating_add(crate::memory::path_bytes(&server.root))
202                        .saturating_add(std::mem::size_of::<PathBuf>() as u64)
203                        .saturating_add(crate::memory::path_bytes(path))
204                        .saturating_add(std::mem::size_of::<Instant>() as u64)
205                });
206        crate::memory::MemoryEstimate::estimated(
207            entry_bytes
208                .saturating_add(order_bytes)
209                .saturating_add(publish_bytes),
210        )
211        .count("diagnostic_entries", self.entries.len())
212        .count("diagnostics", diagnostic_count)
213    }
214
215    /// The current LRU cap (0 = unbounded). Test-only accessor used to verify
216    /// the `lsp.diagnostic_cache_size` config wiring.
217    #[cfg(test)]
218    pub fn capacity_for_test(&self) -> usize {
219        self.capacity
220    }
221
222    pub fn is_empty(&self) -> bool {
223        self.entries.is_empty()
224    }
225
226    /// True if any entry is currently usable, including an empty checked-clean
227    /// report. Watcher-stale entries do not prove current diagnostics.
228    pub fn has_any_fresh_report(&self) -> bool {
229        self.entries.values().any(|entry| !entry.stale)
230    }
231
232    /// Replace diagnostics for a `(server_kind, file)` pair using the
233    /// server's lifecycle root from the active manager. Empty diagnostics
234    /// are preserved as "checked clean" (NOT deleted as before).
235    ///
236    /// Note: the `(server, file)` key uses `ServerKey { kind, root }` so
237    /// concurrent multi-workspace usage doesn't collapse different roots.
238    /// Callers without the root (legacy push handler) should call
239    /// `publish_with_kind` which derives the key.
240    pub fn publish(
241        &mut self,
242        server: ServerKey,
243        file: PathBuf,
244        diagnostics: Vec<StoredDiagnostic>,
245    ) {
246        self.publish_with_result_id(server, file, diagnostics, None);
247    }
248
249    /// Replace diagnostics and record a pull `resultId` for the next
250    /// request. Empty diagnostics are preserved as "checked clean".
251    pub fn publish_with_result_id(
252        &mut self,
253        server: ServerKey,
254        file: PathBuf,
255        diagnostics: Vec<StoredDiagnostic>,
256        result_id: Option<String>,
257    ) {
258        self.publish_full(server, file, diagnostics, result_id, None);
259    }
260
261    /// Replace diagnostics with full provenance (resultId + document version).
262    /// `version` should be the LSP `version` field from `publishDiagnostics`
263    /// when the server provided one, or `None` otherwise.
264    pub fn publish_full(
265        &mut self,
266        server: ServerKey,
267        file: PathBuf,
268        diagnostics: Vec<StoredDiagnostic>,
269        result_id: Option<String>,
270        version: Option<i32>,
271    ) {
272        let key = (server, file);
273        self.next_epoch = self.next_epoch.saturating_add(1);
274        self.generation = self.generation.wrapping_add(1);
275        let entry = DiagnosticEntry {
276            diagnostics,
277            epoch: self.next_epoch,
278            result_id,
279            version,
280            stale: false,
281        };
282
283        self.last_publish_at_for_file
284            .insert(key.clone(), Instant::now());
285
286        if self.entries.contains_key(&key) {
287            self.entries.insert(key.clone(), entry);
288            self.touch_existing(&key);
289        } else {
290            // New entry — apply LRU cap before inserting.
291            if self.capacity > 0 && self.entries.len() >= self.capacity {
292                self.evict_lru();
293            }
294            self.entries.insert(key.clone(), entry);
295            self.order.push(key);
296        }
297    }
298
299    /// Compatibility wrapper for the legacy push path that knows only the
300    /// `ServerKind`. Builds a `ServerKey` with an empty root, which is
301    /// adequate for the single-root-per-kind case the manager currently
302    /// uses for push diagnostics. Multi-root callers should use
303    /// `publish` directly with a real `ServerKey`.
304    pub fn publish_with_kind(
305        &mut self,
306        kind: ServerKind,
307        file: PathBuf,
308        diagnostics: Vec<StoredDiagnostic>,
309    ) {
310        let key = ServerKey {
311            kind,
312            root: PathBuf::new(),
313        };
314        self.publish(key, file, diagnostics);
315    }
316
317    /// Get current diagnostics for a specific file (across all servers).
318    /// Watcher-stale entries are kept for bookkeeping but are not surfaced.
319    pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
320        self.entries
321            .iter()
322            .filter(|((_, stored_file), entry)| stored_file == file && !entry.stale)
323            .flat_map(|(_, entry)| entry.diagnostics.iter())
324            .collect()
325    }
326
327    /// Get the full per-server entry for a file. Useful when callers need
328    /// to know epoch/resultId, not just the diagnostics array.
329    pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
330        self.entries
331            .iter()
332            .filter(|((_, stored_file), _)| stored_file == file)
333            .map(|((key, _), entry)| (key, entry))
334            .collect()
335    }
336
337    /// True if any server has an entry (fresh or stale) for this file.
338    pub fn has_any_report_for_file(&self, file: &Path) -> bool {
339        self.entries.keys().any(|(_, f)| f == file)
340    }
341
342    /// True if any server has a non-stale report for this file.
343    pub fn has_any_fresh_report_for_file(&self, file: &Path) -> bool {
344        self.entries
345            .iter()
346            .any(|((_, stored_file), entry)| stored_file == file && !entry.stale)
347    }
348
349    /// True if this exact server instance has an entry (fresh or stale) for
350    /// this exact file. Pull diagnostics use stale entries as the previous
351    /// resultId cache when asking the server whether diagnostics are unchanged.
352    pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
353        self.entries
354            .contains_key(&(server.clone(), file.to_path_buf()))
355    }
356
357    /// True if this exact server instance has a non-stale report for this file.
358    pub fn has_fresh_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
359        self.entries
360            .get(&(server.clone(), file.to_path_buf()))
361            .is_some_and(|entry| !entry.stale)
362    }
363
364    /// True if this exact server instance published/replaced diagnostics for
365    /// this exact file after `since`. This is intentionally per `(kind, root,
366    /// file)`; a publish for another file must not prove freshness here.
367    pub fn has_publish_for_file_after(
368        &self,
369        server: &ServerKey,
370        file: &Path,
371        since: Instant,
372    ) -> bool {
373        self.last_publish_at_for_file
374            .get(&(server.clone(), file.to_path_buf()))
375            .is_some_and(|published_at| {
376                *published_at >= since && self.has_fresh_report_for_server_file(server, file)
377            })
378    }
379
380    /// Get current diagnostics for files under a directory.
381    pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
382        self.entries
383            .iter()
384            .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
385            .flat_map(|(_, entry)| entry.diagnostics.iter())
386            .collect()
387    }
388
389    /// All current diagnostics, flattened. Watcher-stale entries are hidden.
390    pub fn all(&self) -> Vec<&StoredDiagnostic> {
391        self.entries
392            .values()
393            .filter(|entry| !entry.stale)
394            .flat_map(|entry| entry.diagnostics.iter())
395            .collect()
396    }
397
398    /// Count of errors and warnings across the entire warm set (every file any
399    /// server has published for). Allocation-free — the raw, unfiltered union.
400    /// Callers that want the agent-status-bar semantics (project-root scoped,
401    /// tsconfig-membership filtered, cross-server deduped) should use
402    /// [`filtered_error_warning_counts`](Self::filtered_error_warning_counts).
403    pub fn error_warning_counts(&self) -> (usize, usize) {
404        let mut errors = 0usize;
405        let mut warnings = 0usize;
406        for entry in self.entries.values() {
407            if entry.stale {
408                continue;
409            }
410            for diagnostic in &entry.diagnostics {
411                match diagnostic.severity {
412                    DiagnosticSeverity::Error => errors += 1,
413                    DiagnosticSeverity::Warning => warnings += 1,
414                    _ => {}
415                }
416            }
417        }
418        (errors, warnings)
419    }
420
421    /// Error/warning counts after applying a per-file `keep` predicate,
422    /// excluding environmental/setup diagnostics (see `environmental.rs`),
423    /// and de-duplicating diagnostics that multiple servers reported for the
424    /// same location. This matches `aft_inspect`'s warm semantics
425    /// (`inspect/diagnostics_category.rs`: project-root filter +
426    /// tsconfig-membership skip + environmental filter + `sort_and_dedup`) so
427    /// the agent status bar's E/W agree with `aft_inspect`/`tsc` instead of
428    /// counting build-excluded files and double-counting multi-server overlaps.
429    ///
430    /// The store itself holds no tsconfig/project policy — the caller encodes
431    /// it in `keep` (see `LspManager::filtered_error_warning_counts`). `keep`
432    /// is `FnMut` because the membership cache resolves lazily.
433    pub fn filtered_error_warning_counts(
434        &self,
435        mut keep: impl FnMut(&Path) -> bool,
436    ) -> (usize, usize) {
437        // Dedup key mirrors `sort_and_dedup` in inspect/diagnostics_category.rs
438        // exactly (file, range, severity, message, source) so the bar and
439        // inspect collapse the same multi-server overlaps.
440        let mut seen: std::collections::HashSet<(
441            &Path,
442            u32,
443            u32,
444            u32,
445            u32,
446            &str,
447            &str,
448            Option<&str>,
449        )> = std::collections::HashSet::new();
450        let mut errors = 0usize;
451        let mut warnings = 0usize;
452        for ((_, file), entry) in &self.entries {
453            if entry.stale {
454                continue;
455            }
456            // All diagnostics in an entry share the entry's file, so the keep
457            // predicate (the cost center: tsconfig resolution) runs once per
458            // (server, file) entry, not once per diagnostic.
459            if !keep(file) {
460                continue;
461            }
462            for diagnostic in &entry.diagnostics {
463                if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
464                    continue;
465                }
466                let dedup_key = (
467                    diagnostic.file.as_path(),
468                    diagnostic.line,
469                    diagnostic.column,
470                    diagnostic.end_line,
471                    diagnostic.end_column,
472                    diagnostic.severity.as_str(),
473                    diagnostic.message.as_str(),
474                    diagnostic.source.as_deref(),
475                );
476                if !seen.insert(dedup_key) {
477                    continue;
478                }
479                match diagnostic.severity {
480                    DiagnosticSeverity::Error => errors += 1,
481                    DiagnosticSeverity::Warning => warnings += 1,
482                    _ => {}
483                }
484            }
485        }
486        (errors, warnings)
487    }
488
489    /// Drop all entries for a server kind (e.g., on server crash/restart).
490    /// Prefer `clear_for_server` for real manager cleanup so peer roots of the
491    /// same kind are not wiped.
492    pub fn clear_server(&mut self, server: ServerKind) {
493        let before = self.entries.len();
494        self.entries
495            .retain(|(stored_key, _), _| stored_key.kind != server);
496        self.order
497            .retain(|(stored_key, _)| stored_key.kind != server);
498        self.last_publish_at_for_file
499            .retain(|(stored_key, _), _| stored_key.kind != server);
500        if self.entries.len() != before {
501            self.generation = self.generation.wrapping_add(1);
502        }
503    }
504
505    /// Drop one cached report for a specific server/file pair.
506    pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
507        let cache_key = (key.clone(), file.to_path_buf());
508        if self.entries.remove(&cache_key).is_some() {
509            self.generation = self.generation.wrapping_add(1);
510        }
511        self.order.retain(|entry_key| entry_key != &cache_key);
512        self.last_publish_at_for_file.remove(&cache_key);
513    }
514
515    /// Drop every cached report for a file across all servers. Used when a file
516    /// is deleted/renamed away — its diagnostics would otherwise linger in the
517    /// warm set forever (no server republishes for a path that no longer
518    /// exists), inflating the error/warning counts surfaced in the status bar
519    /// and `aft_inspect`. Returns true if any entry was removed.
520    pub fn clear_for_file(&mut self, file: &Path) -> bool {
521        let before = self.entries.len();
522        self.entries
523            .retain(|(_, stored_file), _| stored_file != file);
524        let removed = self.entries.len() != before;
525        if removed {
526            self.generation = self.generation.wrapping_add(1);
527            self.order.retain(|(_, stored_file)| stored_file != file);
528            self.last_publish_at_for_file
529                .retain(|(_, stored_file), _| stored_file != file);
530        }
531        removed
532    }
533
534    /// Mark every cached report for a file stale without evicting it.
535    ///
536    /// This is used for watcher-observed external edits: the previous
537    /// diagnostics may still be useful as a pull `previousResultId`, but warm
538    /// readers must stop counting them until a server publish or pull response
539    /// proves freshness. Returns `(had_entries, changed)` where `changed` is true
540    /// only if at least one previously-fresh entry became stale.
541    pub fn mark_stale_for_file(&mut self, file: &Path) -> (bool, bool) {
542        let mut had_entries = false;
543        let mut changed = false;
544        for ((_, stored_file), entry) in &mut self.entries {
545            if stored_file != file {
546                continue;
547            }
548            had_entries = true;
549            if !entry.stale {
550                entry.stale = true;
551                changed = true;
552            }
553        }
554        if changed {
555            self.generation = self.generation.wrapping_add(1);
556        }
557        (had_entries, changed)
558    }
559
560    /// Mark one cached report fresh after a server response proves it still
561    /// describes the current document (for example a pull `kind: unchanged`).
562    pub fn mark_fresh_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
563        let cache_key = (key.clone(), file.to_path_buf());
564        let Some(entry) = self.entries.get_mut(&cache_key) else {
565            return false;
566        };
567        let changed = entry.stale;
568        entry.stale = false;
569        if changed {
570            self.generation = self.generation.wrapping_add(1);
571        }
572        self.touch_existing(&cache_key);
573        changed
574    }
575
576    /// Drop all entries for a specific server instance.
577    pub fn clear_for_server(&mut self, key: &ServerKey) {
578        let before = self.entries.len();
579        self.entries.retain(|(k, _), _| k != key);
580        self.order.retain(|(k, _)| k != key);
581        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
582        if self.entries.len() != before {
583            self.generation = self.generation.wrapping_add(1);
584        }
585    }
586
587    /// Backward-compatible alias for tests/callers that already used the
588    /// instance-scoped name.
589    pub fn clear_server_instance(&mut self, key: &ServerKey) {
590        self.clear_for_server(key);
591    }
592
593    /// Remove the least-recently-used entry, returning its key for telemetry.
594    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
595        if self.order.is_empty() {
596            return None;
597        }
598        let evicted = self.order.remove(0);
599        self.entries.remove(&evicted);
600        self.last_publish_at_for_file.remove(&evicted);
601        Some(evicted)
602    }
603
604    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
605        if let Some(idx) = self.order.iter().position(|k| k == key) {
606            let removed = self.order.remove(idx);
607            self.order.push(removed);
608        }
609    }
610}
611
612impl Default for DiagnosticsStore {
613    fn default() -> Self {
614        Self::new()
615    }
616}
617
618/// Convert LSP diagnostics to our stored format.
619/// LSP uses 0-based line/character; we convert to 1-based.
620pub fn from_lsp_diagnostics(
621    file: PathBuf,
622    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
623) -> Vec<StoredDiagnostic> {
624    lsp_diagnostics
625        .into_iter()
626        .map(|diagnostic| StoredDiagnostic {
627            file: file.clone(),
628            line: diagnostic.range.start.line + 1,
629            column: diagnostic.range.start.character + 1,
630            end_line: diagnostic.range.end.line + 1,
631            end_column: diagnostic.range.end.character + 1,
632            severity: match diagnostic.severity {
633                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
634                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
635                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
636                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
637                _ => DiagnosticSeverity::Warning,
638            },
639            message: diagnostic.message,
640            code: diagnostic.code.map(|code| match code {
641                lsp_types::NumberOrString::Number(value) => value.to_string(),
642                lsp_types::NumberOrString::String(value) => value,
643            }),
644            source: diagnostic.source,
645        })
646        .collect()
647}
648
649#[cfg(test)]
650mod tests {
651    use std::path::{Path, PathBuf};
652
653    use lsp_types::{
654        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
655    };
656
657    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
658    use crate::lsp::registry::ServerKind;
659    use crate::lsp::roots::ServerKey;
660
661    fn server_key(kind: ServerKind) -> ServerKey {
662        ServerKey {
663            kind,
664            root: PathBuf::from("/tmp/repo"),
665        }
666    }
667
668    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
669        StoredDiagnostic {
670            file: PathBuf::from(file),
671            line,
672            column: 1,
673            end_line: line,
674            end_column: 2,
675            severity: sev,
676            message: msg.into(),
677            code: None,
678            source: None,
679        }
680    }
681
682    #[test]
683    fn diagnostics_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
684        let mut store = DiagnosticsStore::new();
685        assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
686        let file = PathBuf::from("/tmp/memory.rs");
687        store.publish(
688            server_key(ServerKind::Rust),
689            file.clone(),
690            vec![diag(
691                file.to_str().unwrap(),
692                1,
693                "resident diagnostic message",
694                DiagnosticSeverity::Warning,
695            )],
696        );
697        let estimate = store.estimated_memory();
698        assert!(estimate.estimated_bytes.unwrap() > 0);
699        assert_eq!(estimate.counts["diagnostic_entries"], 1);
700        assert_eq!(estimate.counts["diagnostics"], 1);
701    }
702
703    #[test]
704    fn converts_lsp_positions_to_one_based() {
705        let file = PathBuf::from("/tmp/demo.rs");
706        let diagnostics = from_lsp_diagnostics(
707            file.clone(),
708            vec![Diagnostic {
709                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
710                severity: Some(LspDiagnosticSeverity::ERROR),
711                code: Some(NumberOrString::String("E1".into())),
712                code_description: None,
713                source: Some("fake".into()),
714                message: "boom".into(),
715                related_information: None,
716                tags: None,
717                data: None,
718            }],
719        );
720
721        assert_eq!(diagnostics.len(), 1);
722        assert_eq!(diagnostics[0].file, file);
723        assert_eq!(diagnostics[0].line, 1);
724        assert_eq!(diagnostics[0].column, 1);
725        assert_eq!(diagnostics[0].end_line, 2);
726        assert_eq!(diagnostics[0].end_column, 5);
727        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
728        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
729    }
730
731    #[test]
732    fn publish_replaces_existing_file_diagnostics() {
733        let file = PathBuf::from("/tmp/demo.rs");
734        let mut store = DiagnosticsStore::new();
735        let key = server_key(ServerKind::Rust);
736
737        store.publish(
738            key.clone(),
739            file.clone(),
740            vec![diag(
741                "/tmp/demo.rs",
742                1,
743                "first",
744                DiagnosticSeverity::Warning,
745            )],
746        );
747        store.publish(
748            key.clone(),
749            file.clone(),
750            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
751        );
752
753        let stored = store.for_file(&file);
754        assert_eq!(stored.len(), 1);
755        assert_eq!(stored[0].message, "second");
756    }
757
758    #[test]
759    fn empty_publish_is_preserved_as_checked_clean() {
760        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
761        // Agents need to be able to ask "has this file been checked yet?"
762        // and get a truthful answer.
763        let file = PathBuf::from("/tmp/clean.rs");
764        let mut store = DiagnosticsStore::new();
765        let key = server_key(ServerKind::Rust);
766
767        // First publish has an issue.
768        store.publish(
769            key.clone(),
770            file.clone(),
771            vec![diag(
772                "/tmp/clean.rs",
773                5,
774                "fix me",
775                DiagnosticSeverity::Warning,
776            )],
777        );
778        assert!(store.has_any_report_for_file(&file));
779        assert_eq!(store.for_file(&file).len(), 1);
780
781        // Second publish is empty (the fix worked). Entry is preserved as
782        // "checked clean" rather than deleted.
783        store.publish(key.clone(), file.clone(), Vec::new());
784        assert!(
785            store.has_any_report_for_file(&file),
786            "checked-clean must be distinguishable from never-checked"
787        );
788        assert_eq!(store.for_file(&file).len(), 0);
789
790        let entries = store.entries_for_file(&file);
791        assert_eq!(entries.len(), 1);
792        assert!(entries[0].1.epoch > 0);
793    }
794
795    #[test]
796    fn never_checked_returns_no_report() {
797        let store = DiagnosticsStore::new();
798        let file = PathBuf::from("/tmp/never.rs");
799        assert!(!store.has_any_report_for_file(&file));
800        assert!(store.for_file(&file).is_empty());
801    }
802
803    #[test]
804    fn stale_entries_are_hidden_but_preserved_for_refresh() {
805        let file = PathBuf::from("/tmp/stale.rs");
806        let mut store = DiagnosticsStore::new();
807        let key = server_key(ServerKind::Rust);
808        store.publish(
809            key.clone(),
810            file.clone(),
811            vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
812        );
813
814        let (had_entries, changed) = store.mark_stale_for_file(&file);
815
816        assert!(had_entries);
817        assert!(changed);
818        assert!(store.has_any_report_for_file(&file));
819        assert!(!store.has_any_fresh_report_for_file(&file));
820        assert!(store.for_file(&file).is_empty());
821        assert!(store.all().is_empty());
822        assert_eq!(store.error_warning_counts(), (0, 0));
823        assert_eq!(store.entries_for_file(&file).len(), 1);
824
825        assert!(store.mark_fresh_for_server_file(&key, &file));
826        assert!(store.has_any_fresh_report_for_file(&file));
827        assert_eq!(store.for_file(&file).len(), 1);
828        assert_eq!(store.error_warning_counts(), (1, 0));
829    }
830
831    #[test]
832    fn per_server_state_is_tracked_independently() {
833        let file = PathBuf::from("/tmp/multi.py");
834        let mut store = DiagnosticsStore::new();
835        let pyright_key = server_key(ServerKind::Python);
836        let ty_key = server_key(ServerKind::Ty);
837
838        store.publish(
839            pyright_key,
840            file.clone(),
841            vec![diag(
842                "/tmp/multi.py",
843                1,
844                "pyright says X",
845                DiagnosticSeverity::Error,
846            )],
847        );
848        store.publish(
849            ty_key,
850            file.clone(),
851            vec![diag(
852                "/tmp/multi.py",
853                2,
854                "ty says Y",
855                DiagnosticSeverity::Warning,
856            )],
857        );
858
859        let messages: Vec<&str> = store
860            .for_file(&file)
861            .into_iter()
862            .map(|d| d.message.as_str())
863            .collect();
864
865        assert_eq!(messages.len(), 2, "both servers' reports preserved");
866        assert!(messages.iter().any(|m| m == &"pyright says X"));
867        assert!(messages.iter().any(|m| m == &"ty says Y"));
868    }
869
870    #[test]
871    fn clear_for_server_file_removes_only_exact_entry() {
872        let file_a = PathBuf::from("/tmp/a.rs");
873        let file_b = PathBuf::from("/tmp/b.rs");
874        let mut store = DiagnosticsStore::new();
875        let rust_key = server_key(ServerKind::Rust);
876        let py_key = server_key(ServerKind::Python);
877
878        store.publish(
879            rust_key.clone(),
880            file_a.clone(),
881            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
882        );
883        store.publish(
884            rust_key.clone(),
885            file_b.clone(),
886            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
887        );
888        store.publish(
889            py_key.clone(),
890            file_a.clone(),
891            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
892        );
893
894        store.clear_for_server_file(&rust_key, &file_a);
895
896        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
897        assert!(store.has_report_for_server_file(&rust_key, &file_b));
898        assert!(store.has_report_for_server_file(&py_key, &file_a));
899    }
900
901    #[test]
902    fn lru_evicts_oldest_when_capacity_exceeded() {
903        let mut store = DiagnosticsStore::with_capacity(2);
904        let key = server_key(ServerKind::Rust);
905
906        store.publish(
907            key.clone(),
908            PathBuf::from("/a.rs"),
909            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
910        );
911        store.publish(
912            key.clone(),
913            PathBuf::from("/b.rs"),
914            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
915        );
916        assert_eq!(store.len(), 2);
917
918        // Inserting a third entry should evict /a.rs (oldest).
919        store.publish(
920            key.clone(),
921            PathBuf::from("/c.rs"),
922            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
923        );
924        assert_eq!(store.len(), 2);
925        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
926        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
927        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
928    }
929
930    #[test]
931    fn touching_existing_entry_moves_it_to_end_of_lru() {
932        let mut store = DiagnosticsStore::with_capacity(2);
933        let key = server_key(ServerKind::Rust);
934
935        store.publish(
936            key.clone(),
937            PathBuf::from("/a.rs"),
938            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
939        );
940        store.publish(
941            key.clone(),
942            PathBuf::from("/b.rs"),
943            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
944        );
945
946        // Re-publish /a.rs — this should refresh its LRU position so it's
947        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
948        store.publish(
949            key.clone(),
950            PathBuf::from("/a.rs"),
951            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
952        );
953        store.publish(
954            key.clone(),
955            PathBuf::from("/c.rs"),
956            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
957        );
958
959        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
960        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
961        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
962    }
963
964    #[test]
965    fn capacity_zero_disables_eviction() {
966        let mut store = DiagnosticsStore::with_capacity(0);
967        let key = server_key(ServerKind::Rust);
968
969        for i in 0..50 {
970            store.publish(
971                key.clone(),
972                PathBuf::from(format!("/f{i}.rs")),
973                vec![diag(
974                    &format!("/f{i}.rs"),
975                    1,
976                    "x",
977                    DiagnosticSeverity::Warning,
978                )],
979            );
980        }
981        assert_eq!(store.len(), 50);
982    }
983
984    #[test]
985    fn set_capacity_evicts_on_shrink() {
986        let mut store = DiagnosticsStore::with_capacity(0);
987        let key = server_key(ServerKind::Rust);
988        for i in 0..10 {
989            store.publish(
990                key.clone(),
991                PathBuf::from(format!("/f{i}.rs")),
992                vec![diag(
993                    &format!("/f{i}.rs"),
994                    1,
995                    "x",
996                    DiagnosticSeverity::Warning,
997                )],
998            );
999        }
1000        assert_eq!(store.len(), 10);
1001
1002        store.set_capacity(3);
1003        assert_eq!(store.len(), 3);
1004        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
1005        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
1006        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
1007    }
1008
1009    #[test]
1010    fn epoch_increments_monotonically() {
1011        let mut store = DiagnosticsStore::new();
1012        let key = server_key(ServerKind::Rust);
1013        let file = PathBuf::from("/e.rs");
1014
1015        store.publish(key.clone(), file.clone(), Vec::new());
1016        let e1 = store.entries_for_file(&file)[0].1.epoch;
1017
1018        store.publish(key.clone(), file.clone(), Vec::new());
1019        let e2 = store.entries_for_file(&file)[0].1.epoch;
1020
1021        assert!(e2 > e1, "epoch must increase on republish");
1022    }
1023
1024    #[test]
1025    fn result_id_is_round_tripped() {
1026        let mut store = DiagnosticsStore::new();
1027        let key = server_key(ServerKind::Rust);
1028        let file = PathBuf::from("/r.rs");
1029
1030        store.publish_with_result_id(
1031            key.clone(),
1032            file.clone(),
1033            Vec::new(),
1034            Some("rev-42".to_string()),
1035        );
1036
1037        let entries = store.entries_for_file(&file);
1038        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
1039    }
1040
1041    #[test]
1042    fn clear_server_drops_all_entries_for_kind() {
1043        let mut store = DiagnosticsStore::new();
1044        let py_key = server_key(ServerKind::Python);
1045        let rust_key = server_key(ServerKind::Rust);
1046
1047        store.publish(
1048            py_key.clone(),
1049            PathBuf::from("/a.py"),
1050            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
1051        );
1052        store.publish(
1053            rust_key.clone(),
1054            PathBuf::from("/b.rs"),
1055            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
1056        );
1057
1058        store.clear_server(ServerKind::Python);
1059        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
1060        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1061    }
1062
1063    #[test]
1064    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
1065        let mut store = DiagnosticsStore::new();
1066        let py_key = server_key(ServerKind::Python);
1067        let biome_key = server_key(ServerKind::Biome);
1068
1069        // Two servers both report for the SAME deleted file, plus an unrelated
1070        // file that must survive.
1071        store.publish(
1072            py_key,
1073            PathBuf::from("/gone.ts"),
1074            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
1075        );
1076        store.publish(
1077            biome_key,
1078            PathBuf::from("/gone.ts"),
1079            vec![diag(
1080                "/gone.ts",
1081                7,
1082                "lint warning",
1083                DiagnosticSeverity::Warning,
1084            )],
1085        );
1086        store.publish(
1087            server_key(ServerKind::Rust),
1088            PathBuf::from("/keep.rs"),
1089            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
1090        );
1091
1092        assert_eq!(store.error_warning_counts(), (2, 1));
1093
1094        // Clearing the deleted file drops both server entries for it.
1095        let removed = store.clear_for_file(Path::new("/gone.ts"));
1096        assert!(removed);
1097        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
1098        // The unrelated file's diagnostic is untouched.
1099        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
1100        assert_eq!(store.error_warning_counts(), (1, 0));
1101
1102        // Clearing again is a no-op (nothing left for that file).
1103        assert!(!store.clear_for_file(Path::new("/gone.ts")));
1104    }
1105
1106    #[test]
1107    fn filtered_counts_apply_keep_predicate() {
1108        let mut store = DiagnosticsStore::new();
1109        store.publish(
1110            server_key(ServerKind::TypeScript),
1111            PathBuf::from("/repo/src/app.ts"),
1112            vec![diag(
1113                "/repo/src/app.ts",
1114                1,
1115                "in build",
1116                DiagnosticSeverity::Error,
1117            )],
1118        );
1119        store.publish(
1120            server_key(ServerKind::TypeScript),
1121            PathBuf::from("/repo/src/app.test.ts"),
1122            vec![diag(
1123                "/repo/src/app.test.ts",
1124                1,
1125                "excluded",
1126                DiagnosticSeverity::Error,
1127            )],
1128        );
1129
1130        // Raw count sees both files.
1131        assert_eq!(store.error_warning_counts(), (2, 0));
1132        // Filtered count drops the build-excluded test file.
1133        let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1134        assert_eq!(counts, (1, 0));
1135    }
1136
1137    #[test]
1138    fn filtered_counts_dedup_across_servers() {
1139        let mut store = DiagnosticsStore::new();
1140        let file = "/repo/src/app.ts";
1141        // Two different servers report the SAME diagnostic (same file/range/
1142        // severity/message/source) for one file — e.g. tsserver + a linter that
1143        // both surface an identical issue. Raw counting double-counts; the
1144        // status-bar count must collapse to one (matching inspect sort_and_dedup).
1145        store.publish(
1146            server_key(ServerKind::TypeScript),
1147            PathBuf::from(file),
1148            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1149        );
1150        store.publish(
1151            server_key(ServerKind::Biome),
1152            PathBuf::from(file),
1153            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1154        );
1155
1156        assert_eq!(store.error_warning_counts(), (2, 0));
1157        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1158    }
1159
1160    #[test]
1161    fn filtered_counts_keep_distinct_diagnostics_same_file() {
1162        let mut store = DiagnosticsStore::new();
1163        let file = "/repo/src/app.ts";
1164        // Two servers, genuinely different diagnostics on the same file — both
1165        // must be counted (dedup keys on location+message+source, not file).
1166        store.publish(
1167            server_key(ServerKind::TypeScript),
1168            PathBuf::from(file),
1169            vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1170        );
1171        store.publish(
1172            server_key(ServerKind::Biome),
1173            PathBuf::from(file),
1174            vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1175        );
1176        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1177    }
1178
1179    #[test]
1180    fn filtered_counts_exclude_environmental_diagnostics() {
1181        let mut store = DiagnosticsStore::new();
1182        let file = "/repo/src/app.ts";
1183        store.publish(
1184            server_key(ServerKind::TypeScript),
1185            PathBuf::from(file),
1186            vec![
1187                diag(
1188                    file,
1189                    1,
1190                    "Cannot find name 'foo'.",
1191                    DiagnosticSeverity::Error,
1192                ),
1193                diag(
1194                    file,
1195                    2,
1196                    "Failed to load schema from https://cdn.example/pkg/schema.json",
1197                    DiagnosticSeverity::Error,
1198                ),
1199            ],
1200        );
1201        assert_eq!(store.error_warning_counts(), (2, 0));
1202        assert_eq!(
1203            store.filtered_error_warning_counts(|_| true),
1204            (1, 0),
1205            "environmental schema-fetch must not inflate E count"
1206        );
1207    }
1208
1209    #[test]
1210    fn environmental_flap_does_not_change_filtered_counts() {
1211        let mut store = DiagnosticsStore::new();
1212        let file = "/repo/package.json";
1213        let key = server_key(ServerKind::TypeScript);
1214        let env_msg =
1215            "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1216
1217        assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1218
1219        store.publish(
1220            key.clone(),
1221            PathBuf::from(file),
1222            vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1223        );
1224        assert_eq!(
1225            store.filtered_error_warning_counts(|_| true),
1226            (0, 0),
1227            "publish environmental diagnostic must not change filtered E/W"
1228        );
1229
1230        store.publish(key, PathBuf::from(file), vec![]);
1231        assert_eq!(
1232            store.filtered_error_warning_counts(|_| true),
1233            (0, 0),
1234            "removing environmental diagnostic must not change filtered E/W"
1235        );
1236    }
1237
1238    #[test]
1239    fn mixed_syntax_and_schema_fetch_counts_one_error() {
1240        let mut store = DiagnosticsStore::new();
1241        let file = "/repo/src/mixed.ts";
1242        store.publish(
1243            server_key(ServerKind::TypeScript),
1244            PathBuf::from(file),
1245            vec![
1246                diag(
1247                    file,
1248                    3,
1249                    "Cannot find name 'bar'.",
1250                    DiagnosticSeverity::Error,
1251                ),
1252                diag(
1253                    file,
1254                    1,
1255                    "Failed to resolve schema https://example.com/x.json",
1256                    DiagnosticSeverity::Error,
1257                ),
1258            ],
1259        );
1260        assert_eq!(
1261            store.filtered_error_warning_counts(|_| true),
1262            (1, 0),
1263            "classifier is per-diagnostic: one real syntax error => E1"
1264        );
1265    }
1266}