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 = 8;
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 unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
64 dep_sentinels: Vec<(PathBuf, u128)>,
66}
67
68pub(crate) const LOCKFILES: &[&str] = &[
69 "package-lock.json",
70 "pnpm-lock.yaml",
71 "yarn.lock",
72 "bun.lockb",
73 "poetry.lock",
74 "Pipfile.lock",
75 "uv.lock",
76 "requirements.txt",
77];
78
79fn find_dep_sentinels(root: &Path) -> Vec<(PathBuf, u128)> {
83 let mut dir = root.to_path_buf();
84 loop {
85 let sentinels: Vec<(PathBuf, u128)> = LOCKFILES
86 .iter()
87 .filter_map(|name| {
88 let path = dir.join(name);
89 let meta = fs::metadata(&path).ok()?;
90 let mtime = mtime_of(&meta)?;
91 Some((path, mtime))
92 })
93 .collect();
94 if !sentinels.is_empty() {
95 return sentinels;
96 }
97 if !dir.pop() {
98 return Vec::new();
99 }
100 }
101}
102
103#[derive(Debug)]
104#[non_exhaustive]
105pub enum GraphCacheResult {
106 Hit {
108 graph: ModuleGraph,
109 unresolvable_dynamic: usize,
110 unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
111 unresolved_specifiers: Vec<String>,
112 needs_resave: bool,
114 },
115 Stale {
117 graph: ModuleGraph,
118 unresolvable_dynamic: usize,
119 unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
120 changed_files: Vec<PathBuf>,
121 },
122 Miss,
124}
125
126#[derive(Debug)]
129#[repr(transparent)]
130pub struct CacheWriteHandle(Option<thread::JoinHandle<()>>);
131
132impl CacheWriteHandle {
133 pub const fn none() -> Self {
134 Self(None)
135 }
136
137 pub fn join(mut self) {
141 if let Some(handle) = self.0.take() {
142 let _ = handle.join();
143 }
144 }
145}
146
147impl Drop for CacheWriteHandle {
148 fn drop(&mut self) {
149 if let Some(handle) = self.0.take() {
150 let _ = handle.join();
151 }
152 }
153}
154
155#[derive(Debug)]
156pub struct ParseCache {
157 entries: HashMap<PathBuf, CachedParse>,
158 deferred_parse_data: Option<Vec<u8>>,
159 cached_graph: Option<CachedGraph>,
160 stale_file_mtimes: Option<HashMap<PathBuf, CachedMtime>>,
162 stale_unresolved: Option<Vec<String>>,
163}
164
165impl Default for ParseCache {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171impl ParseCache {
172 pub fn new() -> Self {
173 Self {
174 entries: HashMap::new(),
175 deferred_parse_data: None,
176 cached_graph: None,
177 stale_file_mtimes: None,
178 stale_unresolved: None,
179 }
180 }
181
182 #[allow(clippy::cast_possible_truncation)]
185 pub fn load(root: &Path) -> Self {
186 let path = cache_path(root);
187 let Ok(data) = fs::read(&path) else {
188 return Self::new();
189 };
190 if data.len() < HEADER_SIZE {
191 return Self::new();
192 }
193 let magic = u32::from_le_bytes(data[0..4].try_into().expect("4-byte slice fits u32"));
194 let version = u32::from_le_bytes(data[4..8].try_into().expect("4-byte slice fits u32"));
195 if magic != CACHE_MAGIC || version != CACHE_VERSION {
196 return Self::new();
197 }
198 let graph_len =
199 u64::from_le_bytes(data[8..16].try_into().expect("8-byte slice fits u64")) as usize;
200 let graph_end = HEADER_SIZE + graph_len;
201 if data.len() < graph_end {
202 return Self::new();
203 }
204
205 let cached_graph: Option<CachedGraph> =
206 bitcode::deserialize(&data[HEADER_SIZE..graph_end]).ok();
207
208 let deferred = if data.len() > graph_end {
209 Some(data[graph_end..].to_vec())
210 } else {
211 None
212 };
213
214 Self {
215 entries: HashMap::new(),
216 deferred_parse_data: deferred,
217 cached_graph,
218 stale_file_mtimes: None,
219 stale_unresolved: None,
220 }
221 }
222
223 fn ensure_entries(&mut self) {
224 if let Some(bytes) = self.deferred_parse_data.take() {
225 self.entries = bitcode::deserialize(&bytes).unwrap_or_default();
226 }
227 }
228
229 pub fn try_load_graph(
235 &mut self,
236 entry: &Path,
237 resolve_fn: &(dyn Fn(&str) -> bool + Sync),
238 ) -> GraphCacheResult {
239 let cached = match self.cached_graph.as_ref() {
240 Some(c) if c.entry == entry => c,
241 _ => return GraphCacheResult::Miss,
242 };
243
244 let any_missing = AtomicBool::new(false);
247 let changed_files: Vec<PathBuf> = cached
248 .file_mtimes
249 .par_iter()
250 .filter_map(|(path, saved)| {
251 if let Ok(meta) = fs::metadata(path) {
252 let mtime = mtime_of(&meta)?;
253 if mtime != saved.mtime_nanos || meta.len() != saved.size {
254 Some(path.clone())
255 } else {
256 None
257 }
258 } else {
259 any_missing.store(true, Ordering::Relaxed);
260 None
261 }
262 })
263 .collect();
264
265 if any_missing.load(Ordering::Relaxed) {
266 return GraphCacheResult::Miss;
267 }
268
269 let sentinels_unchanged = !cached.dep_sentinels.is_empty()
274 && cached.dep_sentinels.iter().all(|(path, saved_mtime)| {
275 fs::metadata(path)
276 .ok()
277 .and_then(|m| mtime_of(&m))
278 .is_some_and(|t| t == *saved_mtime)
279 });
280
281 if !sentinels_unchanged {
282 let any_resolves = cached
283 .unresolved_specifiers
284 .par_iter()
285 .any(|spec| resolve_fn(spec));
286 if any_resolves {
287 return GraphCacheResult::Miss;
288 }
289 }
290
291 if changed_files.is_empty() {
292 let cached = self
293 .cached_graph
294 .take()
295 .expect("cached_graph populated by load");
296 return GraphCacheResult::Hit {
297 graph: cached.graph,
298 unresolvable_dynamic: cached.unresolvable_dynamic,
299 unresolvable_dynamic_files: cached.unresolvable_dynamic_files,
300 unresolved_specifiers: cached.unresolved_specifiers,
301 needs_resave: !sentinels_unchanged,
302 };
303 }
304
305 let cached = self
307 .cached_graph
308 .take()
309 .expect("cached_graph populated by load");
310 self.stale_file_mtimes = Some(cached.file_mtimes);
311 self.stale_unresolved = Some(cached.unresolved_specifiers);
312 GraphCacheResult::Stale {
313 graph: cached.graph,
314 unresolvable_dynamic: cached.unresolvable_dynamic,
315 unresolvable_dynamic_files: cached.unresolvable_dynamic_files,
316 changed_files,
317 }
318 }
319
320 pub fn lookup_unchecked(&mut self, path: &Path) -> Option<&ParseResult> {
323 self.ensure_entries();
324 self.entries.get(path).map(|e| &e.result)
325 }
326
327 pub fn save_incremental(
332 &mut self,
333 root: &Path,
334 entry: &Path,
335 graph: &ModuleGraph,
336 changed_files: &[PathBuf],
337 unresolvable_dynamic: usize,
338 unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
339 ) -> CacheWriteHandle {
340 let Some(mut file_mtimes) = self.stale_file_mtimes.take() else {
341 return CacheWriteHandle::none();
342 };
343 let unresolved_specifiers = self.stale_unresolved.take().unwrap_or_default();
344
345 for path in changed_files {
347 if let Ok(meta) = fs::metadata(path)
348 && let Some(mtime) = mtime_of(&meta)
349 && let Some(saved) = file_mtimes.get_mut(path)
350 {
351 saved.mtime_nanos = mtime;
352 saved.size = meta.len();
353 }
354 }
355
356 self.ensure_entries();
357 let entries = std::mem::take(&mut self.entries);
358 let root = root.to_path_buf();
359 let entry = entry.to_path_buf();
360 let graph = graph.clone();
361 let dep_sentinels = find_dep_sentinels(&root);
362
363 CacheWriteHandle(Some(thread::spawn(move || {
364 write_cache_to_disk(
365 root,
366 entry,
367 graph,
368 entries,
369 file_mtimes,
370 unresolved_specifiers,
371 unresolvable_dynamic,
372 unresolvable_dynamic_files,
373 dep_sentinels,
374 );
375 })))
376 }
377
378 pub fn save(
381 &mut self,
382 root: &Path,
383 entry: &Path,
384 graph: &ModuleGraph,
385 unresolved_specifiers: Vec<String>,
386 unresolvable_dynamic: usize,
387 unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
388 ) -> CacheWriteHandle {
389 self.ensure_entries();
390 let entries = std::mem::take(&mut self.entries);
391 let root = root.to_path_buf();
392 let entry = entry.to_path_buf();
393 let graph = graph.clone();
394
395 let dep_sentinels = find_dep_sentinels(&root);
396
397 CacheWriteHandle(Some(thread::spawn(move || {
398 let file_mtimes: HashMap<PathBuf, CachedMtime> = graph
399 .modules
400 .par_iter()
401 .filter_map(|m| {
402 let meta = fs::metadata(&m.path).ok()?;
403 let mtime = mtime_of(&meta)?;
404 Some((
405 m.path.clone(),
406 CachedMtime {
407 mtime_nanos: mtime,
408 size: meta.len(),
409 },
410 ))
411 })
412 .collect();
413
414 write_cache_to_disk(
415 root,
416 entry,
417 graph,
418 entries,
419 file_mtimes,
420 unresolved_specifiers,
421 unresolvable_dynamic,
422 unresolvable_dynamic_files,
423 dep_sentinels,
424 );
425 })))
426 }
427
428 pub fn lookup(&mut self, path: &Path) -> Option<(ParseResult, Vec<Option<PathBuf>>)> {
429 self.ensure_entries();
430 let entry = self.entries.get(path)?;
431 let meta = fs::metadata(path).ok()?;
432 let current_mtime = mtime_of(&meta)?;
433 if current_mtime == entry.mtime_nanos && meta.len() == entry.size {
434 Some((entry.result.clone(), entry.resolved_paths.clone()))
435 } else {
436 None
437 }
438 }
439
440 pub fn insert(
441 &mut self,
442 path: PathBuf,
443 size: u64,
444 mtime_nanos: u128,
445 result: ParseResult,
446 resolved_paths: Vec<Option<PathBuf>>,
447 ) {
448 self.ensure_entries();
449 self.entries.insert(
450 path,
451 CachedParse {
452 mtime_nanos,
453 size,
454 result,
455 resolved_paths,
456 },
457 );
458 }
459}
460
461#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
463fn write_cache_to_disk(
464 root: PathBuf,
465 entry: PathBuf,
466 graph: ModuleGraph,
467 entries: HashMap<PathBuf, CachedParse>,
468 file_mtimes: HashMap<PathBuf, CachedMtime>,
469 unresolved_specifiers: Vec<String>,
470 unresolvable_dynamic: usize,
471 unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
472 dep_sentinels: Vec<(PathBuf, u128)>,
473) {
474 let graph_cache = CachedGraph {
475 entry,
476 graph,
477 file_mtimes,
478 unresolved_specifiers,
479 unresolvable_dynamic,
480 unresolvable_dynamic_files,
481 dep_sentinels,
482 };
483
484 let graph_data = match bitcode::serialize(&graph_cache) {
485 Ok(d) => d,
486 Err(e) => {
487 eprintln!("warning: failed to serialize graph cache: {e}");
488 return;
489 }
490 };
491 let parse_data = match bitcode::serialize(&entries) {
492 Ok(d) => d,
493 Err(e) => {
494 eprintln!("warning: failed to serialize parse cache: {e}");
495 return;
496 }
497 };
498
499 let mut out = Vec::with_capacity(HEADER_SIZE + graph_data.len() + parse_data.len());
500 out.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
501 out.extend_from_slice(&CACHE_VERSION.to_le_bytes());
502 out.extend_from_slice(&(graph_data.len() as u64).to_le_bytes());
503 out.extend_from_slice(&graph_data);
504 out.extend_from_slice(&parse_data);
505
506 if let Err(e) = fs::write(cache_path(&root), &out) {
507 eprintln!("warning: failed to write cache: {e}");
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::graph::EdgeKind;
515 use crate::lang::RawImport;
516
517 fn insert_with_stat(
519 cache: &mut ParseCache,
520 path: PathBuf,
521 result: ParseResult,
522 resolved: Vec<Option<PathBuf>>,
523 ) {
524 let meta = fs::metadata(&path).unwrap();
525 let mtime = mtime_of(&meta).unwrap();
526 cache.insert(path, meta.len(), mtime, result, resolved);
527 }
528
529 #[test]
530 fn parse_cache_hit_when_unchanged() {
531 let tmp = tempfile::tempdir().unwrap();
532 let root = tmp.path().canonicalize().unwrap();
533 let file = root.join("test.py");
534 fs::write(&file, "import os").unwrap();
535
536 let mut cache = ParseCache::new();
537 let result = ParseResult {
538 imports: vec![RawImport {
539 specifier: "os".into(),
540 kind: EdgeKind::Static,
541 }],
542 unresolvable_dynamic: 0,
543 };
544 let resolved = vec![None];
545 insert_with_stat(&mut cache, file.clone(), result, resolved);
546
547 let cached = cache.lookup(&file);
548 assert!(cached.is_some());
549 let (parse_result, resolved_paths) = cached.unwrap();
550 assert_eq!(parse_result.imports.len(), 1);
551 assert_eq!(resolved_paths.len(), 1);
552 assert!(resolved_paths[0].is_none());
553 }
554
555 #[test]
556 fn parse_cache_miss_when_modified() {
557 let tmp = tempfile::tempdir().unwrap();
558 let root = tmp.path().canonicalize().unwrap();
559 let file = root.join("test.py");
560 fs::write(&file, "import os").unwrap();
561
562 let mut cache = ParseCache::new();
563 let result = ParseResult {
564 imports: vec![],
565 unresolvable_dynamic: 0,
566 };
567 insert_with_stat(&mut cache, file.clone(), result, vec![]);
568
569 fs::write(&file, "import os\nimport sys").unwrap();
570
571 assert!(cache.lookup(&file).is_none());
572 }
573
574 #[test]
575 fn parse_cache_save_and_load_roundtrip() {
576 let tmp = tempfile::tempdir().unwrap();
577 let root = tmp.path().canonicalize().unwrap();
578 let file = root.join("test.py");
579 let target = root.join("os_impl.py");
580 fs::write(&file, "import os").unwrap();
581 fs::write(&target, "").unwrap();
582
583 let mut cache = ParseCache::new();
584 let result = ParseResult {
585 imports: vec![RawImport {
586 specifier: "os".into(),
587 kind: EdgeKind::Static,
588 }],
589 unresolvable_dynamic: 1,
590 };
591 let resolved = vec![Some(target.clone())];
592 insert_with_stat(&mut cache, file.clone(), result, resolved);
593
594 let graph = ModuleGraph::new();
595 drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
596
597 let mut loaded = ParseCache::load(&root);
598 let cached = loaded.lookup(&file);
599 assert!(cached.is_some());
600 let (parse_result, resolved_paths) = cached.unwrap();
601 assert_eq!(parse_result.imports.len(), 1);
602 assert_eq!(parse_result.imports[0].specifier, "os");
603 assert_eq!(parse_result.unresolvable_dynamic, 1);
604 assert_eq!(resolved_paths.len(), 1);
605 assert_eq!(resolved_paths[0], Some(target));
606 }
607
608 #[test]
609 fn graph_cache_valid_when_unchanged() {
610 let tmp = tempfile::tempdir().unwrap();
611 let root = tmp.path().canonicalize().unwrap();
612 let file = root.join("entry.py");
613 fs::write(&file, "x = 1").unwrap();
614
615 let mut graph = ModuleGraph::new();
616 let size = fs::metadata(&file).unwrap().len();
617 graph.add_module(file.clone(), size, None);
618
619 let mut cache = ParseCache::new();
620 drop(cache.save(&root, &file, &graph, vec!["os".into()], 2, vec![]));
621
622 let mut loaded = ParseCache::load(&root);
623 let resolve_fn = |_: &str| false;
624 let result = loaded.try_load_graph(&file, &resolve_fn);
625 assert!(matches!(result, GraphCacheResult::Hit { .. }));
626 if let GraphCacheResult::Hit {
627 graph: g,
628 unresolvable_dynamic: unresolvable,
629 ..
630 } = result
631 {
632 assert_eq!(g.module_count(), 1);
633 assert_eq!(unresolvable, 2);
634 }
635 }
636
637 #[test]
638 fn graph_cache_preserves_per_file_unresolvable_dynamic() {
639 let tmp = tempfile::tempdir().unwrap();
640 let root = tmp.path().canonicalize().unwrap();
641 let file_a = root.join("a.py");
642 let file_b = root.join("b.py");
643 fs::write(&file_a, "import x").unwrap();
644 fs::write(&file_b, "import y").unwrap();
645
646 let mut graph = ModuleGraph::new();
647 let size_a = fs::metadata(&file_a).unwrap().len();
648 let size_b = fs::metadata(&file_b).unwrap().len();
649 graph.add_module(file_a.clone(), size_a, None);
650 graph.add_module(file_b.clone(), size_b, None);
651
652 let dynamic_files = vec![(file_a.clone(), 3), (file_b.clone(), 2)];
653 let mut cache = ParseCache::new();
654 drop(cache.save(&root, &file_a, &graph, vec![], 5, dynamic_files));
655
656 let mut loaded = ParseCache::load(&root);
657 let resolve_fn = |_: &str| false;
658 let result = loaded.try_load_graph(&file_a, &resolve_fn);
659 if let GraphCacheResult::Hit {
660 unresolvable_dynamic,
661 unresolvable_dynamic_files,
662 ..
663 } = result
664 {
665 assert_eq!(unresolvable_dynamic, 5);
666 assert_eq!(unresolvable_dynamic_files.len(), 2);
667 assert!(unresolvable_dynamic_files.contains(&(file_a, 3)));
668 assert!(unresolvable_dynamic_files.contains(&(file_b, 2)));
669 } else {
670 panic!("expected Hit, got {result:?}");
671 }
672 }
673
674 #[test]
675 fn graph_cache_stale_when_file_modified() {
676 let tmp = tempfile::tempdir().unwrap();
677 let root = tmp.path().canonicalize().unwrap();
678 let file = root.join("entry.py");
679 fs::write(&file, "x = 1").unwrap();
680
681 let mut graph = ModuleGraph::new();
682 let size = fs::metadata(&file).unwrap().len();
683 graph.add_module(file.clone(), size, None);
684
685 let mut cache = ParseCache::new();
686 drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
687
688 fs::write(&file, "x = 2; y = 3").unwrap();
689
690 let mut loaded = ParseCache::load(&root);
691 let resolve_fn = |_: &str| false;
692 let result = loaded.try_load_graph(&file, &resolve_fn);
693 assert!(matches!(result, GraphCacheResult::Stale { .. }));
694 if let GraphCacheResult::Stale { changed_files, .. } = result {
695 assert_eq!(changed_files.len(), 1);
696 assert_eq!(changed_files[0], file);
697 }
698 }
699
700 #[test]
701 fn graph_cache_invalidates_when_unresolved_import_resolves() {
702 let tmp = tempfile::tempdir().unwrap();
703 let root = tmp.path().canonicalize().unwrap();
704 let file = root.join("entry.py");
705 fs::write(&file, "import foo").unwrap();
706
707 let mut graph = ModuleGraph::new();
708 let size = fs::metadata(&file).unwrap().len();
709 graph.add_module(file.clone(), size, None);
710
711 let mut cache = ParseCache::new();
712 drop(cache.save(&root, &file, &graph, vec!["foo".into()], 0, vec![]));
713
714 let mut loaded = ParseCache::load(&root);
715 let resolve_fn = |spec: &str| spec == "foo";
716 assert!(matches!(
717 loaded.try_load_graph(&file, &resolve_fn),
718 GraphCacheResult::Miss
719 ));
720 }
721
722 #[test]
723 fn graph_cache_invalidates_for_different_entry() {
724 let tmp = tempfile::tempdir().unwrap();
725 let root = tmp.path().canonicalize().unwrap();
726 let file_a = root.join("a.py");
727 let file_b = root.join("b.py");
728 fs::write(&file_a, "x = 1").unwrap();
729 fs::write(&file_b, "y = 2").unwrap();
730
731 let mut graph = ModuleGraph::new();
732 let size = fs::metadata(&file_a).unwrap().len();
733 graph.add_module(file_a.clone(), size, None);
734
735 let mut cache = ParseCache::new();
736 drop(cache.save(&root, &file_a, &graph, vec![], 0, vec![]));
737
738 let mut loaded = ParseCache::load(&root);
739 let resolve_fn = |_: &str| false;
740 assert!(matches!(
741 loaded.try_load_graph(&file_b, &resolve_fn),
742 GraphCacheResult::Miss
743 ));
744 }
745
746 #[test]
747 fn incremental_save_updates_changed_mtimes() {
748 let tmp = tempfile::tempdir().unwrap();
749 let root = tmp.path().canonicalize().unwrap();
750 let file = root.join("entry.py");
751 fs::write(&file, "x = 1").unwrap();
752
753 let mut graph = ModuleGraph::new();
754 let size = fs::metadata(&file).unwrap().len();
755 graph.add_module(file.clone(), size, None);
756
757 let mut cache = ParseCache::new();
758 drop(cache.save(&root, &file, &graph, vec![], 0, vec![]));
759
760 std::thread::sleep(std::time::Duration::from_millis(50));
763 fs::write(&file, "x = 2").unwrap();
764
765 let mut loaded = ParseCache::load(&root);
766 let resolve_fn = |_: &str| false;
767 let result = loaded.try_load_graph(&file, &resolve_fn);
768 assert!(matches!(result, GraphCacheResult::Stale { .. }));
769
770 if let GraphCacheResult::Stale {
771 graph,
772 changed_files,
773 ..
774 } = result
775 {
776 drop(loaded.save_incremental(&root, &file, &graph, &changed_files, 0, vec![]));
778
779 let mut reloaded = ParseCache::load(&root);
781 let result = reloaded.try_load_graph(&file, &resolve_fn);
782 assert!(
783 matches!(result, GraphCacheResult::Hit { .. }),
784 "expected Hit after incremental save"
785 );
786 }
787 }
788
789 #[test]
790 fn incremental_save_preserves_per_file_unresolvable_dynamic() {
791 let tmp = tempfile::tempdir().unwrap();
792 let root = tmp.path().canonicalize().unwrap();
793 let file_a = root.join("a.py");
794 let file_b = root.join("b.py");
795 fs::write(&file_a, "x = 1").unwrap();
796 fs::write(&file_b, "y = 2").unwrap();
797
798 let mut graph = ModuleGraph::new();
799 let size_a = fs::metadata(&file_a).unwrap().len();
800 let size_b = fs::metadata(&file_b).unwrap().len();
801 graph.add_module(file_a.clone(), size_a, None);
802 graph.add_module(file_b.clone(), size_b, None);
803
804 let dynamic_files = vec![(file_a.clone(), 3), (file_b.clone(), 2)];
805 let mut cache = ParseCache::new();
806 drop(cache.save(&root, &file_a, &graph, vec![], 5, dynamic_files));
807
808 std::thread::sleep(std::time::Duration::from_millis(50));
810 fs::write(&file_b, "y = 2; z = 3").unwrap();
811
812 let mut loaded = ParseCache::load(&root);
813 let resolve_fn = |_: &str| false;
814 let result = loaded.try_load_graph(&file_a, &resolve_fn);
815
816 if let GraphCacheResult::Stale {
817 graph,
818 unresolvable_dynamic_files,
819 changed_files,
820 ..
821 } = result
822 {
823 assert_eq!(unresolvable_dynamic_files.len(), 2);
825
826 drop(loaded.save_incremental(
828 &root,
829 &file_a,
830 &graph,
831 &changed_files,
832 5,
833 unresolvable_dynamic_files,
834 ));
835
836 let mut reloaded = ParseCache::load(&root);
838 let result = reloaded.try_load_graph(&file_a, &resolve_fn);
839 if let GraphCacheResult::Hit {
840 unresolvable_dynamic,
841 unresolvable_dynamic_files,
842 ..
843 } = result
844 {
845 assert_eq!(unresolvable_dynamic, 5);
846 assert_eq!(unresolvable_dynamic_files.len(), 2);
847 assert!(unresolvable_dynamic_files.contains(&(file_a, 3)));
848 assert!(unresolvable_dynamic_files.contains(&(file_b, 2)));
849 } else {
850 panic!("expected Hit after incremental save, got {result:?}");
851 }
852 } else {
853 panic!("expected Stale, got {result:?}");
854 }
855 }
856
857 #[test]
858 fn lockfile_sentinel_walks_up_to_workspace_root() {
859 let tmp = tempfile::tempdir().unwrap();
860 let workspace = tmp.path().canonicalize().unwrap();
861 let pkg = workspace.join("packages").join("app");
862 fs::create_dir_all(&pkg).unwrap();
863
864 fs::write(workspace.join("pnpm-lock.yaml"), "lockfileVersion: 9").unwrap();
866 let file = pkg.join("entry.ts");
867 fs::write(&file, "x = 1").unwrap();
868
869 let mut graph = ModuleGraph::new();
870 let size = fs::metadata(&file).unwrap().len();
871 graph.add_module(file.clone(), size, None);
872
873 let mut cache = ParseCache::new();
875 drop(cache.save(&pkg, &file, &graph, vec![], 0, vec![]));
876
877 let mut loaded = ParseCache::load(&pkg);
879 let resolve_fn = |_: &str| false;
880 let result = loaded.try_load_graph(&file, &resolve_fn);
881 match result {
882 GraphCacheResult::Hit { needs_resave, .. } => {
883 assert!(!needs_resave, "sentinels should match — no resave needed");
884 }
885 other => panic!("expected Hit, got {other:?}"),
886 }
887 }
888}