use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path, PathBuf};
use anyhow::Result;
use crate::fs::{RepoContext, TsConfigPath};
mod package;
use package::load_package_info;
pub(crate) use package::{
PackageInfo, package_specifier_parts, resolve_package_export, resolve_package_import,
};
#[cfg(test)]
mod tests;
#[derive(Debug, Clone)]
pub struct ResolveCtx {
pub(crate) repo_root: PathBuf,
pub(crate) source_files: HashSet<PathBuf>,
pub(crate) packages: Vec<PackageInfo>,
pub(crate) package_by_name: BTreeMap<String, usize>,
pub(crate) tsconfigs: Vec<TsConfigPath>,
}
#[derive(Debug, Clone)]
pub enum Resolution {
Resolved(PathBuf),
Unresolved,
}
impl ResolveCtx {
fn new(context: &RepoContext) -> Result<Self> {
let mut packages = Vec::new();
for package_json in &context.package_jsons {
if let Some(package) = load_package_info(package_json)? {
packages.push(package);
}
}
let mut package_by_name = BTreeMap::new();
for (index, package) in packages.iter().enumerate() {
package_by_name.entry(package.name.clone()).or_insert(index);
}
Ok(Self {
repo_root: context.repo_root.clone(),
source_files: context.source_files.iter().cloned().collect(),
packages,
package_by_name,
tsconfigs: context.tsconfigs.clone(),
})
}
fn normalize_importer(&self, importer: &Path) -> PathBuf {
let cleaned = clean_path(importer);
if self.source_files.contains(&cleaned) {
return cleaned;
}
importer.canonicalize().unwrap_or(cleaned)
}
pub(crate) fn resolve_path(
&self,
base: &Path,
specifier: &str,
extensions: &[&str],
) -> Resolution {
let path = if specifier.starts_with('/') {
clean_path(&self.repo_root.join(specifier.trim_start_matches('/')))
} else {
clean_path(&base.join(specifier))
};
self.try_resolve_candidate(&path, extensions)
.map(Resolution::Resolved)
.unwrap_or(Resolution::Unresolved)
}
pub(crate) fn try_resolve_candidate(
&self,
candidate: &Path,
extensions: &[&str],
) -> Option<PathBuf> {
let candidate = clean_path(candidate);
if self.source_files.contains(&candidate) {
let cross_language = candidate
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| !extensions.contains(&ext));
if !cross_language {
return Some(candidate);
}
}
let candidate_ext = candidate.extension().and_then(|ext| ext.to_str());
if candidate_ext.is_none() {
for extension in extensions {
let path = candidate.with_extension(extension);
if self.source_files.contains(&path) {
return Some(path);
}
}
}
if let Some(ext) = candidate_ext {
if !extensions.contains(&ext) {
for extension in extensions {
let path = candidate.with_extension(format!("{ext}.{extension}"));
if self.source_files.contains(&path) {
return Some(path);
}
}
}
if extensions.contains(&"ts") {
for replacement in ts_counterparts(ext) {
let path = candidate.with_extension(replacement);
if self.source_files.contains(&path) {
return Some(path);
}
}
}
}
if extensions.contains(&"ts") {
let mut appended = candidate.clone().into_os_string();
appended.push(".d.ts");
let path = PathBuf::from(appended);
if self.source_files.contains(&path) {
return Some(path);
}
}
if candidate_ext.is_none() {
for extension in extensions {
let path = candidate.join(format!("index.{extension}"));
if self.source_files.contains(&path) {
return Some(path);
}
}
if extensions.contains(&"ts") {
let path = candidate.join("index.d.ts");
if self.source_files.contains(&path) {
return Some(path);
}
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct Resolver {
ctx: ResolveCtx,
}
impl Resolver {
pub fn new(context: &RepoContext) -> Result<Self> {
Ok(Self {
ctx: ResolveCtx::new(context)?,
})
}
pub fn resolve(&self, importer: &Path, specifier: &str) -> Resolution {
let importer = self.ctx.normalize_importer(importer);
crate::language::adapter_for(&importer).resolve(&self.ctx, &importer, specifier)
}
pub fn is_internal_specifier(&self, importer: &Path, specifier: &str) -> bool {
let importer = self.ctx.normalize_importer(importer);
crate::language::adapter_for(&importer).is_internal(&self.ctx, &importer, specifier)
}
}
fn ts_counterparts(ext: &str) -> &'static [&'static str] {
match ext {
"js" => &["ts", "tsx", "d.ts"],
"jsx" => &["tsx"],
"mjs" => &["mts", "d.mts"],
"cjs" => &["cts", "d.cts"],
_ => &[],
}
}
pub(crate) fn match_alias(pattern: &str, specifier: &str) -> Option<Vec<String>> {
if let Some((prefix, suffix)) = pattern.split_once('*') {
if specifier.len() >= prefix.len() + suffix.len()
&& specifier.starts_with(prefix)
&& specifier.ends_with(suffix)
{
let middle = &specifier[prefix.len()..specifier.len() - suffix.len()];
return Some(vec![middle.to_string()]);
}
return None;
}
if pattern == specifier {
Some(Vec::new())
} else {
None
}
}
pub(crate) fn apply_alias_target(target: &str, captures: &[String]) -> String {
let mut resolved = target.to_string();
for capture in captures {
if let Some(index) = resolved.find('*') {
resolved.replace_range(index..=index, capture);
}
}
resolved
}
pub(crate) fn clean_path(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
result.pop();
}
other => result.push(other.as_os_str()),
}
}
result
}