1use std::sync::Arc;
9
10use std::path::{Path, PathBuf};
11
12use fallow_types::discover::{DiscoveredFile, FileId, StableFileKey};
13use fallow_types::extract::{ImportInfo, ReExportInfo};
14use fallow_types::source_fingerprint::SourceFingerprint;
15use oxc_span::Span;
16
17use crate::resolve::{
18 ResolveResult, ResolvedImport, ResolvedModule, ResolvedProject, ResolvedReExport,
19 ResolvedReplacedModuleTarget,
20};
21
22mod store;
23
24pub use store::GraphCacheStore;
25
26pub const GRAPH_CACHE_VERSION: u32 = 47;
219
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
228pub enum CachedResolveResult {
229 InternalModule(StableFileKey),
231 CommonJsInternalModule(StableFileKey),
233 SyntheticAutoImport(StableFileKey),
235 InternalPackageModule {
237 key: StableFileKey,
239 package_name: String,
241 },
242 CommonJsInternalPackageModule {
244 key: StableFileKey,
246 package_name: String,
248 },
249 ExternalFile(PathBuf),
251 NpmPackage(String),
253 CommonJsNpmPackage(String),
255 Unresolvable(String),
257}
258
259impl CachedResolveResult {
260 fn from_resolve_result(
261 target: &ResolveResult,
262 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
263 ) -> Option<Self> {
264 Some(match target {
265 ResolveResult::InternalModule(file_id) => {
266 Self::InternalModule(key_by_file_id.get(file_id)?.clone())
267 }
268 ResolveResult::CommonJsInternalModule(file_id) => {
269 Self::CommonJsInternalModule(key_by_file_id.get(file_id)?.clone())
270 }
271 ResolveResult::SyntheticAutoImport(file_id) => {
272 Self::SyntheticAutoImport(key_by_file_id.get(file_id)?.clone())
273 }
274 ResolveResult::InternalPackageModule {
275 file_id,
276 package_name,
277 } => Self::InternalPackageModule {
278 key: key_by_file_id.get(file_id)?.clone(),
279 package_name: package_name.clone(),
280 },
281 ResolveResult::CommonJsInternalPackageModule {
282 file_id,
283 package_name,
284 } => Self::CommonJsInternalPackageModule {
285 key: key_by_file_id.get(file_id)?.clone(),
286 package_name: package_name.clone(),
287 },
288 ResolveResult::ExternalFile(path) => Self::ExternalFile(path.clone()),
289 ResolveResult::NpmPackage(package_name) => Self::NpmPackage(package_name.clone()),
290 ResolveResult::CommonJsNpmPackage(package_name) => {
291 Self::CommonJsNpmPackage(package_name.clone())
292 }
293 ResolveResult::Unresolvable(specifier) => Self::Unresolvable(specifier.clone()),
294 })
295 }
296
297 fn into_resolve_result(
298 self,
299 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
300 ) -> Option<ResolveResult> {
301 Some(match self {
302 Self::InternalModule(key) => ResolveResult::InternalModule(*id_by_key.get(&key)?),
303 Self::CommonJsInternalModule(key) => {
304 ResolveResult::CommonJsInternalModule(*id_by_key.get(&key)?)
305 }
306 Self::SyntheticAutoImport(key) => {
307 ResolveResult::SyntheticAutoImport(*id_by_key.get(&key)?)
308 }
309 Self::InternalPackageModule { key, package_name } => {
310 ResolveResult::InternalPackageModule {
311 file_id: *id_by_key.get(&key)?,
312 package_name,
313 }
314 }
315 Self::CommonJsInternalPackageModule { key, package_name } => {
316 ResolveResult::CommonJsInternalPackageModule {
317 file_id: *id_by_key.get(&key)?,
318 package_name,
319 }
320 }
321 Self::ExternalFile(path) => ResolveResult::ExternalFile(path),
322 Self::NpmPackage(package_name) => ResolveResult::NpmPackage(package_name),
323 Self::CommonJsNpmPackage(package_name) => {
324 ResolveResult::CommonJsNpmPackage(package_name)
325 }
326 Self::Unresolvable(specifier) => ResolveResult::Unresolvable(specifier),
327 })
328 }
329}
330
331#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
333pub struct CachedResolvedImport {
334 info: CachedImportInfo,
336 target: CachedResolveResult,
338}
339
340impl CachedResolvedImport {
341 fn from_resolved(
342 import: &ResolvedImport,
343 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
344 ) -> Option<Self> {
345 Some(Self {
346 info: CachedImportInfo::from(&import.info),
347 target: CachedResolveResult::from_resolve_result(&import.target, key_by_file_id)?,
348 })
349 }
350
351 fn into_resolved(
352 self,
353 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
354 ) -> Option<ResolvedImport> {
355 Some(ResolvedImport {
356 info: self.info.into(),
357 target: self.target.into_resolve_result(id_by_key)?,
358 })
359 }
360}
361
362#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
364pub struct CachedResolvedReExport {
365 info: CachedReExportInfo,
367 target: CachedResolveResult,
369}
370
371impl CachedResolvedReExport {
372 fn from_resolved(
373 re_export: &ResolvedReExport,
374 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
375 ) -> Option<Self> {
376 Some(Self {
377 info: CachedReExportInfo::from(&re_export.info),
378 target: CachedResolveResult::from_resolve_result(&re_export.target, key_by_file_id)?,
379 })
380 }
381
382 fn into_resolved(
383 self,
384 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
385 ) -> Option<ResolvedReExport> {
386 Some(ResolvedReExport {
387 info: self.info.into(),
388 target: self.target.into_resolve_result(id_by_key)?,
389 })
390 }
391}
392
393#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
395pub struct CachedImportInfo {
396 source: String,
398 imported_name: fallow_types::extract::ImportedName,
400 local_name: String,
402 is_type_only: bool,
404 is_type_only_star: bool,
406 from_style: bool,
408 span: [u32; 2],
410 source_span: [u32; 2],
412}
413
414impl From<&ImportInfo> for CachedImportInfo {
415 fn from(info: &ImportInfo) -> Self {
416 Self {
417 source: info.source.clone(),
418 imported_name: info.imported_name.clone(),
419 local_name: info.local_name.clone(),
420 is_type_only: info.is_type_only,
421 is_type_only_star: info.is_type_only_star,
422 from_style: info.from_style,
423 span: span_to_pair(info.span),
424 source_span: span_to_pair(info.source_span),
425 }
426 }
427}
428
429impl From<CachedImportInfo> for ImportInfo {
430 fn from(info: CachedImportInfo) -> Self {
431 Self {
432 source: info.source,
433 imported_name: info.imported_name,
434 local_name: info.local_name,
435 is_type_only: info.is_type_only,
436 is_type_only_star: info.is_type_only_star,
437 from_style: info.from_style,
438 span: pair_to_span(info.span),
439 source_span: pair_to_span(info.source_span),
440 }
441 }
442}
443
444#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
446pub struct CachedReExportInfo {
447 source: String,
449 imported_name: String,
451 exported_name: String,
453 is_type_only: bool,
455 span: [u32; 2],
457 statement_span: [u32; 2],
459 source_span: [u32; 2],
461}
462
463impl From<&ReExportInfo> for CachedReExportInfo {
464 fn from(info: &ReExportInfo) -> Self {
465 Self {
466 source: info.source.clone(),
467 imported_name: info.imported_name.clone(),
468 exported_name: info.exported_name.clone(),
469 is_type_only: info.is_type_only,
470 span: span_to_pair(info.span),
471 statement_span: span_to_pair(info.statement_span),
472 source_span: span_to_pair(info.source_span),
473 }
474 }
475}
476
477impl From<CachedReExportInfo> for ReExportInfo {
478 fn from(info: CachedReExportInfo) -> Self {
479 Self {
480 source: info.source,
481 imported_name: info.imported_name,
482 exported_name: info.exported_name,
483 is_type_only: info.is_type_only,
484 span: pair_to_span(info.span),
485 statement_span: pair_to_span(info.statement_span),
486 source_span: pair_to_span(info.source_span),
487 }
488 }
489}
490
491#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
493pub struct CachedResolvedModule {
494 key: StableFileKey,
496 resolved_imports: Vec<CachedResolvedImport>,
498 resolved_dynamic_imports: Vec<CachedResolvedImport>,
500 re_exports: Vec<CachedResolvedReExport>,
502 resolved_dynamic_pattern_targets: Vec<Vec<StableFileKey>>,
504}
505
506impl CachedResolvedModule {
507 fn from_resolved(
508 module: &ResolvedModule,
509 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
510 ) -> Option<Self> {
511 Some(Self {
512 key: key_by_file_id.get(&module.file_id)?.clone(),
513 resolved_imports: module
514 .resolved_imports
515 .iter()
516 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
517 .collect::<Option<Vec<_>>>()?,
518 resolved_dynamic_imports: module
519 .resolved_dynamic_imports
520 .iter()
521 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
522 .collect::<Option<Vec<_>>>()?,
523 re_exports: module
524 .re_exports
525 .iter()
526 .map(|re_export| CachedResolvedReExport::from_resolved(re_export, key_by_file_id))
527 .collect::<Option<Vec<_>>>()?,
528 resolved_dynamic_pattern_targets: module
529 .resolved_dynamic_patterns
530 .iter()
531 .map(|(_, targets)| {
532 targets
533 .iter()
534 .map(|target| key_by_file_id.get(target).cloned())
535 .collect::<Option<Vec<_>>>()
536 })
537 .collect::<Option<Vec<_>>>()?,
538 })
539 }
540}
541
542#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
544struct CachedResolvedReplacedModuleTarget {
545 source_key: StableFileKey,
546 target_key: StableFileKey,
547}
548
549impl CachedResolvedReplacedModuleTarget {
550 fn from_resolved(
551 target: ResolvedReplacedModuleTarget,
552 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
553 ) -> Option<Self> {
554 Some(Self {
555 source_key: key_by_file_id.get(&target.source_file)?.clone(),
556 target_key: key_by_file_id.get(&target.target_file)?.clone(),
557 })
558 }
559
560 fn into_resolved(
561 self,
562 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
563 ) -> Option<ResolvedReplacedModuleTarget> {
564 Some(ResolvedReplacedModuleTarget {
565 source_file: *id_by_key.get(&self.source_key)?,
566 target_file: *id_by_key.get(&self.target_key)?,
567 })
568 }
569}
570
571#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
573pub struct CachedResolvedProject {
574 modules: Vec<CachedResolvedModule>,
575 replaced_module_targets: Vec<CachedResolvedReplacedModuleTarget>,
576}
577
578#[must_use]
580pub fn cache_resolved_project(
581 root: &Path,
582 files: &[DiscoveredFile],
583 resolved: &ResolvedProject,
584) -> Option<CachedResolvedProject> {
585 let key_by_file_id = stable_key_by_file_id(root, files);
586 let modules = resolved
587 .modules
588 .iter()
589 .map(|module| CachedResolvedModule::from_resolved(module, &key_by_file_id))
590 .collect::<Option<Vec<_>>>()?;
591 let replaced_module_targets = resolved
592 .replaced_module_targets
593 .iter()
594 .copied()
595 .map(|target| CachedResolvedReplacedModuleTarget::from_resolved(target, &key_by_file_id))
596 .collect::<Option<Vec<_>>>()?;
597 Some(CachedResolvedProject {
598 modules,
599 replaced_module_targets,
600 })
601}
602
603#[must_use]
609pub fn restore_resolved_project(
610 root: &Path,
611 modules: &[fallow_types::extract::ModuleInfo],
612 files: &[DiscoveredFile],
613 cached: &CachedResolvedProject,
614) -> Option<ResolvedProject> {
615 if modules.len() != cached.modules.len() {
616 return None;
617 }
618
619 let mut indexes = RestoreResolvedModuleIndexes::new(root, modules, files);
620 let resolved_modules = cached
621 .modules
622 .iter()
623 .map(|entry| restore_cached_resolved_module(entry, &mut indexes))
624 .collect::<Option<Vec<_>>>()?;
625 let mut replaced_module_targets = cached
626 .replaced_module_targets
627 .iter()
628 .cloned()
629 .map(|target| target.into_resolved(&indexes.file_ids))
630 .collect::<Option<Vec<_>>>()?;
631 replaced_module_targets
632 .sort_unstable_by_key(|target| (target.source_file.0, target.target_file.0));
633 replaced_module_targets.dedup();
634 Some(ResolvedProject {
635 modules: resolved_modules,
636 replaced_module_targets,
637 })
638}
639
640struct RestoreResolvedModuleIndexes<'a> {
641 file_ids: rustc_hash::FxHashMap<StableFileKey, FileId>,
642 modules: rustc_hash::FxHashMap<StableFileKey, &'a fallow_types::extract::ModuleInfo>,
643 paths: rustc_hash::FxHashMap<StableFileKey, std::path::PathBuf>,
644}
645
646impl<'a> RestoreResolvedModuleIndexes<'a> {
647 fn new(
648 root: &Path,
649 modules: &'a [fallow_types::extract::ModuleInfo],
650 files: &[DiscoveredFile],
651 ) -> Self {
652 let key_by_file_id = stable_key_by_file_id(root, files);
653 let id_by_key: rustc_hash::FxHashMap<_, _> = key_by_file_id
654 .iter()
655 .map(|(file_id, key)| (key.clone(), *file_id))
656 .collect();
657 let by_key: rustc_hash::FxHashMap<_, _> = modules
658 .iter()
659 .filter_map(|module| {
660 key_by_file_id
661 .get(&module.file_id)
662 .map(|key| (key.clone(), module))
663 })
664 .collect();
665 let path_by_key: rustc_hash::FxHashMap<_, _> = files
666 .iter()
667 .map(|file| {
668 (
669 StableFileKey::from_root_relative(root, &file.path),
670 file.path.clone(),
671 )
672 })
673 .collect();
674
675 Self {
676 file_ids: id_by_key,
677 modules: by_key,
678 paths: path_by_key,
679 }
680 }
681}
682
683fn restore_cached_resolved_module(
684 entry: &CachedResolvedModule,
685 indexes: &mut RestoreResolvedModuleIndexes<'_>,
686) -> Option<ResolvedModule> {
687 let module = indexes.modules.remove(&entry.key)?;
688 let path = indexes.paths.get(&entry.key)?.clone();
689 let resolved_dynamic_pattern_targets =
690 restore_dynamic_pattern_targets(entry, module, &indexes.file_ids)?;
691
692 Some(ResolvedModule {
693 file_id: module.file_id,
694 path,
695 exports: Arc::clone(&module.exports),
696 re_exports: entry
697 .re_exports
698 .iter()
699 .cloned()
700 .map(|re_export| re_export.into_resolved(&indexes.file_ids))
701 .collect::<Option<Vec<_>>>()?,
702 resolved_imports: entry
703 .resolved_imports
704 .iter()
705 .cloned()
706 .map(|import| import.into_resolved(&indexes.file_ids))
707 .collect::<Option<Vec<_>>>()?,
708 resolved_dynamic_imports: entry
709 .resolved_dynamic_imports
710 .iter()
711 .cloned()
712 .map(|import| import.into_resolved(&indexes.file_ids))
713 .collect::<Option<Vec<_>>>()?,
714 resolved_dynamic_patterns: module
715 .dynamic_import_patterns
716 .iter()
717 .cloned()
718 .zip(resolved_dynamic_pattern_targets)
719 .collect(),
720 member_accesses: Arc::clone(&module.member_accesses),
721 semantic_facts: Arc::clone(&module.semantic_facts),
722 whole_object_uses: Arc::clone(&module.whole_object_uses),
723 has_cjs_exports: module.has_cjs_exports,
724 has_angular_component_template_url: module.has_angular_component_template_url,
725 unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
726 type_referenced_import_bindings: module.type_referenced_import_bindings.clone(),
727 value_referenced_import_bindings: module.value_referenced_import_bindings.clone(),
728 namespace_object_aliases: module.namespace_object_aliases.clone(),
729 exported_factory_returns: Arc::clone(&module.exported_factory_returns),
730 exported_factory_return_object_shapes: Arc::clone(
731 &module.exported_factory_return_object_shapes,
732 ),
733 type_member_types: Arc::clone(&module.type_member_types),
734 })
735}
736
737fn restore_dynamic_pattern_targets(
738 entry: &CachedResolvedModule,
739 module: &fallow_types::extract::ModuleInfo,
740 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
741) -> Option<Vec<Vec<FileId>>> {
742 if entry.resolved_dynamic_pattern_targets.len() != module.dynamic_import_patterns.len() {
743 return None;
744 }
745 entry
746 .resolved_dynamic_pattern_targets
747 .iter()
748 .map(|targets| {
749 targets
750 .iter()
751 .map(|key| id_by_key.get(key).copied())
752 .collect::<Option<Vec<_>>>()
753 })
754 .collect()
755}
756
757fn stable_key_by_file_id(
758 root: &Path,
759 files: &[DiscoveredFile],
760) -> rustc_hash::FxHashMap<FileId, StableFileKey> {
761 files
762 .iter()
763 .map(|file| (file.id, StableFileKey::from_root_relative(root, &file.path)))
764 .collect()
765}
766
767fn span_to_pair(span: Span) -> [u32; 2] {
768 [span.start, span.end]
769}
770
771fn pair_to_span(pair: [u32; 2]) -> Span {
772 Span::new(pair[0], pair[1])
773}
774
775pub(crate) mod span_serde {
783 use oxc_span::Span;
784 use serde::{Deserialize, Deserializer, Serialize, Serializer};
785
786 #[expect(
787 clippy::trivially_copy_pass_by_ref,
788 reason = "serde `serialize_with` / `with` requires a `&T` signature"
789 )]
790 pub fn serialize<S: Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
791 [span.start, span.end].serialize(serializer)
792 }
793
794 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Span, D::Error> {
795 let [start, end] = <[u32; 2]>::deserialize(deserializer)?;
796 Ok(Span::new(start, end))
797 }
798}
799
800pub(crate) mod member_serde {
809 use fallow_types::extract::{MemberInfo, MemberKind};
810 use oxc_span::Span;
811 use serde::{Deserialize, Deserializer, Serialize, Serializer};
812
813 #[derive(Serialize, Deserialize)]
814 struct CachedMemberInfo {
815 name: String,
816 kind: MemberKind,
817 span: [u32; 2],
818 has_decorator: bool,
819 decorator_names: Vec<String>,
820 is_instance_returning_static: bool,
821 is_self_returning: bool,
822 }
823
824 impl From<&MemberInfo> for CachedMemberInfo {
825 fn from(member: &MemberInfo) -> Self {
826 Self {
827 name: member.name.clone(),
828 kind: member.kind,
829 span: [member.span.start, member.span.end],
830 has_decorator: member.has_decorator,
831 decorator_names: member.decorator_names.clone(),
832 is_instance_returning_static: member.is_instance_returning_static,
833 is_self_returning: member.is_self_returning,
834 }
835 }
836 }
837
838 impl From<CachedMemberInfo> for MemberInfo {
839 fn from(cached: CachedMemberInfo) -> Self {
840 Self {
841 name: cached.name,
842 kind: cached.kind,
843 span: Span::new(cached.span[0], cached.span[1]),
844 has_decorator: cached.has_decorator,
845 decorator_names: cached.decorator_names,
846 is_instance_returning_static: cached.is_instance_returning_static,
847 is_self_returning: cached.is_self_returning,
848 }
849 }
850 }
851
852 pub fn serialize<S: Serializer>(
853 members: &[MemberInfo],
854 serializer: S,
855 ) -> Result<S::Ok, S::Error> {
856 let mirror: Vec<CachedMemberInfo> = members.iter().map(CachedMemberInfo::from).collect();
857 mirror.serialize(serializer)
858 }
859
860 pub fn deserialize<'de, D: Deserializer<'de>>(
861 deserializer: D,
862 ) -> Result<Vec<MemberInfo>, D::Error> {
863 let mirror = Vec::<CachedMemberInfo>::deserialize(deserializer)?;
864 Ok(mirror.into_iter().map(MemberInfo::from).collect())
865 }
866}
867
868#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
874pub struct GraphCacheMode {
875 pub resolver_options_hash: u64,
877 pub entry_points_hash: u64,
879 pub plugin_config_hash: u64,
881}
882
883impl GraphCacheMode {
884 #[must_use]
886 pub const fn new(
887 resolver_options_hash: u64,
888 entry_points_hash: u64,
889 plugin_config_hash: u64,
890 ) -> Self {
891 Self {
892 resolver_options_hash,
893 entry_points_hash,
894 plugin_config_hash,
895 }
896 }
897}
898
899#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
901pub struct GraphCacheFile {
902 pub key: StableFileKey,
904 pub file_id: FileId,
911 pub fingerprint: SourceFingerprint,
913}
914
915impl GraphCacheFile {
916 #[must_use]
918 fn from_discovered_file(
919 root: &Path,
920 file: &DiscoveredFile,
921 fingerprint: SourceFingerprint,
922 ) -> Self {
923 Self {
924 key: StableFileKey::from_root_relative(root, &file.path),
925 file_id: file.id,
926 fingerprint,
927 }
928 }
929}
930
931#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
933pub struct GraphCacheManifest {
934 pub version: u32,
936 pub mode: GraphCacheMode,
938 pub files: Vec<GraphCacheFile>,
940}
941
942impl GraphCacheManifest {
943 #[must_use]
945 fn new(mode: GraphCacheMode, mut files: Vec<GraphCacheFile>) -> Self {
946 sort_files(&mut files);
947 Self {
948 version: GRAPH_CACHE_VERSION,
949 mode,
950 files,
951 }
952 }
953
954 pub fn from_discovered_files(
956 root: &Path,
957 files: &[DiscoveredFile],
958 mode: GraphCacheMode,
959 mut fingerprint_for_path: impl FnMut(&Path) -> SourceFingerprint,
960 ) -> Self {
961 let rows = files
962 .iter()
963 .map(|file| {
964 GraphCacheFile::from_discovered_file(root, file, fingerprint_for_path(&file.path))
965 })
966 .collect();
967 Self::new(mode, rows)
968 }
969
970 #[must_use]
972 pub fn matches_inputs(&self, current: &Self) -> bool {
973 self.version == GRAPH_CACHE_VERSION
974 && current.version == GRAPH_CACHE_VERSION
975 && self.mode == current.mode
976 && self.files == current.files
977 }
978
979 #[must_use]
986 pub fn matches_resolution_inputs(&self, current: &Self) -> bool {
987 self.version == GRAPH_CACHE_VERSION
988 && current.version == GRAPH_CACHE_VERSION
989 && self.mode == current.mode
990 && self.files.len() == current.files.len()
991 && self
992 .files
993 .iter()
994 .zip(current.files.iter())
995 .all(|(cached, current)| {
996 cached.key == current.key && cached.fingerprint == current.fingerprint
997 })
998 }
999}
1000
1001fn sort_files(files: &mut [GraphCacheFile]) {
1002 files.sort_unstable_by(|a, b| a.key.cmp(&b.key));
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use std::path::{Path, PathBuf};
1008
1009 use fallow_types::discover::FileId;
1010 use rustc_hash::FxHashMap;
1011
1012 use super::*;
1013
1014 fn file(id: u32, path: &str) -> DiscoveredFile {
1015 DiscoveredFile {
1016 id: FileId(id),
1017 path: PathBuf::from(path),
1018 size_bytes: 1,
1019 }
1020 }
1021
1022 fn mode() -> GraphCacheMode {
1023 GraphCacheMode::new(1, 2, 3)
1024 }
1025
1026 fn fingerprints(pairs: &[(&str, SourceFingerprint)]) -> FxHashMap<PathBuf, SourceFingerprint> {
1027 pairs
1028 .iter()
1029 .map(|(path, fingerprint)| (PathBuf::from(path), *fingerprint))
1030 .collect()
1031 }
1032
1033 fn manifest(
1034 files: &[DiscoveredFile],
1035 mode: GraphCacheMode,
1036 map: &FxHashMap<PathBuf, SourceFingerprint>,
1037 ) -> GraphCacheManifest {
1038 GraphCacheManifest::from_discovered_files(Path::new("/project"), files, mode, |path| {
1039 *map.get(path).unwrap()
1040 })
1041 }
1042
1043 fn import_info(source: &str) -> ImportInfo {
1044 ImportInfo {
1045 source: source.to_string(),
1046 imported_name: fallow_types::extract::ImportedName::SideEffect,
1047 local_name: String::new(),
1048 is_type_only: false,
1049 is_type_only_star: false,
1050 from_style: false,
1051 span: Span::new(0, 0),
1052 source_span: Span::new(0, 0),
1053 }
1054 }
1055
1056 #[test]
1057 fn cached_import_info_round_trip_keeps_every_field() {
1058 let original = ImportInfo {
1063 source: "./impl".to_string(),
1064 imported_name: fallow_types::extract::ImportedName::Namespace,
1065 local_name: "ns".to_string(),
1066 is_type_only: true,
1067 is_type_only_star: true,
1068 from_style: true,
1069 span: Span::new(3, 41),
1070 source_span: Span::new(17, 25),
1071 };
1072
1073 let restored = ImportInfo::from(CachedImportInfo::from(&original));
1074
1075 assert_eq!(format!("{restored:?}"), format!("{original:?}"));
1076 }
1077
1078 #[test]
1079 fn manifest_sorts_by_stable_file_key() {
1080 let files = vec![file(0, "/project/src/z.ts"), file(1, "/project/src/a.ts")];
1081 let map = fingerprints(&[
1082 ("/project/src/z.ts", SourceFingerprint::new(10, 1)),
1083 ("/project/src/a.ts", SourceFingerprint::new(20, 1)),
1084 ]);
1085
1086 let manifest = manifest(&files, mode(), &map);
1087
1088 let keys: Vec<&str> = manifest
1089 .files
1090 .iter()
1091 .map(|file| file.key.as_str())
1092 .collect();
1093 assert_eq!(keys, vec!["src/a.ts", "src/z.ts"]);
1094 }
1095
1096 #[test]
1097 fn manifest_misses_on_file_id_shift_until_graph_remap_exists() {
1098 let before = vec![file(0, "/project/src/a.ts"), file(1, "/project/src/c.ts")];
1099 let after = vec![file(9, "/project/src/c.ts"), file(2, "/project/src/a.ts")];
1100 let map = fingerprints(&[
1101 ("/project/src/a.ts", SourceFingerprint::new(10, 1)),
1102 ("/project/src/c.ts", SourceFingerprint::new(20, 1)),
1103 ]);
1104
1105 let cached = manifest(&before, mode(), &map);
1106 let current = manifest(&after, mode(), &map);
1107
1108 assert!(
1109 !cached.matches_inputs(¤t),
1110 "the persisted graph is still FileId-keyed, so FileId shifts cannot trust it"
1111 );
1112 assert!(
1113 cached.matches_resolution_inputs(¤t),
1114 "stable-keyed resolver payloads may be remapped across FileId shifts"
1115 );
1116 }
1117
1118 #[test]
1119 fn cached_resolve_result_remaps_internal_targets_by_stable_key() {
1120 let key_a = StableFileKey::from_root_relative(
1121 Path::new("/project"),
1122 Path::new("/project/src/a.ts"),
1123 );
1124 let key_b = StableFileKey::from_root_relative(
1125 Path::new("/project"),
1126 Path::new("/project/src/b.ts"),
1127 );
1128 let key_by_file_id =
1129 FxHashMap::from_iter([(FileId(0), key_a.clone()), (FileId(1), key_b.clone())]);
1130 let id_by_key = FxHashMap::from_iter([(key_a, FileId(7)), (key_b, FileId(9))]);
1131
1132 let cached = CachedResolveResult::from_resolve_result(
1133 &ResolveResult::InternalPackageModule {
1134 file_id: FileId(1),
1135 package_name: "@scope/pkg".to_string(),
1136 },
1137 &key_by_file_id,
1138 )
1139 .expect("target file id should map to a stable key");
1140
1141 let restored = cached
1142 .into_resolve_result(&id_by_key)
1143 .expect("stable key should map to current FileId");
1144
1145 assert!(matches!(
1146 restored,
1147 ResolveResult::InternalPackageModule {
1148 file_id: FileId(9),
1149 ref package_name,
1150 } if package_name == "@scope/pkg"
1151 ));
1152 }
1153
1154 #[test]
1155 fn cached_resolve_result_preserves_commonjs_provenance() {
1156 let key = StableFileKey::from_root_relative(
1157 Path::new("/project"),
1158 Path::new("/project/src/dependency.ts"),
1159 );
1160 let key_by_file_id = FxHashMap::from_iter([(FileId(3), key.clone())]);
1161 let id_by_key = FxHashMap::from_iter([(key, FileId(8))]);
1162
1163 let cached = CachedResolveResult::from_resolve_result(
1164 &ResolveResult::CommonJsInternalModule(FileId(3)),
1165 &key_by_file_id,
1166 )
1167 .expect("CommonJS target should map to a stable key");
1168 let restored = cached
1169 .into_resolve_result(&id_by_key)
1170 .expect("stable key should map to the current FileId");
1171
1172 assert!(matches!(
1173 restored,
1174 ResolveResult::CommonJsInternalModule(FileId(8))
1175 ));
1176 }
1177
1178 #[test]
1179 fn cached_resolve_result_preserves_commonjs_bare_package_provenance() {
1180 let cached = CachedResolveResult::from_resolve_result(
1181 &ResolveResult::CommonJsNpmPackage("shared-package".to_string()),
1182 &FxHashMap::default(),
1183 )
1184 .expect("bare CommonJS package should not need a stable file key");
1185 let restored = cached
1186 .into_resolve_result(&FxHashMap::default())
1187 .expect("bare CommonJS package should restore without a file map");
1188
1189 assert!(matches!(
1190 restored,
1191 ResolveResult::CommonJsNpmPackage(package_name)
1192 if package_name == "shared-package"
1193 ));
1194 }
1195
1196 #[test]
1197 fn cache_resolved_project_rejects_unknown_internal_targets() {
1198 let files = vec![file(0, "/project/src/a.ts")];
1199 let module = ResolvedModule {
1200 file_id: FileId(0),
1201 path: PathBuf::from("/project/src/a.ts"),
1202 resolved_imports: vec![ResolvedImport {
1203 info: import_info("./missing"),
1204 target: ResolveResult::InternalModule(FileId(1)),
1205 }],
1206 ..ResolvedModule::default()
1207 };
1208 let project = ResolvedProject {
1209 modules: vec![module],
1210 replaced_module_targets: Vec::new(),
1211 };
1212
1213 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1214
1215 assert!(cached.is_none());
1216 }
1217
1218 #[test]
1219 fn cached_replaced_target_remaps_both_file_ids_by_stable_key() {
1220 let source_key = StableFileKey::from_root_relative(
1221 Path::new("/project"),
1222 Path::new("/project/src/example.test.ts"),
1223 );
1224 let target_key = StableFileKey::from_root_relative(
1225 Path::new("/project"),
1226 Path::new("/project/src/dependency.ts"),
1227 );
1228 let key_by_file_id = FxHashMap::from_iter([
1229 (FileId(2), source_key.clone()),
1230 (FileId(3), target_key.clone()),
1231 ]);
1232 let id_by_key = FxHashMap::from_iter([(source_key, FileId(8)), (target_key, FileId(9))]);
1233 let resolved = ResolvedReplacedModuleTarget {
1234 source_file: FileId(2),
1235 target_file: FileId(3),
1236 };
1237
1238 let cached = CachedResolvedReplacedModuleTarget::from_resolved(resolved, &key_by_file_id)
1239 .expect("both file ids should map to stable keys");
1240 let restored = cached
1241 .into_resolved(&id_by_key)
1242 .expect("both stable keys should map to current file ids");
1243
1244 assert_eq!(
1245 restored,
1246 ResolvedReplacedModuleTarget {
1247 source_file: FileId(8),
1248 target_file: FileId(9),
1249 }
1250 );
1251 }
1252
1253 #[test]
1254 fn cache_resolved_project_rejects_unknown_replacement_targets() {
1255 let files = vec![file(0, "/project/src/example.test.ts")];
1256 let project = ResolvedProject {
1257 modules: vec![ResolvedModule {
1258 file_id: FileId(0),
1259 path: PathBuf::from("/project/src/example.test.ts"),
1260 ..ResolvedModule::default()
1261 }],
1262 replaced_module_targets: vec![ResolvedReplacedModuleTarget {
1263 source_file: FileId(0),
1264 target_file: FileId(1),
1265 }],
1266 };
1267
1268 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1269
1270 assert!(cached.is_none());
1271 }
1272
1273 #[test]
1274 fn manifest_misses_on_fingerprint_change() {
1275 let files = vec![file(0, "/project/src/a.ts")];
1276 let cached_map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
1277 let current_map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(11, 1))]);
1278
1279 let cached = manifest(&files, mode(), &cached_map);
1280 let current = manifest(&files, mode(), ¤t_map);
1281
1282 assert!(!cached.matches_inputs(¤t));
1283 }
1284
1285 #[test]
1286 fn manifest_misses_on_file_deletion() {
1287 let before = vec![
1288 file(0, "/project/src/a.ts"),
1289 file(1, "/project/src/deleted.ts"),
1290 ];
1291 let after = vec![file(0, "/project/src/a.ts")];
1292 let map = fingerprints(&[
1293 ("/project/src/a.ts", SourceFingerprint::new(10, 1)),
1294 ("/project/src/deleted.ts", SourceFingerprint::new(20, 1)),
1295 ]);
1296
1297 let cached = manifest(&before, mode(), &map);
1298 let current = manifest(&after, mode(), &map);
1299
1300 assert!(!cached.matches_inputs(¤t));
1301 }
1302
1303 #[test]
1304 fn manifest_misses_on_file_rename_with_same_fingerprint() {
1305 let before = vec![file(0, "/project/src/old.ts")];
1306 let after = vec![file(0, "/project/src/new.ts")];
1307 let map = fingerprints(&[
1308 ("/project/src/old.ts", SourceFingerprint::new(10, 1)),
1309 ("/project/src/new.ts", SourceFingerprint::new(10, 1)),
1310 ]);
1311
1312 let cached = manifest(&before, mode(), &map);
1313 let current = manifest(&after, mode(), &map);
1314
1315 assert!(!cached.matches_inputs(¤t));
1316 }
1317
1318 #[test]
1319 fn manifest_misses_on_workspace_scoped_file_set() {
1320 let full_project = vec![
1321 file(0, "/project/packages/app/src/index.ts"),
1322 file(1, "/project/packages/shared/src/index.ts"),
1323 ];
1324 let workspace_scoped = vec![file(0, "/project/packages/app/src/index.ts")];
1325 let map = fingerprints(&[
1326 (
1327 "/project/packages/app/src/index.ts",
1328 SourceFingerprint::new(10, 1),
1329 ),
1330 (
1331 "/project/packages/shared/src/index.ts",
1332 SourceFingerprint::new(20, 1),
1333 ),
1334 ]);
1335
1336 let cached = manifest(&full_project, mode(), &map);
1337 let current = manifest(&workspace_scoped, mode(), &map);
1338
1339 assert!(!cached.matches_inputs(¤t));
1340 assert!(!cached.matches_resolution_inputs(¤t));
1341 }
1342
1343 #[test]
1344 fn manifest_misses_on_mode_change() {
1345 let files = vec![file(0, "/project/src/a.ts")];
1346 let map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
1347
1348 let cached = manifest(&files, mode(), &map);
1349 let current = manifest(&files, GraphCacheMode::new(1, 99, 3), &map);
1350
1351 assert!(!cached.matches_inputs(¤t));
1352 }
1353
1354 #[test]
1355 fn manifest_misses_on_version_change() {
1356 let files = vec![file(0, "/project/src/a.ts")];
1357 let map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
1358 let mut cached = manifest(&files, mode(), &map);
1359 let current = manifest(&files, mode(), &map);
1360
1361 cached.version = GRAPH_CACHE_VERSION + 1;
1362
1363 assert!(!cached.matches_inputs(¤t));
1364 }
1365}