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