use oxc_resolver::{
ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions, TsconfigReferences,
};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
pub struct ResolverMap {
resolvers: Vec<(PathBuf, Resolver)>,
fallback: Resolver,
}
impl ResolverMap {
pub fn resolver_for_file(&self, file_path: &Path) -> &Resolver {
for (tsconfig_dir, resolver) in &self.resolvers {
if file_path.starts_with(tsconfig_dir) {
return resolver;
}
}
&self.fallback
}
}
pub fn create_resolver_map(root: &Path, max_depth: usize) -> ResolverMap {
let tsconfigs = find_all_tsconfigs_in_project(root, max_depth);
if !tsconfigs.is_empty() {
tracing::info!(
"Found {} tsconfig.json file(s): {:?}",
tsconfigs.len(),
tsconfigs
);
}
let mut resolvers: Vec<(PathBuf, Resolver)> = tsconfigs
.iter()
.filter_map(|tc| {
let dir = tc.parent()?;
let resolver = create_resolver(Some(tc));
Some((dir.to_path_buf(), resolver))
})
.collect();
resolvers.sort_by_key(|b| std::cmp::Reverse(b.0.components().count()));
ResolverMap {
resolvers,
fallback: create_resolver(None),
}
}
pub fn find_all_tsconfigs_in_project(root: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut found = Vec::new();
let candidate = root.join("tsconfig.json");
if candidate.is_file() {
found.push(candidate);
}
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::new();
queue.push_back((root.to_path_buf(), 0));
while let Some((dir, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.')
|| name_str == "node_modules"
|| name_str == "dist"
|| name_str == "build"
|| name_str == "target"
{
continue;
}
let tsconfig = path.join("tsconfig.json");
if tsconfig.is_file() {
found.push(tsconfig);
}
queue.push_back((path, depth + 1));
}
}
found
}
pub fn create_resolver(tsconfig_path: Option<&Path>) -> Resolver {
let mut options = ResolveOptions {
extensions: vec![
".tsx".into(),
".ts".into(),
".jsx".into(),
".js".into(),
".json".into(),
],
main_files: vec!["index".into()],
condition_names: vec!["node".into(), "import".into()],
..ResolveOptions::default()
};
if let Some(tsconfig) = tsconfig_path {
options.tsconfig = Some(TsconfigDiscovery::Manual(TsconfigOptions {
config_file: tsconfig.to_path_buf(),
references: TsconfigReferences::Auto,
}));
}
Resolver::new(options)
}
pub fn resolve_import_with_resolver(
resolver: &Resolver,
importing_file: &Path,
module_source: &str,
) -> Option<PathBuf> {
let dir = importing_file.parent()?;
match resolver.resolve(dir, module_source) {
Ok(resolution) => Some(resolution.into_path_buf()),
Err(_) => None,
}
}
pub fn is_node_modules_path(path: &Path) -> bool {
path.components().any(|c| c.as_os_str() == "node_modules")
}
#[derive(Debug, Clone)]
pub struct ImportBinding {
pub importing_file: PathBuf,
pub local_name: String,
}
pub fn find_importers_of(
resolver_map: &ResolverMap,
target_file: &Path,
target_symbol: &str,
scan_files: &[PathBuf],
) -> Vec<ImportBinding> {
let mut results = Vec::new();
let target_canonical = match target_file.canonicalize() {
Ok(p) => p,
Err(_) => target_file.to_path_buf(),
};
for file_path in scan_files {
let source = match std::fs::read_to_string(file_path) {
Ok(s) => s,
Err(_) => continue,
};
if let Some(binding) = check_file_imports(
resolver_map,
file_path,
&source,
&target_canonical,
target_symbol,
) {
results.push(binding);
}
}
results
}
fn check_file_imports(
resolver_map: &ResolverMap,
file_path: &Path,
source: &str,
target_file: &Path,
target_symbol: &str,
) -> Option<ImportBinding> {
let allocator = oxc_allocator::Allocator::default();
let source_type = oxc_span::SourceType::from_path(file_path).ok()?;
let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
let resolver = resolver_map.resolver_for_file(file_path);
for item in &parsed.program.body {
if let oxc_ast::ast::Statement::ImportDeclaration(import) = item {
let module_source = import.source.value.as_str();
if !module_source.starts_with('.')
&& !module_source.starts_with('/')
&& !module_source.starts_with('@')
{
continue;
}
let resolved = match resolve_import_with_resolver(resolver, file_path, module_source) {
Some(p) => p,
None => continue,
};
if is_node_modules_path(&resolved) {
continue;
}
let resolved_canonical = resolved.canonicalize().unwrap_or(resolved);
if resolved_canonical != *target_file {
continue;
}
if let Some(specifiers) = &import.specifiers {
for spec in specifiers {
match spec {
oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named) => {
let imported_name = match &named.imported {
oxc_ast::ast::ModuleExportName::IdentifierName(id) => {
id.name.as_str()
}
oxc_ast::ast::ModuleExportName::IdentifierReference(id) => {
id.name.as_str()
}
oxc_ast::ast::ModuleExportName::StringLiteral(s) => {
s.value.as_str()
}
};
if imported_name == target_symbol {
return Some(ImportBinding {
importing_file: file_path.to_path_buf(),
local_name: named.local.name.as_str().to_string(),
});
}
}
oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => {
}
oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {
}
}
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::slice;
#[test]
fn test_create_resolver_map_empty() {
let dir = tempfile::tempdir().unwrap();
let map = create_resolver_map(dir.path(), 3);
assert!(map.resolvers.is_empty());
}
#[test]
fn test_find_tsconfigs_in_project() {
let dir = tempfile::tempdir().unwrap();
let packages = dir.path().join("packages").join("react-core");
fs::create_dir_all(&packages).unwrap();
fs::write(
packages.join("tsconfig.json"),
r#"{ "compilerOptions": {} }"#,
)
.unwrap();
let found = find_all_tsconfigs_in_project(dir.path(), 5);
assert_eq!(found.len(), 1);
}
#[test]
fn test_find_tsconfigs_skips_node_modules() {
let dir = tempfile::tempdir().unwrap();
let nm = dir.path().join("node_modules").join("pkg");
fs::create_dir_all(&nm).unwrap();
fs::write(nm.join("tsconfig.json"), "{}").unwrap();
let found = find_all_tsconfigs_in_project(dir.path(), 3);
assert!(found.is_empty());
}
#[test]
fn test_resolve_relative_import() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
fs::create_dir_all(&src).unwrap();
let target = src.join("helpers.ts");
fs::write(&target, "export function getOUIAProps() {}").unwrap();
let importing = src.join("Button.tsx");
fs::write(&importing, "").unwrap();
let resolver = create_resolver(None);
let resolved = resolve_import_with_resolver(&resolver, &importing, "./helpers");
assert!(resolved.is_some());
assert!(resolved.unwrap().ends_with("helpers.ts"));
}
#[test]
fn test_find_importers_of_named_import() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let helpers_dir = src.join("helpers");
let components_dir = src.join("components");
fs::create_dir_all(&helpers_dir).unwrap();
fs::create_dir_all(&components_dir).unwrap();
let helper_file = helpers_dir.join("ouia.ts");
fs::write(
&helper_file,
"export function getOUIAProps(name: string) { return {}; }",
)
.unwrap();
let button_file = components_dir.join("Button.tsx");
fs::write(
&button_file,
r#"import { getOUIAProps } from '../helpers/ouia';
export const Button = () => null;"#,
)
.unwrap();
let alert_file = components_dir.join("Alert.tsx");
fs::write(&alert_file, "export const Alert = () => null;").unwrap();
let resolver_map = create_resolver_map(dir.path(), 3);
let scan_files = vec![button_file.clone(), alert_file];
let importers = find_importers_of(&resolver_map, &helper_file, "getOUIAProps", &scan_files);
assert_eq!(importers.len(), 1);
assert_eq!(importers[0].importing_file, button_file);
assert_eq!(importers[0].local_name, "getOUIAProps");
}
#[test]
fn test_find_importers_of_aliased_import() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
fs::create_dir_all(&src).unwrap();
let helper_file = src.join("helpers.ts");
fs::write(&helper_file, "export function getOUIAProps() {}").unwrap();
let consumer = src.join("Consumer.tsx");
fs::write(
&consumer,
"import { getOUIAProps as getProps } from './helpers';",
)
.unwrap();
let resolver_map = create_resolver_map(dir.path(), 3);
let importers = find_importers_of(
&resolver_map,
&helper_file,
"getOUIAProps",
slice::from_ref(&consumer),
);
assert_eq!(importers.len(), 1);
assert_eq!(importers[0].local_name, "getProps");
}
#[test]
fn test_find_importers_no_match() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
fs::create_dir_all(&src).unwrap();
let helper_file = src.join("helpers.ts");
fs::write(&helper_file, "export function getOUIAProps() {}").unwrap();
let consumer = src.join("Consumer.tsx");
fs::write(&consumer, "import { otherFunction } from './helpers';").unwrap();
let resolver_map = create_resolver_map(dir.path(), 3);
let importers = find_importers_of(&resolver_map, &helper_file, "getOUIAProps", &[consumer]);
assert!(importers.is_empty());
}
#[test]
fn test_is_node_modules_path() {
assert!(is_node_modules_path(Path::new(
"/project/node_modules/@patternfly/react-core/dist/index.js"
)));
assert!(!is_node_modules_path(Path::new(
"/project/src/components/Foo.tsx"
)));
}
}