Skip to main content

lang_check/
cache.rs

1use std::num::NonZeroUsize;
2
3use lru::LruCache;
4
5use crate::checker::Diagnostic;
6
7/// What identifies one engine's answer for one prose range.
8///
9/// The language and the engine are part of it because the same prose gets a
10/// different answer from Harper than from `LanguageTool`, and a different one
11/// again in `en-GB` than in `en-US`. Everything else that changes an answer
12/// lives in the engine's own config, and a config change rebuilds the engines,
13/// which clears the cache outright.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15struct ResultKey {
16    engine: &'static str,
17    language: String,
18    content: u64,
19}
20
21/// LRU cache of engine answers, keyed by the prose that produced them.
22///
23/// A keystroke re-checks the whole document: the prose is re-extracted and
24/// every range goes back to the engines, though only the edited one changed.
25/// For `LanguageTool` that is the difference between one small request and a
26/// hundred — measured on a 36 kB Typst file against a 4-CPU server, 838 ms per
27/// keystroke pause against 108 ms.
28///
29/// Answers are cached as the engine returned them: offsets relative to the
30/// range, rule ids not yet normalised. Normalisation and the severity
31/// overrides run over cached answers as over fresh ones, so changing a rule's
32/// severity takes effect without waiting for the cache to turn over.
33pub struct ResultCache {
34    /// `None` when the cache is switched off, so the caller needs no second
35    /// code path for that.
36    cache: Option<LruCache<ResultKey, Vec<Diagnostic>>>,
37}
38
39impl ResultCache {
40    /// A cache holding `capacity` answers, or a disabled one when that is zero.
41    ///
42    /// A disabled cache misses every lookup and stores nothing.
43    #[must_use]
44    pub fn new(capacity: usize) -> Self {
45        Self {
46            cache: NonZeroUsize::new(capacity).map(LruCache::new),
47        }
48    }
49
50    fn key(engine: &'static str, language: &str, text: &str) -> ResultKey {
51        ResultKey {
52            engine,
53            language: language.to_string(),
54            content: crate::hashing::content_hash(text),
55        }
56    }
57
58    /// The engine's last answer for this prose, if it is still held.
59    #[must_use]
60    pub fn get(
61        &mut self,
62        engine: &'static str,
63        language: &str,
64        text: &str,
65    ) -> Option<Vec<Diagnostic>> {
66        self.cache
67            .as_mut()?
68            .get(&Self::key(engine, language, text))
69            .cloned()
70    }
71
72    /// Remember what the engine answered for this prose.
73    pub fn put(
74        &mut self,
75        engine: &'static str,
76        language: &str,
77        text: &str,
78        diagnostics: Vec<Diagnostic>,
79    ) {
80        if let Some(cache) = self.cache.as_mut() {
81            cache.put(Self::key(engine, language, text), diagnostics);
82        }
83    }
84
85    /// Number of answers currently held.
86    #[must_use]
87    pub fn len(&self) -> usize {
88        self.cache.as_ref().map_or(0, LruCache::len)
89    }
90
91    #[must_use]
92    pub fn is_empty(&self) -> bool {
93        self.len() == 0
94    }
95
96    /// Drop everything, for when a config change invalidates every answer.
97    pub fn clear(&mut self) {
98        if let Some(cache) = self.cache.as_mut() {
99            cache.clear();
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn diagnostic(rule: &str) -> Diagnostic {
109        Diagnostic {
110            start_byte: 0,
111            end_byte: 4,
112            message: String::new(),
113            suggestions: Vec::new(),
114            rule_id: rule.to_string(),
115            severity: 2,
116            unified_id: String::new(),
117            confidence: 1.0,
118            language: String::new(),
119            pack_installable: false,
120        }
121    }
122
123    #[test]
124    fn an_answer_comes_back_for_the_same_prose() {
125        let mut cache = ResultCache::new(10);
126        assert!(cache.get("harper", "en-US", "some prose").is_none());
127        cache.put(
128            "harper",
129            "en-US",
130            "some prose",
131            vec![diagnostic("harper.Spelling")],
132        );
133        let hit = cache.get("harper", "en-US", "some prose").expect("hit");
134        assert_eq!(hit[0].rule_id, "harper.Spelling");
135    }
136
137    #[test]
138    fn changed_prose_misses() {
139        let mut cache = ResultCache::new(10);
140        cache.put(
141            "harper",
142            "en-US",
143            "some prose",
144            vec![diagnostic("harper.Spelling")],
145        );
146        assert!(cache.get("harper", "en-US", "some prose edited").is_none());
147    }
148
149    #[test]
150    fn another_engine_or_language_misses() {
151        let mut cache = ResultCache::new(10);
152        cache.put(
153            "harper",
154            "en-US",
155            "some prose",
156            vec![diagnostic("harper.Spelling")],
157        );
158        assert!(cache.get("languagetool", "en-US", "some prose").is_none());
159        assert!(cache.get("harper", "fr", "some prose").is_none());
160    }
161
162    #[test]
163    fn the_oldest_answer_is_evicted_at_capacity() {
164        let mut cache = ResultCache::new(2);
165        cache.put("harper", "en-US", "one", Vec::new());
166        cache.put("harper", "en-US", "two", Vec::new());
167        cache.put("harper", "en-US", "three", Vec::new());
168        assert_eq!(cache.len(), 2);
169        assert!(cache.get("harper", "en-US", "one").is_none());
170        assert!(cache.get("harper", "en-US", "three").is_some());
171    }
172
173    #[test]
174    fn a_zero_capacity_cache_is_switched_off() {
175        let mut cache = ResultCache::new(0);
176        cache.put("harper", "en-US", "one", Vec::new());
177        assert!(cache.get("harper", "en-US", "one").is_none());
178        assert!(cache.is_empty());
179    }
180}