amql_engine/
code_cache.rs1use 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
16struct CodeFileEntry {
18 root: CodeElement,
19 mtime: SystemTime,
20}
21
22pub struct CodeCache {
24 index: FxHashMap<RelativePath, CodeFileEntry>,
25 project_root: ProjectRoot,
26}
27
28impl CodeCache {
33 pub fn new(project_root: &Path) -> Self {
35 Self {
36 index: FxHashMap::default(),
37 project_root: ProjectRoot::from(project_root),
38 }
39 }
40
41 pub fn ensure_scope(&mut self, scope: &Scope, resolvers: &ResolverRegistry) {
44 let source_files = glob_source_files(&self.project_root, scope, resolvers);
45
46 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 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 }
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 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; }
105 }
106 Err(_) => {
107 self.index.remove(rel_path);
108 return true;
109 }
110 }
111 }
112
113 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 pub fn get(&self, rel_path: &RelativePath) -> Option<&CodeElement> {
134 self.index.get(rel_path).map(|e| &e.root)
135 }
136
137 #[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 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
159fn 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
175pub 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 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 let root = project_root();
260 let resolvers = make_resolvers();
261
262 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!(!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 let root = project_root();
291 let resolvers = make_resolvers();
292 let mut cache = CodeCache::new(&root);
293
294 cache.ensure_scope(&Scope::from("crates/amql-engine/src/"), &resolvers);
296
297 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}