1use rayon::prelude::*;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::thread;
15use std::time::SystemTime;
16
17use crate::graph::ModuleGraph;
18use crate::lang::ParseResult;
19
20const CACHE_FILE: &str = ".chainsaw.cache";
21const CACHE_VERSION: u32 = 7;
22const CACHE_MAGIC: u32 = 0x4348_5357; const HEADER_SIZE: usize = 16;
25
26pub fn cache_path(root: &Path) -> PathBuf {
27 root.join(CACHE_FILE)
28}
29
30fn mtime_of(meta: &fs::Metadata) -> Option<u128> {
31 meta.modified()
32 .ok()
33 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
34 .map(|d| d.as_nanos())
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
40struct CachedParse {
41 mtime_nanos: u128,
42 size: u64,
43 result: ParseResult,
44 resolved_paths: Vec<Option<PathBuf>>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
50struct CachedMtime {
51 mtime_nanos: u128,
52 size: u64,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56struct CachedGraph {
57 entry: PathBuf,
58 graph: ModuleGraph,
59 file_mtimes: HashMap<PathBuf, CachedMtime>,
60 unresolved_specifiers: Vec<String>,
61 unresolvable_dynamic: usize,
62 dep_sentinels: Vec<(PathBuf, u128)>,
64}
65
66const LOCKFILES: &[&str] = &[
67 "package-lock.json",
68 "pnpm-lock.yaml",
69 "yarn.lock",
70 "bun.lockb",
71 "poetry.lock",
72 "Pipfile.lock",
73 "uv.lock",
74 "requirements.txt",
75];
76
77fn find_dep_sentinels(root: &Path) -> Vec<(PathBuf, u128)> {
81 let mut dir = root.to_path_buf();
82 loop {
83 let sentinels: Vec<(PathBuf, u128)> = LOCKFILES
84 .iter()
85 .filter_map(|name| {
86 let path = dir.join(name);
87 let meta = fs::metadata(&path).ok()?;
88 let mtime = mtime_of(&meta)?;
89 Some((path, mtime))
90 })
91 .collect();
92 if !sentinels.is_empty() {
93 return sentinels;
94 }
95 if !dir.pop() {
96 return Vec::new();
97 }
98 }
99}
100
101#[derive(Debug)]
102#[non_exhaustive]
103pub enum GraphCacheResult {
104 Hit {
106 graph: ModuleGraph,
107 unresolvable_dynamic: usize,
108 unresolved_specifiers: Vec<String>,
109 needs_resave: bool,
111 },
112 Stale {
114 graph: ModuleGraph,
115 unresolvable_dynamic: usize,
116 changed_files: Vec<PathBuf>,
117 },
118 Miss,
120}
121
122#[derive(Debug)]
125#[repr(transparent)]
126pub struct CacheWriteHandle(Option<thread::JoinHandle<()>>);
127
128impl CacheWriteHandle {
129 pub const fn none() -> Self {
130 Self(None)
131 }
132
133 pub fn join(mut self) {
137 if let Some(handle) = self.0.take() {
138 let _ = handle.join();
139 }
140 }
141}
142
143impl Drop for CacheWriteHandle {
144 fn drop(&mut self) {
145 if let Some(handle) = self.0.take() {
146 let _ = handle.join();
147 }
148 }
149}
150
151#[derive(Debug)]
152pub struct ParseCache {
153 entries: HashMap<PathBuf, CachedParse>,
154 deferred_parse_data: Option<Vec<u8>>,
155 cached_graph: Option<CachedGraph>,
156 stale_file_mtimes: Option<HashMap<PathBuf, CachedMtime>>,
158 stale_unresolved: Option<Vec<String>>,
159}
160
161impl Default for ParseCache {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167impl ParseCache {
168 pub fn new() -> Self {
169 Self {
170 entries: HashMap::new(),
171 deferred_parse_data: None,
172 cached_graph: None,
173 stale_file_mtimes: None,
174 stale_unresolved: None,
175 }
176 }
177
178 #[allow(clippy::cast_possible_truncation)]
181 pub fn load(root: &Path) -> Self {
182 let path = cache_path(root);
183 let Ok(data) = fs::read(&path) else {
184 return Self::new();
185 };
186 if data.len() < HEADER_SIZE {
187 return Self::new();
188 }
189 let magic = u32::from_le_bytes(data[0..4].try_into().unwrap());
190 let version = u32::from_le_bytes(data[4..8].try_into().unwrap());
191 if magic != CACHE_MAGIC || version != CACHE_VERSION {
192 return Self::new();
193 }
194 let graph_len = u64::from_le_bytes(data[8..16].try_into().unwrap()) as usize;
195 let graph_end = HEADER_SIZE + graph_len;
196 if data.len() < graph_end {
197 return Self::new();
198 }
199
200 let cached_graph: Option<CachedGraph> =
201 bitcode::deserialize(&data[HEADER_SIZE..graph_end]).ok();
202
203 let deferred = if data.len() > graph_end {
204 Some(data[graph_end..].to_vec())
205 } else {
206 None
207 };
208
209 Self {
210 entries: HashMap::new(),
211 deferred_parse_data: deferred,
212 cached_graph,
213 stale_file_mtimes: None,
214 stale_unresolved: None,
215 }
216 }
217
218 fn ensure_entries(&mut self) {
219 if let Some(bytes) = self.deferred_parse_data.take() {
220 self.entries = bitcode::deserialize(&bytes).unwrap_or_default();
221 }
222 }
223
224 pub fn try_load_graph(
230 &mut self,
231 entry: &Path,
232 resolve_fn: &(dyn Fn(&str) -> bool + Sync),
233 ) -> GraphCacheResult {
234 let cached = match self.cached_graph.as_ref() {
235 Some(c) if c.entry == entry => c,
236 _ => return GraphCacheResult::Miss,
237 };
238
239 let any_missing = AtomicBool::new(false);
242 let changed_files: Vec<PathBuf> = cached
243 .file_mtimes
244 .par_iter()
245 .filter_map(|(path, saved)| if let Ok(meta) = fs::metadata(path) {
246 let mtime = mtime_of(&meta)?;
247 if mtime != saved.mtime_nanos || meta.len() != saved.size {
248 Some(path.clone())
249 } else {
250 None
251 }
252 } else {
253 any_missing.store(true, Ordering::Relaxed);
254 None
255 })
256 .collect();
257
258 if any_missing.load(Ordering::Relaxed) {
259 return GraphCacheResult::Miss;
260 }
261
262 let sentinels_unchanged = !cached.dep_sentinels.is_empty()
267 && cached.dep_sentinels.iter().all(|(path, saved_mtime)| {
268 fs::metadata(path)
269 .ok()
270 .and_then(|m| mtime_of(&m))
271 .is_some_and(|t| t == *saved_mtime)
272 });
273
274 if !sentinels_unchanged {
275 let any_resolves = cached
276 .unresolved_specifiers
277 .par_iter()
278 .any(|spec| resolve_fn(spec));
279 if any_resolves {
280 return GraphCacheResult::Miss;
281 }
282 }
283
284 if changed_files.is_empty() {
285 let cached = self.cached_graph.take().unwrap();
286 return GraphCacheResult::Hit {
287 graph: cached.graph,
288 unresolvable_dynamic: cached.unresolvable_dynamic,
289 unresolved_specifiers: cached.unresolved_specifiers,
290 needs_resave: !sentinels_unchanged,
291 };
292 }
293
294 let cached = self.cached_graph.take().unwrap();
296 self.stale_file_mtimes = Some(cached.file_mtimes);
297 self.stale_unresolved = Some(cached.unresolved_specifiers);
298 GraphCacheResult::Stale {
299 graph: cached.graph,
300 unresolvable_dynamic: cached.unresolvable_dynamic,
301 changed_files,
302 }
303 }
304
305 pub fn lookup_unchecked(&mut self, path: &Path) -> Option<&ParseResult> {
308 self.ensure_entries();
309 self.entries.get(path).map(|e| &e.result)
310 }
311
312 pub fn save_incremental(
317 &mut self,
318 root: &Path,
319 entry: &Path,
320 graph: &ModuleGraph,
321 changed_files: &[PathBuf],
322 unresolvable_dynamic: usize,
323 ) -> CacheWriteHandle {
324 let Some(mut file_mtimes) = self.stale_file_mtimes.take() else {
325 return CacheWriteHandle::none();
326 };
327 let unresolved_specifiers = self.stale_unresolved.take().unwrap_or_default();
328
329 for path in changed_files {
331 if let Ok(meta) = fs::metadata(path)
332 && let Some(mtime) = mtime_of(&meta)
333 && let Some(saved) = file_mtimes.get_mut(path)
334 {
335 saved.mtime_nanos = mtime;
336 saved.size = meta.len();
337 }
338 }
339
340 self.ensure_entries();
341 let entries = std::mem::take(&mut self.entries);
342 let root = root.to_path_buf();
343 let entry = entry.to_path_buf();
344 let graph = graph.clone();
345 let dep_sentinels = find_dep_sentinels(&root);
346
347 CacheWriteHandle(Some(thread::spawn(move || {
348 write_cache_to_disk(root, entry, graph, entries, file_mtimes, unresolved_specifiers, unresolvable_dynamic, dep_sentinels);
349 })))
350 }
351
352 pub fn save(
355 &mut self,
356 root: &Path,
357 entry: &Path,
358 graph: &ModuleGraph,
359 unresolved_specifiers: Vec<String>,
360 unresolvable_dynamic: usize,
361 ) -> CacheWriteHandle {
362 self.ensure_entries();
363 let entries = std::mem::take(&mut self.entries);
364 let root = root.to_path_buf();
365 let entry = entry.to_path_buf();
366 let graph = graph.clone();
367
368 let dep_sentinels = find_dep_sentinels(&root);
369
370 CacheWriteHandle(Some(thread::spawn(move || {
371 let file_mtimes: HashMap<PathBuf, CachedMtime> = graph
372 .modules
373 .par_iter()
374 .filter_map(|m| {
375 let meta = fs::metadata(&m.path).ok()?;
376 let mtime = mtime_of(&meta)?;
377 Some((
378 m.path.clone(),
379 CachedMtime {
380 mtime_nanos: mtime,
381 size: meta.len(),
382 },
383 ))
384 })
385 .collect();
386
387 write_cache_to_disk(root, entry, graph, entries, file_mtimes, unresolved_specifiers, unresolvable_dynamic, dep_sentinels);
388 })))
389 }
390
391 pub fn lookup(&mut self, path: &Path) -> Option<(ParseResult, Vec<Option<PathBuf>>)> {
392 self.ensure_entries();
393 let entry = self.entries.get(path)?;
394 let meta = fs::metadata(path).ok()?;
395 let current_mtime = mtime_of(&meta)?;
396 if current_mtime == entry.mtime_nanos && meta.len() == entry.size {
397 Some((entry.result.clone(), entry.resolved_paths.clone()))
398 } else {
399 None
400 }
401 }
402
403 pub fn insert(
404 &mut self,
405 path: PathBuf,
406 size: u64,
407 mtime_nanos: u128,
408 result: ParseResult,
409 resolved_paths: Vec<Option<PathBuf>>,
410 ) {
411 self.ensure_entries();
412 self.entries.insert(
413 path,
414 CachedParse {
415 mtime_nanos,
416 size,
417 result,
418 resolved_paths,
419 },
420 );
421 }
422}
423
424#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
426fn write_cache_to_disk(
427 root: PathBuf,
428 entry: PathBuf,
429 graph: ModuleGraph,
430 entries: HashMap<PathBuf, CachedParse>,
431 file_mtimes: HashMap<PathBuf, CachedMtime>,
432 unresolved_specifiers: Vec<String>,
433 unresolvable_dynamic: usize,
434 dep_sentinels: Vec<(PathBuf, u128)>,
435) {
436 let graph_cache = CachedGraph {
437 entry,
438 graph,
439 file_mtimes,
440 unresolved_specifiers,
441 unresolvable_dynamic,
442 dep_sentinels,
443 };
444
445 let graph_data = match bitcode::serialize(&graph_cache) {
446 Ok(d) => d,
447 Err(e) => {
448 eprintln!("warning: failed to serialize graph cache: {e}");
449 return;
450 }
451 };
452 let parse_data = match bitcode::serialize(&entries) {
453 Ok(d) => d,
454 Err(e) => {
455 eprintln!("warning: failed to serialize parse cache: {e}");
456 return;
457 }
458 };
459
460 let mut out = Vec::with_capacity(HEADER_SIZE + graph_data.len() + parse_data.len());
461 out.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
462 out.extend_from_slice(&CACHE_VERSION.to_le_bytes());
463 out.extend_from_slice(&(graph_data.len() as u64).to_le_bytes());
464 out.extend_from_slice(&graph_data);
465 out.extend_from_slice(&parse_data);
466
467 if let Err(e) = fs::write(cache_path(&root), &out) {
468 eprintln!("warning: failed to write cache: {e}");
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::graph::EdgeKind;
476 use crate::lang::RawImport;
477
478 fn insert_with_stat(
480 cache: &mut ParseCache,
481 path: PathBuf,
482 result: ParseResult,
483 resolved: Vec<Option<PathBuf>>,
484 ) {
485 let meta = fs::metadata(&path).unwrap();
486 let mtime = mtime_of(&meta).unwrap();
487 cache.insert(path, meta.len(), mtime, result, resolved);
488 }
489
490 #[test]
491 fn parse_cache_hit_when_unchanged() {
492 let tmp = tempfile::tempdir().unwrap();
493 let root = tmp.path().canonicalize().unwrap();
494 let file = root.join("test.py");
495 fs::write(&file, "import os").unwrap();
496
497 let mut cache = ParseCache::new();
498 let result = ParseResult {
499 imports: vec![RawImport {
500 specifier: "os".into(),
501 kind: EdgeKind::Static,
502 }],
503 unresolvable_dynamic: 0,
504 };
505 let resolved = vec![None];
506 insert_with_stat(&mut cache, file.clone(), result, resolved);
507
508 let cached = cache.lookup(&file);
509 assert!(cached.is_some());
510 let (parse_result, resolved_paths) = cached.unwrap();
511 assert_eq!(parse_result.imports.len(), 1);
512 assert_eq!(resolved_paths.len(), 1);
513 assert!(resolved_paths[0].is_none());
514 }
515
516 #[test]
517 fn parse_cache_miss_when_modified() {
518 let tmp = tempfile::tempdir().unwrap();
519 let root = tmp.path().canonicalize().unwrap();
520 let file = root.join("test.py");
521 fs::write(&file, "import os").unwrap();
522
523 let mut cache = ParseCache::new();
524 let result = ParseResult {
525 imports: vec![],
526 unresolvable_dynamic: 0,
527 };
528 insert_with_stat(&mut cache, file.clone(), result, vec![]);
529
530 fs::write(&file, "import os\nimport sys").unwrap();
531
532 assert!(cache.lookup(&file).is_none());
533 }
534
535 #[test]
536 fn parse_cache_save_and_load_roundtrip() {
537 let tmp = tempfile::tempdir().unwrap();
538 let root = tmp.path().canonicalize().unwrap();
539 let file = root.join("test.py");
540 let target = root.join("os_impl.py");
541 fs::write(&file, "import os").unwrap();
542 fs::write(&target, "").unwrap();
543
544 let mut cache = ParseCache::new();
545 let result = ParseResult {
546 imports: vec![RawImport {
547 specifier: "os".into(),
548 kind: EdgeKind::Static,
549 }],
550 unresolvable_dynamic: 1,
551 };
552 let resolved = vec![Some(target.clone())];
553 insert_with_stat(&mut cache, file.clone(), result, resolved);
554
555 let graph = ModuleGraph::new();
556 drop(cache.save(&root, &file, &graph, vec![], 0));
557
558 let mut loaded = ParseCache::load(&root);
559 let cached = loaded.lookup(&file);
560 assert!(cached.is_some());
561 let (parse_result, resolved_paths) = cached.unwrap();
562 assert_eq!(parse_result.imports.len(), 1);
563 assert_eq!(parse_result.imports[0].specifier, "os");
564 assert_eq!(parse_result.unresolvable_dynamic, 1);
565 assert_eq!(resolved_paths.len(), 1);
566 assert_eq!(resolved_paths[0], Some(target));
567 }
568
569 #[test]
570 fn graph_cache_valid_when_unchanged() {
571 let tmp = tempfile::tempdir().unwrap();
572 let root = tmp.path().canonicalize().unwrap();
573 let file = root.join("entry.py");
574 fs::write(&file, "x = 1").unwrap();
575
576 let mut graph = ModuleGraph::new();
577 let size = fs::metadata(&file).unwrap().len();
578 graph.add_module(file.clone(), size, None);
579
580 let mut cache = ParseCache::new();
581 drop(cache.save(&root, &file, &graph, vec!["os".into()], 2));
582
583 let mut loaded = ParseCache::load(&root);
584 let resolve_fn = |_: &str| false;
585 let result = loaded.try_load_graph(&file, &resolve_fn);
586 assert!(matches!(result, GraphCacheResult::Hit { .. }));
587 if let GraphCacheResult::Hit { graph: g, unresolvable_dynamic: unresolvable, .. } = result {
588 assert_eq!(g.module_count(), 1);
589 assert_eq!(unresolvable, 2);
590 }
591 }
592
593 #[test]
594 fn graph_cache_stale_when_file_modified() {
595 let tmp = tempfile::tempdir().unwrap();
596 let root = tmp.path().canonicalize().unwrap();
597 let file = root.join("entry.py");
598 fs::write(&file, "x = 1").unwrap();
599
600 let mut graph = ModuleGraph::new();
601 let size = fs::metadata(&file).unwrap().len();
602 graph.add_module(file.clone(), size, None);
603
604 let mut cache = ParseCache::new();
605 drop(cache.save(&root, &file, &graph, vec![], 0));
606
607 fs::write(&file, "x = 2; y = 3").unwrap();
608
609 let mut loaded = ParseCache::load(&root);
610 let resolve_fn = |_: &str| false;
611 let result = loaded.try_load_graph(&file, &resolve_fn);
612 assert!(matches!(result, GraphCacheResult::Stale { .. }));
613 if let GraphCacheResult::Stale { changed_files, .. } = result {
614 assert_eq!(changed_files.len(), 1);
615 assert_eq!(changed_files[0], file);
616 }
617 }
618
619 #[test]
620 fn graph_cache_invalidates_when_unresolved_import_resolves() {
621 let tmp = tempfile::tempdir().unwrap();
622 let root = tmp.path().canonicalize().unwrap();
623 let file = root.join("entry.py");
624 fs::write(&file, "import foo").unwrap();
625
626 let mut graph = ModuleGraph::new();
627 let size = fs::metadata(&file).unwrap().len();
628 graph.add_module(file.clone(), size, None);
629
630 let mut cache = ParseCache::new();
631 drop(cache.save(&root, &file, &graph, vec!["foo".into()], 0));
632
633 let mut loaded = ParseCache::load(&root);
634 let resolve_fn = |spec: &str| spec == "foo";
635 assert!(matches!(
636 loaded.try_load_graph(&file, &resolve_fn),
637 GraphCacheResult::Miss
638 ));
639 }
640
641 #[test]
642 fn graph_cache_invalidates_for_different_entry() {
643 let tmp = tempfile::tempdir().unwrap();
644 let root = tmp.path().canonicalize().unwrap();
645 let file_a = root.join("a.py");
646 let file_b = root.join("b.py");
647 fs::write(&file_a, "x = 1").unwrap();
648 fs::write(&file_b, "y = 2").unwrap();
649
650 let mut graph = ModuleGraph::new();
651 let size = fs::metadata(&file_a).unwrap().len();
652 graph.add_module(file_a.clone(), size, None);
653
654 let mut cache = ParseCache::new();
655 drop(cache.save(&root, &file_a, &graph, vec![], 0));
656
657 let mut loaded = ParseCache::load(&root);
658 let resolve_fn = |_: &str| false;
659 assert!(matches!(
660 loaded.try_load_graph(&file_b, &resolve_fn),
661 GraphCacheResult::Miss
662 ));
663 }
664
665 #[test]
666 fn incremental_save_updates_changed_mtimes() {
667 let tmp = tempfile::tempdir().unwrap();
668 let root = tmp.path().canonicalize().unwrap();
669 let file = root.join("entry.py");
670 fs::write(&file, "x = 1").unwrap();
671
672 let mut graph = ModuleGraph::new();
673 let size = fs::metadata(&file).unwrap().len();
674 graph.add_module(file.clone(), size, None);
675
676 let mut cache = ParseCache::new();
677 drop(cache.save(&root, &file, &graph, vec![], 0));
678
679 fs::write(&file, "x = 2").unwrap();
681
682 let mut loaded = ParseCache::load(&root);
683 let resolve_fn = |_: &str| false;
684 let result = loaded.try_load_graph(&file, &resolve_fn);
685 assert!(matches!(result, GraphCacheResult::Stale { .. }));
686
687 if let GraphCacheResult::Stale { graph, changed_files, .. } = result {
688 drop(loaded.save_incremental(&root, &file, &graph, &changed_files, 0));
690
691 let mut reloaded = ParseCache::load(&root);
693 let result = reloaded.try_load_graph(&file, &resolve_fn);
694 assert!(
695 matches!(result, GraphCacheResult::Hit { .. }),
696 "expected Hit after incremental save"
697 );
698 }
699 }
700
701 #[test]
702 fn lockfile_sentinel_walks_up_to_workspace_root() {
703 let tmp = tempfile::tempdir().unwrap();
704 let workspace = tmp.path().canonicalize().unwrap();
705 let pkg = workspace.join("packages").join("app");
706 fs::create_dir_all(&pkg).unwrap();
707
708 fs::write(workspace.join("pnpm-lock.yaml"), "lockfileVersion: 9").unwrap();
710 let file = pkg.join("entry.ts");
711 fs::write(&file, "x = 1").unwrap();
712
713 let mut graph = ModuleGraph::new();
714 let size = fs::metadata(&file).unwrap().len();
715 graph.add_module(file.clone(), size, None);
716
717 let mut cache = ParseCache::new();
719 drop(cache.save(&pkg, &file, &graph, vec![], 0));
720
721 let mut loaded = ParseCache::load(&pkg);
723 let resolve_fn = |_: &str| false;
724 let result = loaded.try_load_graph(&file, &resolve_fn);
725 match result {
726 GraphCacheResult::Hit { needs_resave, .. } => {
727 assert!(!needs_resave, "sentinels should match — no resave needed");
728 }
729 other => panic!("expected Hit, got {other:?}"),
730 }
731 }
732}