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_types::discover::FileId;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub enum ResolveResult {
16 InternalModule(FileId),
18 CommonJsInternalModule(FileId),
20 SyntheticAutoImport(FileId),
22 InternalPackageModule {
25 file_id: FileId,
27 package_name: String,
29 },
30 CommonJsInternalPackageModule {
32 file_id: FileId,
34 package_name: String,
36 },
37 ExternalFile(PathBuf),
39 NpmPackage(String),
41 CommonJsNpmPackage(String),
43 Unresolvable(String),
45}
46
47impl ResolveResult {
48 #[must_use]
50 pub const fn internal_file_id(&self) -> Option<FileId> {
51 match self {
52 Self::InternalModule(file_id)
53 | Self::CommonJsInternalModule(file_id)
54 | Self::SyntheticAutoImport(file_id)
55 | Self::InternalPackageModule { file_id, .. }
56 | Self::CommonJsInternalPackageModule { file_id, .. } => Some(*file_id),
57 Self::ExternalFile(_)
58 | Self::NpmPackage(_)
59 | Self::CommonJsNpmPackage(_)
60 | Self::Unresolvable(_) => None,
61 }
62 }
63
64 #[must_use]
66 pub const fn is_synthetic_auto_import(&self) -> bool {
67 matches!(self, Self::SyntheticAutoImport(_))
68 }
69
70 #[must_use]
72 pub const fn is_commonjs_require(&self) -> bool {
73 matches!(
74 self,
75 Self::CommonJsInternalModule(_)
76 | Self::CommonJsInternalPackageModule { .. }
77 | Self::CommonJsNpmPackage(_)
78 )
79 }
80
81 #[must_use]
83 pub const fn is_bare_package(&self) -> bool {
84 matches!(self, Self::NpmPackage(_) | Self::CommonJsNpmPackage(_))
85 }
86
87 #[must_use]
89 pub fn into_commonjs_require(self) -> Self {
90 match self {
91 Self::InternalModule(file_id) => Self::CommonJsInternalModule(file_id),
92 Self::InternalPackageModule {
93 file_id,
94 package_name,
95 } => Self::CommonJsInternalPackageModule {
96 file_id,
97 package_name,
98 },
99 Self::NpmPackage(package_name) => Self::CommonJsNpmPackage(package_name),
100 other => other,
101 }
102 }
103
104 #[must_use]
106 pub fn into_es_module(self) -> Self {
107 match self {
108 Self::CommonJsInternalModule(file_id) => Self::InternalModule(file_id),
109 Self::CommonJsInternalPackageModule {
110 file_id,
111 package_name,
112 } => Self::InternalPackageModule {
113 file_id,
114 package_name,
115 },
116 Self::CommonJsNpmPackage(package_name) => Self::NpmPackage(package_name),
117 other => other,
118 }
119 }
120
121 #[must_use]
123 pub fn package_usage_name(&self) -> Option<&str> {
124 match self {
125 Self::InternalPackageModule { package_name, .. }
126 | Self::CommonJsInternalPackageModule { package_name, .. }
127 | Self::NpmPackage(package_name)
128 | Self::CommonJsNpmPackage(package_name) => Some(package_name),
129 Self::InternalModule(_)
130 | Self::CommonJsInternalModule(_)
131 | Self::SyntheticAutoImport(_)
132 | Self::ExternalFile(_)
133 | Self::Unresolvable(_) => None,
134 }
135 }
136}
137
138#[derive(Debug, Default)]
140pub struct ResolvedProject {
141 pub modules: Vec<ResolvedModule>,
143 pub replaced_module_targets: Vec<ResolvedReplacedModuleTarget>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct ResolvedReplacedModuleTarget {
150 pub source_file: FileId,
152 pub target_file: FileId,
154}
155
156#[derive(Debug, Clone)]
158pub struct ResolvedImport {
159 pub info: fallow_types::extract::ImportInfo,
161 pub target: ResolveResult,
163}
164
165#[derive(Debug, Clone)]
167pub struct ResolvedReExport {
168 pub info: fallow_types::extract::ReExportInfo,
170 pub target: ResolveResult,
172}
173
174pub enum ResolvedSourceEdge<'a> {
176 Import(&'a ResolvedImport),
178 ReExport(&'a ResolvedReExport),
180}
181
182impl<'a> ResolvedSourceEdge<'a> {
183 #[must_use]
185 pub fn source_specifier(&self) -> &'a str {
186 match self {
187 Self::Import(import) => &import.info.source,
188 Self::ReExport(re_export) => &re_export.info.source,
189 }
190 }
191
192 #[must_use]
194 pub const fn target(&self) -> &'a ResolveResult {
195 match self {
196 Self::Import(import) => &import.target,
197 Self::ReExport(re_export) => &re_export.target,
198 }
199 }
200
201 #[must_use]
203 pub const fn is_type_only(&self) -> bool {
204 match self {
205 Self::Import(import) => import.info.is_type_only,
206 Self::ReExport(re_export) => re_export.info.is_type_only,
207 }
208 }
209
210 #[must_use]
212 pub const fn span(&self) -> oxc_span::Span {
213 match self {
214 Self::Import(import) => import.info.span,
215 Self::ReExport(re_export) => re_export.info.span,
216 }
217 }
218
219 #[must_use]
221 pub const fn source_span(&self) -> oxc_span::Span {
222 match self {
223 Self::Import(import) => import.info.source_span,
224 Self::ReExport(re_export) => re_export.info.source_span,
225 }
226 }
227
228 #[must_use]
233 pub const fn statement_span(&self) -> oxc_span::Span {
234 match self {
235 Self::Import(import) => import.info.span,
236 Self::ReExport(re_export) => re_export.info.statement_span,
237 }
238 }
239}
240
241#[derive(Debug)]
247pub struct ResolvedModule {
248 pub file_id: FileId,
250 pub path: PathBuf,
252 pub exports: Arc<[fallow_types::extract::ExportInfo]>,
254 pub re_exports: Vec<ResolvedReExport>,
256 pub resolved_imports: Vec<ResolvedImport>,
258 pub resolved_dynamic_imports: Vec<ResolvedImport>,
260 pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
262 pub member_accesses: Arc<[fallow_types::extract::MemberAccess]>,
264 pub semantic_facts: Arc<[fallow_types::extract::SemanticFact]>,
266 pub whole_object_uses: Arc<[String]>,
268 pub has_cjs_exports: bool,
270 pub has_angular_component_template_url: bool,
274 pub unused_import_bindings: FxHashSet<String>,
276 pub type_referenced_import_bindings: Vec<String>,
278 pub value_referenced_import_bindings: Vec<String>,
280 pub namespace_object_aliases: Vec<fallow_types::extract::NamespaceObjectAlias>,
283 pub exported_factory_returns: Arc<[fallow_types::extract::FactoryReturnExport]>,
286 pub exported_factory_return_object_shapes:
290 Arc<[fallow_types::extract::FactoryReturnObjectShapeExport]>,
291 pub type_member_types: Arc<[fallow_types::extract::TypeMemberTypeEntry]>,
295}
296
297impl Default for ResolvedModule {
298 fn default() -> Self {
299 Self {
300 file_id: FileId(0),
301 path: PathBuf::new(),
302 exports: Arc::default(),
303 re_exports: vec![],
304 resolved_imports: vec![],
305 resolved_dynamic_imports: vec![],
306 resolved_dynamic_patterns: vec![],
307 member_accesses: Arc::default(),
308 semantic_facts: Arc::default(),
309 whole_object_uses: Arc::default(),
310 has_cjs_exports: false,
311 has_angular_component_template_url: false,
312 unused_import_bindings: FxHashSet::default(),
313 type_referenced_import_bindings: vec![],
314 value_referenced_import_bindings: vec![],
315 namespace_object_aliases: vec![],
316 exported_factory_returns: Arc::default(),
317 exported_factory_return_object_shapes: Arc::default(),
318 type_member_types: Arc::default(),
319 }
320 }
321}
322
323impl ResolvedModule {
324 pub fn all_resolved_imports(&self) -> impl Iterator<Item = &ResolvedImport> {
330 self.resolved_imports
331 .iter()
332 .chain(self.resolved_dynamic_imports.iter())
333 }
334
335 pub fn all_resolved_source_edges(&self) -> impl Iterator<Item = ResolvedSourceEdge<'_>> {
341 self.resolved_imports
342 .iter()
343 .map(ResolvedSourceEdge::Import)
344 .chain(
345 self.resolved_dynamic_imports
346 .iter()
347 .map(ResolvedSourceEdge::Import),
348 )
349 .chain(self.re_exports.iter().map(ResolvedSourceEdge::ReExport))
350 }
351}
352
353pub(super) struct ResolveContext<'a> {
358 pub resolver: &'a Resolver,
360 pub style_resolver: &'a Resolver,
364 pub extensions: &'a [String],
366 pub path_to_id: &'a FxHashMap<&'a Path, FileId>,
368 pub raw_path_to_id: &'a FxHashMap<&'a Path, FileId>,
370 pub workspace_roots: &'a FxHashMap<&'a str, &'a Path>,
372 pub package_manifests: &'a [PackageManifestInfo],
374 pub has_deno_import_maps: bool,
377 pub condition_names: &'a [String],
379 pub path_aliases: &'a [(String, String)],
381 pub scss_include_paths: &'a [PathBuf],
385 pub static_dir_mappings: &'a [(PathBuf, String)],
388 pub framework_static_dir_mappings: &'a [(PathBuf, String)],
392 pub root: &'a Path,
394 pub canonical_fallback: Option<&'a CanonicalFallback<'a>>,
398 pub tsconfig_warned: &'a Mutex<FxHashSet<String>>,
403 pub tsconfig_cache: &'a TsconfigCache,
408 pub canonicalize_cache: &'a CanonicalizeCache,
415}
416
417#[derive(Default)]
419pub(super) struct CanonicalizeCache {
420 map: DashMap<PathBuf, Option<PathBuf>, FxBuildHasher>,
421}
422
423impl CanonicalizeCache {
424 pub fn get(&self, path: &Path) -> Option<PathBuf> {
428 if let Some(value) = self.map.get(path) {
429 return value.clone();
430 }
431 let value = dunce::canonicalize(path).ok();
432 self.map.insert(path.to_path_buf(), value.clone());
433 value
434 }
435}
436
437#[derive(Default)]
439pub(super) struct TsconfigCache {
440 json: DashMap<PathBuf, Option<Arc<Value>>, FxBuildHasher>,
441 chains: DashMap<PathBuf, Arc<[PathBuf]>, FxBuildHasher>,
442}
443
444impl TsconfigCache {
445 pub fn json(
452 &self,
453 path: &Path,
454 load: impl FnOnce(&Path) -> Option<Value>,
455 ) -> Option<Arc<Value>> {
456 if let Some(value) = self.json.get(path) {
457 return value.clone();
458 }
459
460 let value = load(path).map(Arc::new);
461 self.json.insert(path.to_path_buf(), value.clone());
462 value
463 }
464
465 pub fn chain(&self, from_file: &Path) -> Option<Arc<[PathBuf]>> {
467 self.chains.get(from_file).map(|entry| Arc::clone(&entry))
468 }
469
470 pub fn store_chain(&self, from_file: &Path, chain: Arc<[PathBuf]>) {
472 self.chains.insert(from_file.to_path_buf(), chain);
473 }
474}
475
476#[derive(Debug, Clone)]
478pub(super) struct PackageManifestInfo {
479 pub root: PathBuf,
481 pub canonical_root: PathBuf,
483 pub name: Option<String>,
485 pub package_json: fallow_config::PackageJson,
487 pub deno_import_map: Vec<DenoImportMapEntry>,
489}
490
491#[derive(Debug, Clone)]
493pub(super) struct DenoImportMapEntry {
494 pub key: String,
495 pub target: String,
496 pub declaring_dir: PathBuf,
497}
498
499pub(super) struct CanonicalFallback<'a> {
501 files: &'a [fallow_types::discover::DiscoveredFile],
502 map: std::sync::OnceLock<FxHashMap<std::path::PathBuf, FileId>>,
503}
504
505impl<'a> CanonicalFallback<'a> {
506 pub const fn new(files: &'a [fallow_types::discover::DiscoveredFile]) -> Self {
507 Self {
508 files,
509 map: std::sync::OnceLock::new(),
510 }
511 }
512
513 pub fn get(&self, canonical: &Path) -> Option<FileId> {
515 let map = self.map.get_or_init(|| {
516 tracing::debug!(
517 "intra-project symlinks detected, building canonical path index ({} files)",
518 self.files.len()
519 );
520 self.files
521 .iter()
522 .filter_map(|f| {
523 dunce::canonicalize(&f.path)
524 .ok()
525 .map(|canonical| (canonical, f.id))
526 })
527 .collect()
528 });
529 map.get(canonical).copied()
530 }
531}
532
533#[cfg(all(test, not(miri)))]
534mod tests {
535 use super::*;
536 use fallow_types::discover::DiscoveredFile;
537
538 #[test]
539 fn canonical_fallback_returns_none_for_empty_files() {
540 let files: Vec<DiscoveredFile> = vec![];
541 let fallback = CanonicalFallback::new(&files);
542 assert!(fallback.get(Path::new("/nonexistent")).is_none());
543 }
544
545 #[test]
546 fn canonical_fallback_finds_existing_file() {
547 let temp = std::env::temp_dir().join("fallow-test-canonical-fallback");
548 let _ = std::fs::create_dir_all(&temp);
549 let test_file = temp.join("test.ts");
550 std::fs::write(&test_file, "").unwrap();
551
552 let files = vec![DiscoveredFile {
553 id: FileId(42),
554 path: test_file.clone(),
555 size_bytes: 0,
556 }];
557 let fallback = CanonicalFallback::new(&files);
558
559 let canonical = dunce::canonicalize(&test_file).unwrap();
560 assert_eq!(fallback.get(&canonical), Some(FileId(42)));
561
562 assert_eq!(fallback.get(&canonical), Some(FileId(42)));
563
564 let _ = std::fs::remove_dir_all(&temp);
565 }
566
567 #[test]
568 fn canonical_fallback_returns_none_for_missing_path() {
569 let temp = std::env::temp_dir().join("fallow-test-canonical-miss");
570 let _ = std::fs::create_dir_all(&temp);
571 let test_file = temp.join("exists.ts");
572 std::fs::write(&test_file, "").unwrap();
573
574 let files = vec![DiscoveredFile {
575 id: FileId(1),
576 path: test_file,
577 size_bytes: 0,
578 }];
579 let fallback = CanonicalFallback::new(&files);
580 assert!(fallback.get(Path::new("/nonexistent/file.ts")).is_none());
581
582 let _ = std::fs::remove_dir_all(&temp);
583 }
584
585 #[test]
586 fn tsconfig_cache_loads_once_and_shares_the_parsed_document() {
587 let cache = TsconfigCache::default();
588 let path = Path::new("/project/tsconfig.json");
589 let loads = std::sync::atomic::AtomicUsize::new(0);
590 let load = |_: &Path| {
591 loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
592 Some(serde_json::json!({ "compilerOptions": {} }))
593 };
594
595 let first = cache.json(path, load).unwrap();
596 let second = cache.json(path, load).unwrap();
597
598 assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
599 assert!(
600 Arc::ptr_eq(&first, &second),
601 "repeat reads must share one allocation rather than deep-copy"
602 );
603 }
604
605 #[test]
607 fn tsconfig_cache_caches_a_failed_load() {
608 let cache = TsconfigCache::default();
609 let path = Path::new("/project/missing.json");
610 let loads = std::sync::atomic::AtomicUsize::new(0);
611 let load = |_: &Path| {
612 loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
613 None
614 };
615
616 assert!(cache.json(path, load).is_none());
617 assert!(cache.json(path, load).is_none());
618 assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
619 }
620
621 #[test]
622 fn tsconfig_cache_round_trips_a_chain() {
623 let cache = TsconfigCache::default();
624 let from_file = Path::new("/project/src/index.ts");
625 assert!(cache.chain(from_file).is_none());
626
627 let chain: Arc<[PathBuf]> = vec![PathBuf::from("/project/tsconfig.json")].into();
628 cache.store_chain(from_file, Arc::clone(&chain));
629
630 assert!(Arc::ptr_eq(&cache.chain(from_file).unwrap(), &chain));
631 }
632
633 #[test]
635 fn tsconfig_cache_is_consistent_under_concurrent_access() {
636 const THREADS: usize = 8;
637 const PATHS: usize = 32;
638
639 let cache = TsconfigCache::default();
640 std::thread::scope(|scope| {
641 for _ in 0..THREADS {
642 scope.spawn(|| {
643 for index in 0..PATHS {
644 let path = PathBuf::from(format!("/project/{index}/tsconfig.json"));
645 let json = cache
646 .json(&path, |_| Some(serde_json::json!({ "index": index })))
647 .unwrap();
648 assert_eq!(json["index"], index);
649 }
650 });
651 }
652 });
653 }
654
655 #[test]
656 fn canonicalize_cache_returns_the_same_result_on_repeat_lookups() {
657 let temp = tempfile::tempdir().expect("create temp dir");
658 let file = temp.path().join("file.ts");
659 std::fs::write(&file, "").unwrap();
660
661 let cache = CanonicalizeCache::default();
662 let expected = dunce::canonicalize(&file).ok();
663 assert_eq!(cache.get(&file), expected);
664 assert_eq!(cache.get(&file), expected);
665 assert!(cache.get(&temp.path().join("missing.ts")).is_none());
666 }
667
668 #[test]
669 fn commonjs_provenance_wraps_internal_and_bare_package_targets() {
670 assert!(matches!(
671 ResolveResult::InternalModule(FileId(4)).into_commonjs_require(),
672 ResolveResult::CommonJsInternalModule(FileId(4))
673 ));
674 assert!(matches!(
675 ResolveResult::InternalPackageModule {
676 file_id: FileId(5),
677 package_name: "pkg".to_string(),
678 }
679 .into_commonjs_require(),
680 ResolveResult::CommonJsInternalPackageModule {
681 file_id: FileId(5),
682 package_name,
683 } if package_name == "pkg"
684 ));
685 assert!(matches!(
686 ResolveResult::NpmPackage("pkg".to_string()).into_commonjs_require(),
687 ResolveResult::CommonJsNpmPackage(package_name) if package_name == "pkg"
688 ));
689 }
690}
691
692pub const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
697
698pub const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
700
701pub const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];