Skip to main content

lanekeep_cache/
key.rs

1//! What decides whether a cached result may be used.
2//!
3//! The key is a hash over everything a file's result depends on except its dependencies,
4//! which are checked separately (§8.2). Everything listed here is an input because leaving
5//! any of it out produces the same class of bug: a result computed by code, configuration or
6//! a grammar that no longer exists, served as though it were current.
7//!
8//! Over-invalidation costs a recompute. Under-invalidation reports the wrong answer and
9//! gives no sign that it did. The two are not symmetric, which is why anything doubtful goes
10//! in the key.
11
12use lanekeep_core::ContentHash;
13
14/// The on-disk format's version.
15///
16/// Bumped when the encoding changes. Because it feeds the key, an old file simply misses
17/// rather than being misread — the cache is disposable, so a format change costs one cold
18/// run and needs no migration.
19pub const FORMAT_VERSION: u32 = 4;
20
21/// Everything about a run that every file's key shares.
22///
23/// Computed once and reused, because hashing the ruleset and config per file would repeat
24/// identical work thousands of times per run.
25#[derive(Debug, Clone)]
26pub struct RunKey {
27    prefix: blake3::Hasher,
28}
29
30impl RunKey {
31    /// Fold in everything that is constant for a run.
32    ///
33    /// `engine_version` should be major.minor only: a patch release by definition changes no
34    /// behavior a rule can observe, and invalidating every cache on it would make patch
35    /// upgrades expensive for no benefit.
36    #[must_use]
37    pub fn new(
38        engine_version: &str,
39        host_api_version: u32,
40        ruleset_hash: &[u8],
41        config_hash: &[u8],
42        grammars: &[GrammarKey],
43    ) -> Self {
44        let mut prefix = blake3::Hasher::new();
45
46        // Length-prefixed, so `("ab", "c")` and `("a", "bc")` cannot hash alike. Without
47        // this two genuinely different runs could share a key, which is the one failure
48        // this whole module exists to prevent.
49        write_field(&mut prefix, b"lanekeep-cache");
50        write_field(&mut prefix, &FORMAT_VERSION.to_le_bytes());
51        write_field(&mut prefix, engine_version.as_bytes());
52        write_field(&mut prefix, &host_api_version.to_le_bytes());
53        write_field(&mut prefix, ruleset_hash);
54        write_field(&mut prefix, config_hash);
55
56        // Every registered grammar, not the one a given file happens to use. A grammar bump
57        // changes node shapes and therefore what a query matches; folding the whole set in
58        // means a bump anywhere invalidates everything, which over-invalidates by exactly
59        // the files that use the other languages — a recompute, against the alternative of
60        // reasoning per file about which grammars a file's rules could have involved.
61        write_field(&mut prefix, &(grammars.len() as u64).to_le_bytes());
62        for grammar in grammars {
63            write_field(&mut prefix, grammar.id.as_bytes());
64            write_field(&mut prefix, &grammar.abi.to_le_bytes());
65        }
66
67        Self { prefix }
68    }
69
70    /// The key for a file whose result depends on the date.
71    ///
72    /// Only for a file carrying an expiring suppression. Folding the date into every key
73    /// would invalidate the whole cache daily for the sake of the handful of files that
74    /// have one — and leaving it out entirely would serve an expired suppression as though
75    /// it were still in force, which is the one thing an expiry exists to prevent.
76    #[must_use]
77    pub fn for_dated_file(&self, path: &str, content: &ContentHash, today: &str) -> CacheKey {
78        let mut hasher = self.prefix.clone();
79        write_field(&mut hasher, path.as_bytes());
80        write_field(&mut hasher, content.as_bytes());
81        write_field(&mut hasher, today.as_bytes());
82        CacheKey(*hasher.finalize().as_bytes())
83    }
84
85    /// The key for one file.
86    ///
87    /// The **path** is an input as well as the content, because path gates make results
88    /// path-sensitive — a moved file with identical bytes is not a hit.
89    #[must_use]
90    pub fn for_file(&self, path: &str, content: &ContentHash) -> CacheKey {
91        let mut hasher = self.prefix.clone();
92        write_field(&mut hasher, path.as_bytes());
93        write_field(&mut hasher, content.as_bytes());
94        CacheKey(*hasher.finalize().as_bytes())
95    }
96}
97
98/// The grammar a file was parsed with.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct GrammarKey {
101    /// The language's identifier.
102    pub id: String,
103    /// The tree-sitter ABI version the grammar was built against.
104    pub abi: u32,
105}
106
107/// A cache key: what an entry is stored under.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
109pub struct CacheKey([u8; 32]);
110
111impl CacheKey {
112    /// Wrap raw bytes, for decoding an entry that is already on disk.
113    #[must_use]
114    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
115        Self(bytes)
116    }
117
118    /// The raw bytes.
119    #[must_use]
120    pub const fn as_bytes(&self) -> &[u8; 32] {
121        &self.0
122    }
123}
124
125impl std::fmt::Display for CacheKey {
126    /// The first eight hex characters, which is all a diagnostic needs.
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        for byte in &self.0[..4] {
129            write!(f, "{byte:02x}")?;
130        }
131        Ok(())
132    }
133}
134
135/// Absorb a field, length-prefixed so concatenation is unambiguous.
136fn write_field(hasher: &mut blake3::Hasher, bytes: &[u8]) {
137    // `u64` rather than `usize`, so a cache written on a 64-bit host is readable by a
138    // 32-bit one — the key would otherwise differ for no reason a user could see.
139    hasher.update(&(bytes.len() as u64).to_le_bytes());
140    hasher.update(bytes);
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn run() -> RunKey {
148        RunKey::new("0.1", 1, b"ruleset", b"config", &[grammar()])
149    }
150
151    fn grammar() -> GrammarKey {
152        GrammarKey {
153            id: "typescript".to_owned(),
154            abi: 15,
155        }
156    }
157
158    fn content(seed: u8) -> ContentHash {
159        ContentHash::new([seed; 32])
160    }
161
162    fn key_of(run: &RunKey, path: &str, seed: u8) -> CacheKey {
163        run.for_file(path, &content(seed))
164    }
165
166    #[test]
167    fn the_same_inputs_give_the_same_key() {
168        assert_eq!(key_of(&run(), "src/a.ts", 1), key_of(&run(), "src/a.ts", 1));
169    }
170
171    #[test]
172    fn changing_the_content_changes_the_key() {
173        assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&run(), "src/a.ts", 2));
174    }
175
176    #[test]
177    fn moving_a_file_changes_the_key() {
178        // Path gates make results path-sensitive, so identical bytes at a new path are not
179        // a hit — a rule restricted to `src/**` must not have its verdict follow the file
180        // into `test/**`.
181        assert_ne!(
182            key_of(&run(), "src/a.ts", 1),
183            key_of(&run(), "test/a.ts", 1)
184        );
185    }
186
187    #[test]
188    fn changing_the_ruleset_changes_the_key() {
189        let other = RunKey::new("0.1", 1, b"different", b"config", &[grammar()]);
190        assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
191    }
192
193    #[test]
194    fn changing_the_config_changes_the_key() {
195        let other = RunKey::new("0.1", 1, b"ruleset", b"different", &[grammar()]);
196        assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
197    }
198
199    #[test]
200    fn changing_the_engine_version_changes_the_key() {
201        let other = RunKey::new("0.2", 1, b"ruleset", b"config", &[grammar()]);
202        assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
203    }
204
205    #[test]
206    fn changing_the_host_api_version_changes_the_key() {
207        // A result computed without a host function is not a valid result for a run that
208        // has it: the rule could not have called something that did not exist.
209        let other = RunKey::new("0.1", 2, b"ruleset", b"config", &[grammar()]);
210        assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
211    }
212
213    #[test]
214    fn adding_a_grammar_changes_the_key() {
215        let more = RunKey::new(
216            "0.1",
217            1,
218            b"ruleset",
219            b"config",
220            &[
221                grammar(),
222                GrammarKey {
223                    id: "javascript".to_owned(),
224                    abi: 15,
225                },
226            ],
227        );
228        assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&more, "src/a.ts", 1));
229    }
230
231    #[test]
232    fn changing_the_grammar_abi_changes_the_key() {
233        // A grammar bump changes node shapes and therefore what a query matches.
234        let bumped = RunKey::new(
235            "0.1",
236            1,
237            b"ruleset",
238            b"config",
239            &[GrammarKey {
240                id: "typescript".to_owned(),
241                abi: 16,
242            }],
243        );
244        assert_ne!(
245            key_of(&run(), "src/a.ts", 1),
246            key_of(&bumped, "src/a.ts", 1)
247        );
248    }
249
250    #[test]
251    fn changing_the_language_changes_the_key() {
252        let other = RunKey::new(
253            "0.1",
254            1,
255            b"ruleset",
256            b"config",
257            &[GrammarKey {
258                id: "javascript".to_owned(),
259                abi: 15,
260            }],
261        );
262        assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
263    }
264
265    #[test]
266    fn fields_cannot_run_together() {
267        // The reason every field is length-prefixed. Without it `("ab", "c")` and
268        // `("a", "bc")` hash alike, and two genuinely different runs share a key — which is
269        // the one failure mode a cache must not have.
270        let one = RunKey::new("0.1", 1, b"ab", b"c", &[grammar()]);
271        let other = RunKey::new("0.1", 1, b"a", b"bc", &[grammar()]);
272        assert_ne!(key_of(&one, "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
273
274        // And on the per-file side: a path and a content digest must not be able to run
275        // together into the same byte sequence as a different pair.
276        assert_ne!(
277            run().for_file("src/ab.ts", &content(1)),
278            run().for_file("src/a", &content(1))
279        );
280    }
281
282    #[test]
283    fn a_dated_key_changes_with_the_date() {
284        // An expiring suppression served from a cache written yesterday would never expire.
285        let content = content(1);
286        assert_ne!(
287            run().for_dated_file("src/a.ts", &content, "2026-08-01"),
288            run().for_dated_file("src/a.ts", &content, "2026-08-02")
289        );
290    }
291
292    #[test]
293    fn a_dated_key_differs_from_an_undated_one() {
294        let content = content(1);
295        assert_ne!(
296            run().for_file("src/a.ts", &content),
297            run().for_dated_file("src/a.ts", &content, "2026-08-01")
298        );
299    }
300
301    #[test]
302    fn a_key_renders_short_for_diagnostics() {
303        let rendered = key_of(&run(), "src/a.ts", 1).to_string();
304        assert_eq!(rendered.len(), 8);
305        assert!(rendered.chars().all(|c| c.is_ascii_hexdigit()));
306    }
307}