Skip to main content

meta_ast/
cache.rs

1//! Extraction cache and BLAKE3 content fingerprinting.
2//!
3//! Re-parsing source code with tree-sitter on every edit is expensive. The
4//! cache keeps the previous `Arc<FileExtraction>` for every file. A BLAKE3
5//! fingerprint of the bytes decides whether the extraction is reused.
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use crate::model::FileExtraction;
11
12/// BLAKE3 cryptographic hash of a file's raw bytes.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct Fingerprint([u8; 32]);
15
16impl Fingerprint {
17    /// Compute the fingerprint of a byte slice.
18    pub fn of(bytes: &[u8]) -> Self {
19        Self(*blake3::hash(bytes).as_bytes())
20    }
21
22    /// Raw 32-byte hash.
23    pub fn as_bytes(&self) -> &[u8; 32] {
24        &self.0
25    }
26}
27
28/// Compute a deterministic 256-bit BLAKE3 fingerprint for content bytes.
29pub fn fingerprint(bytes: &[u8]) -> Fingerprint {
30    Fingerprint::of(bytes)
31}
32
33/// Cache of per-file extraction results keyed by path.
34///
35/// IDs inside the cached extractions stay valid. New extractions must start
36/// above [`ExtractionCache::max_symbol_id`].
37#[derive(Debug, Default)]
38pub struct ExtractionCache {
39    pub(crate) extractions: HashMap<PathBuf, Arc<FileExtraction>>,
40    pub(crate) fingerprints: HashMap<PathBuf, Fingerprint>,
41}
42
43/// Maximum raw ID in an iterator, or 0 when empty.
44///
45/// IDs grow monotonically and are never reused after deletion.
46fn max_raw(ids: impl Iterator<Item = u32>) -> u32 {
47    ids.max().unwrap_or(0)
48}
49
50impl ExtractionCache {
51    /// Create an empty cache.
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Cached extraction for a path.
57    pub fn get(&self, path: &Path) -> Option<&Arc<FileExtraction>> {
58        self.extractions.get(path)
59    }
60
61    /// Cached fingerprint for a path.
62    pub fn fingerprint_of(&self, path: &Path) -> Option<Fingerprint> {
63        self.fingerprints.get(path).copied()
64    }
65
66    /// Find the highest raw symbol ID allocated across all cached files.
67    pub fn max_symbol_id(&self) -> u32 {
68        max_raw(
69            self.extractions
70                .values()
71                .flat_map(|ext| ext.symbols.iter().map(|s| s.id.to_raw())),
72        )
73    }
74
75    /// Find the highest raw data node ID allocated across all cached files.
76    #[cfg(feature = "dataflow")]
77    pub fn max_data_node_id(&self) -> u32 {
78        max_raw(
79            self.extractions
80                .values()
81                .flat_map(|ext| ext.data_nodes.iter().map(|d| d.id.to_raw())),
82        )
83    }
84
85    /// Update or insert a file's fingerprint and shared extraction.
86    pub fn update(&mut self, path: PathBuf, fp: Fingerprint, extraction: Arc<FileExtraction>) {
87        self.fingerprints.insert(path.clone(), fp);
88        self.extractions.insert(path, extraction);
89    }
90
91    /// Remove a file from the extraction and fingerprint cache.
92    pub fn remove(&mut self, path: &Path) {
93        self.fingerprints.remove(path);
94        self.extractions.remove(path);
95    }
96
97    /// Number of cached files.
98    pub fn len(&self) -> usize {
99        self.extractions.len()
100    }
101
102    /// True when no file is cached.
103    pub fn is_empty(&self) -> bool {
104        self.extractions.is_empty()
105    }
106
107    /// Iterate over cached paths.
108    pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
109        self.extractions.keys()
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn blake3_fingerprint_is_deterministic() {
119        let content = b"fn main() { println!(\"hello\"); }";
120        let fp1 = fingerprint(content);
121        let fp2 = fingerprint(content);
122        assert_eq!(fp1, fp2);
123        assert_eq!(
124            blake3::hash(content).as_bytes(),
125            fp1.as_bytes(),
126            "Fingerprint must match BLAKE3 hash"
127        );
128    }
129
130    #[test]
131    fn cache_update_and_remove() {
132        let mut cache = ExtractionCache::new();
133        let path = PathBuf::from("foo.py");
134        let bytes = b"def foo(): pass\n";
135        let fp = fingerprint(bytes);
136        let mut base = FileExtraction::empty(path.clone(), crate::language::LangId::Python);
137        base.ast_node_count = 5;
138        let extraction = Arc::new(base);
139
140        cache.update(path.clone(), fp, Arc::clone(&extraction));
141        assert_eq!(cache.len(), 1);
142        assert_eq!(cache.fingerprint_of(&path), Some(fp));
143
144        let cached = cache.get(&path).unwrap();
145        assert!(Arc::ptr_eq(&extraction, cached));
146
147        cache.remove(&path);
148        assert!(cache.is_empty());
149        assert!(cache.fingerprint_of(&path).is_none());
150    }
151
152    #[test]
153    fn max_symbol_id_scans_cached_files() {
154        let mut cache = ExtractionCache::new();
155        assert_eq!(cache.max_symbol_id(), 0);
156        let path = PathBuf::from("a.py");
157        let mut file = FileExtraction::empty(path.clone(), crate::language::LangId::Python);
158        file.symbols.push(crate::model::Symbol {
159            id: crate::model::SymbolId::new(7).unwrap(),
160            name: "seven".to_string(),
161            kind: crate::model::SymbolKind::Function,
162            language: crate::language::LangId::Python,
163            file_path: path.clone(),
164            source_range: crate::model::SourceRange {
165                byte_start: 0,
166                byte_end: 1,
167                start: crate::model::LineColumn { line: 0, column: 0 },
168                end: crate::model::LineColumn { line: 0, column: 1 },
169            },
170            name_range: None,
171            visibility: None,
172            signature: None,
173            docstring: None,
174            is_async: false,
175        });
176        cache.update(path, fingerprint(b"x"), Arc::new(file));
177        assert_eq!(cache.max_symbol_id(), 7);
178    }
179}