use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use dashmap::DashMap;
use oxc_resolver::Resolver;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use serde_json::Value;
use fallow_types::discover::FileId;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum ResolveResult {
InternalModule(FileId),
CommonJsInternalModule(FileId),
SyntheticAutoImport(FileId),
InternalPackageModule {
file_id: FileId,
package_name: String,
},
CommonJsInternalPackageModule {
file_id: FileId,
package_name: String,
},
ExternalFile(PathBuf),
NpmPackage(String),
CommonJsNpmPackage(String),
Unresolvable(String),
}
impl ResolveResult {
#[must_use]
pub const fn internal_file_id(&self) -> Option<FileId> {
match self {
Self::InternalModule(file_id)
| Self::CommonJsInternalModule(file_id)
| Self::SyntheticAutoImport(file_id)
| Self::InternalPackageModule { file_id, .. }
| Self::CommonJsInternalPackageModule { file_id, .. } => Some(*file_id),
Self::ExternalFile(_)
| Self::NpmPackage(_)
| Self::CommonJsNpmPackage(_)
| Self::Unresolvable(_) => None,
}
}
#[must_use]
pub const fn is_synthetic_auto_import(&self) -> bool {
matches!(self, Self::SyntheticAutoImport(_))
}
#[must_use]
pub const fn is_commonjs_require(&self) -> bool {
matches!(
self,
Self::CommonJsInternalModule(_)
| Self::CommonJsInternalPackageModule { .. }
| Self::CommonJsNpmPackage(_)
)
}
#[must_use]
pub const fn is_bare_package(&self) -> bool {
matches!(self, Self::NpmPackage(_) | Self::CommonJsNpmPackage(_))
}
#[must_use]
pub fn into_commonjs_require(self) -> Self {
match self {
Self::InternalModule(file_id) => Self::CommonJsInternalModule(file_id),
Self::InternalPackageModule {
file_id,
package_name,
} => Self::CommonJsInternalPackageModule {
file_id,
package_name,
},
Self::NpmPackage(package_name) => Self::CommonJsNpmPackage(package_name),
other => other,
}
}
#[must_use]
pub fn into_es_module(self) -> Self {
match self {
Self::CommonJsInternalModule(file_id) => Self::InternalModule(file_id),
Self::CommonJsInternalPackageModule {
file_id,
package_name,
} => Self::InternalPackageModule {
file_id,
package_name,
},
Self::CommonJsNpmPackage(package_name) => Self::NpmPackage(package_name),
other => other,
}
}
#[must_use]
pub fn package_usage_name(&self) -> Option<&str> {
match self {
Self::InternalPackageModule { package_name, .. }
| Self::CommonJsInternalPackageModule { package_name, .. }
| Self::NpmPackage(package_name)
| Self::CommonJsNpmPackage(package_name) => Some(package_name),
Self::InternalModule(_)
| Self::CommonJsInternalModule(_)
| Self::SyntheticAutoImport(_)
| Self::ExternalFile(_)
| Self::Unresolvable(_) => None,
}
}
}
#[derive(Debug, Default)]
pub struct ResolvedProject {
pub modules: Vec<ResolvedModule>,
pub replaced_module_targets: Vec<ResolvedReplacedModuleTarget>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedReplacedModuleTarget {
pub source_file: FileId,
pub target_file: FileId,
}
#[derive(Debug, Clone)]
pub struct ResolvedImport {
pub info: fallow_types::extract::ImportInfo,
pub target: ResolveResult,
}
#[derive(Debug, Clone)]
pub struct ResolvedReExport {
pub info: fallow_types::extract::ReExportInfo,
pub target: ResolveResult,
}
pub enum ResolvedSourceEdge<'a> {
Import(&'a ResolvedImport),
ReExport(&'a ResolvedReExport),
}
impl<'a> ResolvedSourceEdge<'a> {
#[must_use]
pub fn source_specifier(&self) -> &'a str {
match self {
Self::Import(import) => &import.info.source,
Self::ReExport(re_export) => &re_export.info.source,
}
}
#[must_use]
pub const fn target(&self) -> &'a ResolveResult {
match self {
Self::Import(import) => &import.target,
Self::ReExport(re_export) => &re_export.target,
}
}
#[must_use]
pub const fn is_type_only(&self) -> bool {
match self {
Self::Import(import) => import.info.is_type_only,
Self::ReExport(re_export) => re_export.info.is_type_only,
}
}
#[must_use]
pub const fn span(&self) -> oxc_span::Span {
match self {
Self::Import(import) => import.info.span,
Self::ReExport(re_export) => re_export.info.span,
}
}
#[must_use]
pub const fn source_span(&self) -> oxc_span::Span {
match self {
Self::Import(import) => import.info.source_span,
Self::ReExport(re_export) => re_export.info.source_span,
}
}
#[must_use]
pub const fn statement_span(&self) -> oxc_span::Span {
match self {
Self::Import(import) => import.info.span,
Self::ReExport(re_export) => re_export.info.statement_span,
}
}
}
#[derive(Debug)]
pub struct ResolvedModule {
pub file_id: FileId,
pub path: PathBuf,
pub exports: Arc<[fallow_types::extract::ExportInfo]>,
pub re_exports: Vec<ResolvedReExport>,
pub resolved_imports: Vec<ResolvedImport>,
pub resolved_dynamic_imports: Vec<ResolvedImport>,
pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
pub member_accesses: Arc<[fallow_types::extract::MemberAccess]>,
pub semantic_facts: Arc<[fallow_types::extract::SemanticFact]>,
pub whole_object_uses: Arc<[String]>,
pub has_cjs_exports: bool,
pub has_angular_component_template_url: bool,
pub unused_import_bindings: FxHashSet<String>,
pub type_referenced_import_bindings: Vec<String>,
pub value_referenced_import_bindings: Vec<String>,
pub namespace_object_aliases: Vec<fallow_types::extract::NamespaceObjectAlias>,
pub exported_factory_returns: Arc<[fallow_types::extract::FactoryReturnExport]>,
pub exported_factory_return_object_shapes:
Arc<[fallow_types::extract::FactoryReturnObjectShapeExport]>,
pub type_member_types: Arc<[fallow_types::extract::TypeMemberTypeEntry]>,
}
impl Default for ResolvedModule {
fn default() -> Self {
Self {
file_id: FileId(0),
path: PathBuf::new(),
exports: Arc::default(),
re_exports: vec![],
resolved_imports: vec![],
resolved_dynamic_imports: vec![],
resolved_dynamic_patterns: vec![],
member_accesses: Arc::default(),
semantic_facts: Arc::default(),
whole_object_uses: Arc::default(),
has_cjs_exports: false,
has_angular_component_template_url: false,
unused_import_bindings: FxHashSet::default(),
type_referenced_import_bindings: vec![],
value_referenced_import_bindings: vec![],
namespace_object_aliases: vec![],
exported_factory_returns: Arc::default(),
exported_factory_return_object_shapes: Arc::default(),
type_member_types: Arc::default(),
}
}
}
impl ResolvedModule {
pub fn all_resolved_imports(&self) -> impl Iterator<Item = &ResolvedImport> {
self.resolved_imports
.iter()
.chain(self.resolved_dynamic_imports.iter())
}
pub fn all_resolved_source_edges(&self) -> impl Iterator<Item = ResolvedSourceEdge<'_>> {
self.resolved_imports
.iter()
.map(ResolvedSourceEdge::Import)
.chain(
self.resolved_dynamic_imports
.iter()
.map(ResolvedSourceEdge::Import),
)
.chain(self.re_exports.iter().map(ResolvedSourceEdge::ReExport))
}
}
pub(super) struct ResolveContext<'a> {
pub resolver: &'a Resolver,
pub style_resolver: &'a Resolver,
pub extensions: &'a [String],
pub path_to_id: &'a FxHashMap<&'a Path, FileId>,
pub raw_path_to_id: &'a FxHashMap<&'a Path, FileId>,
pub workspace_roots: &'a FxHashMap<&'a str, &'a Path>,
pub package_manifests: &'a [PackageManifestInfo],
pub has_deno_import_maps: bool,
pub condition_names: &'a [String],
pub path_aliases: &'a [(String, String)],
pub scss_include_paths: &'a [PathBuf],
pub static_dir_mappings: &'a [(PathBuf, String)],
pub framework_static_dir_mappings: &'a [(PathBuf, String)],
pub root: &'a Path,
pub canonical_fallback: Option<&'a CanonicalFallback<'a>>,
pub tsconfig_warned: &'a Mutex<FxHashSet<String>>,
pub tsconfig_cache: &'a TsconfigCache,
pub canonicalize_cache: &'a CanonicalizeCache,
}
#[derive(Default)]
pub(super) struct CanonicalizeCache {
map: DashMap<PathBuf, Option<PathBuf>, FxBuildHasher>,
}
impl CanonicalizeCache {
pub fn get(&self, path: &Path) -> Option<PathBuf> {
if let Some(value) = self.map.get(path) {
return value.clone();
}
let value = dunce::canonicalize(path).ok();
self.map.insert(path.to_path_buf(), value.clone());
value
}
}
#[derive(Default)]
pub(super) struct TsconfigCache {
json: DashMap<PathBuf, Option<Arc<Value>>, FxBuildHasher>,
chains: DashMap<PathBuf, Arc<[PathBuf]>, FxBuildHasher>,
}
impl TsconfigCache {
pub fn json(
&self,
path: &Path,
load: impl FnOnce(&Path) -> Option<Value>,
) -> Option<Arc<Value>> {
if let Some(value) = self.json.get(path) {
return value.clone();
}
let value = load(path).map(Arc::new);
self.json.insert(path.to_path_buf(), value.clone());
value
}
pub fn chain(&self, from_file: &Path) -> Option<Arc<[PathBuf]>> {
self.chains.get(from_file).map(|entry| Arc::clone(&entry))
}
pub fn store_chain(&self, from_file: &Path, chain: Arc<[PathBuf]>) {
self.chains.insert(from_file.to_path_buf(), chain);
}
}
#[derive(Debug, Clone)]
pub(super) struct PackageManifestInfo {
pub root: PathBuf,
pub canonical_root: PathBuf,
pub name: Option<String>,
pub package_json: fallow_config::PackageJson,
pub deno_import_map: Vec<DenoImportMapEntry>,
}
#[derive(Debug, Clone)]
pub(super) struct DenoImportMapEntry {
pub key: String,
pub target: String,
pub declaring_dir: PathBuf,
}
pub(super) struct CanonicalFallback<'a> {
files: &'a [fallow_types::discover::DiscoveredFile],
map: std::sync::OnceLock<FxHashMap<std::path::PathBuf, FileId>>,
}
impl<'a> CanonicalFallback<'a> {
pub const fn new(files: &'a [fallow_types::discover::DiscoveredFile]) -> Self {
Self {
files,
map: std::sync::OnceLock::new(),
}
}
pub fn get(&self, canonical: &Path) -> Option<FileId> {
let map = self.map.get_or_init(|| {
tracing::debug!(
"intra-project symlinks detected, building canonical path index ({} files)",
self.files.len()
);
self.files
.iter()
.filter_map(|f| {
dunce::canonicalize(&f.path)
.ok()
.map(|canonical| (canonical, f.id))
})
.collect()
});
map.get(canonical).copied()
}
}
#[cfg(all(test, not(miri)))]
mod tests {
use super::*;
use fallow_types::discover::DiscoveredFile;
#[test]
fn canonical_fallback_returns_none_for_empty_files() {
let files: Vec<DiscoveredFile> = vec![];
let fallback = CanonicalFallback::new(&files);
assert!(fallback.get(Path::new("/nonexistent")).is_none());
}
#[test]
fn canonical_fallback_finds_existing_file() {
let temp = std::env::temp_dir().join("fallow-test-canonical-fallback");
let _ = std::fs::create_dir_all(&temp);
let test_file = temp.join("test.ts");
std::fs::write(&test_file, "").unwrap();
let files = vec![DiscoveredFile {
id: FileId(42),
path: test_file.clone(),
size_bytes: 0,
}];
let fallback = CanonicalFallback::new(&files);
let canonical = dunce::canonicalize(&test_file).unwrap();
assert_eq!(fallback.get(&canonical), Some(FileId(42)));
assert_eq!(fallback.get(&canonical), Some(FileId(42)));
let _ = std::fs::remove_dir_all(&temp);
}
#[test]
fn canonical_fallback_returns_none_for_missing_path() {
let temp = std::env::temp_dir().join("fallow-test-canonical-miss");
let _ = std::fs::create_dir_all(&temp);
let test_file = temp.join("exists.ts");
std::fs::write(&test_file, "").unwrap();
let files = vec![DiscoveredFile {
id: FileId(1),
path: test_file,
size_bytes: 0,
}];
let fallback = CanonicalFallback::new(&files);
assert!(fallback.get(Path::new("/nonexistent/file.ts")).is_none());
let _ = std::fs::remove_dir_all(&temp);
}
#[test]
fn tsconfig_cache_loads_once_and_shares_the_parsed_document() {
let cache = TsconfigCache::default();
let path = Path::new("/project/tsconfig.json");
let loads = std::sync::atomic::AtomicUsize::new(0);
let load = |_: &Path| {
loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(serde_json::json!({ "compilerOptions": {} }))
};
let first = cache.json(path, load).unwrap();
let second = cache.json(path, load).unwrap();
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(
Arc::ptr_eq(&first, &second),
"repeat reads must share one allocation rather than deep-copy"
);
}
#[test]
fn tsconfig_cache_caches_a_failed_load() {
let cache = TsconfigCache::default();
let path = Path::new("/project/missing.json");
let loads = std::sync::atomic::AtomicUsize::new(0);
let load = |_: &Path| {
loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
None
};
assert!(cache.json(path, load).is_none());
assert!(cache.json(path, load).is_none());
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn tsconfig_cache_round_trips_a_chain() {
let cache = TsconfigCache::default();
let from_file = Path::new("/project/src/index.ts");
assert!(cache.chain(from_file).is_none());
let chain: Arc<[PathBuf]> = vec![PathBuf::from("/project/tsconfig.json")].into();
cache.store_chain(from_file, Arc::clone(&chain));
assert!(Arc::ptr_eq(&cache.chain(from_file).unwrap(), &chain));
}
#[test]
fn tsconfig_cache_is_consistent_under_concurrent_access() {
const THREADS: usize = 8;
const PATHS: usize = 32;
let cache = TsconfigCache::default();
std::thread::scope(|scope| {
for _ in 0..THREADS {
scope.spawn(|| {
for index in 0..PATHS {
let path = PathBuf::from(format!("/project/{index}/tsconfig.json"));
let json = cache
.json(&path, |_| Some(serde_json::json!({ "index": index })))
.unwrap();
assert_eq!(json["index"], index);
}
});
}
});
}
#[test]
fn canonicalize_cache_returns_the_same_result_on_repeat_lookups() {
let temp = tempfile::tempdir().expect("create temp dir");
let file = temp.path().join("file.ts");
std::fs::write(&file, "").unwrap();
let cache = CanonicalizeCache::default();
let expected = dunce::canonicalize(&file).ok();
assert_eq!(cache.get(&file), expected);
assert_eq!(cache.get(&file), expected);
assert!(cache.get(&temp.path().join("missing.ts")).is_none());
}
#[test]
fn commonjs_provenance_wraps_internal_and_bare_package_targets() {
assert!(matches!(
ResolveResult::InternalModule(FileId(4)).into_commonjs_require(),
ResolveResult::CommonJsInternalModule(FileId(4))
));
assert!(matches!(
ResolveResult::InternalPackageModule {
file_id: FileId(5),
package_name: "pkg".to_string(),
}
.into_commonjs_require(),
ResolveResult::CommonJsInternalPackageModule {
file_id: FileId(5),
package_name,
} if package_name == "pkg"
));
assert!(matches!(
ResolveResult::NpmPackage("pkg".to_string()).into_commonjs_require(),
ResolveResult::CommonJsNpmPackage(package_name) if package_name == "pkg"
));
}
}
pub const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
pub const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
pub const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];