Skip to main content

zeph_acp/lsp/
cache.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Bounded LRU cache for LSP diagnostics pushed via `lsp/publishDiagnostics`.
5//!
6//! URI keys are compared as-is (the IDE is expected to use consistent URIs).
7//! URI normalization (case, symlinks) is deferred to a follow-up if cache misses
8//! are observed in practice.
9
10use std::collections::{HashMap, VecDeque};
11
12use super::types::LspDiagnostic;
13
14/// Bounded per-session LRU cache for diagnostics pushed via `lsp/publishDiagnostics`.
15///
16/// Holds diagnostics for at most `max_files` files. When a new URI is inserted at
17/// capacity, the least-recently-used file is evicted.
18///
19/// URI keys are compared as-is; normalization (case, symlinks) is the IDE's responsibility.
20///
21/// # Examples
22///
23/// ```
24/// use zeph_acp::DiagnosticsCache;
25///
26/// let mut cache = DiagnosticsCache::new(10);
27/// assert!(cache.is_empty());
28/// assert!(cache.peek("file:///src/main.rs").is_none());
29/// ```
30pub struct DiagnosticsCache {
31    entries: HashMap<String, Vec<LspDiagnostic>>,
32    order: VecDeque<String>,
33    max_files: usize,
34}
35
36impl DiagnosticsCache {
37    /// Create a new cache with the given file limit (minimum 1).
38    ///
39    /// # Examples
40    ///
41    /// ```
42    /// use zeph_acp::DiagnosticsCache;
43    ///
44    /// let cache = DiagnosticsCache::new(50);
45    /// assert!(cache.is_empty());
46    /// ```
47    #[must_use]
48    pub fn new(max_files: usize) -> Self {
49        Self {
50            entries: HashMap::new(),
51            order: VecDeque::new(),
52            max_files: max_files.max(1),
53        }
54    }
55
56    /// Insert or replace diagnostics for a URI, evicting the oldest entry when at capacity.
57    pub fn update(&mut self, uri: String, diagnostics: Vec<LspDiagnostic>) {
58        if self.entries.contains_key(&uri) {
59            // Move to back (most recently used).
60            self.order.retain(|u| u != &uri);
61        } else if self.entries.len() >= self.max_files {
62            // Evict least recently used.
63            if let Some(evicted) = self.order.pop_front() {
64                self.entries.remove(&evicted);
65            }
66        }
67        self.order.push_back(uri.clone());
68        self.entries.insert(uri, diagnostics);
69    }
70
71    /// Peek at diagnostics for a URI without refreshing LRU order.
72    ///
73    /// Returns `None` if not cached. Reads do **not** affect eviction order — only
74    /// `update()` refreshes recency. Rename from `get()` to make peek semantics explicit.
75    #[must_use]
76    pub fn peek(&self, uri: &str) -> Option<&[LspDiagnostic]> {
77        self.entries.get(uri).map(Vec::as_slice)
78    }
79
80    /// Return all files that have at least one diagnostic.
81    #[must_use]
82    pub fn all_non_empty(&self) -> Vec<(&str, &[LspDiagnostic])> {
83        self.entries
84            .iter()
85            .filter(|(_, diags)| !diags.is_empty())
86            .map(|(uri, diags)| (uri.as_str(), diags.as_slice()))
87            .collect()
88    }
89
90    /// Clear all cached diagnostics.
91    pub fn clear(&mut self) {
92        self.entries.clear();
93        self.order.clear();
94    }
95
96    /// Number of files currently in the cache.
97    #[must_use]
98    pub fn len(&self) -> usize {
99        self.entries.len()
100    }
101
102    /// Returns `true` if the cache has no entries.
103    #[must_use]
104    pub fn is_empty(&self) -> bool {
105        self.entries.is_empty()
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::lsp::types::{LspDiagnosticSeverity, LspPosition, LspRange};
113
114    fn make_diag(msg: &str) -> LspDiagnostic {
115        LspDiagnostic {
116            range: LspRange {
117                start: LspPosition {
118                    line: 1,
119                    character: 0,
120                },
121                end: LspPosition {
122                    line: 1,
123                    character: 1,
124                },
125            },
126            severity: Some(LspDiagnosticSeverity::Error),
127            code: None,
128            source: None,
129            message: msg.to_owned(),
130        }
131    }
132
133    #[test]
134    fn insert_and_get() {
135        let mut cache = DiagnosticsCache::new(5);
136        cache.update("file:///a.rs".to_owned(), vec![make_diag("err1")]);
137        let diags = cache.peek("file:///a.rs").unwrap();
138        assert_eq!(diags.len(), 1);
139        assert_eq!(diags[0].message, "err1");
140    }
141
142    #[test]
143    fn missing_uri_returns_none() {
144        let cache = DiagnosticsCache::new(5);
145        assert!(cache.peek("file:///missing.rs").is_none());
146    }
147
148    #[test]
149    fn lru_eviction_removes_oldest() {
150        let mut cache = DiagnosticsCache::new(2);
151        cache.update("file:///a.rs".to_owned(), vec![make_diag("a")]);
152        cache.update("file:///b.rs".to_owned(), vec![make_diag("b")]);
153        // Insert third — should evict "a".
154        cache.update("file:///c.rs".to_owned(), vec![make_diag("c")]);
155
156        assert!(cache.peek("file:///a.rs").is_none(), "a should be evicted");
157        assert!(cache.peek("file:///b.rs").is_some());
158        assert!(cache.peek("file:///c.rs").is_some());
159        assert_eq!(cache.len(), 2);
160    }
161
162    #[test]
163    fn update_existing_uri_does_not_grow() {
164        let mut cache = DiagnosticsCache::new(2);
165        cache.update("file:///a.rs".to_owned(), vec![make_diag("v1")]);
166        cache.update("file:///a.rs".to_owned(), vec![make_diag("v2")]);
167        assert_eq!(cache.len(), 1);
168        let diags = cache.peek("file:///a.rs").unwrap();
169        assert_eq!(diags[0].message, "v2");
170    }
171
172    #[test]
173    fn update_existing_moves_to_recent() {
174        let mut cache = DiagnosticsCache::new(2);
175        cache.update("file:///a.rs".to_owned(), vec![make_diag("a")]);
176        cache.update("file:///b.rs".to_owned(), vec![make_diag("b")]);
177        // Touch "a" — making it most recent.
178        cache.update("file:///a.rs".to_owned(), vec![make_diag("a2")]);
179        // Insert "c" — should evict "b" (least recently used).
180        cache.update("file:///c.rs".to_owned(), vec![make_diag("c")]);
181
182        assert!(cache.peek("file:///b.rs").is_none(), "b should be evicted");
183        assert!(cache.peek("file:///a.rs").is_some());
184        assert!(cache.peek("file:///c.rs").is_some());
185    }
186
187    #[test]
188    fn all_non_empty_skips_empty_diags() {
189        let mut cache = DiagnosticsCache::new(5);
190        cache.update("file:///a.rs".to_owned(), vec![make_diag("err")]);
191        cache.update("file:///b.rs".to_owned(), vec![]);
192        let non_empty = cache.all_non_empty();
193        assert_eq!(non_empty.len(), 1);
194        assert_eq!(non_empty[0].0, "file:///a.rs");
195    }
196
197    #[test]
198    fn clear_removes_all() {
199        let mut cache = DiagnosticsCache::new(5);
200        cache.update("file:///a.rs".to_owned(), vec![make_diag("err")]);
201        cache.clear();
202        assert!(cache.is_empty());
203        assert!(cache.peek("file:///a.rs").is_none());
204    }
205
206    #[test]
207    fn max_files_one_always_evicts() {
208        let mut cache = DiagnosticsCache::new(1);
209        cache.update("file:///a.rs".to_owned(), vec![make_diag("a")]);
210        cache.update("file:///b.rs".to_owned(), vec![make_diag("b")]);
211        assert!(cache.peek("file:///a.rs").is_none());
212        assert!(cache.peek("file:///b.rs").is_some());
213        assert_eq!(cache.len(), 1);
214    }
215}