1use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5
6use dashmap::DashMap;
7use oxc_resolver::Resolver;
8use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
9use serde_json::Value;
10
11use fallow_config::TsconfigOutputMap;
12use fallow_types::discover::FileId;
13
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16pub enum ResolveResult {
17 InternalModule(FileId),
19 CommonJsInternalModule(FileId),
21 SyntheticAutoImport(FileId),
23 InternalPackageModule {
26 file_id: FileId,
28 package_name: String,
30 },
31 CommonJsInternalPackageModule {
33 file_id: FileId,
35 package_name: String,
37 },
38 ExternalFile(PathBuf),
40 NpmPackage(String),
42 CommonJsNpmPackage(String),
44 Unresolvable(String),
46}
47
48impl ResolveResult {
49 #[must_use]
51 pub const fn internal_file_id(&self) -> Option<FileId> {
52 match self {
53 Self::InternalModule(file_id)
54 | Self::CommonJsInternalModule(file_id)
55 | Self::SyntheticAutoImport(file_id)
56 | Self::InternalPackageModule { file_id, .. }
57 | Self::CommonJsInternalPackageModule { file_id, .. } => Some(*file_id),
58 Self::ExternalFile(_)
59 | Self::NpmPackage(_)
60 | Self::CommonJsNpmPackage(_)
61 | Self::Unresolvable(_) => None,
62 }
63 }
64
65 #[must_use]
67 pub const fn is_synthetic_auto_import(&self) -> bool {
68 matches!(self, Self::SyntheticAutoImport(_))
69 }
70
71 #[must_use]
73 pub const fn is_commonjs_require(&self) -> bool {
74 matches!(
75 self,
76 Self::CommonJsInternalModule(_)
77 | Self::CommonJsInternalPackageModule { .. }
78 | Self::CommonJsNpmPackage(_)
79 )
80 }
81
82 #[must_use]
84 pub const fn is_bare_package(&self) -> bool {
85 matches!(self, Self::NpmPackage(_) | Self::CommonJsNpmPackage(_))
86 }
87
88 #[must_use]
90 pub fn into_commonjs_require(self) -> Self {
91 match self {
92 Self::InternalModule(file_id) => Self::CommonJsInternalModule(file_id),
93 Self::InternalPackageModule {
94 file_id,
95 package_name,
96 } => Self::CommonJsInternalPackageModule {
97 file_id,
98 package_name,
99 },
100 Self::NpmPackage(package_name) => Self::CommonJsNpmPackage(package_name),
101 other => other,
102 }
103 }
104
105 #[must_use]
107 pub fn into_es_module(self) -> Self {
108 match self {
109 Self::CommonJsInternalModule(file_id) => Self::InternalModule(file_id),
110 Self::CommonJsInternalPackageModule {
111 file_id,
112 package_name,
113 } => Self::InternalPackageModule {
114 file_id,
115 package_name,
116 },
117 Self::CommonJsNpmPackage(package_name) => Self::NpmPackage(package_name),
118 other => other,
119 }
120 }
121
122 #[must_use]
124 pub fn package_usage_name(&self) -> Option<&str> {
125 match self {
126 Self::InternalPackageModule { package_name, .. }
127 | Self::CommonJsInternalPackageModule { package_name, .. }
128 | Self::NpmPackage(package_name)
129 | Self::CommonJsNpmPackage(package_name) => Some(package_name),
130 Self::InternalModule(_)
131 | Self::CommonJsInternalModule(_)
132 | Self::SyntheticAutoImport(_)
133 | Self::ExternalFile(_)
134 | Self::Unresolvable(_) => None,
135 }
136 }
137}
138
139#[derive(Debug, Default)]
141pub struct ResolvedProject {
142 pub modules: Vec<ResolvedModule>,
144 pub replaced_module_targets: Vec<ResolvedReplacedModuleTarget>,
146 pub work: super::ResolveWork,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct ResolvedReplacedModuleTarget {
154 pub source_file: FileId,
156 pub target_file: FileId,
158}
159
160#[derive(Debug, Clone)]
162pub struct ResolvedImport {
163 pub info: fallow_types::extract::ImportInfo,
165 pub target: ResolveResult,
167}
168
169#[derive(Debug, Clone)]
171pub struct ResolvedReExport {
172 pub info: fallow_types::extract::ReExportInfo,
174 pub target: ResolveResult,
176}
177
178pub enum ResolvedSourceEdge<'a> {
180 Import(&'a ResolvedImport),
182 ReExport(&'a ResolvedReExport),
184}
185
186impl<'a> ResolvedSourceEdge<'a> {
187 #[must_use]
189 pub fn source_specifier(&self) -> &'a str {
190 match self {
191 Self::Import(import) => &import.info.source,
192 Self::ReExport(re_export) => &re_export.info.source,
193 }
194 }
195
196 #[must_use]
198 pub const fn target(&self) -> &'a ResolveResult {
199 match self {
200 Self::Import(import) => &import.target,
201 Self::ReExport(re_export) => &re_export.target,
202 }
203 }
204
205 #[must_use]
207 pub const fn is_type_only(&self) -> bool {
208 match self {
209 Self::Import(import) => import.info.is_type_only,
210 Self::ReExport(re_export) => re_export.info.is_type_only,
211 }
212 }
213
214 #[must_use]
216 pub const fn span(&self) -> oxc_span::Span {
217 match self {
218 Self::Import(import) => import.info.span,
219 Self::ReExport(re_export) => re_export.info.span,
220 }
221 }
222
223 #[must_use]
225 pub const fn source_span(&self) -> oxc_span::Span {
226 match self {
227 Self::Import(import) => import.info.source_span,
228 Self::ReExport(re_export) => re_export.info.source_span,
229 }
230 }
231
232 #[must_use]
237 pub const fn statement_span(&self) -> oxc_span::Span {
238 match self {
239 Self::Import(import) => import.info.span,
240 Self::ReExport(re_export) => re_export.info.statement_span,
241 }
242 }
243}
244
245#[derive(Debug)]
251pub struct ResolvedModule {
252 pub file_id: FileId,
254 pub path: PathBuf,
256 pub exports: Arc<[fallow_types::extract::ExportInfo]>,
258 pub re_exports: Vec<ResolvedReExport>,
260 pub resolved_imports: Vec<ResolvedImport>,
262 pub resolved_dynamic_imports: Vec<ResolvedImport>,
264 pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
266 pub member_accesses: Arc<[fallow_types::extract::MemberAccess]>,
268 pub semantic_facts: Arc<[fallow_types::extract::SemanticFact]>,
270 pub whole_object_uses: Arc<[String]>,
272 pub has_cjs_exports: bool,
274 pub has_angular_component_template_url: bool,
278 pub unused_import_bindings: FxHashSet<String>,
280 pub type_referenced_import_bindings: Vec<String>,
282 pub value_referenced_import_bindings: Vec<String>,
284 pub namespace_object_aliases: Vec<fallow_types::extract::NamespaceObjectAlias>,
287 pub exported_factory_returns: Arc<[fallow_types::extract::FactoryReturnExport]>,
290 pub exported_factory_return_object_shapes:
294 Arc<[fallow_types::extract::FactoryReturnObjectShapeExport]>,
295 pub type_member_types: Arc<[fallow_types::extract::TypeMemberTypeEntry]>,
299 pub missing_export_targets: Vec<MissingExportTarget>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
309pub struct MissingExportTarget {
310 pub specifier: String,
312 pub paths: Vec<PathBuf>,
314}
315
316impl Default for ResolvedModule {
317 fn default() -> Self {
318 Self {
319 file_id: FileId(0),
320 path: PathBuf::new(),
321 exports: Arc::default(),
322 re_exports: vec![],
323 resolved_imports: vec![],
324 resolved_dynamic_imports: vec![],
325 resolved_dynamic_patterns: vec![],
326 member_accesses: Arc::default(),
327 semantic_facts: Arc::default(),
328 whole_object_uses: Arc::default(),
329 has_cjs_exports: false,
330 has_angular_component_template_url: false,
331 unused_import_bindings: FxHashSet::default(),
332 type_referenced_import_bindings: vec![],
333 value_referenced_import_bindings: vec![],
334 namespace_object_aliases: vec![],
335 exported_factory_returns: Arc::default(),
336 exported_factory_return_object_shapes: Arc::default(),
337 type_member_types: Arc::default(),
338 missing_export_targets: vec![],
339 }
340 }
341}
342
343impl ResolvedModule {
344 pub fn all_resolved_imports(&self) -> impl Iterator<Item = &ResolvedImport> {
350 self.resolved_imports
351 .iter()
352 .chain(self.resolved_dynamic_imports.iter())
353 }
354
355 pub fn all_resolved_source_edges(&self) -> impl Iterator<Item = ResolvedSourceEdge<'_>> {
361 self.resolved_imports
362 .iter()
363 .map(ResolvedSourceEdge::Import)
364 .chain(
365 self.resolved_dynamic_imports
366 .iter()
367 .map(ResolvedSourceEdge::Import),
368 )
369 .chain(self.re_exports.iter().map(ResolvedSourceEdge::ReExport))
370 }
371}
372
373pub(super) struct ResolveContext<'a> {
378 pub resolver: &'a Resolver,
380 pub script_resolver: &'a Resolver,
383 pub style_resolver: &'a Resolver,
387 pub extensions: &'a [String],
389 pub path_to_id: &'a FxHashMap<&'a Path, FileId>,
391 pub raw_path_to_id: &'a FxHashMap<&'a Path, FileId>,
393 pub workspace_roots: &'a FxHashMap<&'a str, &'a Path>,
395 pub package_manifests: &'a [PackageManifestInfo],
397 pub has_deno_import_maps: bool,
400 pub condition_names: &'a [String],
402 pub path_aliases: &'a [(String, String)],
404 pub scss_include_paths: &'a [PathBuf],
408 pub static_dir_mappings: &'a [(PathBuf, String)],
411 pub framework_static_dir_mappings: &'a [(PathBuf, String)],
415 pub root: &'a Path,
417 pub canonical_fallback: Option<&'a CanonicalFallback<'a>>,
421 pub tsconfig_warned: &'a Mutex<FxHashSet<String>>,
426 pub tsconfig_cache: &'a TsconfigCache,
431 pub canonicalize_cache: &'a CanonicalizeCache,
438}
439
440#[derive(Default)]
442pub(super) struct CanonicalizeCache {
443 map: DashMap<PathBuf, Option<PathBuf>, FxBuildHasher>,
444}
445
446impl CanonicalizeCache {
447 pub fn get(&self, path: &Path) -> Option<PathBuf> {
451 if let Some(value) = self.map.get(path) {
452 return value.clone();
453 }
454 let value = dunce::canonicalize(path).ok();
455 self.map.insert(path.to_path_buf(), value.clone());
456 value
457 }
458
459 pub fn distinct_paths(&self) -> usize {
463 self.map.len()
464 }
465}
466
467#[derive(Default)]
469pub(super) struct TsconfigCache {
470 json: DashMap<PathBuf, Option<Arc<Value>>, FxBuildHasher>,
471 chains: DashMap<PathBuf, Arc<[PathBuf]>, FxBuildHasher>,
472 output_maps: DashMap<PathBuf, Arc<TsconfigOutputMap>, FxBuildHasher>,
473}
474
475impl TsconfigCache {
476 pub fn json(
483 &self,
484 path: &Path,
485 load: impl FnOnce(&Path) -> Option<Value>,
486 ) -> Option<Arc<Value>> {
487 if let Some(value) = self.json.get(path) {
488 return value.clone();
489 }
490
491 let value = load(path).map(Arc::new);
492 self.json.insert(path.to_path_buf(), value.clone());
493 value
494 }
495
496 pub fn chain(&self, from_file: &Path) -> Option<Arc<[PathBuf]>> {
498 self.chains.get(from_file).map(|entry| Arc::clone(&entry))
499 }
500
501 pub fn store_chain(&self, from_file: &Path, chain: Arc<[PathBuf]>) {
503 self.chains.insert(from_file.to_path_buf(), chain);
504 }
505
506 pub fn output_map(&self, package_root: &Path) -> Arc<TsconfigOutputMap> {
509 if let Some(map) = self.output_maps.get(package_root) {
510 return Arc::clone(&map);
511 }
512 let map = Arc::new(TsconfigOutputMap::from_project(package_root));
513 self.output_maps
514 .insert(package_root.to_path_buf(), Arc::clone(&map));
515 map
516 }
517}
518
519#[derive(Debug, Clone)]
521pub(super) struct PackageManifestInfo {
522 pub root: PathBuf,
524 pub canonical_root: PathBuf,
526 pub name: Option<String>,
528 pub package_json: fallow_config::PackageJson,
530 pub deno_import_map: Vec<DenoImportMapEntry>,
532}
533
534#[derive(Debug, Clone)]
536pub(super) struct DenoImportMapEntry {
537 pub key: String,
538 pub target: String,
539 pub declaring_dir: PathBuf,
540}
541
542pub(super) struct CanonicalFallback<'a> {
544 files: &'a [fallow_types::discover::DiscoveredFile],
545 map: std::sync::OnceLock<FxHashMap<std::path::PathBuf, FileId>>,
546}
547
548impl<'a> CanonicalFallback<'a> {
549 pub const fn new(files: &'a [fallow_types::discover::DiscoveredFile]) -> Self {
550 Self {
551 files,
552 map: std::sync::OnceLock::new(),
553 }
554 }
555
556 pub fn get(&self, canonical: &Path) -> Option<FileId> {
558 let map = self.map.get_or_init(|| {
559 super::work::note_canonicalize(self.files.len() as u64);
560 tracing::debug!(
561 "intra-project symlinks detected, building canonical path index ({} files)",
562 self.files.len()
563 );
564 self.files
565 .iter()
566 .filter_map(|f| {
567 dunce::canonicalize(&f.path)
568 .ok()
569 .map(|canonical| (canonical, f.id))
570 })
571 .collect()
572 });
573 map.get(canonical).copied()
574 }
575}
576
577#[cfg(all(test, not(miri)))]
578mod tests {
579 use super::*;
580 use fallow_types::discover::DiscoveredFile;
581
582 #[test]
583 fn canonical_fallback_returns_none_for_empty_files() {
584 let files: Vec<DiscoveredFile> = vec![];
585 let fallback = CanonicalFallback::new(&files);
586 assert!(fallback.get(Path::new("/nonexistent")).is_none());
587 }
588
589 #[test]
590 fn canonical_fallback_finds_existing_file() {
591 let temp = std::env::temp_dir().join("fallow-test-canonical-fallback");
592 let _ = std::fs::create_dir_all(&temp);
593 let test_file = temp.join("test.ts");
594 std::fs::write(&test_file, "").unwrap();
595
596 let files = vec![DiscoveredFile {
597 id: FileId(42),
598 path: test_file.clone(),
599 size_bytes: 0,
600 }];
601 let fallback = CanonicalFallback::new(&files);
602
603 let canonical = dunce::canonicalize(&test_file).unwrap();
604 assert_eq!(fallback.get(&canonical), Some(FileId(42)));
605
606 assert_eq!(fallback.get(&canonical), Some(FileId(42)));
607
608 let _ = std::fs::remove_dir_all(&temp);
609 }
610
611 #[test]
612 fn canonical_fallback_returns_none_for_missing_path() {
613 let temp = std::env::temp_dir().join("fallow-test-canonical-miss");
614 let _ = std::fs::create_dir_all(&temp);
615 let test_file = temp.join("exists.ts");
616 std::fs::write(&test_file, "").unwrap();
617
618 let files = vec![DiscoveredFile {
619 id: FileId(1),
620 path: test_file,
621 size_bytes: 0,
622 }];
623 let fallback = CanonicalFallback::new(&files);
624 assert!(fallback.get(Path::new("/nonexistent/file.ts")).is_none());
625
626 let _ = std::fs::remove_dir_all(&temp);
627 }
628
629 #[test]
630 fn tsconfig_cache_loads_once_and_shares_the_parsed_document() {
631 let cache = TsconfigCache::default();
632 let path = Path::new("/project/tsconfig.json");
633 let loads = std::sync::atomic::AtomicUsize::new(0);
634 let load = |_: &Path| {
635 loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
636 Some(serde_json::json!({ "compilerOptions": {} }))
637 };
638
639 let first = cache.json(path, load).unwrap();
640 let second = cache.json(path, load).unwrap();
641
642 assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
643 assert!(
644 Arc::ptr_eq(&first, &second),
645 "repeat reads must share one allocation rather than deep-copy"
646 );
647 }
648
649 #[test]
651 fn tsconfig_cache_caches_a_failed_load() {
652 let cache = TsconfigCache::default();
653 let path = Path::new("/project/missing.json");
654 let loads = std::sync::atomic::AtomicUsize::new(0);
655 let load = |_: &Path| {
656 loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
657 None
658 };
659
660 assert!(cache.json(path, load).is_none());
661 assert!(cache.json(path, load).is_none());
662 assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
663 }
664
665 #[test]
666 fn tsconfig_cache_round_trips_a_chain() {
667 let cache = TsconfigCache::default();
668 let from_file = Path::new("/project/src/index.ts");
669 assert!(cache.chain(from_file).is_none());
670
671 let chain: Arc<[PathBuf]> = vec![PathBuf::from("/project/tsconfig.json")].into();
672 cache.store_chain(from_file, Arc::clone(&chain));
673
674 assert!(Arc::ptr_eq(&cache.chain(from_file).unwrap(), &chain));
675 }
676
677 #[test]
679 fn tsconfig_cache_is_consistent_under_concurrent_access() {
680 const THREADS: usize = 8;
681 const PATHS: usize = 32;
682
683 let cache = TsconfigCache::default();
684 std::thread::scope(|scope| {
685 for _ in 0..THREADS {
686 scope.spawn(|| {
687 for index in 0..PATHS {
688 let path = PathBuf::from(format!("/project/{index}/tsconfig.json"));
689 let json = cache
690 .json(&path, |_| Some(serde_json::json!({ "index": index })))
691 .unwrap();
692 assert_eq!(json["index"], index);
693 }
694 });
695 }
696 });
697 }
698
699 #[test]
700 fn canonicalize_cache_returns_the_same_result_on_repeat_lookups() {
701 let temp = tempfile::tempdir().expect("create temp dir");
702 let file = temp.path().join("file.ts");
703 std::fs::write(&file, "").unwrap();
704
705 let cache = CanonicalizeCache::default();
706 let expected = dunce::canonicalize(&file).ok();
707 assert_eq!(cache.get(&file), expected);
708 assert_eq!(cache.get(&file), expected);
709 assert!(cache.get(&temp.path().join("missing.ts")).is_none());
710 }
711
712 #[test]
713 fn commonjs_provenance_wraps_internal_and_bare_package_targets() {
714 assert!(matches!(
715 ResolveResult::InternalModule(FileId(4)).into_commonjs_require(),
716 ResolveResult::CommonJsInternalModule(FileId(4))
717 ));
718 assert!(matches!(
719 ResolveResult::InternalPackageModule {
720 file_id: FileId(5),
721 package_name: "pkg".to_string(),
722 }
723 .into_commonjs_require(),
724 ResolveResult::CommonJsInternalPackageModule {
725 file_id: FileId(5),
726 package_name,
727 } if package_name == "pkg"
728 ));
729 assert!(matches!(
730 ResolveResult::NpmPackage("pkg".to_string()).into_commonjs_require(),
731 ResolveResult::CommonJsNpmPackage(package_name) if package_name == "pkg"
732 ));
733 }
734}
735
736pub const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
741
742pub const MISSING_ONLY_OUTPUT_DIRS: &[&str] = &["lib"];
751
752pub const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
754
755pub const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];