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 ResolveResult, ResolvedImport, ResolvedModule, ResolvedProject, ResolvedReExport,
19 ResolvedReplacedModuleTarget,
20};
21
22mod store;
23
24pub use store::{GRAPH_CACHE_FILE, GraphCacheStore};
25
26pub const GRAPH_CACHE_VERSION: u32 = 50;
237
238#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
246pub enum CachedResolveResult {
247 InternalModule(StableFileKey),
249 CommonJsInternalModule(StableFileKey),
251 SyntheticAutoImport(StableFileKey),
253 InternalPackageModule {
255 key: StableFileKey,
257 package_name: String,
259 },
260 CommonJsInternalPackageModule {
262 key: StableFileKey,
264 package_name: String,
266 },
267 ExternalFile(PathBuf),
269 NpmPackage(String),
271 CommonJsNpmPackage(String),
273 Unresolvable(String),
275}
276
277impl CachedResolveResult {
278 fn from_resolve_result(
279 target: &ResolveResult,
280 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
281 ) -> Option<Self> {
282 Some(match target {
283 ResolveResult::InternalModule(file_id) => {
284 Self::InternalModule(key_by_file_id.get(file_id)?.clone())
285 }
286 ResolveResult::CommonJsInternalModule(file_id) => {
287 Self::CommonJsInternalModule(key_by_file_id.get(file_id)?.clone())
288 }
289 ResolveResult::SyntheticAutoImport(file_id) => {
290 Self::SyntheticAutoImport(key_by_file_id.get(file_id)?.clone())
291 }
292 ResolveResult::InternalPackageModule {
293 file_id,
294 package_name,
295 } => Self::InternalPackageModule {
296 key: key_by_file_id.get(file_id)?.clone(),
297 package_name: package_name.clone(),
298 },
299 ResolveResult::CommonJsInternalPackageModule {
300 file_id,
301 package_name,
302 } => Self::CommonJsInternalPackageModule {
303 key: key_by_file_id.get(file_id)?.clone(),
304 package_name: package_name.clone(),
305 },
306 ResolveResult::ExternalFile(path) => Self::ExternalFile(path.clone()),
307 ResolveResult::NpmPackage(package_name) => Self::NpmPackage(package_name.clone()),
308 ResolveResult::CommonJsNpmPackage(package_name) => {
309 Self::CommonJsNpmPackage(package_name.clone())
310 }
311 ResolveResult::Unresolvable(specifier) => Self::Unresolvable(specifier.clone()),
312 })
313 }
314
315 fn into_resolve_result(
316 self,
317 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
318 ) -> Option<ResolveResult> {
319 Some(match self {
320 Self::InternalModule(key) => ResolveResult::InternalModule(*id_by_key.get(&key)?),
321 Self::CommonJsInternalModule(key) => {
322 ResolveResult::CommonJsInternalModule(*id_by_key.get(&key)?)
323 }
324 Self::SyntheticAutoImport(key) => {
325 ResolveResult::SyntheticAutoImport(*id_by_key.get(&key)?)
326 }
327 Self::InternalPackageModule { key, package_name } => {
328 ResolveResult::InternalPackageModule {
329 file_id: *id_by_key.get(&key)?,
330 package_name,
331 }
332 }
333 Self::CommonJsInternalPackageModule { key, package_name } => {
334 ResolveResult::CommonJsInternalPackageModule {
335 file_id: *id_by_key.get(&key)?,
336 package_name,
337 }
338 }
339 Self::ExternalFile(path) => ResolveResult::ExternalFile(path),
340 Self::NpmPackage(package_name) => ResolveResult::NpmPackage(package_name),
341 Self::CommonJsNpmPackage(package_name) => {
342 ResolveResult::CommonJsNpmPackage(package_name)
343 }
344 Self::Unresolvable(specifier) => ResolveResult::Unresolvable(specifier),
345 })
346 }
347}
348
349#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
351pub struct CachedResolvedImport {
352 info: CachedImportInfo,
354 target: CachedResolveResult,
356}
357
358impl CachedResolvedImport {
359 fn from_resolved(
360 import: &ResolvedImport,
361 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
362 ) -> Option<Self> {
363 Some(Self {
364 info: CachedImportInfo::from(&import.info),
365 target: CachedResolveResult::from_resolve_result(&import.target, key_by_file_id)?,
366 })
367 }
368
369 fn into_resolved(
370 self,
371 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
372 ) -> Option<ResolvedImport> {
373 Some(ResolvedImport {
374 info: self.info.into(),
375 target: self.target.into_resolve_result(id_by_key)?,
376 })
377 }
378}
379
380#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
382pub struct CachedResolvedReExport {
383 info: CachedReExportInfo,
385 target: CachedResolveResult,
387}
388
389impl CachedResolvedReExport {
390 fn from_resolved(
391 re_export: &ResolvedReExport,
392 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
393 ) -> Option<Self> {
394 Some(Self {
395 info: CachedReExportInfo::from(&re_export.info),
396 target: CachedResolveResult::from_resolve_result(&re_export.target, key_by_file_id)?,
397 })
398 }
399
400 fn into_resolved(
401 self,
402 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
403 ) -> Option<ResolvedReExport> {
404 Some(ResolvedReExport {
405 info: self.info.into(),
406 target: self.target.into_resolve_result(id_by_key)?,
407 })
408 }
409}
410
411#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
413pub struct CachedImportInfo {
414 source: String,
416 imported_name: fallow_types::extract::ImportedName,
418 local_name: String,
420 is_type_only: bool,
422 is_type_only_star: bool,
424 from_style: bool,
426 span: [u32; 2],
428 source_span: [u32; 2],
430}
431
432impl From<&ImportInfo> for CachedImportInfo {
433 fn from(info: &ImportInfo) -> Self {
434 Self {
435 source: info.source.clone(),
436 imported_name: info.imported_name.clone(),
437 local_name: info.local_name.clone(),
438 is_type_only: info.is_type_only,
439 is_type_only_star: info.is_type_only_star,
440 from_style: info.from_style,
441 span: span_to_pair(info.span),
442 source_span: span_to_pair(info.source_span),
443 }
444 }
445}
446
447impl From<CachedImportInfo> for ImportInfo {
448 fn from(info: CachedImportInfo) -> Self {
449 Self {
450 source: info.source,
451 imported_name: info.imported_name,
452 local_name: info.local_name,
453 is_type_only: info.is_type_only,
454 is_type_only_star: info.is_type_only_star,
455 from_style: info.from_style,
456 span: pair_to_span(info.span),
457 source_span: pair_to_span(info.source_span),
458 }
459 }
460}
461
462#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
464pub struct CachedReExportInfo {
465 source: String,
467 imported_name: String,
469 exported_name: String,
471 is_type_only: bool,
473 span: [u32; 2],
475 statement_span: [u32; 2],
477 source_span: [u32; 2],
479}
480
481impl From<&ReExportInfo> for CachedReExportInfo {
482 fn from(info: &ReExportInfo) -> Self {
483 Self {
484 source: info.source.clone(),
485 imported_name: info.imported_name.clone(),
486 exported_name: info.exported_name.clone(),
487 is_type_only: info.is_type_only,
488 span: span_to_pair(info.span),
489 statement_span: span_to_pair(info.statement_span),
490 source_span: span_to_pair(info.source_span),
491 }
492 }
493}
494
495impl From<CachedReExportInfo> for ReExportInfo {
496 fn from(info: CachedReExportInfo) -> Self {
497 Self {
498 source: info.source,
499 imported_name: info.imported_name,
500 exported_name: info.exported_name,
501 is_type_only: info.is_type_only,
502 span: pair_to_span(info.span),
503 statement_span: pair_to_span(info.statement_span),
504 source_span: pair_to_span(info.source_span),
505 }
506 }
507}
508
509#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
511pub struct CachedResolvedModule {
512 key: StableFileKey,
514 resolved_imports: Vec<CachedResolvedImport>,
516 resolved_dynamic_imports: Vec<CachedResolvedImport>,
518 re_exports: Vec<CachedResolvedReExport>,
520 resolved_dynamic_pattern_targets: Vec<Vec<StableFileKey>>,
522}
523
524impl CachedResolvedModule {
525 fn from_resolved(
526 module: &ResolvedModule,
527 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
528 ) -> Option<Self> {
529 Some(Self {
530 key: key_by_file_id.get(&module.file_id)?.clone(),
531 resolved_imports: module
532 .resolved_imports
533 .iter()
534 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
535 .collect::<Option<Vec<_>>>()?,
536 resolved_dynamic_imports: module
537 .resolved_dynamic_imports
538 .iter()
539 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
540 .collect::<Option<Vec<_>>>()?,
541 re_exports: module
542 .re_exports
543 .iter()
544 .map(|re_export| CachedResolvedReExport::from_resolved(re_export, key_by_file_id))
545 .collect::<Option<Vec<_>>>()?,
546 resolved_dynamic_pattern_targets: module
547 .resolved_dynamic_patterns
548 .iter()
549 .map(|(_, targets)| {
550 targets
551 .iter()
552 .map(|target| key_by_file_id.get(target).cloned())
553 .collect::<Option<Vec<_>>>()
554 })
555 .collect::<Option<Vec<_>>>()?,
556 })
557 }
558}
559
560#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
562struct CachedResolvedReplacedModuleTarget {
563 source_key: StableFileKey,
564 target_key: StableFileKey,
565}
566
567impl CachedResolvedReplacedModuleTarget {
568 fn from_resolved(
569 target: ResolvedReplacedModuleTarget,
570 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
571 ) -> Option<Self> {
572 Some(Self {
573 source_key: key_by_file_id.get(&target.source_file)?.clone(),
574 target_key: key_by_file_id.get(&target.target_file)?.clone(),
575 })
576 }
577
578 fn into_resolved(
579 self,
580 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
581 ) -> Option<ResolvedReplacedModuleTarget> {
582 Some(ResolvedReplacedModuleTarget {
583 source_file: *id_by_key.get(&self.source_key)?,
584 target_file: *id_by_key.get(&self.target_key)?,
585 })
586 }
587}
588
589#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
591pub struct CachedResolvedProject {
592 modules: Vec<CachedResolvedModule>,
593 replaced_module_targets: Vec<CachedResolvedReplacedModuleTarget>,
594}
595
596#[must_use]
598pub fn cache_resolved_project(
599 root: &Path,
600 files: &[DiscoveredFile],
601 resolved: &ResolvedProject,
602) -> Option<CachedResolvedProject> {
603 let key_by_file_id = stable_key_by_file_id(root, files);
604 let modules = resolved
605 .modules
606 .iter()
607 .map(|module| CachedResolvedModule::from_resolved(module, &key_by_file_id))
608 .collect::<Option<Vec<_>>>()?;
609 let replaced_module_targets = resolved
610 .replaced_module_targets
611 .iter()
612 .copied()
613 .map(|target| CachedResolvedReplacedModuleTarget::from_resolved(target, &key_by_file_id))
614 .collect::<Option<Vec<_>>>()?;
615 Some(CachedResolvedProject {
616 modules,
617 replaced_module_targets,
618 })
619}
620
621#[must_use]
627pub fn restore_resolved_project(
628 root: &Path,
629 modules: &[fallow_types::extract::ModuleInfo],
630 files: &[DiscoveredFile],
631 cached: &CachedResolvedProject,
632) -> Option<ResolvedProject> {
633 if modules.len() != cached.modules.len() {
634 return None;
635 }
636
637 let mut indexes = RestoreResolvedModuleIndexes::new(root, modules, files);
638 let resolved_modules = cached
639 .modules
640 .iter()
641 .map(|entry| restore_cached_resolved_module(entry, &mut indexes))
642 .collect::<Option<Vec<_>>>()?;
643 let mut replaced_module_targets = cached
644 .replaced_module_targets
645 .iter()
646 .cloned()
647 .map(|target| target.into_resolved(&indexes.file_ids))
648 .collect::<Option<Vec<_>>>()?;
649 replaced_module_targets
650 .sort_unstable_by_key(|target| (target.source_file.0, target.target_file.0));
651 replaced_module_targets.dedup();
652 Some(ResolvedProject {
653 modules: resolved_modules,
654 replaced_module_targets,
655 })
656}
657
658struct RestoreResolvedModuleIndexes<'a> {
659 file_ids: rustc_hash::FxHashMap<StableFileKey, FileId>,
660 modules: rustc_hash::FxHashMap<StableFileKey, &'a fallow_types::extract::ModuleInfo>,
661 paths: rustc_hash::FxHashMap<StableFileKey, std::path::PathBuf>,
662}
663
664impl<'a> RestoreResolvedModuleIndexes<'a> {
665 fn new(
666 root: &Path,
667 modules: &'a [fallow_types::extract::ModuleInfo],
668 files: &[DiscoveredFile],
669 ) -> Self {
670 let key_by_file_id = stable_key_by_file_id(root, files);
671 let id_by_key: rustc_hash::FxHashMap<_, _> = key_by_file_id
672 .iter()
673 .map(|(file_id, key)| (key.clone(), *file_id))
674 .collect();
675 let by_key: rustc_hash::FxHashMap<_, _> = modules
676 .iter()
677 .filter_map(|module| {
678 key_by_file_id
679 .get(&module.file_id)
680 .map(|key| (key.clone(), module))
681 })
682 .collect();
683 let path_by_key: rustc_hash::FxHashMap<_, _> = files
684 .iter()
685 .map(|file| {
686 (
687 StableFileKey::from_root_relative(root, &file.path),
688 file.path.clone(),
689 )
690 })
691 .collect();
692
693 Self {
694 file_ids: id_by_key,
695 modules: by_key,
696 paths: path_by_key,
697 }
698 }
699}
700
701fn restore_cached_resolved_module(
702 entry: &CachedResolvedModule,
703 indexes: &mut RestoreResolvedModuleIndexes<'_>,
704) -> Option<ResolvedModule> {
705 let module = indexes.modules.remove(&entry.key)?;
706 let path = indexes.paths.get(&entry.key)?.clone();
707 let resolved_dynamic_pattern_targets =
708 restore_dynamic_pattern_targets(entry, module, &indexes.file_ids)?;
709
710 Some(ResolvedModule {
711 file_id: module.file_id,
712 path,
713 exports: Arc::clone(&module.exports),
714 re_exports: entry
715 .re_exports
716 .iter()
717 .cloned()
718 .map(|re_export| re_export.into_resolved(&indexes.file_ids))
719 .collect::<Option<Vec<_>>>()?,
720 resolved_imports: entry
721 .resolved_imports
722 .iter()
723 .cloned()
724 .map(|import| import.into_resolved(&indexes.file_ids))
725 .collect::<Option<Vec<_>>>()?,
726 resolved_dynamic_imports: entry
727 .resolved_dynamic_imports
728 .iter()
729 .cloned()
730 .map(|import| import.into_resolved(&indexes.file_ids))
731 .collect::<Option<Vec<_>>>()?,
732 resolved_dynamic_patterns: module
733 .dynamic_import_patterns
734 .iter()
735 .cloned()
736 .zip(resolved_dynamic_pattern_targets)
737 .collect(),
738 member_accesses: Arc::clone(&module.member_accesses),
739 semantic_facts: Arc::clone(&module.semantic_facts),
740 whole_object_uses: Arc::clone(&module.whole_object_uses),
741 has_cjs_exports: module.has_cjs_exports,
742 has_angular_component_template_url: module.has_angular_component_template_url,
743 unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
744 type_referenced_import_bindings: module.type_referenced_import_bindings.clone(),
745 value_referenced_import_bindings: module.value_referenced_import_bindings.clone(),
746 namespace_object_aliases: module.namespace_object_aliases.clone(),
747 exported_factory_returns: Arc::clone(&module.exported_factory_returns),
748 exported_factory_return_object_shapes: Arc::clone(
749 &module.exported_factory_return_object_shapes,
750 ),
751 type_member_types: Arc::clone(&module.type_member_types),
752 })
753}
754
755fn restore_dynamic_pattern_targets(
756 entry: &CachedResolvedModule,
757 module: &fallow_types::extract::ModuleInfo,
758 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
759) -> Option<Vec<Vec<FileId>>> {
760 if entry.resolved_dynamic_pattern_targets.len() != module.dynamic_import_patterns.len() {
761 return None;
762 }
763 entry
764 .resolved_dynamic_pattern_targets
765 .iter()
766 .map(|targets| {
767 targets
768 .iter()
769 .map(|key| id_by_key.get(key).copied())
770 .collect::<Option<Vec<_>>>()
771 })
772 .collect()
773}
774
775fn stable_key_by_file_id(
776 root: &Path,
777 files: &[DiscoveredFile],
778) -> rustc_hash::FxHashMap<FileId, StableFileKey> {
779 files
780 .iter()
781 .map(|file| (file.id, StableFileKey::from_root_relative(root, &file.path)))
782 .collect()
783}
784
785fn span_to_pair(span: Span) -> [u32; 2] {
786 [span.start, span.end]
787}
788
789fn pair_to_span(pair: [u32; 2]) -> Span {
790 Span::new(pair[0], pair[1])
791}
792
793pub(crate) mod span_serde {
801 use oxc_span::Span;
802 use serde::{Deserialize, Deserializer, Serialize, Serializer};
803
804 #[expect(
805 clippy::trivially_copy_pass_by_ref,
806 reason = "serde `serialize_with` / `with` requires a `&T` signature"
807 )]
808 pub fn serialize<S: Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
809 [span.start, span.end].serialize(serializer)
810 }
811
812 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Span, D::Error> {
813 let [start, end] = <[u32; 2]>::deserialize(deserializer)?;
814 Ok(Span::new(start, end))
815 }
816}
817
818pub(crate) mod member_serde {
827 use fallow_types::extract::{MemberInfo, MemberKind};
828 use oxc_span::Span;
829 use serde::{Deserialize, Deserializer, Serialize, Serializer};
830
831 #[derive(Serialize, Deserialize)]
832 struct CachedMemberInfo {
833 name: String,
834 kind: MemberKind,
835 span: [u32; 2],
836 has_decorator: bool,
837 decorator_names: Vec<String>,
838 is_instance_returning_static: bool,
839 is_self_returning: bool,
840 }
841
842 impl From<&MemberInfo> for CachedMemberInfo {
843 fn from(member: &MemberInfo) -> Self {
844 Self {
845 name: member.name.clone(),
846 kind: member.kind,
847 span: [member.span.start, member.span.end],
848 has_decorator: member.has_decorator,
849 decorator_names: member.decorator_names.clone(),
850 is_instance_returning_static: member.is_instance_returning_static,
851 is_self_returning: member.is_self_returning,
852 }
853 }
854 }
855
856 impl From<CachedMemberInfo> for MemberInfo {
857 fn from(cached: CachedMemberInfo) -> Self {
858 Self {
859 name: cached.name,
860 kind: cached.kind,
861 span: Span::new(cached.span[0], cached.span[1]),
862 has_decorator: cached.has_decorator,
863 decorator_names: cached.decorator_names,
864 is_instance_returning_static: cached.is_instance_returning_static,
865 is_self_returning: cached.is_self_returning,
866 }
867 }
868 }
869
870 pub fn serialize<S: Serializer>(
871 members: &[MemberInfo],
872 serializer: S,
873 ) -> Result<S::Ok, S::Error> {
874 let mirror: Vec<CachedMemberInfo> = members.iter().map(CachedMemberInfo::from).collect();
875 mirror.serialize(serializer)
876 }
877
878 pub fn deserialize<'de, D: Deserializer<'de>>(
879 deserializer: D,
880 ) -> Result<Vec<MemberInfo>, D::Error> {
881 let mirror = Vec::<CachedMemberInfo>::deserialize(deserializer)?;
882 Ok(mirror.into_iter().map(MemberInfo::from).collect())
883 }
884}
885
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
892pub struct GraphCacheMode {
893 pub resolver_options_hash: u64,
895 pub entry_points_hash: u64,
897 pub plugin_config_hash: u64,
899}
900
901impl GraphCacheMode {
902 #[must_use]
904 pub const fn new(
905 resolver_options_hash: u64,
906 entry_points_hash: u64,
907 plugin_config_hash: u64,
908 ) -> Self {
909 Self {
910 resolver_options_hash,
911 entry_points_hash,
912 plugin_config_hash,
913 }
914 }
915}
916
917#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
919pub struct GraphCacheFile {
920 pub key: StableFileKey,
922 pub file_id: FileId,
929 pub content_hash: u64,
941}
942
943impl GraphCacheFile {
944 #[must_use]
946 fn from_discovered_file(root: &Path, file: &DiscoveredFile, content_hash: u64) -> Self {
947 Self {
948 key: StableFileKey::from_root_relative(root, &file.path),
949 file_id: file.id,
950 content_hash,
951 }
952 }
953}
954
955#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
957pub struct GraphCacheManifest {
958 pub root: PathBuf,
960 pub version: u32,
962 pub mode: GraphCacheMode,
964 pub files: Vec<GraphCacheFile>,
966}
967
968impl GraphCacheManifest {
969 #[must_use]
971 fn new(root: &Path, mode: GraphCacheMode, mut files: Vec<GraphCacheFile>) -> Self {
972 sort_files(&mut files);
973 Self {
974 root: root.to_path_buf(),
975 version: GRAPH_CACHE_VERSION,
976 mode,
977 files,
978 }
979 }
980
981 pub fn from_discovered_files(
983 root: &Path,
984 files: &[DiscoveredFile],
985 mode: GraphCacheMode,
986 mut content_hash_for_file: impl FnMut(&DiscoveredFile) -> u64,
987 ) -> Self {
988 let rows = files
989 .iter()
990 .map(|file| {
991 GraphCacheFile::from_discovered_file(root, file, content_hash_for_file(file))
992 })
993 .collect();
994 Self::new(root, mode, rows)
995 }
996
997 #[must_use]
999 pub fn matches_inputs(&self, current: &Self) -> bool {
1000 self.version == GRAPH_CACHE_VERSION
1001 && current.version == GRAPH_CACHE_VERSION
1002 && self.root == current.root
1003 && self.mode == current.mode
1004 && self.files == current.files
1005 }
1006
1007 #[must_use]
1015 pub fn classify_resolution_mismatch(&self, current: &Self) -> Option<CacheRejection> {
1016 if self.version != GRAPH_CACHE_VERSION || current.version != GRAPH_CACHE_VERSION {
1017 return Some(CacheRejection::VersionMismatch);
1018 }
1019 if self.root != current.root {
1020 return Some(CacheRejection::RootMismatch);
1021 }
1022 if self.mode != current.mode {
1023 return Some(CacheRejection::ModeMismatch);
1024 }
1025 if self.files.len() != current.files.len()
1026 || self
1027 .files
1028 .iter()
1029 .zip(current.files.iter())
1030 .any(|(cached, current)| cached.key != current.key)
1031 {
1032 return Some(CacheRejection::FileSetChanged);
1033 }
1034 self.files
1035 .iter()
1036 .zip(current.files.iter())
1037 .any(|(cached, current)| cached.content_hash != current.content_hash)
1038 .then_some(CacheRejection::FingerprintChanged)
1039 }
1040
1041 #[must_use]
1048 pub fn matches_resolution_inputs(&self, current: &Self) -> bool {
1049 self.version == GRAPH_CACHE_VERSION
1050 && current.version == GRAPH_CACHE_VERSION
1051 && self.root == current.root
1052 && self.mode == current.mode
1053 && self.files.len() == current.files.len()
1054 && self
1055 .files
1056 .iter()
1057 .zip(current.files.iter())
1058 .all(|(cached, current)| {
1059 cached.key == current.key && cached.content_hash == current.content_hash
1060 })
1061 }
1062}
1063
1064fn sort_files(files: &mut [GraphCacheFile]) {
1065 files.sort_unstable_by(|a, b| a.key.cmp(&b.key));
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use std::path::{Path, PathBuf};
1071
1072 use fallow_types::discover::FileId;
1073 use fallow_types::extract::{DynamicImportPattern, ModuleInfo, ModuleLoadMechanism};
1074 use rustc_hash::FxHashMap;
1075
1076 use super::*;
1077
1078 fn file(id: u32, path: &str) -> DiscoveredFile {
1079 DiscoveredFile {
1080 id: FileId(id),
1081 path: PathBuf::from(path),
1082 size_bytes: 1,
1083 }
1084 }
1085
1086 fn mode() -> GraphCacheMode {
1087 GraphCacheMode::new(1, 2, 3)
1088 }
1089
1090 fn content_hashes(pairs: &[(&str, u64)]) -> FxHashMap<PathBuf, u64> {
1091 pairs
1092 .iter()
1093 .map(|(path, hash)| (PathBuf::from(path), *hash))
1094 .collect()
1095 }
1096
1097 fn manifest(
1098 files: &[DiscoveredFile],
1099 mode: GraphCacheMode,
1100 map: &FxHashMap<PathBuf, u64>,
1101 ) -> GraphCacheManifest {
1102 GraphCacheManifest::from_discovered_files(Path::new("/project"), files, mode, |file| {
1103 *map.get(&file.path).unwrap()
1104 })
1105 }
1106
1107 fn import_info(source: &str) -> ImportInfo {
1108 ImportInfo {
1109 source: source.to_string(),
1110 imported_name: fallow_types::extract::ImportedName::SideEffect,
1111 local_name: String::new(),
1112 is_type_only: false,
1113 is_type_only_star: false,
1114 from_style: false,
1115 span: Span::new(0, 0),
1116 source_span: Span::new(0, 0),
1117 }
1118 }
1119
1120 #[test]
1121 fn cached_import_info_round_trip_keeps_every_field() {
1122 let original = ImportInfo {
1127 source: "./impl".to_string(),
1128 imported_name: fallow_types::extract::ImportedName::Namespace,
1129 local_name: "ns".to_string(),
1130 is_type_only: true,
1131 is_type_only_star: true,
1132 from_style: true,
1133 span: Span::new(3, 41),
1134 source_span: Span::new(17, 25),
1135 };
1136
1137 let restored = ImportInfo::from(CachedImportInfo::from(&original));
1138
1139 assert_eq!(format!("{restored:?}"), format!("{original:?}"));
1140 }
1141
1142 #[test]
1143 fn manifest_sorts_by_stable_file_key() {
1144 let files = vec![file(0, "/project/src/z.ts"), file(1, "/project/src/a.ts")];
1145 let map = content_hashes(&[("/project/src/z.ts", 10), ("/project/src/a.ts", 20)]);
1146
1147 let manifest = manifest(&files, mode(), &map);
1148
1149 let keys: Vec<&str> = manifest
1150 .files
1151 .iter()
1152 .map(|file| file.key.as_str())
1153 .collect();
1154 assert_eq!(keys, vec!["src/a.ts", "src/z.ts"]);
1155 }
1156
1157 #[test]
1158 fn manifest_misses_on_file_id_shift_until_graph_remap_exists() {
1159 let before = vec![file(0, "/project/src/a.ts"), file(1, "/project/src/c.ts")];
1160 let after = vec![file(9, "/project/src/c.ts"), file(2, "/project/src/a.ts")];
1161 let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/c.ts", 20)]);
1162
1163 let cached = manifest(&before, mode(), &map);
1164 let current = manifest(&after, mode(), &map);
1165
1166 assert!(
1167 !cached.matches_inputs(¤t),
1168 "the persisted graph is still FileId-keyed, so FileId shifts cannot trust it"
1169 );
1170 assert!(
1171 cached.matches_resolution_inputs(¤t),
1172 "stable-keyed resolver payloads may be remapped across FileId shifts"
1173 );
1174 }
1175
1176 #[test]
1177 fn cached_resolve_result_remaps_internal_targets_by_stable_key() {
1178 let key_a = StableFileKey::from_root_relative(
1179 Path::new("/project"),
1180 Path::new("/project/src/a.ts"),
1181 );
1182 let key_b = StableFileKey::from_root_relative(
1183 Path::new("/project"),
1184 Path::new("/project/src/b.ts"),
1185 );
1186 let key_by_file_id =
1187 FxHashMap::from_iter([(FileId(0), key_a.clone()), (FileId(1), key_b.clone())]);
1188 let id_by_key = FxHashMap::from_iter([(key_a, FileId(7)), (key_b, FileId(9))]);
1189
1190 let cached = CachedResolveResult::from_resolve_result(
1191 &ResolveResult::InternalPackageModule {
1192 file_id: FileId(1),
1193 package_name: "@scope/pkg".to_string(),
1194 },
1195 &key_by_file_id,
1196 )
1197 .expect("target file id should map to a stable key");
1198
1199 let restored = cached
1200 .into_resolve_result(&id_by_key)
1201 .expect("stable key should map to current FileId");
1202
1203 assert!(matches!(
1204 restored,
1205 ResolveResult::InternalPackageModule {
1206 file_id: FileId(9),
1207 ref package_name,
1208 } if package_name == "@scope/pkg"
1209 ));
1210 }
1211
1212 #[test]
1213 fn cached_resolve_result_preserves_commonjs_provenance() {
1214 let key = StableFileKey::from_root_relative(
1215 Path::new("/project"),
1216 Path::new("/project/src/dependency.ts"),
1217 );
1218 let key_by_file_id = FxHashMap::from_iter([(FileId(3), key.clone())]);
1219 let id_by_key = FxHashMap::from_iter([(key, FileId(8))]);
1220
1221 let cached = CachedResolveResult::from_resolve_result(
1222 &ResolveResult::CommonJsInternalModule(FileId(3)),
1223 &key_by_file_id,
1224 )
1225 .expect("CommonJS target should map to a stable key");
1226 let restored = cached
1227 .into_resolve_result(&id_by_key)
1228 .expect("stable key should map to the current FileId");
1229
1230 assert!(matches!(
1231 restored,
1232 ResolveResult::CommonJsInternalModule(FileId(8))
1233 ));
1234 }
1235
1236 #[test]
1237 fn cached_resolve_result_preserves_commonjs_bare_package_provenance() {
1238 let cached = CachedResolveResult::from_resolve_result(
1239 &ResolveResult::CommonJsNpmPackage("shared-package".to_string()),
1240 &FxHashMap::default(),
1241 )
1242 .expect("bare CommonJS package should not need a stable file key");
1243 let restored = cached
1244 .into_resolve_result(&FxHashMap::default())
1245 .expect("bare CommonJS package should restore without a file map");
1246
1247 assert!(matches!(
1248 restored,
1249 ResolveResult::CommonJsNpmPackage(package_name)
1250 if package_name == "shared-package"
1251 ));
1252 }
1253
1254 #[test]
1255 fn cache_resolved_project_rejects_unknown_internal_targets() {
1256 let files = vec![file(0, "/project/src/a.ts")];
1257 let module = ResolvedModule {
1258 file_id: FileId(0),
1259 path: PathBuf::from("/project/src/a.ts"),
1260 resolved_imports: vec![ResolvedImport {
1261 info: import_info("./missing"),
1262 target: ResolveResult::InternalModule(FileId(1)),
1263 }],
1264 ..ResolvedModule::default()
1265 };
1266 let project = ResolvedProject {
1267 modules: vec![module],
1268 replaced_module_targets: Vec::new(),
1269 };
1270
1271 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1272
1273 assert!(cached.is_none());
1274 }
1275
1276 #[test]
1277 fn cached_dynamic_pattern_targets_preserve_empty_rows() {
1278 let files = vec![file(0, "/project/src/app.ts")];
1279 let pattern = DynamicImportPattern {
1280 prefix: "./missing/".into(),
1281 suffix: Some(".ts".into()),
1282 span: Span::new(0, 1),
1283 mechanism: ModuleLoadMechanism::EsModule,
1284 };
1285 let module = ModuleInfo {
1286 dynamic_import_patterns: vec![pattern.clone()],
1287 ..ModuleInfo::empty(FileId(0))
1288 };
1289 let resolved = ResolvedProject {
1290 modules: vec![ResolvedModule {
1291 file_id: FileId(0),
1292 path: PathBuf::from("/project/src/app.ts"),
1293 resolved_dynamic_patterns: vec![(pattern, Vec::new())],
1294 ..ResolvedModule::default()
1295 }],
1296 replaced_module_targets: Vec::new(),
1297 };
1298
1299 let cached = cache_resolved_project(Path::new("/project"), &files, &resolved)
1300 .expect("all dynamic pattern targets should have stable keys");
1301 let restored = restore_resolved_project(
1302 Path::new("/project"),
1303 std::slice::from_ref(&module),
1304 &files,
1305 &cached,
1306 )
1307 .expect("cached dynamic pattern rows should align with extracted patterns");
1308
1309 assert_eq!(restored.modules[0].resolved_dynamic_patterns.len(), 1);
1310 assert!(
1311 restored.modules[0].resolved_dynamic_patterns[0]
1312 .1
1313 .is_empty()
1314 );
1315
1316 let mut sparse_cached = cached;
1317 sparse_cached.modules[0]
1318 .resolved_dynamic_pattern_targets
1319 .clear();
1320 assert!(
1321 restore_resolved_project(
1322 Path::new("/project"),
1323 std::slice::from_ref(&module),
1324 &files,
1325 &sparse_cached,
1326 )
1327 .is_none(),
1328 "version-50 sparse rows must take the safe cache-miss path"
1329 );
1330 }
1331
1332 #[test]
1333 fn cached_replaced_target_remaps_both_file_ids_by_stable_key() {
1334 let source_key = StableFileKey::from_root_relative(
1335 Path::new("/project"),
1336 Path::new("/project/src/example.test.ts"),
1337 );
1338 let target_key = StableFileKey::from_root_relative(
1339 Path::new("/project"),
1340 Path::new("/project/src/dependency.ts"),
1341 );
1342 let key_by_file_id = FxHashMap::from_iter([
1343 (FileId(2), source_key.clone()),
1344 (FileId(3), target_key.clone()),
1345 ]);
1346 let id_by_key = FxHashMap::from_iter([(source_key, FileId(8)), (target_key, FileId(9))]);
1347 let resolved = ResolvedReplacedModuleTarget {
1348 source_file: FileId(2),
1349 target_file: FileId(3),
1350 };
1351
1352 let cached = CachedResolvedReplacedModuleTarget::from_resolved(resolved, &key_by_file_id)
1353 .expect("both file ids should map to stable keys");
1354 let restored = cached
1355 .into_resolved(&id_by_key)
1356 .expect("both stable keys should map to current file ids");
1357
1358 assert_eq!(
1359 restored,
1360 ResolvedReplacedModuleTarget {
1361 source_file: FileId(8),
1362 target_file: FileId(9),
1363 }
1364 );
1365 }
1366
1367 #[test]
1368 fn cache_resolved_project_rejects_unknown_replacement_targets() {
1369 let files = vec![file(0, "/project/src/example.test.ts")];
1370 let project = ResolvedProject {
1371 modules: vec![ResolvedModule {
1372 file_id: FileId(0),
1373 path: PathBuf::from("/project/src/example.test.ts"),
1374 ..ResolvedModule::default()
1375 }],
1376 replaced_module_targets: vec![ResolvedReplacedModuleTarget {
1377 source_file: FileId(0),
1378 target_file: FileId(1),
1379 }],
1380 };
1381
1382 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1383
1384 assert!(cached.is_none());
1385 }
1386
1387 #[test]
1388 fn manifest_misses_on_content_change() {
1389 let files = vec![file(0, "/project/src/a.ts")];
1390 let cached_map = content_hashes(&[("/project/src/a.ts", 10)]);
1391 let current_map = content_hashes(&[("/project/src/a.ts", 11)]);
1392
1393 let cached = manifest(&files, mode(), &cached_map);
1394 let current = manifest(&files, mode(), ¤t_map);
1395
1396 assert!(!cached.matches_inputs(¤t));
1397 }
1398
1399 #[test]
1400 fn manifest_misses_on_file_deletion() {
1401 let before = vec![
1402 file(0, "/project/src/a.ts"),
1403 file(1, "/project/src/deleted.ts"),
1404 ];
1405 let after = vec![file(0, "/project/src/a.ts")];
1406 let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/deleted.ts", 20)]);
1407
1408 let cached = manifest(&before, mode(), &map);
1409 let current = manifest(&after, mode(), &map);
1410
1411 assert!(!cached.matches_inputs(¤t));
1412 }
1413
1414 #[test]
1415 fn manifest_misses_on_file_rename_with_same_content() {
1416 let before = vec![file(0, "/project/src/old.ts")];
1417 let after = vec![file(0, "/project/src/new.ts")];
1418 let map = content_hashes(&[("/project/src/old.ts", 10), ("/project/src/new.ts", 10)]);
1419
1420 let cached = manifest(&before, mode(), &map);
1421 let current = manifest(&after, mode(), &map);
1422
1423 assert!(!cached.matches_inputs(¤t));
1424 }
1425
1426 #[test]
1427 fn manifest_misses_on_workspace_scoped_file_set() {
1428 let full_project = vec![
1429 file(0, "/project/packages/app/src/index.ts"),
1430 file(1, "/project/packages/shared/src/index.ts"),
1431 ];
1432 let workspace_scoped = vec![file(0, "/project/packages/app/src/index.ts")];
1433 let map = content_hashes(&[
1434 ("/project/packages/app/src/index.ts", 10),
1435 ("/project/packages/shared/src/index.ts", 20),
1436 ]);
1437
1438 let cached = manifest(&full_project, mode(), &map);
1439 let current = manifest(&workspace_scoped, mode(), &map);
1440
1441 assert!(!cached.matches_inputs(¤t));
1442 assert!(!cached.matches_resolution_inputs(¤t));
1443 }
1444
1445 #[test]
1446 fn manifest_misses_on_mode_change() {
1447 let files = vec![file(0, "/project/src/a.ts")];
1448 let map = content_hashes(&[("/project/src/a.ts", 10)]);
1449
1450 let cached = manifest(&files, mode(), &map);
1451 let current = manifest(&files, GraphCacheMode::new(1, 99, 3), &map);
1452
1453 assert!(!cached.matches_inputs(¤t));
1454 }
1455
1456 #[test]
1457 fn manifest_misses_on_version_change() {
1458 let files = vec![file(0, "/project/src/a.ts")];
1459 let map = content_hashes(&[("/project/src/a.ts", 10)]);
1460 let mut cached = manifest(&files, mode(), &map);
1461 let current = manifest(&files, mode(), &map);
1462
1463 cached.version = GRAPH_CACHE_VERSION + 1;
1464
1465 assert!(!cached.matches_inputs(¤t));
1466 }
1467}