use crate::map::lang::Lang;
use std::collections::HashMap;
pub struct Resolver {
by_path: HashMap<String, usize>,
by_stem: HashMap<String, Vec<usize>>,
by_name: HashMap<String, Vec<usize>>,
}
impl Resolver {
pub fn new(paths: &[String]) -> Self {
let mut by_path = HashMap::new();
let mut by_stem: HashMap<String, Vec<usize>> = HashMap::new();
let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
for (i, p) in paths.iter().enumerate() {
by_path.insert(p.clone(), i);
let stem = strip_ext(p);
by_stem.entry(stem.clone()).or_default().push(i);
if let Some(dir) = package_dir(&stem) {
by_stem.entry(dir).or_default().push(i);
}
if let Some(base) = stem.rsplit('/').next() {
by_name.entry(base.to_string()).or_default().push(i);
}
}
Self { by_path, by_stem, by_name }
}
pub fn resolve(&self, from_rel: &str, lang: Lang, raw: &str) -> Option<usize> {
match lang {
Lang::JavaScript | Lang::TypeScript | Lang::Tsx => self.resolve_relative(from_rel, raw),
Lang::Python => self.resolve_python(from_rel, raw),
Lang::Rust => self.resolve_rust(from_rel, raw),
Lang::Java => self.resolve_java(raw),
Lang::Go => self.resolve_go(raw),
Lang::CSharp => self.resolve_csharp(raw),
}
}
fn lookup_stem(&self, stem: &str) -> Option<usize> {
let stem = stem.trim_start_matches("./").trim_start_matches('/');
if let Some(i) = self.by_path.get(stem) { return Some(*i); }
self.by_stem.get(stem).and_then(|v| v.first().copied())
}
fn resolve_relative(&self, from_rel: &str, raw: &str) -> Option<usize> {
if !raw.starts_with('.') { return None; }
let base = parent_dir(from_rel);
let joined = normalise(&format!("{base}/{raw}"));
self.lookup_stem(&joined).or_else(|| self.lookup_stem(&format!("{joined}/index")))
}
fn resolve_python(&self, from_rel: &str, raw: &str) -> Option<usize> {
if let Some(rest) = raw.strip_prefix('.') {
let mut dir = parent_dir(from_rel).to_string();
let mut rest = rest;
while let Some(r) = rest.strip_prefix('.') {
dir = parent_dir(&dir).to_string();
rest = r;
}
let tail = rest.replace('.', "/");
let joined = normalise(&if tail.is_empty() { dir } else { format!("{dir}/{tail}") });
return self.lookup_stem(&joined).or_else(|| self.lookup_stem(&format!("{joined}/__init__")));
}
let as_path = raw.replace('.', "/");
self.lookup_stem(&as_path)
.or_else(|| self.lookup_stem(&format!("src/{as_path}")))
.or_else(|| self.unique_by_name(raw.rsplit('.').next().unwrap_or(raw)))
}
fn resolve_rust(&self, from_rel: &str, raw: &str) -> Option<usize> {
let cleaned = raw.trim_start_matches("use ").trim_end_matches(';').trim();
let segments: Vec<&str> = cleaned
.split("::")
.map(|s| s.trim())
.take_while(|s| !s.starts_with('{') && *s != "*")
.collect();
if segments.is_empty() { return None; }
if segments.len() == 1 && !matches!(segments[0], "crate" | "self" | "super" | "std" | "core" | "alloc") {
let dir = module_dir(from_rel);
if let Some(i) = self.lookup_stem(&format!("{dir}/{}", segments[0])) { return Some(i); }
if let Some(i) = self.lookup_stem(&format!("{dir}/{}/mod", segments[0])) { return Some(i); }
}
let mut path_segs: Vec<String> = Vec::new();
let mut base = String::new();
for (n, seg) in segments.iter().enumerate() {
match *seg {
"crate" if n == 0 => base = crate_root(from_rel),
"self" if n == 0 => base = module_dir(from_rel),
"super" if n == 0 => base = parent_dir(&module_dir(from_rel)).to_string(),
"std" | "core" | "alloc" if n == 0 => return None,
s => path_segs.push(s.to_string()),
}
}
if base.is_empty() { base = crate_root(from_rel); }
while !path_segs.is_empty() {
let joined = normalise(&format!("{base}/{}", path_segs.join("/")));
if let Some(i) = self.lookup_stem(&joined) { return Some(i); }
if let Some(i) = self.lookup_stem(&format!("{joined}/mod")) { return Some(i); }
path_segs.pop();
}
None
}
fn resolve_java(&self, raw: &str) -> Option<usize> {
let as_path = raw.replace('.', "/");
self.by_stem.iter()
.filter(|(stem, _)| stem.ends_with(&as_path))
.min_by_key(|(stem, _)| stem.len())
.and_then(|(_, v)| v.first().copied())
}
fn resolve_go(&self, raw: &str) -> Option<usize> {
let mut best: Option<(usize, usize)> = None;
for (stem, idxs) in &self.by_stem {
let dir = parent_dir(stem);
if dir.is_empty() { continue; }
if raw.ends_with(dir) {
let score = dir.len();
if best.is_none_or(|(s, _)| score > s)
&& let Some(i) = idxs.first()
{
best = Some((score, *i));
}
}
}
best.map(|(_, i)| i)
}
fn resolve_csharp(&self, raw: &str) -> Option<usize> {
let as_path = raw.replace('.', "/");
self.by_stem
.iter()
.filter(|(stem, _)| parent_dir(stem).ends_with(&as_path))
.min_by_key(|(stem, _)| stem.len())
.and_then(|(_, v)| v.first().copied())
}
fn unique_by_name(&self, name: &str) -> Option<usize> {
match self.by_name.get(name) {
Some(v) if v.len() == 1 => Some(v[0]),
_ => None,
}
}
}
fn strip_ext(p: &str) -> String {
match p.rfind('.') {
Some(i) if !p[i..].contains('/') => p[..i].to_string(),
_ => p.to_string(),
}
}
fn package_dir(stem: &str) -> Option<String> {
for marker in ["/mod", "/index", "/__init__"] {
if let Some(d) = stem.strip_suffix(marker) {
return Some(d.to_string());
}
}
None
}
fn parent_dir(p: &str) -> &str {
match p.rfind('/') { Some(i) => &p[..i], None => "" }
}
fn module_dir(from_rel: &str) -> String {
let base = from_rel.rsplit('/').next().unwrap_or(from_rel);
if matches!(base, "mod.rs" | "lib.rs" | "main.rs") {
parent_dir(from_rel).to_string()
} else {
strip_ext(from_rel)
}
}
fn crate_root(from_rel: &str) -> String {
match from_rel.find("src/") {
Some(i) => from_rel[..i + 3].trim_end_matches('/').to_string(),
None => parent_dir(from_rel).to_string(),
}
}
fn normalise(p: &str) -> String {
let mut out: Vec<&str> = Vec::new();
for seg in p.split('/') {
match seg {
"" | "." => {}
".." => { out.pop(); }
s => out.push(s),
}
}
out.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
fn files() -> Vec<String> {
["src/main.rs", "src/api/mod.rs", "src/api/routes.rs", "src/core/auth.rs",
"web/app/index.ts", "web/app/alpha.ts", "pkg/thing/thing.go",
"py/pkg/__init__.py", "py/pkg/util.py",
"java/com/acme/Foo.java",
"cs/Acme/Widgets/Widget.cs", "cs/Acme/Widgets/Spinner.cs"]
.iter().map(|s| s.to_string()).collect()
}
#[test]
fn csharp_using_resolves_through_the_namespace_directory() {
let r = Resolver::new(&files());
let hit = r.resolve("cs/Acme/App.cs", Lang::CSharp, "Acme.Widgets");
assert!(hit.is_some(), "Acme.Widgets should resolve into cs/Acme/Widgets");
assert_eq!(r.resolve("cs/Acme/App.cs", Lang::CSharp, "System.Text.Json"), None);
assert_eq!(r.resolve("cs/Acme/App.cs", Lang::CSharp, "System"), None);
}
#[test]
fn rust_crate_and_mod_paths() {
let f = files();
let r = Resolver::new(&f);
assert_eq!(r.resolve("src/main.rs", Lang::Rust, "crate::api::routes"), Some(2));
assert_eq!(r.resolve("src/main.rs", Lang::Rust, "api"), Some(1));
assert_eq!(r.resolve("src/api/mod.rs", Lang::Rust, "self::routes"), Some(2));
assert_eq!(r.resolve("src/api/routes.rs", Lang::Rust, "super::core::auth"), None);
assert_eq!(r.resolve("src/main.rs", Lang::Rust, "std::collections::HashMap"), None);
}
#[test]
fn rust_super_is_relative_to_the_module_not_the_directory() {
let f = files();
let r = Resolver::new(&f);
assert_eq!(r.resolve("src/api/routes.rs", Lang::Rust, "super::core::auth"), None);
assert_eq!(r.resolve("src/api/mod.rs", Lang::Rust, "super::core::auth"), Some(3));
}
#[test]
fn rust_item_suffix_falls_back_to_module() {
let r = Resolver::new(&files());
assert_eq!(r.resolve("src/main.rs", Lang::Rust, "crate::core::auth::AuthGuard"), Some(3));
}
#[test]
fn js_relative_and_index() {
let r = Resolver::new(&files());
assert_eq!(r.resolve("web/app/index.ts", Lang::TypeScript, "./alpha"), Some(5));
assert_eq!(r.resolve("web/app/alpha.ts", Lang::TypeScript, "."), Some(4));
assert_eq!(r.resolve("web/app/alpha.ts", Lang::TypeScript, "react"), None);
}
#[test]
fn python_relative_and_absolute() {
let r = Resolver::new(&files());
assert_eq!(r.resolve("py/pkg/__init__.py", Lang::Python, ".util"), Some(8));
assert_eq!(r.resolve("other.py", Lang::Python, "py.pkg.util"), Some(8));
}
#[test]
fn java_and_go_suffix_matching() {
let r = Resolver::new(&files());
assert_eq!(r.resolve("x.java", Lang::Java, "com.acme.Foo"), Some(9));
assert_eq!(r.resolve("x.go", Lang::Go, "github.com/me/repo/pkg/thing"), Some(6));
}
}