Skip to main content

quarb_code/
ast_cache.rs

1//! Persistent per-file AST cache for [`CodeAdapter`](crate::CodeAdapter):
2//! content-addressed, grammar-fingerprint-gated.
3//!
4//! On a cache HIT, [`load`] rebuilds the exact `Vec<Node>` a fresh
5//! `build()` would produce and the caller skips tree-sitter; on a
6//! MISS, the caller parses and [`store`] writes the arbor. The
7//! design commitment: a hit either reconstructs a byte-identical
8//! arbor or returns `None`. There is no "usually right" — any
9//! header mismatch, footer mismatch, structural anomaly, short
10//! read, or interning failure yields `None`, and the caller
11//! reparses (correct, just not fast). So a corrupt, truncated, or
12//! stale cache file can never produce a wrong answer.
13//!
14//! ## Format (little-endian; body varints are ULEB128)
15//!
16//! ```text
17//! [124-byte header] [kind dict] [field dict] [N node records] [8-byte FNV footer]
18//! ```
19//!
20//! The header carries a full grammar fingerprint — the tree-sitter
21//! ABI version, the node-kind / field / parse-state counts, and a
22//! SHA-256 digest of the entire id↔string tables — so any grammar
23//! change that could alter parse output invalidates every entry
24//! (the file lives under a digest-named directory too, so stale
25//! entries are simply never looked up). The key is SHA-256 of the
26//! source bytes; collision safety rests on those 256 bits, and the
27//! FNV footer only guards against accidental corruption.
28//!
29//! The on-disk node record is seven ULEB128 varints, all
30//! non-negative by construction (parent always precedes child;
31//! pre-order start bytes and rows are monotone). `children` and
32//! `fields` are NOT stored — they are rebuilt on load by replaying
33//! `build()`'s exact linking step, which is why the reconstruction
34//! is provably identical.
35
36use std::collections::HashMap;
37use std::io::Write;
38use std::path::PathBuf;
39use std::rc::Rc;
40
41use quarb::NodeId;
42use tree_sitter::Language;
43
44use crate::Node;
45
46const MAGIC: &[u8; 8] = b"QUARBAST";
47/// Bump on any change to the on-disk layout — and also whenever a
48/// release bumps a grammar dependency: builds without a reachable
49/// Cargo.lock (`cargo install`) stamp the fingerprint "nolock", so
50/// the version bump here is what re-keys their cache across a
51/// scanner-only grammar upgrade the runtime tables cannot see.
52const FORMAT_VERSION: u16 = 1;
53const HEADER_LEN: usize = 124;
54
55/// A cache rooted at a directory. Cheap to clone (a `PathBuf`).
56#[derive(Clone)]
57pub struct Cache {
58    root: PathBuf,
59}
60
61impl Cache {
62    pub fn new(root: PathBuf) -> Self {
63        Cache { root }
64    }
65
66    /// `$QUARB_CACHE_DIR`, else `~/.quarb/cache`.
67    pub fn default_dir() -> PathBuf {
68        if let Some(d) = std::env::var_os("QUARB_CACHE_DIR") {
69            return PathBuf::from(d);
70        }
71        let home = std::env::var_os("HOME")
72            .map(PathBuf::from)
73            .unwrap_or_else(|| PathBuf::from("."));
74        home.join(".quarb").join("cache")
75    }
76}
77
78// ---------------------------------------------------------------------------
79// Grammar fingerprint, memoized per language per process.
80// ---------------------------------------------------------------------------
81
82struct Meta {
83    abi: u32,
84    kind_count: u32,
85    field_count: u32,
86    state_count: u32,
87    digest: [u8; 32],
88}
89
90thread_local! {
91    static META: std::cell::RefCell<HashMap<u16, Rc<Meta>>> =
92        std::cell::RefCell::new(HashMap::new());
93}
94
95/// The grammar fingerprint for `tag`/`lang` — hashing the full
96/// node-kind and field id↔string tables so any renaming or
97/// renumbering changes the digest. Memoized: computed once per
98/// grammar per process, not per file.
99fn meta(tag: u16, lang: &Language) -> Rc<Meta> {
100    if let Some(m) = META.with(|m| m.borrow().get(&tag).cloned()) {
101        return m;
102    }
103    let m = Rc::new(Meta {
104        abi: lang.version() as u32,
105        kind_count: lang.node_kind_count() as u32,
106        field_count: lang.field_count() as u32,
107        state_count: lang.parse_state_count() as u32,
108        digest: grammar_digest(env!("QUARB_GRAMMAR_STAMP"), env!("CARGO_PKG_VERSION"), lang),
109    });
110    META.with(|mm| mm.borrow_mut().insert(tag, m.clone()));
111    m
112}
113
114/// SHA-256 of the grammar fingerprint: the crate/grammar version
115/// `stamp` and `crate_ver` (which cover the external scanner that
116/// the runtime tables cannot), then the full node-kind and field
117/// id↔string tables. Any renaming, renumbering, or grammar-crate
118/// upgrade changes this digest, so a stale cache is never accepted.
119fn grammar_digest(stamp: &str, crate_ver: &str, lang: &Language) -> [u8; 32] {
120    let mut buf = Vec::new();
121    for s in [stamp, crate_ver] {
122        buf.extend((s.len() as u32).to_le_bytes());
123        buf.extend_from_slice(s.as_bytes());
124    }
125    let kc = lang.node_kind_count();
126    buf.extend((kc as u32).to_le_bytes());
127    for id in 0..kc as u16 {
128        if let Some(s) = lang.node_kind_for_id(id) {
129            buf.extend(id.to_le_bytes());
130            buf.push(lang.node_kind_is_named(id) as u8);
131            buf.extend((s.len() as u32).to_le_bytes());
132            buf.extend_from_slice(s.as_bytes());
133        }
134    }
135    let fc = lang.field_count();
136    buf.extend((fc as u32).to_le_bytes());
137    // Field ids are 1-based (NonZeroU16).
138    for id in 1..=fc as u16 {
139        if let Some(s) = lang.field_name_for_id(id) {
140            buf.extend(id.to_le_bytes());
141            buf.extend((s.len() as u32).to_le_bytes());
142            buf.extend_from_slice(s.as_bytes());
143        }
144    }
145    quarb::sha256(&buf)
146}
147
148/// The grammar tag for a file extension (mirrors [`crate::language`]).
149/// 0 = unsupported.
150pub(crate) fn lang_tag(ext: &str) -> u16 {
151    match ext {
152        "rs" => 1,
153        "py" => 2,
154        "js" | "mjs" | "cjs" | "jsx" => 3,
155        "c" | "h" => 4,
156        _ => 0,
157    }
158}
159
160fn lang_name(tag: u16) -> &'static str {
161    match tag {
162        1 => "rust",
163        2 => "python",
164        3 => "javascript",
165        4 => "c",
166        _ => "unknown",
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Primitives.
172// ---------------------------------------------------------------------------
173
174fn write_uleb(out: &mut Vec<u8>, mut v: u64) {
175    loop {
176        let b = (v & 0x7f) as u8;
177        v >>= 7;
178        if v == 0 {
179            out.push(b);
180            return;
181        }
182        out.push(b | 0x80);
183    }
184}
185
186/// Read a ULEB128 from `buf` at `*pos`, advancing it. `None` on a
187/// truncated or over-long (corrupt) encoding — never panics.
188fn read_uleb(buf: &[u8], pos: &mut usize) -> Option<u64> {
189    let mut v = 0u64;
190    let mut shift = 0u32;
191    loop {
192        let b = *buf.get(*pos)?;
193        *pos += 1;
194        if shift >= 64 {
195            return None; // more than 10 groups: corrupt
196        }
197        v |= ((b & 0x7f) as u64) << shift;
198        if b & 0x80 == 0 {
199            return Some(v);
200        }
201        shift += 7;
202    }
203}
204
205fn fnv1a(data: &[u8]) -> u64 {
206    let mut h = 0xcbf2_9ce4_8422_2325u64;
207    for &b in data {
208        h ^= b as u64;
209        h = h.wrapping_mul(0x0000_0100_0000_01b3);
210    }
211    h
212}
213
214fn hex(bytes: &[u8]) -> String {
215    let mut s = String::with_capacity(bytes.len() * 2);
216    for b in bytes {
217        s.push_str(&format!("{b:02x}"));
218    }
219    s
220}
221
222/// `<root>/ast/v<FMT>/<lang>-<digest8>/<hh>/<hex>.qast` — the format
223/// generation, grammar-build, and 256-way content shard are all in
224/// the path, so a version or grammar change can't even collide.
225fn path_for(cache: &Cache, tag: u16, m: &Meta, content_hex: &str) -> PathBuf {
226    let dig8: String = hex(&m.digest).chars().take(8).collect();
227    cache
228        .root
229        .join("ast")
230        .join(format!("v{FORMAT_VERSION}"))
231        .join(format!("{}-{}", lang_name(tag), dig8))
232        .join(&content_hex[..2])
233        .join(format!("{content_hex}.qast"))
234}
235
236// ---------------------------------------------------------------------------
237// Encode / decode.
238// ---------------------------------------------------------------------------
239
240/// Serialize `nodes` to the cache format. `None` if any node's kind
241/// or field string fails to round-trip through the grammar's
242/// id↔string tables (aliasing / unexpected symbol) — the caller
243/// then simply does not cache this file.
244fn encode(
245    nodes: &[Node],
246    tag: u16,
247    lang: &Language,
248    m: &Meta,
249    content_hash: &[u8; 32],
250    content_len: u64,
251) -> Option<Vec<u8>> {
252    let mut kind_ids: Vec<u16> = Vec::new();
253    let mut kind_map: HashMap<u16, u32> = HashMap::new();
254    let mut field_ids: Vec<u16> = Vec::new();
255    let mut field_map: HashMap<u16, u32> = HashMap::new();
256    let mut recs: Vec<(u32, u32)> = Vec::with_capacity(nodes.len());
257
258    for n in nodes {
259        // All interned nodes are named; the round-trip verify makes
260        // the `named = true` assumption self-checking.
261        let gid = lang.id_for_node_kind(n.kind, true);
262        if lang.node_kind_for_id(gid) != Some(n.kind) {
263            return None;
264        }
265        let kidx = *kind_map.entry(gid).or_insert_with(|| {
266            let i = kind_ids.len() as u32;
267            kind_ids.push(gid);
268            i
269        });
270        let fidx1 = match n.field {
271            None => 0u32,
272            Some(f) => {
273                let fid = lang.field_id_for_name(f)?.get();
274                if lang.field_name_for_id(fid) != Some(f) {
275                    return None;
276                }
277                let i = *field_map.entry(fid).or_insert_with(|| {
278                    let i = field_ids.len() as u32;
279                    field_ids.push(fid);
280                    i
281                });
282                i + 1
283            }
284        };
285        recs.push((kidx, fidx1));
286    }
287
288    let mut out = Vec::with_capacity(HEADER_LEN + nodes.len() * 8 + 8);
289    out.extend_from_slice(MAGIC);
290    out.extend(FORMAT_VERSION.to_le_bytes());
291    out.extend(0u16.to_le_bytes()); // flags
292    out.extend(tag.to_le_bytes());
293    out.extend(0u16.to_le_bytes()); // pad
294    out.extend(m.abi.to_le_bytes());
295    out.extend(m.kind_count.to_le_bytes());
296    out.extend(m.field_count.to_le_bytes());
297    out.extend(m.state_count.to_le_bytes());
298    out.extend(0u32.to_le_bytes()); // reserved
299    out.extend(0u32.to_le_bytes()); // reserved
300    out.extend_from_slice(&m.digest);
301    out.extend_from_slice(content_hash);
302    out.extend(content_len.to_le_bytes());
303    out.extend((nodes.len() as u64).to_le_bytes());
304    out.extend((kind_ids.len() as u16).to_le_bytes());
305    out.extend((field_ids.len() as u16).to_le_bytes());
306    debug_assert_eq!(out.len(), HEADER_LEN);
307
308    for &id in &kind_ids {
309        write_uleb(&mut out, id as u64);
310    }
311    for &id in &field_ids {
312        write_uleb(&mut out, id as u64);
313    }
314
315    let mut prev_start = 0u64;
316    let mut prev_sline = 0u64;
317    for (i, n) in nodes.iter().enumerate() {
318        let (kidx, fidx1) = recs[i];
319        let parent_delta = match n.parent {
320            None => 0u64,
321            Some(p) => (i as u64).checked_sub(p.0)?, // parent precedes child
322        };
323        let start = n.start as u64;
324        let start_delta = start.checked_sub(prev_start)?;
325        prev_start = start;
326        let len = (n.end as u64).checked_sub(start)?;
327        let sline = n.start_line as u64;
328        let sline_delta = sline.checked_sub(prev_sline)?;
329        prev_sline = sline;
330        let espan = (n.end_line as u64).checked_sub(sline)?;
331
332        write_uleb(&mut out, parent_delta);
333        write_uleb(&mut out, kidx as u64);
334        write_uleb(&mut out, fidx1 as u64);
335        write_uleb(&mut out, start_delta);
336        write_uleb(&mut out, len);
337        write_uleb(&mut out, sline_delta);
338        write_uleb(&mut out, espan);
339    }
340
341    let f = fnv1a(&out);
342    out.extend(f.to_le_bytes());
343    Some(out)
344}
345
346/// Deserialize, validating every invariant against `lang` and
347/// `source`. `None` on any anomaly — the caller reparses.
348fn decode(bytes: &[u8], tag: u16, lang: &Language, source: &str) -> Option<Vec<Node>> {
349    if bytes.len() < HEADER_LEN + 8 {
350        return None;
351    }
352    let body_len = bytes.len() - 8;
353    let stored_footer = u64::from_le_bytes(bytes[body_len..].try_into().ok()?);
354    if fnv1a(&bytes[..body_len]) != stored_footer {
355        return None;
356    }
357    if &bytes[0..8] != MAGIC {
358        return None;
359    }
360    let rd_u16 = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]);
361    let rd_u32 = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
362    let rd_u64 = |o: usize| u64::from_le_bytes(bytes[o..o + 8].try_into().unwrap());
363    if rd_u16(8) != FORMAT_VERSION || rd_u16(12) != tag {
364        return None;
365    }
366    let m = meta(tag, lang);
367    if rd_u32(16) != m.abi
368        || rd_u32(20) != m.kind_count
369        || rd_u32(24) != m.field_count
370        || rd_u32(28) != m.state_count
371        || bytes[40..72] != m.digest
372    {
373        return None;
374    }
375    // The content is in hand (we read the file to key on it), so
376    // verify the stored hash and length against it — a defence
377    // against a wrong file landing at this path.
378    if bytes[72..104] != quarb::sha256(source.as_bytes()) {
379        return None;
380    }
381    if rd_u64(104) != source.len() as u64 {
382        return None;
383    }
384    let node_count = rd_u64(112) as usize;
385    let kdlen = rd_u16(120) as usize;
386    let fdlen = rd_u16(122) as usize;
387
388    let mut pos = HEADER_LEN;
389    let mut kind_dict: Vec<&'static str> = Vec::with_capacity(kdlen);
390    for _ in 0..kdlen {
391        let gid = read_uleb(bytes, &mut pos)?;
392        if gid > u16::MAX as u64 {
393            return None;
394        }
395        kind_dict.push(lang.node_kind_for_id(gid as u16)?);
396    }
397    let mut field_dict: Vec<&'static str> = Vec::with_capacity(fdlen);
398    for _ in 0..fdlen {
399        let gid = read_uleb(bytes, &mut pos)?;
400        if gid > u16::MAX as u64 {
401            return None;
402        }
403        field_dict.push(lang.field_name_for_id(gid as u16)?);
404    }
405
406    let src_len = source.len() as u64;
407    // node_count is attacker-controllable (a corrupt or poisoned
408    // header). Each node is ≥7 varint bytes, so the real count cannot
409    // exceed body_len/7; and each `Node` is ~128 bytes, so an
410    // unbounded reservation would amplify a large body ~18×. Cap by
411    // BOTH the logical bound and a modest absolute ceiling, so the
412    // reservation is ≤ ~128 MB no matter what the header claims — a
413    // lying count then just runs off the body and returns None below.
414    // The Vec still grows normally for a genuinely huge (real) file.
415    let reserve = node_count.min(body_len / 7).min(1 << 20);
416    let mut nodes: Vec<Node> = Vec::with_capacity(reserve);
417    let mut start = 0u64;
418    let mut sline = 0u64;
419    for i in 0..node_count {
420        let parent_delta = read_uleb(bytes, &mut pos)?;
421        let kidx = read_uleb(bytes, &mut pos)? as usize;
422        let fidx1 = read_uleb(bytes, &mut pos)?;
423        let start_delta = read_uleb(bytes, &mut pos)?;
424        let len = read_uleb(bytes, &mut pos)?;
425        let sline_delta = read_uleb(bytes, &mut pos)?;
426        let espan = read_uleb(bytes, &mut pos)?;
427
428        let parent = if i == 0 {
429            if parent_delta != 0 {
430                return None;
431            }
432            None
433        } else {
434            if parent_delta == 0 || parent_delta > i as u64 {
435                return None;
436            }
437            Some(NodeId(i as u64 - parent_delta))
438        };
439        if kidx >= kdlen {
440            return None;
441        }
442        let field = if fidx1 == 0 {
443            None
444        } else {
445            let fi = (fidx1 - 1) as usize;
446            if fi >= fdlen {
447                return None;
448            }
449            Some(field_dict[fi])
450        };
451        start = start.checked_add(start_delta)?;
452        let end = start.checked_add(len)?;
453        if end > src_len {
454            return None;
455        }
456        sline = sline.checked_add(sline_delta)?;
457        let end_line = sline.checked_add(espan)?;
458
459        nodes.push(Node {
460            kind: kind_dict[kidx],
461            field,
462            parent,
463            children: Vec::new(),
464            start: start as usize,
465            end: end as usize,
466            start_line: sline as usize,
467            end_line: end_line as usize,
468            fields: Vec::new(),
469        });
470        // build()'s linking step, replayed exactly.
471        if let Some(p) = parent {
472            let pi = p.0 as usize;
473            let clen = nodes[pi].children.len();
474            if let Some(f) = field {
475                nodes[pi].fields.push((f, clen));
476            }
477            nodes[pi].children.push(NodeId(i as u64));
478        }
479    }
480    // Every byte between header and footer must be consumed exactly.
481    if pos != body_len {
482        return None;
483    }
484    Some(nodes)
485}
486
487// ---------------------------------------------------------------------------
488// Public store / load (file I/O).
489// ---------------------------------------------------------------------------
490
491/// Try to load a cached arbor for `source`. `None` on a miss or any
492/// validation failure. `tag` must be nonzero (a supported grammar).
493pub(crate) fn load(
494    cache: &Cache,
495    tag: u16,
496    lang: &Language,
497    content_hash: &[u8; 32],
498    source: &str,
499) -> Option<Vec<Node>> {
500    let m = meta(tag, lang);
501    let path = path_for(cache, tag, &m, &hex(content_hash));
502    let bytes = std::fs::read(&path).ok()?;
503    decode(&bytes, tag, lang, source)
504}
505
506/// Best-effort store. Silent on any failure (interning refusal,
507/// unwritable cache dir, races) — caching never breaks a query.
508pub(crate) fn store(
509    cache: &Cache,
510    nodes: &[Node],
511    tag: u16,
512    lang: &Language,
513    content_hash: &[u8; 32],
514    content_len: u64,
515) {
516    let m = meta(tag, lang);
517    let bytes = match encode(nodes, tag, lang, &m, content_hash, content_len) {
518        Some(b) => b,
519        None => return,
520    };
521    let path = path_for(cache, tag, &m, &hex(content_hash));
522    let Some(dir) = path.parent() else { return };
523    if std::fs::create_dir_all(dir).is_err() {
524        return;
525    }
526    // Write to a temp file in the same directory, then atomically
527    // rename into place. Concurrent writers produce byte-identical
528    // images, so last-writer-wins is benign; a reader sees either
529    // the old complete file or the new one, never a torn one.
530    if let Ok(mut tmp) = tempfile::NamedTempFile::new_in(dir)
531        && tmp.write_all(&bytes).is_ok()
532    {
533        let _ = tmp.persist(&path);
534    }
535}
536
537#[cfg(test)]
538mod tests;