use std::path::{Component, Path, PathBuf};
use serde_json::Value;
use super::fallbacks::{extract_package_name_from_node_modules_path, is_declaration_file_name};
use super::path_info::{
extract_package_name, is_bare_specifier, is_path_alias, is_valid_package_name,
};
use super::specifier::{
local_tsconfig_chain, path_alias_capture, path_alias_specificity, read_tsconfig_json_cached,
tsconfig_paths_base_dir,
};
use super::types::ResolveContext;
pub(super) fn package_usage_name_for_import(
ctx: &ResolveContext<'_>,
from_file: &Path,
specifier: &str,
resolved_path: &Path,
) -> Option<String> {
package_usage_name_for_resolved_package(specifier, resolved_path, |resolved_package| {
nearest_tsconfig_alias_targets(ctx, from_file, specifier)
.iter()
.filter_map(|target| node_modules_package_root(target))
.any(|(name, _)| name == resolved_package)
})
}
pub(super) fn package_usage_name_for_resolved_package(
specifier: &str,
resolved_path: &Path,
alias_targets_package: impl FnOnce(&str) -> bool,
) -> Option<String> {
let resolved_package = extract_package_name_from_node_modules_path(resolved_path)?;
if !is_bare_specifier(specifier) || is_path_alias(specifier) {
return Some(resolved_package);
}
let is_declaration = resolved_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(is_declaration_file_name);
if !is_declaration && alias_targets_package(&resolved_package) {
return Some(resolved_package);
}
Some(extract_package_name(specifier))
}
pub(super) fn package_usage_name_for_external_bare_specifier(specifier: &str) -> Option<String> {
if !is_bare_specifier(specifier) || is_path_alias(specifier) {
return None;
}
if specifier.starts_with('@') && specifier.split('/').nth(1).is_none_or(str::is_empty) {
return None;
}
let package_name = extract_package_name(specifier);
is_valid_package_name(&package_name).then_some(package_name)
}
pub(super) fn uninstalled_tsconfig_alias_target_package(
ctx: &ResolveContext<'_>,
from_file: &Path,
specifier: &str,
) -> Option<String> {
let (name, package_root) = nearest_tsconfig_alias_targets(ctx, from_file, specifier)
.iter()
.find_map(|target| node_modules_package_root(target))?;
(!name.starts_with("@types/") && !package_root.is_dir()).then_some(name)
}
fn nearest_tsconfig_alias_targets(
ctx: &ResolveContext<'_>,
from_file: &Path,
specifier: &str,
) -> Vec<PathBuf> {
let chain = local_tsconfig_chain(ctx, from_file);
let Some((tsconfig_path, json)) = chain.iter().find_map(|tsconfig_path| {
let json = read_tsconfig_json_cached(ctx, tsconfig_path)?;
json.get("compilerOptions")?.get("paths")?;
Some((tsconfig_path, json))
}) else {
return Vec::new();
};
let Some(compiler_options) = json.get("compilerOptions") else {
return Vec::new();
};
let Some(paths) = compiler_options.get("paths").and_then(Value::as_object) else {
return Vec::new();
};
let mut matches: Vec<_> = paths
.iter()
.filter(|(pattern, _)| pattern.as_str() != "*")
.filter_map(|(pattern, targets)| {
path_alias_capture(pattern, specifier).map(|capture| (pattern, capture, targets))
})
.collect();
if matches.is_empty() {
return Vec::new();
}
matches.sort_by_key(|(pattern, _, _)| std::cmp::Reverse(path_alias_specificity(pattern)));
let base_dir = tsconfig_paths_base_dir(ctx, tsconfig_path, compiler_options);
matches
.into_iter()
.filter_map(|(_, capture, targets)| Some((capture, targets.as_array()?)))
.flat_map(|(capture, targets)| {
targets
.iter()
.filter_map(Value::as_str)
.map(move |target| target.replacen('*', capture, 1))
})
.map(|target| base_dir.join(target))
.collect()
}
fn node_modules_package_root(path: &Path) -> Option<(String, PathBuf)> {
let name = extract_package_name_from_node_modules_path(path)?;
let components: Vec<Component<'_>> = path.components().collect();
let node_modules = components
.iter()
.rposition(|component| *component == Component::Normal("node_modules".as_ref()))?;
let end = node_modules + 1 + name.split('/').count();
let root = components.get(..end)?.iter().collect();
Some((name, root))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{
node_modules_package_root, package_usage_name_for_external_bare_specifier,
package_usage_name_for_resolved_package,
};
#[test]
fn tsconfig_alias_credits_the_resolved_package() {
let resolved = Path::new("/p/node_modules/@acme/theme/src/components/shared/button.ts");
assert_eq!(
package_usage_name_for_resolved_package("@components/shared/button", resolved, |_| {
true
}),
Some("@acme/theme".to_string()),
);
assert_eq!(
package_usage_name_for_resolved_package("@components/shared/button", resolved, |_| {
false
}),
Some("@components/shared".to_string()),
);
}
#[test]
fn tsconfig_alias_onto_declaration_file_keeps_the_declared_name() {
let resolved = Path::new("/p/node_modules/@types/react/index.d.ts");
assert_eq!(
package_usage_name_for_resolved_package("react", resolved, |_| true),
Some("react".to_string()),
);
}
#[test]
fn node_modules_package_root_stops_at_the_package_directory() {
assert_eq!(
node_modules_package_root(Path::new(
"/p/docs/.vitepress/../node_modules/@acme/theme/src/assets/icons"
)),
Some((
"@acme/theme".to_string(),
PathBuf::from("/p/docs/.vitepress/../node_modules/@acme/theme"),
)),
);
assert_eq!(
node_modules_package_root(Path::new("/p/node_modules/lodash/fp")),
Some((
"lodash".to_string(),
PathBuf::from("/p/node_modules/lodash")
)),
);
assert_eq!(node_modules_package_root(Path::new("/p/src/x.ts")), None);
}
#[test]
fn package_usage_name_prefers_declared_name_for_bare_specifiers() {
let resolved = Path::new(
"/p/node_modules/.pnpm/unstorage-nightly@2.0.0/node_modules/unstorage-nightly",
);
assert_eq!(
package_usage_name_for_resolved_package("unstorage", resolved, |_| false),
Some("unstorage".to_string()),
);
let resolved_scoped =
Path::new("/p/node_modules/.pnpm/@jsr+std__csv@1.0.6/node_modules/@jsr/std__csv");
assert_eq!(
package_usage_name_for_resolved_package("@std/csv/stringify", resolved_scoped, |_| {
false
}),
Some("@std/csv".to_string()),
);
let resolved_plain = Path::new("/p/node_modules/lodash");
assert_eq!(
package_usage_name_for_resolved_package("lodash", resolved_plain, |_| { false }),
Some("lodash".to_string()),
);
let resolved_import_map = Path::new("/p/node_modules/polyfill-pkg/index.js");
assert_eq!(
package_usage_name_for_resolved_package("#polyfill", resolved_import_map, |_| false),
Some("polyfill-pkg".to_string()),
);
assert_eq!(
package_usage_name_for_resolved_package("unstorage", Path::new("/p/src/x.ts"), |_| {
false
}),
None,
);
}
#[test]
fn external_bare_specifier_package_usage_credits_declared_package() {
assert_eq!(
package_usage_name_for_external_bare_specifier("@mre/shared"),
Some("@mre/shared".to_string()),
);
assert_eq!(
package_usage_name_for_external_bare_specifier("@mre/shared/subpath"),
Some("@mre/shared".to_string()),
);
assert_eq!(package_usage_name_for_external_bare_specifier("@mre"), None,);
assert_eq!(
package_usage_name_for_external_bare_specifier("#polyfill"),
None,
);
assert_eq!(
package_usage_name_for_external_bare_specifier("@/shared"),
None,
);
}
}