use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
pub trait ImportResolver: Send + Sync {
fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf>;
fn clear_cache(&self) {}
}
pub(crate) fn strip_import_quotes(raw: &str) -> &str {
raw.trim_matches(|c| c == '"' || c == '\'')
}
pub(crate) fn strip_c_family_quotes(raw: &str) -> &str {
raw.trim_matches(|c| c == '<' || c == '>' || c == '"' || c == '\'')
}
pub(crate) fn probe_relative(
raw: &str,
source_dir: &Path,
extensions: &[&str],
is_file: &dyn Fn(&Path) -> bool,
fallback: bool,
) -> Option<PathBuf> {
let base = if raw.starts_with('/') {
PathBuf::from("/")
} else {
source_dir.to_path_buf()
};
let path = base.join(raw);
for ext in extensions {
let candidate = if ext.is_empty() {
path.clone()
} else {
path.with_extension(ext.trim_start_matches('.'))
};
if is_file(&candidate) {
return Some(candidate);
}
}
fallback.then(|| path.clone())
}
pub(crate) fn resolve_js_family_import(
raw: &str,
source_dir: &Path,
extensions: &[&str],
is_file: &dyn Fn(&Path) -> bool,
) -> Option<PathBuf> {
let raw = strip_import_quotes(raw);
if raw.is_empty() {
return None;
}
if !raw.starts_with('.') && !raw.starts_with('/') {
return Some(PathBuf::from(raw));
}
probe_relative(raw, source_dir, extensions, is_file, true)
}
pub(crate) fn resolve_c_family_import(raw: &str, source_dir: &Path) -> Option<PathBuf> {
let raw = strip_c_family_quotes(raw);
if raw.is_empty() {
return None;
}
let path = source_dir.join(raw);
if path.extension().is_none() {
Some(path.with_extension("h"))
} else {
Some(path)
}
}
pub(crate) fn python_candidate_paths(
raw: &str,
source_dir: &Path,
project_root: &Path,
) -> Option<(PathBuf, PathBuf)> {
let raw = strip_import_quotes(raw);
if raw.is_empty() {
return None;
}
if raw.starts_with('.') {
let relative = raw.trim_start_matches('.');
if relative.is_empty() {
let init = source_dir.join("__init__.py");
return Some((init.clone(), init));
}
let path = source_dir.join(relative.replace('.', std::path::MAIN_SEPARATOR_STR));
let init = path.join("__init__.py");
let module = path.with_extension("py");
Some((init, module))
} else {
let path = project_root.join(raw.replace('.', std::path::MAIN_SEPARATOR_STR));
let init = path.join("__init__.py");
let module = path.with_extension("py");
Some((init, module))
}
}
pub(crate) fn go_relative_path(raw: &str, source_dir: &Path) -> Option<PathBuf> {
raw.strip_prefix('.')
.map(|relative| source_dir.join(relative).with_extension("go"))
}
pub(crate) fn find_go_module(project_root: &Path) -> Option<(PathBuf, String)> {
let mut current = Some(project_root);
while let Some(dir) = current {
let go_mod = dir.join("go.mod");
if go_mod.is_file() {
if let Ok(content) = std::fs::read_to_string(&go_mod) {
for line in content.lines() {
let line = line.trim();
if let Some(module) = line.strip_prefix("module ") {
return Some((dir.to_path_buf(), module.trim().to_string()));
}
}
}
return None;
}
current = dir.parent();
}
None
}
pub struct StatelessResolver {
f: fn(&str, &Path, &Path) -> Option<PathBuf>,
}
impl StatelessResolver {
pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
Self { f }
}
}
impl ImportResolver for StatelessResolver {
fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
(self.f)(raw, source_dir, project_root)
}
}
pub struct PythonResolver {
f: fn(&str, &Path, &Path) -> Option<PathBuf>,
exists_cache: RwLock<HashMap<PathBuf, bool>>,
}
impl PythonResolver {
pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
Self {
f,
exists_cache: RwLock::new(HashMap::new()),
}
}
pub fn clear_cache(&self) {
let mut cache = self
.exists_cache
.write()
.unwrap_or_else(|poison| poison.into_inner());
cache.clear();
}
}
impl ImportResolver for PythonResolver {
fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
let check_exists = |path: &Path| -> bool {
let cache_val = self
.exists_cache
.read()
.ok()
.and_then(|cache| cache.get(path).copied());
if let Some(res) = cache_val {
return res;
}
let res = path.exists();
if let Ok(mut cache) = self.exists_cache.write() {
cache.insert(path.to_path_buf(), res);
}
res
};
if let Some((init_path, _)) = python_candidate_paths(raw, source_dir, project_root) {
let stripped = strip_import_quotes(raw);
if stripped.trim_start_matches('.').is_empty() && stripped.starts_with('.') {
return Some(init_path);
}
if check_exists(&init_path) {
return Some(init_path);
}
} else {
return None;
}
(self.f)(raw, source_dir, project_root)
}
fn clear_cache(&self) {
PythonResolver::clear_cache(self);
}
}
pub struct GoModResolver {
f: fn(&str, &Path, &Path) -> Option<PathBuf>,
cached_module: RwLock<Option<Option<(PathBuf, String)>>>,
}
impl GoModResolver {
pub fn new(f: fn(&str, &Path, &Path) -> Option<PathBuf>) -> Self {
Self {
f,
cached_module: RwLock::new(None),
}
}
pub fn clear_cache(&self) {
let mut guard = self
.cached_module
.write()
.unwrap_or_else(|poison| poison.into_inner());
*guard = None;
}
}
impl ImportResolver for GoModResolver {
fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
let raw = strip_import_quotes(raw);
if raw.is_empty() {
return None;
}
if let Some(path) = go_relative_path(raw, source_dir) {
return Some(path);
}
let cached = self
.cached_module
.read()
.ok()
.and_then(|guard| guard.clone());
let module_info = match cached {
Some(info) => info,
None => {
let computed = find_go_module(project_root);
if let Ok(mut guard) = self.cached_module.write() {
*guard = Some(computed.clone());
}
computed
}
};
let matched_module = module_info.as_ref().and_then(|(dir, name)| {
if raw.starts_with(name) {
Some((dir, name))
} else {
None
}
});
if let Some((dir, module_name)) = matched_module {
let relative = raw[module_name.len()..].trim_start_matches('/');
return Some(dir.join(relative).with_extension("go"));
}
(self.f)(raw, source_dir, project_root)
}
fn clear_cache(&self) {
GoModResolver::clear_cache(self);
}
}
pub struct NodeResolver {
f: fn(&str, &Path, &Path) -> Option<PathBuf>,
extensions: &'static [&'static str],
is_file_cache: RwLock<HashMap<PathBuf, bool>>,
}
pub(crate) const JS_EXTS: &[&str] = &["", ".js", ".json", ".node", ".mjs", ".cjs"];
pub(crate) const TS_EXTS: &[&str] = &["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"];
impl NodeResolver {
pub fn new(
f: fn(&str, &Path, &Path) -> Option<PathBuf>,
extensions: &'static [&'static str],
) -> Self {
Self {
f,
extensions,
is_file_cache: RwLock::new(HashMap::new()),
}
}
pub fn clear_cache(&self) {
let mut cache = self
.is_file_cache
.write()
.unwrap_or_else(|poison| poison.into_inner());
cache.clear();
}
}
impl ImportResolver for NodeResolver {
fn resolve(&self, raw: &str, source_dir: &Path, project_root: &Path) -> Option<PathBuf> {
let raw = strip_import_quotes(raw);
if raw.is_empty() {
return None;
}
let check_is_file = |path: &Path| -> bool {
let cache_val = self
.is_file_cache
.read()
.ok()
.and_then(|cache| cache.get(path).copied());
if let Some(res) = cache_val {
return res;
}
let res = path.is_file();
if let Ok(mut cache) = self.is_file_cache.write() {
cache.insert(path.to_path_buf(), res);
}
res
};
if !raw.starts_with('.') && !raw.starts_with('/') {
return (self.f)(raw, source_dir, project_root);
}
let path = if raw.starts_with('/') {
PathBuf::from("/").join(raw)
} else {
source_dir.join(raw)
};
for ext in self.extensions {
let candidate = if ext.is_empty() {
path.clone()
} else {
path.with_extension(ext.trim_start_matches('.'))
};
if check_is_file(&candidate) {
return Some(candidate);
}
}
(self.f)(raw, source_dir, project_root)
}
fn clear_cache(&self) {
NodeResolver::clear_cache(self);
}
}
pub fn make_resolver(lang: crate::language::LangId) -> Box<dyn ImportResolver> {
let f = lang.spec().import_path_resolver;
match lang {
crate::language::LangId::Python => Box::new(PythonResolver::new(f)),
crate::language::LangId::Go => Box::new(GoModResolver::new(f)),
crate::language::LangId::JavaScript => Box::new(NodeResolver::new(f, JS_EXTS)),
crate::language::LangId::TypeScript | crate::language::LangId::Tsx => {
Box::new(NodeResolver::new(f, TS_EXTS))
}
_ => Box::new(StatelessResolver::new(f)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[test]
fn stateless_resolver_delegates_to_fn() {
fn my_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
Some(root.join(format!("{raw}.py")))
}
let resolver = StatelessResolver::new(my_resolver);
let result = resolver.resolve("foo", Path::new("/src"), Path::new("/proj"));
assert_eq!(result, Some(PathBuf::from("/proj/foo.py")));
}
#[test]
fn stateless_resolver_returns_none_for_unresolvable() {
fn null_resolver(_raw: &str, _src: &Path, _root: &Path) -> Option<PathBuf> {
None
}
let resolver = StatelessResolver::new(null_resolver);
let result = resolver.resolve("anything", Path::new("/src"), Path::new("/proj"));
assert!(result.is_none());
}
#[test]
fn make_resolver_returns_working_resolver_for_python() {
use crate::language::LangId;
let resolver = make_resolver(LangId::Python);
let result = resolver.resolve("b", Path::new("/proj/a"), Path::new("/proj"));
let _ = result; }
#[test]
fn import_resolver_trait_is_object_safe() {
fn accepts_boxed(_resolver: &dyn ImportResolver) {}
fn null_resolver(_raw: &str, _src: &Path, _root: &Path) -> Option<PathBuf> {
None
}
let resolver = StatelessResolver::new(null_resolver);
accepts_boxed(&resolver);
}
#[test]
fn python_resolver_resolves_import() {
fn dummy_python_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
Some(root.join(format!("{raw}.py")))
}
let resolver = PythonResolver::new(dummy_python_resolver);
let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
assert_eq!(result, Some(PathBuf::from("/proj/test.py")));
}
#[test]
fn tsconfig_resolver_resolves_import() {
fn dummy_ts_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
Some(root.join(format!("{raw}.ts")))
}
let resolver = NodeResolver::new(dummy_ts_resolver, TS_EXTS);
let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
assert_eq!(result, Some(PathBuf::from("/proj/test.ts")));
}
#[test]
fn node_resolver_uses_configured_extensions() {
fn fallback(raw: &str, _source: &Path, _root: &Path) -> Option<PathBuf> {
Some(PathBuf::from(raw))
}
let temp = std::env::temp_dir().join("node_resolver_uses_configured_extensions");
if temp.exists() {
let _ = std::fs::remove_dir_all(&temp);
}
std::fs::create_dir_all(&temp).unwrap();
let target = temp.join("mod.json");
std::fs::write(&target, "{}").unwrap();
let js = NodeResolver::new(fallback, JS_EXTS);
assert_eq!(js.resolve("./mod", &temp, &temp), Some(target.clone()));
let ts = NodeResolver::new(fallback, TS_EXTS);
assert_eq!(
ts.resolve("./mod", &temp, &temp),
Some(PathBuf::from("./mod"))
);
let _ = std::fs::remove_dir_all(&temp);
}
#[test]
fn shared_quote_strip_helpers() {
assert_eq!(strip_import_quotes("\"react\""), "react");
assert_eq!(strip_c_family_quotes("<stdio.h>"), "stdio.h");
assert_eq!(
resolve_c_family_import("<stdio.h>", Path::new("/src")),
Some(PathBuf::from("/src/stdio.h"))
);
}
#[test]
fn go_mod_resolver_resolves_import() {
fn dummy_go_resolver(raw: &str, _source: &Path, root: &Path) -> Option<PathBuf> {
Some(root.join(format!("{raw}.go")))
}
let resolver = GoModResolver::new(dummy_go_resolver);
let result = resolver.resolve("test", Path::new("/src"), Path::new("/proj"));
assert_eq!(result, Some(PathBuf::from("/proj/test.go")));
}
#[test]
fn go_mod_resolver_memoizes_go_mod_file() {
let temp_dir = std::env::temp_dir().join("go_mod_resolver_memoizes_go_mod_file");
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
std::fs::create_dir_all(&temp_dir).unwrap();
let go_mod_path = temp_dir.join("go.mod");
std::fs::write(&go_mod_path, "module myproject\n").unwrap();
let resolver = make_resolver(crate::language::LangId::Go);
let res1 = resolver.resolve("myproject/sub", &temp_dir, &temp_dir);
assert_eq!(res1, Some(temp_dir.join("sub.go")));
std::fs::remove_file(&go_mod_path).unwrap();
let res2 = resolver.resolve("myproject/other", &temp_dir, &temp_dir);
assert_eq!(res2, Some(temp_dir.join("other.go")));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn python_resolver_memoizes_exists_checks() {
let temp_dir = std::env::temp_dir().join("python_resolver_memoizes_exists_checks");
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
std::fs::create_dir_all(&temp_dir).unwrap();
let pkg_dir = temp_dir.join("my_package");
std::fs::create_dir_all(&pkg_dir).unwrap();
let init_py = pkg_dir.join("__init__.py");
std::fs::write(&init_py, "").unwrap();
let resolver = make_resolver(crate::language::LangId::Python);
let res1 = resolver.resolve("my_package", &temp_dir, &temp_dir);
assert_eq!(res1, Some(init_py.clone()));
std::fs::remove_file(&init_py).unwrap();
let res2 = resolver.resolve("my_package", &temp_dir, &temp_dir);
assert_eq!(res2, Some(init_py));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn tsconfig_resolver_memoizes_is_file_checks() {
let temp_dir = std::env::temp_dir().join("tsconfig_resolver_memoizes_is_file_checks");
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
std::fs::create_dir_all(&temp_dir).unwrap();
let ts_file = temp_dir.join("my_file.ts");
std::fs::write(&ts_file, "").unwrap();
let resolver = make_resolver(crate::language::LangId::TypeScript);
let res1 = resolver.resolve("./my_file", &temp_dir, &temp_dir);
assert_eq!(res1, Some(ts_file.clone()));
std::fs::remove_file(&ts_file).unwrap();
let res2 = resolver.resolve("./my_file", &temp_dir, &temp_dir);
assert_eq!(res2, Some(ts_file));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn resolver_clear_cache_invalidates_memoized_state() {
let temp_dir = std::env::temp_dir().join("resolver_clear_cache_invalidates_memoized_state");
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
std::fs::create_dir_all(&temp_dir).unwrap();
let pkg_dir = temp_dir.join("py_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let init_py = pkg_dir.join("__init__.py");
std::fs::write(&init_py, "").unwrap();
let py_resolver = make_resolver(crate::language::LangId::Python);
assert_eq!(
py_resolver.resolve("py_pkg", &temp_dir, &temp_dir),
Some(init_py.clone())
);
std::fs::remove_file(&init_py).unwrap();
assert_eq!(
py_resolver.resolve("py_pkg", &temp_dir, &temp_dir),
Some(init_py.clone())
);
py_resolver.clear_cache();
assert_eq!(
py_resolver.resolve("py_pkg", &temp_dir, &temp_dir),
Some(temp_dir.join("py_pkg.py"))
);
let go_mod_path = temp_dir.join("go.mod");
std::fs::write(&go_mod_path, "module oldmod\n").unwrap();
let go_resolver = make_resolver(crate::language::LangId::Go);
assert_eq!(
go_resolver.resolve("oldmod/sub", &temp_dir, &temp_dir),
Some(temp_dir.join("sub.go"))
);
std::fs::write(&go_mod_path, "module newmod\n").unwrap();
assert_eq!(
go_resolver.resolve("oldmod/sub", &temp_dir, &temp_dir),
Some(temp_dir.join("sub.go"))
);
go_resolver.clear_cache();
assert_eq!(
go_resolver.resolve("oldmod/sub", &temp_dir, &temp_dir),
None
);
assert_eq!(
go_resolver.resolve("newmod/sub", &temp_dir, &temp_dir),
Some(temp_dir.join("sub.go"))
);
let ts_file = temp_dir.join("ts_file.ts");
std::fs::write(&ts_file, "").unwrap();
let ts_resolver = make_resolver(crate::language::LangId::TypeScript);
assert_eq!(
ts_resolver.resolve("./ts_file", &temp_dir, &temp_dir),
Some(ts_file.clone())
);
std::fs::remove_file(&ts_file).unwrap();
assert_eq!(
ts_resolver.resolve("./ts_file", &temp_dir, &temp_dir),
Some(ts_file)
);
ts_resolver.clear_cache();
assert_eq!(
ts_resolver.resolve("./ts_file", &temp_dir, &temp_dir),
Some(temp_dir.join("./ts_file"))
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}