Skip to main content

lattice_completion/
cache.rs

1//! Cache for generator output (DESIGN.md ยง5.11.3, Q13).
2//!
3//! Caching is opt-in: a generator returns `Some(CacheKey)` from
4//! `cache_key()` to participate. The cache lives on the
5//! [`crate::CompletionRegistry`]; the pipeline consults it before
6//! invoking a generator's `generate()`.
7//!
8//! The cache stores `Vec<RawCandidate>` (the post-generate set,
9//! pre-match). The matcher / ranker / annotators always run against
10//! current state -- only generation is cached. This is the right
11//! granularity: re-walking the registry for every keystroke is the
12//! expensive op; matching against ~100 cached candidates is cheap.
13
14use std::collections::HashMap;
15use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18use crate::candidate::{CacheKey, RawCandidate};
19
20#[derive(Debug)]
21struct CachedEntry {
22    candidates: Vec<RawCandidate>,
23    inserted_at: Instant,
24    ttl: Duration,
25}
26
27/// Generator cache. Held inside the registry; owned by the registry
28/// because cache invalidation hooks into registry mutations
29/// (registering a new command bumps the commands generator's
30/// version, naturally invalidating cached entries via the new key).
31#[derive(Debug, Default)]
32pub struct GeneratorCache {
33    entries: Mutex<HashMap<CacheKey, CachedEntry>>,
34}
35
36impl GeneratorCache {
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Return cached candidates for `key` if present and not
42    /// expired. Caller is responsible for cloning if it needs the
43    /// returned `Vec` to outlive the cache; this signature returns
44    /// an owned clone to avoid lock-poisoning footguns.
45    pub fn get(&self, key: &CacheKey) -> Option<Vec<RawCandidate>> {
46        // `expect` here is intentional: Mutex poisoning would mean
47        // a panic occurred while another thread held the lock,
48        // which itself is a programmer bug we want to surface.
49        #[allow(clippy::unwrap_used)]
50        let entries = self.entries.lock().unwrap();
51        let entry = entries.get(key)?;
52        if entry.inserted_at.elapsed() >= entry.ttl {
53            return None;
54        }
55        Some(entry.candidates.clone())
56    }
57
58    /// Store `candidates` under `key` with the given soft TTL.
59    pub fn put(&self, key: CacheKey, candidates: Vec<RawCandidate>, ttl: Duration) {
60        #[allow(clippy::unwrap_used)]
61        let mut entries = self.entries.lock().unwrap();
62        entries.insert(
63            key,
64            CachedEntry {
65                candidates,
66                inserted_at: Instant::now(),
67                ttl,
68            },
69        );
70    }
71
72    /// Remove an entry. Used by tests and future explicit-
73    /// invalidation paths.
74    pub fn invalidate(&self, key: &CacheKey) {
75        #[allow(clippy::unwrap_used)]
76        self.entries.lock().unwrap().remove(key);
77    }
78
79    /// Drop every cached entry. Used by `clear-completion-cache`
80    /// command (post-1.0) and in tests.
81    pub fn clear(&self) {
82        #[allow(clippy::unwrap_used)]
83        self.entries.lock().unwrap().clear();
84    }
85
86    /// How many entries are currently cached. For tests + a future
87    /// `:cache-stats` command.
88    pub fn len(&self) -> usize {
89        #[allow(clippy::unwrap_used)]
90        self.entries.lock().unwrap().len()
91    }
92
93    pub fn is_empty(&self) -> bool {
94        self.len() == 0
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    #![allow(clippy::unwrap_used, clippy::panic)]
101    use super::*;
102    use crate::candidate::{CandidateData, CandidateKind};
103
104    fn sample(text: &str) -> RawCandidate {
105        RawCandidate {
106            insert_text: None,
107            text: text.into(),
108            display: text.into(),
109            kind: CandidateKind::Plain,
110            data: CandidateData::Plain,
111            source: None,
112            accept_action: None,
113            annotations: Vec::new(),
114            display_spans: Vec::new(),
115        }
116    }
117
118    #[test]
119    fn empty_cache_returns_none() {
120        let c = GeneratorCache::new();
121        assert!(c.get(&CacheKey::new("nope")).is_none());
122        assert!(c.is_empty());
123    }
124
125    #[test]
126    fn put_then_get_round_trips() {
127        let c = GeneratorCache::new();
128        let key = CacheKey::new("k1");
129        c.put(
130            key.clone(),
131            vec![sample("a"), sample("b")],
132            Duration::from_secs(60),
133        );
134        let got = c.get(&key).unwrap();
135        assert_eq!(got.len(), 2);
136        assert_eq!(got[0].text, "a");
137        assert_eq!(c.len(), 1);
138    }
139
140    #[test]
141    fn distinct_keys_are_independent() {
142        let c = GeneratorCache::new();
143        c.put(
144            CacheKey::new("a"),
145            vec![sample("aa")],
146            Duration::from_secs(60),
147        );
148        c.put(
149            CacheKey::new("b"),
150            vec![sample("bb")],
151            Duration::from_secs(60),
152        );
153        assert_eq!(c.get(&CacheKey::new("a")).unwrap()[0].text, "aa");
154        assert_eq!(c.get(&CacheKey::new("b")).unwrap()[0].text, "bb");
155    }
156
157    #[test]
158    fn ttl_zero_evicts_immediately() {
159        // Entry expires the moment we read it back; verifies the
160        // TTL comparison is `>=` not `>`.
161        let c = GeneratorCache::new();
162        let key = CacheKey::new("ttl0");
163        c.put(key.clone(), vec![sample("x")], Duration::ZERO);
164        // Spin until > 0ns has elapsed.
165        std::thread::sleep(Duration::from_micros(100));
166        assert!(c.get(&key).is_none());
167    }
168
169    #[test]
170    fn invalidate_drops_entry() {
171        let c = GeneratorCache::new();
172        let key = CacheKey::new("k");
173        c.put(key.clone(), vec![sample("x")], Duration::from_secs(60));
174        c.invalidate(&key);
175        assert!(c.get(&key).is_none());
176    }
177
178    #[test]
179    fn clear_drops_all_entries() {
180        let c = GeneratorCache::new();
181        c.put(
182            CacheKey::new("a"),
183            vec![sample("a")],
184            Duration::from_secs(60),
185        );
186        c.put(
187            CacheKey::new("b"),
188            vec![sample("b")],
189            Duration::from_secs(60),
190        );
191        c.clear();
192        assert!(c.is_empty());
193    }
194
195    #[test]
196    fn put_overwrites_existing_key() {
197        let c = GeneratorCache::new();
198        let key = CacheKey::new("k");
199        c.put(key.clone(), vec![sample("old")], Duration::from_secs(60));
200        c.put(key.clone(), vec![sample("new")], Duration::from_secs(60));
201        assert_eq!(c.get(&key).unwrap()[0].text, "new");
202        assert_eq!(c.len(), 1);
203    }
204}