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