1use std::sync::Arc;
9
10use std::path::{Path, PathBuf};
11
12use fallow_types::cache_rejection::CacheRejection;
13use fallow_types::discover::{DiscoveredFile, FileId, StableFileKey};
14use fallow_types::extract::{ImportInfo, ReExportInfo};
15use oxc_span::Span;
16
17use crate::resolve::{
18 MissingExportTarget, ResolveResult, ResolvedImport, ResolvedModule, ResolvedProject,
19 ResolvedReExport, ResolvedReplacedModuleTarget,
20};
21
22mod store;
23
24pub use store::{GRAPH_CACHE_FILE, GraphCacheStore};
25
26pub const GRAPH_CACHE_VERSION: u32 = 91;
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
53pub enum CachedResolveResult {
54 InternalModule(StableFileKey),
56 CommonJsInternalModule(StableFileKey),
58 SyntheticAutoImport(StableFileKey),
60 InternalPackageModule {
62 key: StableFileKey,
64 package_name: String,
66 },
67 CommonJsInternalPackageModule {
69 key: StableFileKey,
71 package_name: String,
73 },
74 ExternalFile(PathBuf),
76 NpmPackage(String),
78 CommonJsNpmPackage(String),
80 Unresolvable(String),
82}
83
84impl CachedResolveResult {
85 fn from_resolve_result(
86 target: &ResolveResult,
87 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
88 ) -> Option<Self> {
89 Some(match target {
90 ResolveResult::InternalModule(file_id) => {
91 Self::InternalModule(key_by_file_id.get(file_id)?.clone())
92 }
93 ResolveResult::CommonJsInternalModule(file_id) => {
94 Self::CommonJsInternalModule(key_by_file_id.get(file_id)?.clone())
95 }
96 ResolveResult::SyntheticAutoImport(file_id) => {
97 Self::SyntheticAutoImport(key_by_file_id.get(file_id)?.clone())
98 }
99 ResolveResult::InternalPackageModule {
100 file_id,
101 package_name,
102 } => Self::InternalPackageModule {
103 key: key_by_file_id.get(file_id)?.clone(),
104 package_name: package_name.clone(),
105 },
106 ResolveResult::CommonJsInternalPackageModule {
107 file_id,
108 package_name,
109 } => Self::CommonJsInternalPackageModule {
110 key: key_by_file_id.get(file_id)?.clone(),
111 package_name: package_name.clone(),
112 },
113 ResolveResult::ExternalFile(path) => Self::ExternalFile(path.clone()),
114 ResolveResult::NpmPackage(package_name) => Self::NpmPackage(package_name.clone()),
115 ResolveResult::CommonJsNpmPackage(package_name) => {
116 Self::CommonJsNpmPackage(package_name.clone())
117 }
118 ResolveResult::Unresolvable(specifier) => Self::Unresolvable(specifier.clone()),
119 })
120 }
121
122 fn into_resolve_result(
123 self,
124 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
125 ) -> Option<ResolveResult> {
126 Some(match self {
127 Self::InternalModule(key) => ResolveResult::InternalModule(*id_by_key.get(&key)?),
128 Self::CommonJsInternalModule(key) => {
129 ResolveResult::CommonJsInternalModule(*id_by_key.get(&key)?)
130 }
131 Self::SyntheticAutoImport(key) => {
132 ResolveResult::SyntheticAutoImport(*id_by_key.get(&key)?)
133 }
134 Self::InternalPackageModule { key, package_name } => {
135 ResolveResult::InternalPackageModule {
136 file_id: *id_by_key.get(&key)?,
137 package_name,
138 }
139 }
140 Self::CommonJsInternalPackageModule { key, package_name } => {
141 ResolveResult::CommonJsInternalPackageModule {
142 file_id: *id_by_key.get(&key)?,
143 package_name,
144 }
145 }
146 Self::ExternalFile(path) => ResolveResult::ExternalFile(path),
147 Self::NpmPackage(package_name) => ResolveResult::NpmPackage(package_name),
148 Self::CommonJsNpmPackage(package_name) => {
149 ResolveResult::CommonJsNpmPackage(package_name)
150 }
151 Self::Unresolvable(specifier) => ResolveResult::Unresolvable(specifier),
152 })
153 }
154}
155
156#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
158pub struct CachedResolvedImport {
159 info: CachedImportInfo,
161 target: CachedResolveResult,
163}
164
165impl CachedResolvedImport {
166 fn from_resolved(
167 import: &ResolvedImport,
168 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
169 ) -> Option<Self> {
170 Some(Self {
171 info: CachedImportInfo::from(&import.info),
172 target: CachedResolveResult::from_resolve_result(&import.target, key_by_file_id)?,
173 })
174 }
175
176 fn into_resolved(
177 self,
178 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
179 ) -> Option<ResolvedImport> {
180 Some(ResolvedImport {
181 info: self.info.into(),
182 target: self.target.into_resolve_result(id_by_key)?,
183 })
184 }
185}
186
187#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
189pub struct CachedResolvedReExport {
190 info: CachedReExportInfo,
192 target: CachedResolveResult,
194}
195
196impl CachedResolvedReExport {
197 fn from_resolved(
198 re_export: &ResolvedReExport,
199 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
200 ) -> Option<Self> {
201 Some(Self {
202 info: CachedReExportInfo::from(&re_export.info),
203 target: CachedResolveResult::from_resolve_result(&re_export.target, key_by_file_id)?,
204 })
205 }
206
207 fn into_resolved(
208 self,
209 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
210 ) -> Option<ResolvedReExport> {
211 Some(ResolvedReExport {
212 info: self.info.into(),
213 target: self.target.into_resolve_result(id_by_key)?,
214 })
215 }
216}
217
218#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
220pub struct CachedImportInfo {
221 source: String,
223 imported_name: fallow_types::extract::ImportedName,
225 local_name: String,
227 is_type_only: bool,
229 is_type_only_star: bool,
231 from_style: bool,
233 span: [u32; 2],
235 source_span: [u32; 2],
237}
238
239impl From<&ImportInfo> for CachedImportInfo {
240 fn from(info: &ImportInfo) -> Self {
241 Self {
242 source: info.source.clone(),
243 imported_name: info.imported_name.clone(),
244 local_name: info.local_name.clone(),
245 is_type_only: info.is_type_only,
246 is_type_only_star: info.is_type_only_star,
247 from_style: info.from_style,
248 span: span_to_pair(info.span),
249 source_span: span_to_pair(info.source_span),
250 }
251 }
252}
253
254impl From<CachedImportInfo> for ImportInfo {
255 fn from(info: CachedImportInfo) -> Self {
256 Self {
257 source: info.source,
258 imported_name: info.imported_name,
259 local_name: info.local_name,
260 is_type_only: info.is_type_only,
261 is_type_only_star: info.is_type_only_star,
262 from_style: info.from_style,
263 span: pair_to_span(info.span),
264 source_span: pair_to_span(info.source_span),
265 }
266 }
267}
268
269#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
271pub struct CachedReExportInfo {
272 source: String,
274 imported_name: String,
276 exported_name: String,
278 is_type_only: bool,
280 span: [u32; 2],
282 statement_span: [u32; 2],
284 source_span: [u32; 2],
286}
287
288impl From<&ReExportInfo> for CachedReExportInfo {
289 fn from(info: &ReExportInfo) -> Self {
290 Self {
291 source: info.source.clone(),
292 imported_name: info.imported_name.clone(),
293 exported_name: info.exported_name.clone(),
294 is_type_only: info.is_type_only,
295 span: span_to_pair(info.span),
296 statement_span: span_to_pair(info.statement_span),
297 source_span: span_to_pair(info.source_span),
298 }
299 }
300}
301
302impl From<CachedReExportInfo> for ReExportInfo {
303 fn from(info: CachedReExportInfo) -> Self {
304 Self {
305 source: info.source,
306 imported_name: info.imported_name,
307 exported_name: info.exported_name,
308 is_type_only: info.is_type_only,
309 span: pair_to_span(info.span),
310 statement_span: pair_to_span(info.statement_span),
311 source_span: pair_to_span(info.source_span),
312 }
313 }
314}
315
316#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
318pub struct CachedResolvedModule {
319 key: StableFileKey,
321 resolved_imports: Vec<CachedResolvedImport>,
323 resolved_dynamic_imports: Vec<CachedResolvedImport>,
325 re_exports: Vec<CachedResolvedReExport>,
327 resolved_dynamic_pattern_targets: Vec<Vec<StableFileKey>>,
329 missing_export_targets: Vec<MissingExportTarget>,
331}
332
333impl CachedResolvedModule {
334 fn from_resolved(
335 module: &ResolvedModule,
336 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
337 ) -> Option<Self> {
338 Some(Self {
339 key: key_by_file_id.get(&module.file_id)?.clone(),
340 resolved_imports: module
341 .resolved_imports
342 .iter()
343 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
344 .collect::<Option<Vec<_>>>()?,
345 resolved_dynamic_imports: module
346 .resolved_dynamic_imports
347 .iter()
348 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
349 .collect::<Option<Vec<_>>>()?,
350 re_exports: module
351 .re_exports
352 .iter()
353 .map(|re_export| CachedResolvedReExport::from_resolved(re_export, key_by_file_id))
354 .collect::<Option<Vec<_>>>()?,
355 resolved_dynamic_pattern_targets: module
356 .resolved_dynamic_patterns
357 .iter()
358 .map(|(_, targets)| {
359 targets
360 .iter()
361 .map(|target| key_by_file_id.get(target).cloned())
362 .collect::<Option<Vec<_>>>()
363 })
364 .collect::<Option<Vec<_>>>()?,
365 missing_export_targets: module.missing_export_targets.clone(),
366 })
367 }
368}
369
370#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
372struct CachedResolvedReplacedModuleTarget {
373 source_key: StableFileKey,
374 target_key: StableFileKey,
375}
376
377impl CachedResolvedReplacedModuleTarget {
378 fn from_resolved(
379 target: ResolvedReplacedModuleTarget,
380 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
381 ) -> Option<Self> {
382 Some(Self {
383 source_key: key_by_file_id.get(&target.source_file)?.clone(),
384 target_key: key_by_file_id.get(&target.target_file)?.clone(),
385 })
386 }
387
388 fn into_resolved(
389 self,
390 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
391 ) -> Option<ResolvedReplacedModuleTarget> {
392 Some(ResolvedReplacedModuleTarget {
393 source_file: *id_by_key.get(&self.source_key)?,
394 target_file: *id_by_key.get(&self.target_key)?,
395 })
396 }
397}
398
399#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
401pub struct CachedResolvedProject {
402 modules: Vec<CachedResolvedModule>,
403 replaced_module_targets: Vec<CachedResolvedReplacedModuleTarget>,
404}
405
406#[must_use]
408pub fn cache_resolved_project(
409 root: &Path,
410 files: &[DiscoveredFile],
411 resolved: &ResolvedProject,
412) -> Option<CachedResolvedProject> {
413 let key_by_file_id = stable_key_by_file_id(root, files);
414 let modules = resolved
415 .modules
416 .iter()
417 .map(|module| CachedResolvedModule::from_resolved(module, &key_by_file_id))
418 .collect::<Option<Vec<_>>>()?;
419 let replaced_module_targets = resolved
420 .replaced_module_targets
421 .iter()
422 .copied()
423 .map(|target| CachedResolvedReplacedModuleTarget::from_resolved(target, &key_by_file_id))
424 .collect::<Option<Vec<_>>>()?;
425 Some(CachedResolvedProject {
426 modules,
427 replaced_module_targets,
428 })
429}
430
431#[must_use]
437pub fn restore_resolved_project(
438 root: &Path,
439 modules: &[fallow_types::extract::ModuleInfo],
440 files: &[DiscoveredFile],
441 cached: &CachedResolvedProject,
442) -> Option<ResolvedProject> {
443 if modules.len() != cached.modules.len() {
444 return None;
445 }
446
447 let mut indexes = RestoreResolvedModuleIndexes::new(root, modules, files);
448 let resolved_modules = cached
449 .modules
450 .iter()
451 .map(|entry| restore_cached_resolved_module(entry, &mut indexes))
452 .collect::<Option<Vec<_>>>()?;
453 let mut replaced_module_targets = cached
454 .replaced_module_targets
455 .iter()
456 .cloned()
457 .map(|target| target.into_resolved(&indexes.file_ids))
458 .collect::<Option<Vec<_>>>()?;
459 replaced_module_targets
460 .sort_unstable_by_key(|target| (target.source_file.0, target.target_file.0));
461 replaced_module_targets.dedup();
462 Some(ResolvedProject {
463 modules: resolved_modules,
464 replaced_module_targets,
465 work: crate::resolve::ResolveWork::default(),
466 })
467}
468
469struct RestoreResolvedModuleIndexes<'a> {
470 file_ids: rustc_hash::FxHashMap<StableFileKey, FileId>,
471 modules: rustc_hash::FxHashMap<StableFileKey, &'a fallow_types::extract::ModuleInfo>,
472 paths: rustc_hash::FxHashMap<StableFileKey, std::path::PathBuf>,
473}
474
475impl<'a> RestoreResolvedModuleIndexes<'a> {
476 fn new(
477 root: &Path,
478 modules: &'a [fallow_types::extract::ModuleInfo],
479 files: &[DiscoveredFile],
480 ) -> Self {
481 let key_by_file_id = stable_key_by_file_id(root, files);
482 let id_by_key: rustc_hash::FxHashMap<_, _> = key_by_file_id
483 .iter()
484 .map(|(file_id, key)| (key.clone(), *file_id))
485 .collect();
486 let by_key: rustc_hash::FxHashMap<_, _> = modules
487 .iter()
488 .filter_map(|module| {
489 key_by_file_id
490 .get(&module.file_id)
491 .map(|key| (key.clone(), module))
492 })
493 .collect();
494 let path_by_key: rustc_hash::FxHashMap<_, _> = files
495 .iter()
496 .map(|file| {
497 (
498 StableFileKey::from_root_relative(root, &file.path),
499 file.path.clone(),
500 )
501 })
502 .collect();
503
504 Self {
505 file_ids: id_by_key,
506 modules: by_key,
507 paths: path_by_key,
508 }
509 }
510}
511
512fn restore_cached_resolved_module(
513 entry: &CachedResolvedModule,
514 indexes: &mut RestoreResolvedModuleIndexes<'_>,
515) -> Option<ResolvedModule> {
516 let module = indexes.modules.remove(&entry.key)?;
517 let path = indexes.paths.get(&entry.key)?.clone();
518 let resolved_dynamic_pattern_targets =
519 restore_dynamic_pattern_targets(entry, module, &indexes.file_ids)?;
520
521 Some(ResolvedModule {
522 file_id: module.file_id,
523 path,
524 exports: Arc::clone(&module.exports),
525 re_exports: entry
526 .re_exports
527 .iter()
528 .cloned()
529 .map(|re_export| re_export.into_resolved(&indexes.file_ids))
530 .collect::<Option<Vec<_>>>()?,
531 resolved_imports: entry
532 .resolved_imports
533 .iter()
534 .cloned()
535 .map(|import| import.into_resolved(&indexes.file_ids))
536 .collect::<Option<Vec<_>>>()?,
537 resolved_dynamic_imports: entry
538 .resolved_dynamic_imports
539 .iter()
540 .cloned()
541 .map(|import| import.into_resolved(&indexes.file_ids))
542 .collect::<Option<Vec<_>>>()?,
543 resolved_dynamic_patterns: module
544 .dynamic_import_patterns
545 .iter()
546 .cloned()
547 .zip(resolved_dynamic_pattern_targets)
548 .collect(),
549 member_accesses: Arc::clone(&module.member_accesses),
550 semantic_facts: Arc::clone(&module.semantic_facts),
551 whole_object_uses: Arc::clone(&module.whole_object_uses),
552 has_cjs_exports: module.has_cjs_exports,
553 has_angular_component_template_url: module.has_angular_component_template_url,
554 unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
555 type_referenced_import_bindings: module.type_referenced_import_bindings.clone(),
556 value_referenced_import_bindings: module.value_referenced_import_bindings.clone(),
557 namespace_object_aliases: module.namespace_object_aliases.clone(),
558 exported_factory_returns: Arc::clone(&module.exported_factory_returns),
559 exported_factory_return_object_shapes: Arc::clone(
560 &module.exported_factory_return_object_shapes,
561 ),
562 type_member_types: Arc::clone(&module.type_member_types),
563 missing_export_targets: entry.missing_export_targets.clone(),
564 })
565}
566
567fn restore_dynamic_pattern_targets(
568 entry: &CachedResolvedModule,
569 module: &fallow_types::extract::ModuleInfo,
570 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
571) -> Option<Vec<Vec<FileId>>> {
572 if entry.resolved_dynamic_pattern_targets.len() != module.dynamic_import_patterns.len() {
573 return None;
574 }
575 entry
576 .resolved_dynamic_pattern_targets
577 .iter()
578 .map(|targets| {
579 targets
580 .iter()
581 .map(|key| id_by_key.get(key).copied())
582 .collect::<Option<Vec<_>>>()
583 })
584 .collect()
585}
586
587fn stable_key_by_file_id(
588 root: &Path,
589 files: &[DiscoveredFile],
590) -> rustc_hash::FxHashMap<FileId, StableFileKey> {
591 files
592 .iter()
593 .map(|file| (file.id, StableFileKey::from_root_relative(root, &file.path)))
594 .collect()
595}
596
597fn span_to_pair(span: Span) -> [u32; 2] {
598 [span.start, span.end]
599}
600
601fn pair_to_span(pair: [u32; 2]) -> Span {
602 Span::new(pair[0], pair[1])
603}
604
605pub(crate) mod span_serde {
613 use oxc_span::Span;
614 use serde::{Deserialize, Deserializer, Serialize, Serializer};
615
616 #[expect(
617 clippy::trivially_copy_pass_by_ref,
618 reason = "serde `serialize_with` / `with` requires a `&T` signature"
619 )]
620 pub fn serialize<S: Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
621 [span.start, span.end].serialize(serializer)
622 }
623
624 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Span, D::Error> {
625 let [start, end] = <[u32; 2]>::deserialize(deserializer)?;
626 Ok(Span::new(start, end))
627 }
628}
629
630pub(crate) mod member_serde {
639 use fallow_types::extract::{MemberInfo, MemberKind};
640 use oxc_span::Span;
641 use serde::{Deserialize, Deserializer, Serialize, Serializer};
642
643 #[derive(Serialize, Deserialize)]
644 struct CachedMemberInfo {
645 name: String,
646 kind: MemberKind,
647 span: [u32; 2],
648 has_decorator: bool,
649 decorator_names: Vec<String>,
650 is_instance_returning_static: bool,
651 is_self_returning: bool,
652 }
653
654 impl From<&MemberInfo> for CachedMemberInfo {
655 fn from(member: &MemberInfo) -> Self {
656 Self {
657 name: member.name.clone(),
658 kind: member.kind,
659 span: [member.span.start, member.span.end],
660 has_decorator: member.has_decorator,
661 decorator_names: member.decorator_names.clone(),
662 is_instance_returning_static: member.is_instance_returning_static,
663 is_self_returning: member.is_self_returning,
664 }
665 }
666 }
667
668 impl From<CachedMemberInfo> for MemberInfo {
669 fn from(cached: CachedMemberInfo) -> Self {
670 Self {
671 name: cached.name,
672 kind: cached.kind,
673 span: Span::new(cached.span[0], cached.span[1]),
674 has_decorator: cached.has_decorator,
675 decorator_names: cached.decorator_names,
676 is_instance_returning_static: cached.is_instance_returning_static,
677 is_self_returning: cached.is_self_returning,
678 override_base: None,
679 }
680 }
681 }
682
683 pub fn serialize<S: Serializer>(
684 members: &[MemberInfo],
685 serializer: S,
686 ) -> Result<S::Ok, S::Error> {
687 let mirror: Vec<CachedMemberInfo> = members.iter().map(CachedMemberInfo::from).collect();
688 mirror.serialize(serializer)
689 }
690
691 pub fn deserialize<'de, D: Deserializer<'de>>(
692 deserializer: D,
693 ) -> Result<Vec<MemberInfo>, D::Error> {
694 let mirror = Vec::<CachedMemberInfo>::deserialize(deserializer)?;
695 Ok(mirror.into_iter().map(MemberInfo::from).collect())
696 }
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
705pub struct GraphCacheMode {
706 pub resolver_options_hash: u64,
708 pub entry_points_hash: u64,
710 pub plugin_config_hash: u64,
712}
713
714impl GraphCacheMode {
715 #[must_use]
717 pub const fn new(
718 resolver_options_hash: u64,
719 entry_points_hash: u64,
720 plugin_config_hash: u64,
721 ) -> Self {
722 Self {
723 resolver_options_hash,
724 entry_points_hash,
725 plugin_config_hash,
726 }
727 }
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
732pub struct GraphCacheFile {
733 pub key: StableFileKey,
735 pub file_id: FileId,
742 pub content_hash: u64,
754}
755
756impl GraphCacheFile {
757 #[must_use]
759 fn from_discovered_file(root: &Path, file: &DiscoveredFile, content_hash: u64) -> Self {
760 Self {
761 key: StableFileKey::from_root_relative(root, &file.path),
762 file_id: file.id,
763 content_hash,
764 }
765 }
766}
767
768#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
770pub struct GraphCacheManifest {
771 pub root: PathBuf,
773 pub version: u32,
775 pub mode: GraphCacheMode,
777 pub files: Vec<GraphCacheFile>,
779}
780
781impl GraphCacheManifest {
782 #[must_use]
784 fn new(root: &Path, mode: GraphCacheMode, mut files: Vec<GraphCacheFile>) -> Self {
785 sort_files(&mut files);
786 Self {
787 root: root.to_path_buf(),
788 version: GRAPH_CACHE_VERSION,
789 mode,
790 files,
791 }
792 }
793
794 pub fn from_discovered_files(
796 root: &Path,
797 files: &[DiscoveredFile],
798 mode: GraphCacheMode,
799 mut content_hash_for_file: impl FnMut(&DiscoveredFile) -> u64,
800 ) -> Self {
801 let rows = files
802 .iter()
803 .map(|file| {
804 GraphCacheFile::from_discovered_file(root, file, content_hash_for_file(file))
805 })
806 .collect();
807 Self::new(root, mode, rows)
808 }
809
810 #[must_use]
812 pub fn matches_inputs(&self, current: &Self) -> bool {
813 self.version == GRAPH_CACHE_VERSION
814 && current.version == GRAPH_CACHE_VERSION
815 && self.root == current.root
816 && self.mode == current.mode
817 && self.files == current.files
818 }
819
820 #[must_use]
828 pub fn classify_resolution_mismatch(&self, current: &Self) -> Option<CacheRejection> {
829 if self.version != GRAPH_CACHE_VERSION || current.version != GRAPH_CACHE_VERSION {
830 return Some(CacheRejection::VersionMismatch);
831 }
832 if self.root != current.root {
833 return Some(CacheRejection::RootMismatch);
834 }
835 if self.mode != current.mode {
836 return Some(CacheRejection::ModeMismatch);
837 }
838 if self.files.len() != current.files.len()
839 || self
840 .files
841 .iter()
842 .zip(current.files.iter())
843 .any(|(cached, current)| cached.key != current.key)
844 {
845 return Some(CacheRejection::FileSetChanged);
846 }
847 self.files
848 .iter()
849 .zip(current.files.iter())
850 .any(|(cached, current)| cached.content_hash != current.content_hash)
851 .then_some(CacheRejection::FingerprintChanged)
852 }
853}
854
855fn sort_files(files: &mut [GraphCacheFile]) {
856 files.sort_unstable_by(|a, b| a.key.cmp(&b.key));
857}
858
859#[cfg(test)]
860mod tests {
861 use std::path::{Path, PathBuf};
862
863 use fallow_types::discover::FileId;
864 use fallow_types::extract::{DynamicImportPattern, ModuleInfo, ModuleLoadMechanism};
865 use rustc_hash::FxHashMap;
866
867 use super::*;
868
869 fn file(id: u32, path: &str) -> DiscoveredFile {
870 DiscoveredFile {
871 id: FileId(id),
872 path: PathBuf::from(path),
873 size_bytes: 1,
874 }
875 }
876
877 fn mode() -> GraphCacheMode {
878 GraphCacheMode::new(1, 2, 3)
879 }
880
881 fn content_hashes(pairs: &[(&str, u64)]) -> FxHashMap<PathBuf, u64> {
882 pairs
883 .iter()
884 .map(|(path, hash)| (PathBuf::from(path), *hash))
885 .collect()
886 }
887
888 fn manifest(
889 files: &[DiscoveredFile],
890 mode: GraphCacheMode,
891 map: &FxHashMap<PathBuf, u64>,
892 ) -> GraphCacheManifest {
893 GraphCacheManifest::from_discovered_files(Path::new("/project"), files, mode, |file| {
894 *map.get(&file.path).unwrap()
895 })
896 }
897
898 fn import_info(source: &str) -> ImportInfo {
899 ImportInfo {
900 source: source.to_string(),
901 imported_name: fallow_types::extract::ImportedName::SideEffect,
902 local_name: String::new(),
903 is_type_only: false,
904 is_type_only_star: false,
905 from_style: false,
906 span: Span::new(0, 0),
907 source_span: Span::new(0, 0),
908 }
909 }
910
911 #[test]
912 fn cached_import_info_round_trip_keeps_every_field() {
913 let original = ImportInfo {
918 source: "./impl".to_string(),
919 imported_name: fallow_types::extract::ImportedName::Namespace,
920 local_name: "ns".to_string(),
921 is_type_only: true,
922 is_type_only_star: true,
923 from_style: true,
924 span: Span::new(3, 41),
925 source_span: Span::new(17, 25),
926 };
927
928 let restored = ImportInfo::from(CachedImportInfo::from(&original));
929
930 assert_eq!(format!("{restored:?}"), format!("{original:?}"));
931 }
932
933 #[test]
934 fn manifest_sorts_by_stable_file_key() {
935 let files = vec![file(0, "/project/src/z.ts"), file(1, "/project/src/a.ts")];
936 let map = content_hashes(&[("/project/src/z.ts", 10), ("/project/src/a.ts", 20)]);
937
938 let manifest = manifest(&files, mode(), &map);
939
940 let keys: Vec<&str> = manifest
941 .files
942 .iter()
943 .map(|file| file.key.as_str())
944 .collect();
945 assert_eq!(keys, vec!["src/a.ts", "src/z.ts"]);
946 }
947
948 #[test]
949 fn manifest_misses_on_file_id_shift_until_graph_remap_exists() {
950 let before = vec![file(0, "/project/src/a.ts"), file(1, "/project/src/c.ts")];
951 let after = vec![file(9, "/project/src/c.ts"), file(2, "/project/src/a.ts")];
952 let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/c.ts", 20)]);
953
954 let cached = manifest(&before, mode(), &map);
955 let current = manifest(&after, mode(), &map);
956
957 assert!(
958 !cached.matches_inputs(¤t),
959 "the persisted graph is still FileId-keyed, so FileId shifts cannot trust it"
960 );
961 assert_eq!(
962 cached.classify_resolution_mismatch(¤t),
963 None,
964 "stable-keyed resolver payloads may be remapped across FileId shifts"
965 );
966 }
967
968 #[test]
969 fn cached_resolve_result_remaps_internal_targets_by_stable_key() {
970 let key_a = StableFileKey::from_root_relative(
971 Path::new("/project"),
972 Path::new("/project/src/a.ts"),
973 );
974 let key_b = StableFileKey::from_root_relative(
975 Path::new("/project"),
976 Path::new("/project/src/b.ts"),
977 );
978 let key_by_file_id =
979 FxHashMap::from_iter([(FileId(0), key_a.clone()), (FileId(1), key_b.clone())]);
980 let id_by_key = FxHashMap::from_iter([(key_a, FileId(7)), (key_b, FileId(9))]);
981
982 let cached = CachedResolveResult::from_resolve_result(
983 &ResolveResult::InternalPackageModule {
984 file_id: FileId(1),
985 package_name: "@scope/pkg".to_string(),
986 },
987 &key_by_file_id,
988 )
989 .expect("target file id should map to a stable key");
990
991 let restored = cached
992 .into_resolve_result(&id_by_key)
993 .expect("stable key should map to current FileId");
994
995 assert!(matches!(
996 restored,
997 ResolveResult::InternalPackageModule {
998 file_id: FileId(9),
999 ref package_name,
1000 } if package_name == "@scope/pkg"
1001 ));
1002 }
1003
1004 #[test]
1005 fn cached_resolve_result_preserves_commonjs_provenance() {
1006 let key = StableFileKey::from_root_relative(
1007 Path::new("/project"),
1008 Path::new("/project/src/dependency.ts"),
1009 );
1010 let key_by_file_id = FxHashMap::from_iter([(FileId(3), key.clone())]);
1011 let id_by_key = FxHashMap::from_iter([(key, FileId(8))]);
1012
1013 let cached = CachedResolveResult::from_resolve_result(
1014 &ResolveResult::CommonJsInternalModule(FileId(3)),
1015 &key_by_file_id,
1016 )
1017 .expect("CommonJS target should map to a stable key");
1018 let restored = cached
1019 .into_resolve_result(&id_by_key)
1020 .expect("stable key should map to the current FileId");
1021
1022 assert!(matches!(
1023 restored,
1024 ResolveResult::CommonJsInternalModule(FileId(8))
1025 ));
1026 }
1027
1028 #[test]
1029 fn cached_resolve_result_preserves_commonjs_bare_package_provenance() {
1030 let cached = CachedResolveResult::from_resolve_result(
1031 &ResolveResult::CommonJsNpmPackage("shared-package".to_string()),
1032 &FxHashMap::default(),
1033 )
1034 .expect("bare CommonJS package should not need a stable file key");
1035 let restored = cached
1036 .into_resolve_result(&FxHashMap::default())
1037 .expect("bare CommonJS package should restore without a file map");
1038
1039 assert!(matches!(
1040 restored,
1041 ResolveResult::CommonJsNpmPackage(package_name)
1042 if package_name == "shared-package"
1043 ));
1044 }
1045
1046 #[test]
1047 fn cache_resolved_project_rejects_unknown_internal_targets() {
1048 let files = vec![file(0, "/project/src/a.ts")];
1049 let module = ResolvedModule {
1050 file_id: FileId(0),
1051 path: PathBuf::from("/project/src/a.ts"),
1052 resolved_imports: vec![ResolvedImport {
1053 info: import_info("./missing"),
1054 target: ResolveResult::InternalModule(FileId(1)),
1055 }],
1056 ..ResolvedModule::default()
1057 };
1058 let project = ResolvedProject {
1059 modules: vec![module],
1060 replaced_module_targets: Vec::new(),
1061 ..ResolvedProject::default()
1062 };
1063
1064 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1065
1066 assert!(cached.is_none());
1067 }
1068
1069 #[test]
1070 fn cached_graph_restores_current_factory_property_sources() {
1071 use fallow_types::extract::{
1072 FactoryPropertySource, FactoryReturnObjectPropertySourceFact, SemanticFact,
1073 };
1074 let files = vec![file(0, "/project/src/app.ts")];
1075 let project = ResolvedProject {
1076 modules: vec![ResolvedModule {
1077 file_id: FileId(0),
1078 path: PathBuf::from("/project/src/app.ts"),
1079 ..ResolvedModule::default()
1080 }],
1081 ..ResolvedProject::default()
1082 };
1083 let cached = cache_resolved_project(Path::new("/project"), &files, &project)
1084 .expect("stable graph cache");
1085 let fact = SemanticFact::FactoryReturnObjectPropertySource(Box::new(
1086 FactoryReturnObjectPropertySourceFact {
1087 factory_export_name: "createUi".into(),
1088 property_path: "page".into(),
1089 source: FactoryPropertySource::FunctionReturn {
1090 callee_name: "make".into(),
1091 },
1092 },
1093 ));
1094 let callback_source = SemanticFact::FactoryReturnObjectPropertySource(Box::new(
1095 FactoryReturnObjectPropertySourceFact {
1096 factory_export_name: "wrap".into(),
1097 property_path: "value".into(),
1098 source: FactoryPropertySource::CallbackParameterReturn { parameter_index: 0 },
1099 },
1100 ));
1101 let callback_access = SemanticFact::CallbackFactoryPropertyAccess(Box::new(
1102 fallow_types::extract::CallbackFactoryPropertyAccessFact {
1103 callee_name: "wrap".into(),
1104 callee_member: None,
1105 property_path: "value".into(),
1106 member: "go".into(),
1107 vue_only: false,
1108 assigned_class: Some("Assigned".into()),
1109 arguments: vec![fallow_types::extract::CallbackFactoryArgument {
1110 parameter_index: 0,
1111 class_local_name: "C".into(),
1112 getter: true,
1113 lazy_safe: true,
1114 }],
1115 },
1116 ));
1117 for current in [vec![fact, callback_source, callback_access], Vec::new()] {
1118 let module = ModuleInfo {
1119 semantic_facts: current.clone().into(),
1120 ..ModuleInfo::empty(FileId(0))
1121 };
1122 let restored =
1123 restore_resolved_project(Path::new("/project"), &[module], &files, &cached)
1124 .expect("current parsed facts with cached resolution");
1125 assert_eq!(
1126 restored.modules[0].semantic_facts.as_ref(),
1127 current.as_slice()
1128 );
1129 }
1130 }
1131
1132 #[test]
1133 fn cached_dynamic_pattern_targets_preserve_empty_rows() {
1134 let files = vec![file(0, "/project/src/app.ts")];
1135 let pattern = DynamicImportPattern {
1136 prefix: "./missing/".into(),
1137 suffix: Some(".ts".into()),
1138 span: Span::new(0, 1),
1139 mechanism: ModuleLoadMechanism::EsModule,
1140 excludes: Vec::new(),
1141 };
1142 let module = ModuleInfo {
1143 dynamic_import_patterns: vec![pattern.clone()],
1144 ..ModuleInfo::empty(FileId(0))
1145 };
1146 let resolved = ResolvedProject {
1147 modules: vec![ResolvedModule {
1148 file_id: FileId(0),
1149 path: PathBuf::from("/project/src/app.ts"),
1150 resolved_dynamic_patterns: vec![(pattern, Vec::new())],
1151 ..ResolvedModule::default()
1152 }],
1153 replaced_module_targets: Vec::new(),
1154 ..ResolvedProject::default()
1155 };
1156
1157 let cached = cache_resolved_project(Path::new("/project"), &files, &resolved)
1158 .expect("all dynamic pattern targets should have stable keys");
1159 let restored = restore_resolved_project(
1160 Path::new("/project"),
1161 std::slice::from_ref(&module),
1162 &files,
1163 &cached,
1164 )
1165 .expect("cached dynamic pattern rows should align with extracted patterns");
1166
1167 assert_eq!(restored.modules[0].resolved_dynamic_patterns.len(), 1);
1168 assert!(
1169 restored.modules[0].resolved_dynamic_patterns[0]
1170 .1
1171 .is_empty()
1172 );
1173
1174 let mut sparse_cached = cached;
1175 sparse_cached.modules[0]
1176 .resolved_dynamic_pattern_targets
1177 .clear();
1178 assert!(
1179 restore_resolved_project(
1180 Path::new("/project"),
1181 std::slice::from_ref(&module),
1182 &files,
1183 &sparse_cached,
1184 )
1185 .is_none(),
1186 "version-50 sparse rows must take the safe cache-miss path"
1187 );
1188 }
1189
1190 #[test]
1191 fn cached_replaced_target_remaps_both_file_ids_by_stable_key() {
1192 let source_key = StableFileKey::from_root_relative(
1193 Path::new("/project"),
1194 Path::new("/project/src/example.test.ts"),
1195 );
1196 let target_key = StableFileKey::from_root_relative(
1197 Path::new("/project"),
1198 Path::new("/project/src/dependency.ts"),
1199 );
1200 let key_by_file_id = FxHashMap::from_iter([
1201 (FileId(2), source_key.clone()),
1202 (FileId(3), target_key.clone()),
1203 ]);
1204 let id_by_key = FxHashMap::from_iter([(source_key, FileId(8)), (target_key, FileId(9))]);
1205 let resolved = ResolvedReplacedModuleTarget {
1206 source_file: FileId(2),
1207 target_file: FileId(3),
1208 };
1209
1210 let cached = CachedResolvedReplacedModuleTarget::from_resolved(resolved, &key_by_file_id)
1211 .expect("both file ids should map to stable keys");
1212 let restored = cached
1213 .into_resolved(&id_by_key)
1214 .expect("both stable keys should map to current file ids");
1215
1216 assert_eq!(
1217 restored,
1218 ResolvedReplacedModuleTarget {
1219 source_file: FileId(8),
1220 target_file: FileId(9),
1221 }
1222 );
1223 }
1224
1225 #[test]
1226 fn cache_resolved_project_rejects_unknown_replacement_targets() {
1227 let files = vec![file(0, "/project/src/example.test.ts")];
1228 let project = ResolvedProject {
1229 modules: vec![ResolvedModule {
1230 file_id: FileId(0),
1231 path: PathBuf::from("/project/src/example.test.ts"),
1232 ..ResolvedModule::default()
1233 }],
1234 replaced_module_targets: vec![ResolvedReplacedModuleTarget {
1235 source_file: FileId(0),
1236 target_file: FileId(1),
1237 }],
1238 ..ResolvedProject::default()
1239 };
1240
1241 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1242
1243 assert!(cached.is_none());
1244 }
1245
1246 #[test]
1247 fn manifest_misses_on_content_change() {
1248 let files = vec![file(0, "/project/src/a.ts")];
1249 let cached_map = content_hashes(&[("/project/src/a.ts", 10)]);
1250 let current_map = content_hashes(&[("/project/src/a.ts", 11)]);
1251
1252 let cached = manifest(&files, mode(), &cached_map);
1253 let current = manifest(&files, mode(), ¤t_map);
1254
1255 assert!(!cached.matches_inputs(¤t));
1256 }
1257
1258 #[test]
1259 fn manifest_misses_on_file_deletion() {
1260 let before = vec![
1261 file(0, "/project/src/a.ts"),
1262 file(1, "/project/src/deleted.ts"),
1263 ];
1264 let after = vec![file(0, "/project/src/a.ts")];
1265 let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/deleted.ts", 20)]);
1266
1267 let cached = manifest(&before, mode(), &map);
1268 let current = manifest(&after, mode(), &map);
1269
1270 assert!(!cached.matches_inputs(¤t));
1271 }
1272
1273 #[test]
1274 fn manifest_misses_on_file_rename_with_same_content() {
1275 let before = vec![file(0, "/project/src/old.ts")];
1276 let after = vec![file(0, "/project/src/new.ts")];
1277 let map = content_hashes(&[("/project/src/old.ts", 10), ("/project/src/new.ts", 10)]);
1278
1279 let cached = manifest(&before, mode(), &map);
1280 let current = manifest(&after, mode(), &map);
1281
1282 assert!(!cached.matches_inputs(¤t));
1283 }
1284
1285 #[test]
1286 fn manifest_misses_on_workspace_scoped_file_set() {
1287 let full_project = vec![
1288 file(0, "/project/packages/app/src/index.ts"),
1289 file(1, "/project/packages/shared/src/index.ts"),
1290 ];
1291 let workspace_scoped = vec![file(0, "/project/packages/app/src/index.ts")];
1292 let map = content_hashes(&[
1293 ("/project/packages/app/src/index.ts", 10),
1294 ("/project/packages/shared/src/index.ts", 20),
1295 ]);
1296
1297 let cached = manifest(&full_project, mode(), &map);
1298 let current = manifest(&workspace_scoped, mode(), &map);
1299
1300 assert!(!cached.matches_inputs(¤t));
1301 assert_eq!(
1302 cached.classify_resolution_mismatch(¤t),
1303 Some(CacheRejection::FileSetChanged)
1304 );
1305 }
1306
1307 #[test]
1308 fn manifest_misses_on_mode_change() {
1309 let files = vec![file(0, "/project/src/a.ts")];
1310 let map = content_hashes(&[("/project/src/a.ts", 10)]);
1311
1312 let cached = manifest(&files, mode(), &map);
1313 let current = manifest(&files, GraphCacheMode::new(1, 99, 3), &map);
1314
1315 assert!(!cached.matches_inputs(¤t));
1316 }
1317
1318 #[test]
1319 fn manifest_misses_on_version_change() {
1320 let files = vec![file(0, "/project/src/a.ts")];
1321 let map = content_hashes(&[("/project/src/a.ts", 10)]);
1322 let mut cached = manifest(&files, mode(), &map);
1323 let current = manifest(&files, mode(), &map);
1324
1325 cached.version = GRAPH_CACHE_VERSION + 1;
1326
1327 assert!(!cached.matches_inputs(¤t));
1328 }
1329}