Skip to main content

amql_engine/
code_cache.rs

1//! Per-file cache of parsed `CodeElement` trees with mtime invalidation.
2//!
3//! The code cache lazily parses source files within a given scope, caches
4//! results keyed by relative path, and invalidates entries when the file's
5//! mtime changes.
6
7use crate::resolver::{CodeElement, ResolverRegistry};
8use crate::types::{ProjectRoot, RelativePath, Scope};
9use rayon::prelude::*;
10use rustc_hash::FxHashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14use std::time::SystemTime;
15
16/// Cached parse result for a single source file.
17struct CodeFileEntry {
18    root: CodeElement,
19    mtime: SystemTime,
20}
21
22/// Per-file cache of parsed `CodeElement` trees with mtime invalidation.
23pub struct CodeCache {
24    index: FxHashMap<RelativePath, CodeFileEntry>,
25    project_root: ProjectRoot,
26}
27
28// Future: DependencyTracker hook — per-file mtime invalidation is sufficient
29// for now. Disjoint-set tracking becomes relevant when resolvers follow
30// cross-file dependencies (e.g. TypeScript re-exports).
31
32impl CodeCache {
33    /// Create an empty cache rooted at the given project directory.
34    pub fn new(project_root: &Path) -> Self {
35        Self {
36            index: FxHashMap::default(),
37            project_root: ProjectRoot::from(project_root),
38        }
39    }
40
41    /// Lazily parse all code files within scope. Skips cached+fresh files.
42    /// Uses rayon for parallel parsing of stale/missing files.
43    pub fn ensure_scope(&mut self, scope: &Scope, resolvers: &ResolverRegistry) {
44        let source_files = glob_source_files(&self.project_root, scope, resolvers);
45
46        // Partition into files that need (re-)parsing
47        let project_root = &self.project_root;
48        let needs_parse: Vec<_> = source_files
49            .into_iter()
50            .filter(|abs_path| {
51                let rel = crate::paths::relative(project_root, abs_path);
52                if let Some(entry) = self.index.get(&*rel) {
53                    // Check mtime — skip if fresh
54                    match fs::metadata(abs_path) {
55                        Ok(meta) => {
56                            let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
57                            mtime > entry.mtime
58                        }
59                        Err(_) => true,
60                    }
61                } else {
62                    true // not cached
63                }
64            })
65            .collect();
66
67        if needs_parse.is_empty() {
68            return;
69        }
70
71        let parsed: Vec<_> = needs_parse
72            .par_iter()
73            .filter_map(|abs_path| {
74                let resolver = resolvers.get_for_file(abs_path)?;
75                let root = resolver.resolve(abs_path).ok()?;
76                let mtime = fs::metadata(abs_path)
77                    .and_then(|m| m.modified())
78                    .unwrap_or(SystemTime::UNIX_EPOCH);
79                let rel = crate::paths::relative(project_root, abs_path);
80                let root = relativize_source_files(root, &rel);
81                Some((rel, CodeFileEntry { root, mtime }))
82            })
83            .collect();
84
85        for (rel, entry) in parsed {
86            self.index.insert(rel, entry);
87        }
88    }
89
90    /// Check mtime and re-parse a single file if stale. Returns true if re-parsed.
91    pub fn refresh_if_stale(
92        &mut self,
93        rel_path: &RelativePath,
94        resolvers: &ResolverRegistry,
95    ) -> bool {
96        let abs_path = self.project_root.join(rel_path);
97
98        if let Some(entry) = self.index.get(rel_path) {
99            match fs::metadata(&abs_path) {
100                Ok(meta) => {
101                    let new_mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
102                    if new_mtime <= entry.mtime {
103                        return false; // still fresh
104                    }
105                }
106                Err(_) => {
107                    self.index.remove(rel_path);
108                    return true;
109                }
110            }
111        }
112
113        // Parse (or re-parse)
114        let resolver = match resolvers.get_for_file(&abs_path) {
115            Some(r) => r,
116            None => return false,
117        };
118        match resolver.resolve(&abs_path) {
119            Ok(root) => {
120                let mtime = fs::metadata(&abs_path)
121                    .and_then(|m| m.modified())
122                    .unwrap_or(SystemTime::UNIX_EPOCH);
123                let rel = rel_path.clone();
124                let root = relativize_source_files(root, &rel);
125                self.index.insert(rel, CodeFileEntry { root, mtime });
126                true
127            }
128            Err(_) => false,
129        }
130    }
131
132    /// Get cached code element tree for a file.
133    pub fn get(&self, rel_path: &RelativePath) -> Option<&CodeElement> {
134        self.index.get(rel_path).map(|e| &e.root)
135    }
136
137    /// Inject test data directly (for unit tests).
138    #[cfg(test)]
139    pub fn inject_test_data(&mut self, rel_path: &str, root: CodeElement) {
140        self.index.insert(
141            RelativePath::from(rel_path),
142            CodeFileEntry {
143                root,
144                mtime: SystemTime::now(),
145            },
146        );
147    }
148
149    /// Get all cached (rel_path, CodeElement) pairs within a scope prefix.
150    pub fn elements_in_scope(&self, scope: &Scope) -> Vec<(&RelativePath, &CodeElement)> {
151        self.index
152            .iter()
153            .filter(|(rel, _)| scope.is_empty() || rel.starts_with(&**scope))
154            .map(|(rel, entry)| (rel, &entry.root))
155            .collect()
156    }
157}
158
159/// Return a CodeElement tree with `source.file` set to the relative path.
160fn relativize_source_files(element: CodeElement, rel: &RelativePath) -> CodeElement {
161    CodeElement {
162        source: crate::resolver::SourceLocation {
163            file: rel.clone(),
164            ..element.source
165        },
166        children: element
167            .children
168            .into_iter()
169            .map(|c| relativize_source_files(c, rel))
170            .collect(),
171        ..element
172    }
173}
174
175/// Glob source files within a scope, filtered to resolver-supported extensions.
176/// Reuses `ignore::WalkBuilder` pattern from `glob_annotation_files`.
177pub fn glob_source_files(
178    project_root: &Path,
179    scope: &Scope,
180    resolvers: &ResolverRegistry,
181) -> Vec<PathBuf> {
182    let walk_root = if scope.is_empty() {
183        project_root.to_path_buf()
184    } else {
185        let candidate = project_root.join(&**scope);
186        if candidate.is_file() {
187            // Single file scope — just return it if resolver supports it
188            if resolvers.has_resolver_for(&candidate) {
189                return vec![candidate];
190            }
191            return vec![];
192        }
193        candidate
194    };
195
196    if !walk_root.exists() {
197        return vec![];
198    }
199
200    let results = Mutex::new(Vec::new());
201
202    let walker = ignore::WalkBuilder::new(&walk_root)
203        .hidden(false)
204        .git_ignore(true)
205        .git_global(true)
206        .git_exclude(true)
207        .filter_entry(|entry| {
208            let name = entry.file_name().to_string_lossy();
209            if entry.file_type().is_some_and(|ft| ft.is_dir()) {
210                return !crate::paths::SKIP_DIRS.contains(&name.as_ref());
211            }
212            true
213        })
214        .build_parallel();
215
216    walker.run(|| {
217        Box::new(|entry| {
218            if let Ok(entry) = entry {
219                let path = entry.path();
220                if path.is_file()
221                    && resolvers.has_resolver_for(path)
222                    && !path
223                        .file_name()
224                        .is_some_and(|n| n.to_string_lossy().ends_with(".aqm"))
225                {
226                    results.lock().unwrap().push(path.to_path_buf());
227                }
228            }
229            ignore::WalkState::Continue
230        })
231    });
232
233    results.into_inner().unwrap()
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::resolver::RustResolver;
240
241    fn project_root() -> PathBuf {
242        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
243            .parent()
244            .unwrap()
245            .parent()
246            .unwrap()
247            .to_path_buf()
248    }
249
250    fn make_resolvers() -> ResolverRegistry {
251        let mut reg = ResolverRegistry::new();
252        reg.register(Box::new(RustResolver));
253        reg
254    }
255
256    #[test]
257    fn globs_source_files() {
258        // Arrange
259        let root = project_root();
260        let resolvers = make_resolvers();
261
262        // Act
263        let dir_files =
264            glob_source_files(&root, &Scope::from("crates/amql-engine/src/"), &resolvers);
265        let single_file = glob_source_files(
266            &root,
267            &Scope::from("crates/amql-engine/src/selector.rs"),
268            &resolvers,
269        );
270        let all_files = glob_source_files(&root, &Scope::from(""), &resolvers);
271
272        // Assert
273        assert!(!dir_files.is_empty());
274        assert!(dir_files
275            .iter()
276            .all(|p| p.extension().is_some_and(|e| e == "rs")));
277
278        assert_eq!(single_file.len(), 1);
279        assert!(single_file[0].ends_with("selector.rs"));
280
281        assert!(
282            all_files.len() > 5,
283            "Should find many .rs files project-wide"
284        );
285    }
286
287    #[test]
288    fn caches_and_scopes() {
289        // Arrange
290        let root = project_root();
291        let resolvers = make_resolvers();
292        let mut cache = CodeCache::new(&root);
293
294        // Act
295        cache.ensure_scope(&Scope::from("crates/amql-engine/src/"), &resolvers);
296
297        // Assert
298        let selector = cache.get(&RelativePath::from("crates/amql-engine/src/selector.rs"));
299        assert!(selector.is_some());
300        assert_eq!(selector.unwrap().tag, "module");
301
302        let all = cache.elements_in_scope(&Scope::from("crates/amql-engine/src/"));
303        assert!(all.len() >= 5, "Should have multiple source files cached");
304
305        let selector_only = cache.elements_in_scope(&Scope::from("crates/amql-engine/src/selector"));
306        assert_eq!(selector_only.len(), 1);
307
308        let count1 = cache.elements_in_scope(&Scope::from("")).len();
309        cache.ensure_scope(&Scope::from("crates/amql-engine/src/"), &resolvers);
310        let count2 = cache.elements_in_scope(&Scope::from("")).len();
311        assert_eq!(count1, count2, "Second ensure_scope should be idempotent");
312    }
313}