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 = 56;
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}
330
331impl CachedResolvedModule {
332 fn from_resolved(
333 module: &ResolvedModule,
334 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
335 ) -> Option<Self> {
336 Some(Self {
337 key: key_by_file_id.get(&module.file_id)?.clone(),
338 resolved_imports: module
339 .resolved_imports
340 .iter()
341 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
342 .collect::<Option<Vec<_>>>()?,
343 resolved_dynamic_imports: module
344 .resolved_dynamic_imports
345 .iter()
346 .map(|import| CachedResolvedImport::from_resolved(import, key_by_file_id))
347 .collect::<Option<Vec<_>>>()?,
348 re_exports: module
349 .re_exports
350 .iter()
351 .map(|re_export| CachedResolvedReExport::from_resolved(re_export, key_by_file_id))
352 .collect::<Option<Vec<_>>>()?,
353 resolved_dynamic_pattern_targets: module
354 .resolved_dynamic_patterns
355 .iter()
356 .map(|(_, targets)| {
357 targets
358 .iter()
359 .map(|target| key_by_file_id.get(target).cloned())
360 .collect::<Option<Vec<_>>>()
361 })
362 .collect::<Option<Vec<_>>>()?,
363 })
364 }
365}
366
367#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
369struct CachedResolvedReplacedModuleTarget {
370 source_key: StableFileKey,
371 target_key: StableFileKey,
372}
373
374impl CachedResolvedReplacedModuleTarget {
375 fn from_resolved(
376 target: ResolvedReplacedModuleTarget,
377 key_by_file_id: &rustc_hash::FxHashMap<FileId, StableFileKey>,
378 ) -> Option<Self> {
379 Some(Self {
380 source_key: key_by_file_id.get(&target.source_file)?.clone(),
381 target_key: key_by_file_id.get(&target.target_file)?.clone(),
382 })
383 }
384
385 fn into_resolved(
386 self,
387 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
388 ) -> Option<ResolvedReplacedModuleTarget> {
389 Some(ResolvedReplacedModuleTarget {
390 source_file: *id_by_key.get(&self.source_key)?,
391 target_file: *id_by_key.get(&self.target_key)?,
392 })
393 }
394}
395
396#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
398pub struct CachedResolvedProject {
399 modules: Vec<CachedResolvedModule>,
400 replaced_module_targets: Vec<CachedResolvedReplacedModuleTarget>,
401}
402
403#[must_use]
405pub fn cache_resolved_project(
406 root: &Path,
407 files: &[DiscoveredFile],
408 resolved: &ResolvedProject,
409) -> Option<CachedResolvedProject> {
410 let key_by_file_id = stable_key_by_file_id(root, files);
411 let modules = resolved
412 .modules
413 .iter()
414 .map(|module| CachedResolvedModule::from_resolved(module, &key_by_file_id))
415 .collect::<Option<Vec<_>>>()?;
416 let replaced_module_targets = resolved
417 .replaced_module_targets
418 .iter()
419 .copied()
420 .map(|target| CachedResolvedReplacedModuleTarget::from_resolved(target, &key_by_file_id))
421 .collect::<Option<Vec<_>>>()?;
422 Some(CachedResolvedProject {
423 modules,
424 replaced_module_targets,
425 })
426}
427
428#[must_use]
434pub fn restore_resolved_project(
435 root: &Path,
436 modules: &[fallow_types::extract::ModuleInfo],
437 files: &[DiscoveredFile],
438 cached: &CachedResolvedProject,
439) -> Option<ResolvedProject> {
440 if modules.len() != cached.modules.len() {
441 return None;
442 }
443
444 let mut indexes = RestoreResolvedModuleIndexes::new(root, modules, files);
445 let resolved_modules = cached
446 .modules
447 .iter()
448 .map(|entry| restore_cached_resolved_module(entry, &mut indexes))
449 .collect::<Option<Vec<_>>>()?;
450 let mut replaced_module_targets = cached
451 .replaced_module_targets
452 .iter()
453 .cloned()
454 .map(|target| target.into_resolved(&indexes.file_ids))
455 .collect::<Option<Vec<_>>>()?;
456 replaced_module_targets
457 .sort_unstable_by_key(|target| (target.source_file.0, target.target_file.0));
458 replaced_module_targets.dedup();
459 Some(ResolvedProject {
460 modules: resolved_modules,
461 replaced_module_targets,
462 work: crate::resolve::ResolveWork::default(),
463 })
464}
465
466struct RestoreResolvedModuleIndexes<'a> {
467 file_ids: rustc_hash::FxHashMap<StableFileKey, FileId>,
468 modules: rustc_hash::FxHashMap<StableFileKey, &'a fallow_types::extract::ModuleInfo>,
469 paths: rustc_hash::FxHashMap<StableFileKey, std::path::PathBuf>,
470}
471
472impl<'a> RestoreResolvedModuleIndexes<'a> {
473 fn new(
474 root: &Path,
475 modules: &'a [fallow_types::extract::ModuleInfo],
476 files: &[DiscoveredFile],
477 ) -> Self {
478 let key_by_file_id = stable_key_by_file_id(root, files);
479 let id_by_key: rustc_hash::FxHashMap<_, _> = key_by_file_id
480 .iter()
481 .map(|(file_id, key)| (key.clone(), *file_id))
482 .collect();
483 let by_key: rustc_hash::FxHashMap<_, _> = modules
484 .iter()
485 .filter_map(|module| {
486 key_by_file_id
487 .get(&module.file_id)
488 .map(|key| (key.clone(), module))
489 })
490 .collect();
491 let path_by_key: rustc_hash::FxHashMap<_, _> = files
492 .iter()
493 .map(|file| {
494 (
495 StableFileKey::from_root_relative(root, &file.path),
496 file.path.clone(),
497 )
498 })
499 .collect();
500
501 Self {
502 file_ids: id_by_key,
503 modules: by_key,
504 paths: path_by_key,
505 }
506 }
507}
508
509fn restore_cached_resolved_module(
510 entry: &CachedResolvedModule,
511 indexes: &mut RestoreResolvedModuleIndexes<'_>,
512) -> Option<ResolvedModule> {
513 let module = indexes.modules.remove(&entry.key)?;
514 let path = indexes.paths.get(&entry.key)?.clone();
515 let resolved_dynamic_pattern_targets =
516 restore_dynamic_pattern_targets(entry, module, &indexes.file_ids)?;
517
518 Some(ResolvedModule {
519 file_id: module.file_id,
520 path,
521 exports: Arc::clone(&module.exports),
522 re_exports: entry
523 .re_exports
524 .iter()
525 .cloned()
526 .map(|re_export| re_export.into_resolved(&indexes.file_ids))
527 .collect::<Option<Vec<_>>>()?,
528 resolved_imports: entry
529 .resolved_imports
530 .iter()
531 .cloned()
532 .map(|import| import.into_resolved(&indexes.file_ids))
533 .collect::<Option<Vec<_>>>()?,
534 resolved_dynamic_imports: entry
535 .resolved_dynamic_imports
536 .iter()
537 .cloned()
538 .map(|import| import.into_resolved(&indexes.file_ids))
539 .collect::<Option<Vec<_>>>()?,
540 resolved_dynamic_patterns: module
541 .dynamic_import_patterns
542 .iter()
543 .cloned()
544 .zip(resolved_dynamic_pattern_targets)
545 .collect(),
546 member_accesses: Arc::clone(&module.member_accesses),
547 semantic_facts: Arc::clone(&module.semantic_facts),
548 whole_object_uses: Arc::clone(&module.whole_object_uses),
549 has_cjs_exports: module.has_cjs_exports,
550 has_angular_component_template_url: module.has_angular_component_template_url,
551 unused_import_bindings: module.unused_import_bindings.iter().cloned().collect(),
552 type_referenced_import_bindings: module.type_referenced_import_bindings.clone(),
553 value_referenced_import_bindings: module.value_referenced_import_bindings.clone(),
554 namespace_object_aliases: module.namespace_object_aliases.clone(),
555 exported_factory_returns: Arc::clone(&module.exported_factory_returns),
556 exported_factory_return_object_shapes: Arc::clone(
557 &module.exported_factory_return_object_shapes,
558 ),
559 type_member_types: Arc::clone(&module.type_member_types),
560 })
561}
562
563fn restore_dynamic_pattern_targets(
564 entry: &CachedResolvedModule,
565 module: &fallow_types::extract::ModuleInfo,
566 id_by_key: &rustc_hash::FxHashMap<StableFileKey, FileId>,
567) -> Option<Vec<Vec<FileId>>> {
568 if entry.resolved_dynamic_pattern_targets.len() != module.dynamic_import_patterns.len() {
569 return None;
570 }
571 entry
572 .resolved_dynamic_pattern_targets
573 .iter()
574 .map(|targets| {
575 targets
576 .iter()
577 .map(|key| id_by_key.get(key).copied())
578 .collect::<Option<Vec<_>>>()
579 })
580 .collect()
581}
582
583fn stable_key_by_file_id(
584 root: &Path,
585 files: &[DiscoveredFile],
586) -> rustc_hash::FxHashMap<FileId, StableFileKey> {
587 files
588 .iter()
589 .map(|file| (file.id, StableFileKey::from_root_relative(root, &file.path)))
590 .collect()
591}
592
593fn span_to_pair(span: Span) -> [u32; 2] {
594 [span.start, span.end]
595}
596
597fn pair_to_span(pair: [u32; 2]) -> Span {
598 Span::new(pair[0], pair[1])
599}
600
601pub(crate) mod span_serde {
609 use oxc_span::Span;
610 use serde::{Deserialize, Deserializer, Serialize, Serializer};
611
612 #[expect(
613 clippy::trivially_copy_pass_by_ref,
614 reason = "serde `serialize_with` / `with` requires a `&T` signature"
615 )]
616 pub fn serialize<S: Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
617 [span.start, span.end].serialize(serializer)
618 }
619
620 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Span, D::Error> {
621 let [start, end] = <[u32; 2]>::deserialize(deserializer)?;
622 Ok(Span::new(start, end))
623 }
624}
625
626pub(crate) mod member_serde {
635 use fallow_types::extract::{MemberInfo, MemberKind};
636 use oxc_span::Span;
637 use serde::{Deserialize, Deserializer, Serialize, Serializer};
638
639 #[derive(Serialize, Deserialize)]
640 struct CachedMemberInfo {
641 name: String,
642 kind: MemberKind,
643 span: [u32; 2],
644 has_decorator: bool,
645 decorator_names: Vec<String>,
646 is_instance_returning_static: bool,
647 is_self_returning: bool,
648 }
649
650 impl From<&MemberInfo> for CachedMemberInfo {
651 fn from(member: &MemberInfo) -> Self {
652 Self {
653 name: member.name.clone(),
654 kind: member.kind,
655 span: [member.span.start, member.span.end],
656 has_decorator: member.has_decorator,
657 decorator_names: member.decorator_names.clone(),
658 is_instance_returning_static: member.is_instance_returning_static,
659 is_self_returning: member.is_self_returning,
660 }
661 }
662 }
663
664 impl From<CachedMemberInfo> for MemberInfo {
665 fn from(cached: CachedMemberInfo) -> Self {
666 Self {
667 name: cached.name,
668 kind: cached.kind,
669 span: Span::new(cached.span[0], cached.span[1]),
670 has_decorator: cached.has_decorator,
671 decorator_names: cached.decorator_names,
672 is_instance_returning_static: cached.is_instance_returning_static,
673 is_self_returning: cached.is_self_returning,
674 }
675 }
676 }
677
678 pub fn serialize<S: Serializer>(
679 members: &[MemberInfo],
680 serializer: S,
681 ) -> Result<S::Ok, S::Error> {
682 let mirror: Vec<CachedMemberInfo> = members.iter().map(CachedMemberInfo::from).collect();
683 mirror.serialize(serializer)
684 }
685
686 pub fn deserialize<'de, D: Deserializer<'de>>(
687 deserializer: D,
688 ) -> Result<Vec<MemberInfo>, D::Error> {
689 let mirror = Vec::<CachedMemberInfo>::deserialize(deserializer)?;
690 Ok(mirror.into_iter().map(MemberInfo::from).collect())
691 }
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
700pub struct GraphCacheMode {
701 pub resolver_options_hash: u64,
703 pub entry_points_hash: u64,
705 pub plugin_config_hash: u64,
707}
708
709impl GraphCacheMode {
710 #[must_use]
712 pub const fn new(
713 resolver_options_hash: u64,
714 entry_points_hash: u64,
715 plugin_config_hash: u64,
716 ) -> Self {
717 Self {
718 resolver_options_hash,
719 entry_points_hash,
720 plugin_config_hash,
721 }
722 }
723}
724
725#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
727pub struct GraphCacheFile {
728 pub key: StableFileKey,
730 pub file_id: FileId,
737 pub content_hash: u64,
749}
750
751impl GraphCacheFile {
752 #[must_use]
754 fn from_discovered_file(root: &Path, file: &DiscoveredFile, content_hash: u64) -> Self {
755 Self {
756 key: StableFileKey::from_root_relative(root, &file.path),
757 file_id: file.id,
758 content_hash,
759 }
760 }
761}
762
763#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
765pub struct GraphCacheManifest {
766 pub root: PathBuf,
768 pub version: u32,
770 pub mode: GraphCacheMode,
772 pub files: Vec<GraphCacheFile>,
774}
775
776impl GraphCacheManifest {
777 #[must_use]
779 fn new(root: &Path, mode: GraphCacheMode, mut files: Vec<GraphCacheFile>) -> Self {
780 sort_files(&mut files);
781 Self {
782 root: root.to_path_buf(),
783 version: GRAPH_CACHE_VERSION,
784 mode,
785 files,
786 }
787 }
788
789 pub fn from_discovered_files(
791 root: &Path,
792 files: &[DiscoveredFile],
793 mode: GraphCacheMode,
794 mut content_hash_for_file: impl FnMut(&DiscoveredFile) -> u64,
795 ) -> Self {
796 let rows = files
797 .iter()
798 .map(|file| {
799 GraphCacheFile::from_discovered_file(root, file, content_hash_for_file(file))
800 })
801 .collect();
802 Self::new(root, mode, rows)
803 }
804
805 #[must_use]
807 pub fn matches_inputs(&self, current: &Self) -> bool {
808 self.version == GRAPH_CACHE_VERSION
809 && current.version == GRAPH_CACHE_VERSION
810 && self.root == current.root
811 && self.mode == current.mode
812 && self.files == current.files
813 }
814
815 #[must_use]
823 pub fn classify_resolution_mismatch(&self, current: &Self) -> Option<CacheRejection> {
824 if self.version != GRAPH_CACHE_VERSION || current.version != GRAPH_CACHE_VERSION {
825 return Some(CacheRejection::VersionMismatch);
826 }
827 if self.root != current.root {
828 return Some(CacheRejection::RootMismatch);
829 }
830 if self.mode != current.mode {
831 return Some(CacheRejection::ModeMismatch);
832 }
833 if self.files.len() != current.files.len()
834 || self
835 .files
836 .iter()
837 .zip(current.files.iter())
838 .any(|(cached, current)| cached.key != current.key)
839 {
840 return Some(CacheRejection::FileSetChanged);
841 }
842 self.files
843 .iter()
844 .zip(current.files.iter())
845 .any(|(cached, current)| cached.content_hash != current.content_hash)
846 .then_some(CacheRejection::FingerprintChanged)
847 }
848}
849
850fn sort_files(files: &mut [GraphCacheFile]) {
851 files.sort_unstable_by(|a, b| a.key.cmp(&b.key));
852}
853
854#[cfg(test)]
855mod tests {
856 use std::path::{Path, PathBuf};
857
858 use fallow_types::discover::FileId;
859 use fallow_types::extract::{DynamicImportPattern, ModuleInfo, ModuleLoadMechanism};
860 use rustc_hash::FxHashMap;
861
862 use super::*;
863
864 fn file(id: u32, path: &str) -> DiscoveredFile {
865 DiscoveredFile {
866 id: FileId(id),
867 path: PathBuf::from(path),
868 size_bytes: 1,
869 }
870 }
871
872 fn mode() -> GraphCacheMode {
873 GraphCacheMode::new(1, 2, 3)
874 }
875
876 fn content_hashes(pairs: &[(&str, u64)]) -> FxHashMap<PathBuf, u64> {
877 pairs
878 .iter()
879 .map(|(path, hash)| (PathBuf::from(path), *hash))
880 .collect()
881 }
882
883 fn manifest(
884 files: &[DiscoveredFile],
885 mode: GraphCacheMode,
886 map: &FxHashMap<PathBuf, u64>,
887 ) -> GraphCacheManifest {
888 GraphCacheManifest::from_discovered_files(Path::new("/project"), files, mode, |file| {
889 *map.get(&file.path).unwrap()
890 })
891 }
892
893 fn import_info(source: &str) -> ImportInfo {
894 ImportInfo {
895 source: source.to_string(),
896 imported_name: fallow_types::extract::ImportedName::SideEffect,
897 local_name: String::new(),
898 is_type_only: false,
899 is_type_only_star: false,
900 from_style: false,
901 span: Span::new(0, 0),
902 source_span: Span::new(0, 0),
903 }
904 }
905
906 #[test]
907 fn cached_import_info_round_trip_keeps_every_field() {
908 let original = ImportInfo {
913 source: "./impl".to_string(),
914 imported_name: fallow_types::extract::ImportedName::Namespace,
915 local_name: "ns".to_string(),
916 is_type_only: true,
917 is_type_only_star: true,
918 from_style: true,
919 span: Span::new(3, 41),
920 source_span: Span::new(17, 25),
921 };
922
923 let restored = ImportInfo::from(CachedImportInfo::from(&original));
924
925 assert_eq!(format!("{restored:?}"), format!("{original:?}"));
926 }
927
928 #[test]
929 fn manifest_sorts_by_stable_file_key() {
930 let files = vec![file(0, "/project/src/z.ts"), file(1, "/project/src/a.ts")];
931 let map = content_hashes(&[("/project/src/z.ts", 10), ("/project/src/a.ts", 20)]);
932
933 let manifest = manifest(&files, mode(), &map);
934
935 let keys: Vec<&str> = manifest
936 .files
937 .iter()
938 .map(|file| file.key.as_str())
939 .collect();
940 assert_eq!(keys, vec!["src/a.ts", "src/z.ts"]);
941 }
942
943 #[test]
944 fn manifest_misses_on_file_id_shift_until_graph_remap_exists() {
945 let before = vec![file(0, "/project/src/a.ts"), file(1, "/project/src/c.ts")];
946 let after = vec![file(9, "/project/src/c.ts"), file(2, "/project/src/a.ts")];
947 let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/c.ts", 20)]);
948
949 let cached = manifest(&before, mode(), &map);
950 let current = manifest(&after, mode(), &map);
951
952 assert!(
953 !cached.matches_inputs(¤t),
954 "the persisted graph is still FileId-keyed, so FileId shifts cannot trust it"
955 );
956 assert_eq!(
957 cached.classify_resolution_mismatch(¤t),
958 None,
959 "stable-keyed resolver payloads may be remapped across FileId shifts"
960 );
961 }
962
963 #[test]
964 fn cached_resolve_result_remaps_internal_targets_by_stable_key() {
965 let key_a = StableFileKey::from_root_relative(
966 Path::new("/project"),
967 Path::new("/project/src/a.ts"),
968 );
969 let key_b = StableFileKey::from_root_relative(
970 Path::new("/project"),
971 Path::new("/project/src/b.ts"),
972 );
973 let key_by_file_id =
974 FxHashMap::from_iter([(FileId(0), key_a.clone()), (FileId(1), key_b.clone())]);
975 let id_by_key = FxHashMap::from_iter([(key_a, FileId(7)), (key_b, FileId(9))]);
976
977 let cached = CachedResolveResult::from_resolve_result(
978 &ResolveResult::InternalPackageModule {
979 file_id: FileId(1),
980 package_name: "@scope/pkg".to_string(),
981 },
982 &key_by_file_id,
983 )
984 .expect("target file id should map to a stable key");
985
986 let restored = cached
987 .into_resolve_result(&id_by_key)
988 .expect("stable key should map to current FileId");
989
990 assert!(matches!(
991 restored,
992 ResolveResult::InternalPackageModule {
993 file_id: FileId(9),
994 ref package_name,
995 } if package_name == "@scope/pkg"
996 ));
997 }
998
999 #[test]
1000 fn cached_resolve_result_preserves_commonjs_provenance() {
1001 let key = StableFileKey::from_root_relative(
1002 Path::new("/project"),
1003 Path::new("/project/src/dependency.ts"),
1004 );
1005 let key_by_file_id = FxHashMap::from_iter([(FileId(3), key.clone())]);
1006 let id_by_key = FxHashMap::from_iter([(key, FileId(8))]);
1007
1008 let cached = CachedResolveResult::from_resolve_result(
1009 &ResolveResult::CommonJsInternalModule(FileId(3)),
1010 &key_by_file_id,
1011 )
1012 .expect("CommonJS target should map to a stable key");
1013 let restored = cached
1014 .into_resolve_result(&id_by_key)
1015 .expect("stable key should map to the current FileId");
1016
1017 assert!(matches!(
1018 restored,
1019 ResolveResult::CommonJsInternalModule(FileId(8))
1020 ));
1021 }
1022
1023 #[test]
1024 fn cached_resolve_result_preserves_commonjs_bare_package_provenance() {
1025 let cached = CachedResolveResult::from_resolve_result(
1026 &ResolveResult::CommonJsNpmPackage("shared-package".to_string()),
1027 &FxHashMap::default(),
1028 )
1029 .expect("bare CommonJS package should not need a stable file key");
1030 let restored = cached
1031 .into_resolve_result(&FxHashMap::default())
1032 .expect("bare CommonJS package should restore without a file map");
1033
1034 assert!(matches!(
1035 restored,
1036 ResolveResult::CommonJsNpmPackage(package_name)
1037 if package_name == "shared-package"
1038 ));
1039 }
1040
1041 #[test]
1042 fn cache_resolved_project_rejects_unknown_internal_targets() {
1043 let files = vec![file(0, "/project/src/a.ts")];
1044 let module = ResolvedModule {
1045 file_id: FileId(0),
1046 path: PathBuf::from("/project/src/a.ts"),
1047 resolved_imports: vec![ResolvedImport {
1048 info: import_info("./missing"),
1049 target: ResolveResult::InternalModule(FileId(1)),
1050 }],
1051 ..ResolvedModule::default()
1052 };
1053 let project = ResolvedProject {
1054 modules: vec![module],
1055 replaced_module_targets: Vec::new(),
1056 ..ResolvedProject::default()
1057 };
1058
1059 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1060
1061 assert!(cached.is_none());
1062 }
1063
1064 #[test]
1065 fn cached_dynamic_pattern_targets_preserve_empty_rows() {
1066 let files = vec![file(0, "/project/src/app.ts")];
1067 let pattern = DynamicImportPattern {
1068 prefix: "./missing/".into(),
1069 suffix: Some(".ts".into()),
1070 span: Span::new(0, 1),
1071 mechanism: ModuleLoadMechanism::EsModule,
1072 };
1073 let module = ModuleInfo {
1074 dynamic_import_patterns: vec![pattern.clone()],
1075 ..ModuleInfo::empty(FileId(0))
1076 };
1077 let resolved = ResolvedProject {
1078 modules: vec![ResolvedModule {
1079 file_id: FileId(0),
1080 path: PathBuf::from("/project/src/app.ts"),
1081 resolved_dynamic_patterns: vec![(pattern, Vec::new())],
1082 ..ResolvedModule::default()
1083 }],
1084 replaced_module_targets: Vec::new(),
1085 ..ResolvedProject::default()
1086 };
1087
1088 let cached = cache_resolved_project(Path::new("/project"), &files, &resolved)
1089 .expect("all dynamic pattern targets should have stable keys");
1090 let restored = restore_resolved_project(
1091 Path::new("/project"),
1092 std::slice::from_ref(&module),
1093 &files,
1094 &cached,
1095 )
1096 .expect("cached dynamic pattern rows should align with extracted patterns");
1097
1098 assert_eq!(restored.modules[0].resolved_dynamic_patterns.len(), 1);
1099 assert!(
1100 restored.modules[0].resolved_dynamic_patterns[0]
1101 .1
1102 .is_empty()
1103 );
1104
1105 let mut sparse_cached = cached;
1106 sparse_cached.modules[0]
1107 .resolved_dynamic_pattern_targets
1108 .clear();
1109 assert!(
1110 restore_resolved_project(
1111 Path::new("/project"),
1112 std::slice::from_ref(&module),
1113 &files,
1114 &sparse_cached,
1115 )
1116 .is_none(),
1117 "version-50 sparse rows must take the safe cache-miss path"
1118 );
1119 }
1120
1121 #[test]
1122 fn cached_replaced_target_remaps_both_file_ids_by_stable_key() {
1123 let source_key = StableFileKey::from_root_relative(
1124 Path::new("/project"),
1125 Path::new("/project/src/example.test.ts"),
1126 );
1127 let target_key = StableFileKey::from_root_relative(
1128 Path::new("/project"),
1129 Path::new("/project/src/dependency.ts"),
1130 );
1131 let key_by_file_id = FxHashMap::from_iter([
1132 (FileId(2), source_key.clone()),
1133 (FileId(3), target_key.clone()),
1134 ]);
1135 let id_by_key = FxHashMap::from_iter([(source_key, FileId(8)), (target_key, FileId(9))]);
1136 let resolved = ResolvedReplacedModuleTarget {
1137 source_file: FileId(2),
1138 target_file: FileId(3),
1139 };
1140
1141 let cached = CachedResolvedReplacedModuleTarget::from_resolved(resolved, &key_by_file_id)
1142 .expect("both file ids should map to stable keys");
1143 let restored = cached
1144 .into_resolved(&id_by_key)
1145 .expect("both stable keys should map to current file ids");
1146
1147 assert_eq!(
1148 restored,
1149 ResolvedReplacedModuleTarget {
1150 source_file: FileId(8),
1151 target_file: FileId(9),
1152 }
1153 );
1154 }
1155
1156 #[test]
1157 fn cache_resolved_project_rejects_unknown_replacement_targets() {
1158 let files = vec![file(0, "/project/src/example.test.ts")];
1159 let project = ResolvedProject {
1160 modules: vec![ResolvedModule {
1161 file_id: FileId(0),
1162 path: PathBuf::from("/project/src/example.test.ts"),
1163 ..ResolvedModule::default()
1164 }],
1165 replaced_module_targets: vec![ResolvedReplacedModuleTarget {
1166 source_file: FileId(0),
1167 target_file: FileId(1),
1168 }],
1169 ..ResolvedProject::default()
1170 };
1171
1172 let cached = cache_resolved_project(Path::new("/project"), &files, &project);
1173
1174 assert!(cached.is_none());
1175 }
1176
1177 #[test]
1178 fn manifest_misses_on_content_change() {
1179 let files = vec![file(0, "/project/src/a.ts")];
1180 let cached_map = content_hashes(&[("/project/src/a.ts", 10)]);
1181 let current_map = content_hashes(&[("/project/src/a.ts", 11)]);
1182
1183 let cached = manifest(&files, mode(), &cached_map);
1184 let current = manifest(&files, mode(), ¤t_map);
1185
1186 assert!(!cached.matches_inputs(¤t));
1187 }
1188
1189 #[test]
1190 fn manifest_misses_on_file_deletion() {
1191 let before = vec![
1192 file(0, "/project/src/a.ts"),
1193 file(1, "/project/src/deleted.ts"),
1194 ];
1195 let after = vec![file(0, "/project/src/a.ts")];
1196 let map = content_hashes(&[("/project/src/a.ts", 10), ("/project/src/deleted.ts", 20)]);
1197
1198 let cached = manifest(&before, mode(), &map);
1199 let current = manifest(&after, mode(), &map);
1200
1201 assert!(!cached.matches_inputs(¤t));
1202 }
1203
1204 #[test]
1205 fn manifest_misses_on_file_rename_with_same_content() {
1206 let before = vec![file(0, "/project/src/old.ts")];
1207 let after = vec![file(0, "/project/src/new.ts")];
1208 let map = content_hashes(&[("/project/src/old.ts", 10), ("/project/src/new.ts", 10)]);
1209
1210 let cached = manifest(&before, mode(), &map);
1211 let current = manifest(&after, mode(), &map);
1212
1213 assert!(!cached.matches_inputs(¤t));
1214 }
1215
1216 #[test]
1217 fn manifest_misses_on_workspace_scoped_file_set() {
1218 let full_project = vec![
1219 file(0, "/project/packages/app/src/index.ts"),
1220 file(1, "/project/packages/shared/src/index.ts"),
1221 ];
1222 let workspace_scoped = vec![file(0, "/project/packages/app/src/index.ts")];
1223 let map = content_hashes(&[
1224 ("/project/packages/app/src/index.ts", 10),
1225 ("/project/packages/shared/src/index.ts", 20),
1226 ]);
1227
1228 let cached = manifest(&full_project, mode(), &map);
1229 let current = manifest(&workspace_scoped, mode(), &map);
1230
1231 assert!(!cached.matches_inputs(¤t));
1232 assert_eq!(
1233 cached.classify_resolution_mismatch(¤t),
1234 Some(CacheRejection::FileSetChanged)
1235 );
1236 }
1237
1238 #[test]
1239 fn manifest_misses_on_mode_change() {
1240 let files = vec![file(0, "/project/src/a.ts")];
1241 let map = content_hashes(&[("/project/src/a.ts", 10)]);
1242
1243 let cached = manifest(&files, mode(), &map);
1244 let current = manifest(&files, GraphCacheMode::new(1, 99, 3), &map);
1245
1246 assert!(!cached.matches_inputs(¤t));
1247 }
1248
1249 #[test]
1250 fn manifest_misses_on_version_change() {
1251 let files = vec![file(0, "/project/src/a.ts")];
1252 let map = content_hashes(&[("/project/src/a.ts", 10)]);
1253 let mut cached = manifest(&files, mode(), &map);
1254 let current = manifest(&files, mode(), &map);
1255
1256 cached.version = GRAPH_CACHE_VERSION + 1;
1257
1258 assert!(!cached.matches_inputs(¤t));
1259 }
1260}