use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use lazy_static::lazy_static;
use regex::Regex;
use serde_json::Value as JsonValue;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ModuleIndexError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Path outside project root: {0}")]
PathOutsideRoot(PathBuf),
#[error("Invalid UTF-8 in path: {0}")]
InvalidUtf8(PathBuf),
}
#[derive(Debug, Default)]
pub struct ModuleIndex {
project_root: PathBuf,
module_to_file: HashMap<String, PathBuf>,
file_to_module: HashMap<PathBuf, String>,
namespace_packages: HashSet<String>,
language: String,
metadata: ModuleIndexMetadata,
}
#[derive(Debug, Default, Clone)]
struct ModuleIndexMetadata {
python_src_root: Option<PathBuf>,
ts_base_url: Option<PathBuf>,
ts_paths: Vec<TsPathMapping>,
js_package_name: Option<String>,
go_module_path: Option<String>,
rust_crate_name: Option<String>,
php_psr4: Vec<(String, PathBuf)>,
}
#[derive(Debug, Clone, Default)]
struct TsPathMapping {
alias_pattern: String,
target_patterns: Vec<String>,
}
impl ModuleIndex {
pub fn new(project_root: PathBuf, language: &str) -> Self {
let metadata = ModuleIndexMetadata::detect(&project_root, language);
Self {
project_root,
module_to_file: HashMap::new(),
file_to_module: HashMap::new(),
namespace_packages: HashSet::new(),
language: language.to_lowercase(),
metadata,
}
}
pub fn build(root: &Path, language: &str) -> Result<Self, ModuleIndexError> {
Self::build_with_ignore(root, language, true)
}
pub fn build_with_ignore(
root: &Path,
language: &str,
respect_ignore: bool,
) -> Result<Self, ModuleIndexError> {
let canonical_root = resolve_path(root, root)?;
let mut index = Self::new(canonical_root.clone(), language);
let mut dirs_with_py_files: HashSet<PathBuf> = HashSet::new();
let mut dirs_with_init: HashSet<PathBuf> = HashSet::new();
let extensions = get_language_extensions(language);
let walker = WalkBuilder::new(&canonical_root)
.hidden(true) .git_ignore(respect_ignore)
.git_global(respect_ignore)
.git_exclude(respect_ignore)
.filter_entry(|entry| {
let file_name = entry.file_name().to_string_lossy();
!should_skip_directory(&file_name)
})
.build();
let mut files: Vec<PathBuf> = Vec::new();
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
let is_relevant = ext
.as_ref()
.map(|e| extensions.contains(&e.as_str()))
.unwrap_or(false);
if !is_relevant {
continue;
}
let canonical = match resolve_path(path, &canonical_root) {
Ok(p) => p,
Err(_) => continue, };
if language == "python" {
if let Some(parent) = canonical.parent() {
dirs_with_py_files.insert(parent.to_path_buf());
let file_name = canonical.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "__init__.py" {
dirs_with_init.insert(parent.to_path_buf());
}
}
}
files.push(canonical);
}
if language == "python" {
for dir in &dirs_with_py_files {
if !dirs_with_init.contains(dir) {
let module = index.path_to_module(dir);
if !module.is_empty() {
index.namespace_packages.insert(module);
}
}
}
}
let mut init_files: Vec<PathBuf> = Vec::new();
let mut other_files: Vec<PathBuf> = Vec::new();
for path in files {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if language == "python" && file_name == "__init__.py" {
init_files.push(path);
} else {
other_files.push(path);
}
}
for path in init_files {
index.index_file(&path)?;
}
for path in other_files {
let module = index.compute_module_name(&path);
if language == "python" && !module.is_empty() {
if index.module_to_file.contains_key(&module) {
continue;
}
}
index.index_file(&path)?;
}
Ok(index)
}
fn index_file(&mut self, path: &Path) -> Result<(), ModuleIndexError> {
let module = self.compute_module_name(path);
if module.is_empty() {
return Ok(());
}
let normalized_module = normalize_module_key(&module);
self.module_to_file
.insert(normalized_module.clone(), path.to_path_buf());
self.file_to_module
.insert(path.to_path_buf(), normalized_module.clone());
let mut aliases = self.compute_module_aliases(&module, path);
if let Some(declared) = self.declared_package_alias(path) {
aliases.push(declared);
}
for alias in aliases {
let normalized_alias = normalize_module_key(&alias);
self.module_to_file
.entry(normalized_alias)
.or_insert_with(|| path.to_path_buf());
}
Ok(())
}
fn compute_module_name(&self, path: &Path) -> String {
match self.language.as_str() {
"python" => self.compute_python_module_name(path),
"typescript" | "javascript" => self.compute_typescript_module_name(path),
"rust" => self.compute_rust_module_name(path),
"go" => self.compute_go_module_name(path),
"java" => self.compute_java_module_name(path),
"kotlin" => self.compute_kotlin_module_name(path),
"scala" => self.compute_scala_module_name(path),
"csharp" | "c#" => self.compute_csharp_module_name(path),
"php" => self.compute_php_module_name(path),
"ruby" => self.compute_ruby_module_name(path),
"lua" => self.compute_lua_module_name(path),
"luau" => self.compute_lua_module_name(path),
"elixir" => self.compute_elixir_module_name(path),
"swift" => self.compute_swift_module_name(path),
"c" => self.compute_c_module_name(path),
"cpp" | "c++" => self.compute_cpp_module_name(path),
"ocaml" => self.compute_ocaml_module_name(path),
_ => self.compute_python_module_name(path), }
}
fn compute_module_aliases(&self, module: &str, path: &Path) -> Vec<String> {
let mut aliases = Vec::new();
let simple = simple_module_name(module);
if simple != module {
aliases.push(simple.to_string());
}
match self.language.as_str() {
"typescript" | "javascript" => {
let module_no_dot = module.strip_prefix("./").unwrap_or(module);
aliases.push(module_no_dot.to_string());
let mut stripped_by_base_url: Option<String> = None;
if let Some(base_url) = &self.metadata.ts_base_url {
if let Ok(rel) = base_url.strip_prefix(&self.project_root) {
let base = normalize_relative_str(rel);
let normalized = module.replace('\\', "/");
let candidates = [
format!("./{}/", base),
format!("{}/", base),
format!("./{}", base),
base.clone(),
];
for prefix in candidates {
if normalized.starts_with(&prefix) {
let stripped = normalized[prefix.len()..].trim_start_matches('/');
if !stripped.is_empty() {
aliases.push(stripped.to_string());
stripped_by_base_url = Some(stripped.to_string());
}
}
}
}
}
if let Some(pkg) = &self.metadata.js_package_name {
let base = stripped_by_base_url
.as_deref()
.unwrap_or(module_no_dot)
.trim_start_matches('/');
if base.is_empty() {
aliases.push(pkg.to_string());
} else {
aliases.push(format!("{}/{}", pkg, base));
}
}
if is_ts_index_file(path) {
aliases.push(format!("{}/index", module));
if module_no_dot != module {
aliases.push(format!("{}/index", module_no_dot));
}
}
aliases.extend(ts_path_aliases_for_file(
path,
&self.project_root,
self.metadata.ts_base_url.as_ref(),
&self.metadata.ts_paths,
));
}
"rust" => {
if let Some(stripped) = module.strip_prefix("crate::") {
aliases.push(stripped.to_string());
}
if let Some(crate_name) = &self.metadata.rust_crate_name {
if module == "crate" {
aliases.push(crate_name.to_string());
} else if let Some(stripped) = module.strip_prefix("crate::") {
aliases.push(format!("{}::{}", crate_name, stripped));
}
}
}
"go" => {
if let Some(prefix) = &self.metadata.go_module_path {
let base = module.trim_start_matches("./").trim_start_matches('/');
if base.is_empty() {
aliases.push(prefix.to_string());
} else {
aliases.push(format!("{}/{}", prefix.trim_end_matches('/'), base));
}
}
}
"php" => {
if module.contains('\\') {
aliases.push(module.replace('\\', "/"));
}
if module.contains('/') {
aliases.push(module.replace('/', "\\"));
}
if !module.starts_with('\\') {
aliases.push(format!("\\{}", module));
}
if !self.metadata.php_psr4.is_empty() {
for (prefix, dir) in &self.metadata.php_psr4 {
if let Ok(rel) = path.strip_prefix(dir) {
let rel_str = normalize_relative_str(rel);
let rel_str = strip_extension_any(&rel_str, &[".php"]);
if rel_str.is_empty() {
continue;
}
let ns_suffix = rel_str.replace('/', "\\");
let mut ns_prefix = prefix.clone();
if !ns_prefix.ends_with('\\') {
ns_prefix.push('\\');
}
aliases.push(format!("{}{}", ns_prefix, ns_suffix));
}
}
}
}
"ruby" => {
if let Some(camel) = ruby_module_alias_from_path(path, &self.project_root) {
aliases.push(camel);
}
}
"lua" | "luau" => {
if module.contains('.') {
aliases.push(module.replace('.', "/"));
}
if module.contains('/') {
aliases.push(module.replace('/', "."));
}
}
"c" | "cpp" => {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
aliases.push(file_name.to_string());
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
aliases.push(stem.to_string());
}
if let Ok(rel) = path.strip_prefix(&self.project_root) {
let rel_str = normalize_relative_str(rel);
aliases.push(rel_str.clone());
let rel_no_ext = strip_extension_any(
&rel_str,
&[".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"],
);
if rel_no_ext != rel_str {
aliases.push(rel_no_ext.to_string());
}
}
}
"swift" => {
if let Some(root) = swift_module_root(path, &self.project_root) {
aliases.push(root);
}
}
"elixir" => {
if !module.starts_with("Elixir.") {
aliases.push(format!("Elixir.{}", module));
}
}
_ => {}
}
aliases
}
fn declared_package_alias(&self, path: &Path) -> Option<String> {
match self.language.as_str() {
"java" | "kotlin" | "scala" => parse_java_like_package(path),
"csharp" | "c#" => parse_csharp_namespace(path),
_ => None,
}
}
fn compute_python_module_name(&self, path: &Path) -> String {
let relative = if let Some(src_root) = &self.metadata.python_src_root {
if let Ok(r) = path.strip_prefix(src_root) {
r
} else {
match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
}
}
} else {
match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
}
};
let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "__init__.py" {
let parent = relative.parent().unwrap_or(Path::new(""));
let parts: Vec<&str> = parent
.iter()
.filter_map(|s| s.to_str())
.filter(|s| !s.is_empty())
.collect();
return parts.join(".");
}
let stem = relative.with_extension("");
let parts: Vec<&str> = stem
.iter()
.filter_map(|s| s.to_str())
.filter(|s| !s.is_empty())
.collect();
parts.join(".")
}
fn compute_typescript_module_name(&self, path: &Path) -> String {
let relative = match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
};
let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "index.ts" || file_name == "index.tsx" || file_name == "index.js" {
let parent = relative.parent().unwrap_or(Path::new(""));
return format!("./{}", parent.display());
}
let stem = relative.with_extension("");
format!("./{}", stem.display())
}
fn compute_rust_module_name(&self, path: &Path) -> String {
let relative = match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
};
let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "lib.rs" || file_name == "main.rs" {
return "crate".to_string();
}
if file_name == "mod.rs" {
let parent = relative.parent().unwrap_or(Path::new(""));
let parts: Vec<&str> = parent
.iter()
.filter_map(|s| s.to_str())
.filter(|s| *s != "src" && !s.is_empty())
.collect();
if parts.is_empty() {
return "crate".to_string();
}
return format!("crate::{}", parts.join("::"));
}
let stem = relative.with_extension("");
let parts: Vec<&str> = stem
.iter()
.filter_map(|s| s.to_str())
.filter(|s| *s != "src" && !s.is_empty())
.collect();
if parts.is_empty() {
return "crate".to_string();
}
format!("crate::{}", parts.join("::"))
}
fn compute_go_module_name(&self, path: &Path) -> String {
let relative = match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
};
relative
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default()
}
fn compute_java_module_name(&self, path: &Path) -> String {
compute_dot_module_name(path, &self.project_root, &JAVA_PREFIXES, &[".java"])
}
fn compute_kotlin_module_name(&self, path: &Path) -> String {
compute_dot_module_name(path, &self.project_root, &KOTLIN_PREFIXES, &[".kt", ".kts"])
}
fn compute_scala_module_name(&self, path: &Path) -> String {
compute_dot_module_name(path, &self.project_root, &SCALA_PREFIXES, &[".scala"])
}
fn compute_csharp_module_name(&self, path: &Path) -> String {
compute_dot_module_name(path, &self.project_root, &CSHARP_PREFIXES, &[".cs"])
}
fn compute_php_module_name(&self, path: &Path) -> String {
compute_separator_module_name(path, &self.project_root, &PHP_PREFIXES, &[".php"], '\\')
}
fn compute_ruby_module_name(&self, path: &Path) -> String {
compute_separator_module_name(path, &self.project_root, &RUBY_PREFIXES, &[".rb"], '/')
}
fn compute_lua_module_name(&self, path: &Path) -> String {
compute_dot_module_name(path, &self.project_root, &LUA_PREFIXES, &[".lua", ".luau"])
}
fn compute_elixir_module_name(&self, path: &Path) -> String {
let relative = match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
};
let mut rel_str = normalize_relative_str(relative);
let mut module_parts: Vec<String> = Vec::new();
if let Some(rest) = rel_str.strip_prefix("apps/") {
let mut parts = rest.splitn(2, '/');
if let Some(app) = parts.next() {
if let Some(after_app) = parts.next() {
if let Some(after_lib) = after_app.strip_prefix("lib/") {
module_parts.push(snake_to_camel(app));
rel_str = after_lib.to_string();
}
}
}
}
if module_parts.is_empty() {
if let Some(after_lib) = rel_str.strip_prefix("lib/") {
rel_str = after_lib.to_string();
}
}
let rel_str = strip_extension_any(&rel_str, &[".ex", ".exs"]);
for segment in rel_str.split('/') {
if segment.is_empty() {
continue;
}
module_parts.push(snake_to_camel(segment));
}
module_parts.join(".")
}
fn compute_swift_module_name(&self, path: &Path) -> String {
let relative = match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
};
let rel_str = normalize_relative_str(relative);
if let Some(rest) = rel_str.strip_prefix("Sources/") {
return swift_module_from_sources(rest);
}
if let Some(rest) = rel_str.strip_prefix("Tests/") {
return swift_module_from_sources(rest);
}
compute_dot_module_name(path, &self.project_root, &SWIFT_PREFIXES, &[".swift"])
}
fn compute_c_module_name(&self, path: &Path) -> String {
compute_path_module_name(path, &self.project_root)
}
fn compute_cpp_module_name(&self, path: &Path) -> String {
compute_path_module_name(path, &self.project_root)
}
fn compute_ocaml_module_name(&self, path: &Path) -> String {
compute_dot_module_name(path, &self.project_root, &OCAML_PREFIXES, &[".ml", ".mli"])
}
fn path_to_module(&self, path: &Path) -> String {
let relative = match path.strip_prefix(&self.project_root) {
Ok(r) => r,
Err(_) => return String::new(),
};
let parts: Vec<&str> = relative
.iter()
.filter_map(|s| s.to_str())
.filter(|s| !s.is_empty())
.collect();
parts.join(".")
}
pub fn lookup(&self, module: &str) -> Option<&Path> {
let normalized = normalize_module_key(module);
self.module_to_file.get(&normalized).map(|p| p.as_path())
}
pub fn reverse_lookup(&self, path: &Path) -> Option<&str> {
let canonical = match resolve_path(path, &self.project_root) {
Ok(p) => p,
Err(_) => path.to_path_buf(),
};
self.file_to_module.get(&canonical).map(|s| s.as_str())
}
pub fn is_project_module(&self, module: &str) -> bool {
let normalized = normalize_module_key(module);
if self.module_to_file.contains_key(&normalized) {
return true;
}
if let Some(dot_pos) = normalized.rfind('.') {
let parent = &normalized[..dot_pos];
if self.namespace_packages.contains(parent) {
return true;
}
}
self.namespace_packages.contains(&normalized)
}
pub fn is_namespace_package(&self, module: &str) -> bool {
let normalized = normalize_module_key(module);
self.namespace_packages.contains(&normalized)
}
pub fn modules(&self) -> impl Iterator<Item = &str> {
self.module_to_file.keys().map(|s| s.as_str())
}
pub fn len(&self) -> usize {
self.module_to_file.len()
}
pub fn is_empty(&self) -> bool {
self.module_to_file.is_empty()
}
pub fn project_root(&self) -> &Path {
&self.project_root
}
pub fn language(&self) -> &str {
&self.language
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Path)> {
self.module_to_file
.iter()
.map(|(m, p)| (m.as_str(), p.as_path()))
}
}
fn resolve_path(path: &Path, root: &Path) -> Result<PathBuf, ModuleIndexError> {
let canonical = dunce::canonicalize(path).map_err(ModuleIndexError::Io)?;
if path != root {
let canonical_root = dunce::canonicalize(root).map_err(ModuleIndexError::Io)?;
if !canonical.starts_with(&canonical_root) {
return Err(ModuleIndexError::PathOutsideRoot(canonical));
}
}
Ok(canonical)
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn normalize_module_key(key: &str) -> String {
key.to_lowercase()
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn normalize_module_key(key: &str) -> String {
key.to_string()
}
impl ModuleIndexMetadata {
fn detect(root: &Path, language: &str) -> Self {
let lang = language.to_lowercase();
let mut meta = ModuleIndexMetadata::default();
if lang == "python" {
meta.python_src_root = detect_python_src_root(root);
}
if lang == "typescript" || lang == "javascript" {
meta.ts_base_url = detect_ts_base_url(root);
meta.ts_paths = detect_ts_paths(root);
meta.js_package_name = detect_js_package_name(root);
}
if lang == "go" {
meta.go_module_path = detect_go_module_path(root);
}
if lang == "rust" {
meta.rust_crate_name = detect_rust_crate_name(root);
}
if lang == "php" {
meta.php_psr4 = detect_php_psr4(root);
}
meta
}
}
fn detect_python_src_root(root: &Path) -> Option<PathBuf> {
let candidate = root.join("src");
if !candidate.is_dir() {
return None;
}
let walker = WalkBuilder::new(&candidate)
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.max_depth(Some(6))
.build();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext.eq_ignore_ascii_case("py") {
return Some(candidate);
}
}
}
}
None
}
fn detect_go_module_path(root: &Path) -> Option<String> {
let path = root.join("go.mod");
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("module ") {
let module = trimmed.trim_start_matches("module ").trim();
if !module.is_empty() {
return Some(module.to_string());
}
}
}
None
}
fn detect_rust_crate_name(root: &Path) -> Option<String> {
let path = root.join("Cargo.toml");
let content = std::fs::read_to_string(path).ok()?;
let mut in_package = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_package = trimmed == "[package]";
continue;
}
if !in_package {
continue;
}
if trimmed.starts_with("name") {
if let Some((_, value)) = trimmed.split_once('=') {
let name = value.trim().trim_matches('"').trim_matches('\'');
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
None
}
fn detect_js_package_name(root: &Path) -> Option<String> {
let json = read_json_file(root.join("package.json"))?;
json.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn detect_ts_base_url(root: &Path) -> Option<PathBuf> {
let configs = load_tsconfig_chain(root.join("tsconfig.json"));
for config in configs.iter().rev() {
let compiler = config.json.get("compilerOptions")?;
let base_url = compiler.get("baseUrl")?.as_str()?;
let base_url = base_url.trim();
if base_url.is_empty() {
continue;
}
let base_dir = config.path.parent().unwrap_or(root);
return Some(base_dir.join(base_url));
}
None
}
fn detect_ts_paths(root: &Path) -> Vec<TsPathMapping> {
let configs = load_tsconfig_chain(root.join("tsconfig.json"));
let mut merged: HashMap<String, Vec<String>> = HashMap::new();
for config in configs {
if let Some(compiler) = config.json.get("compilerOptions") {
if let Some(paths) = compiler.get("paths") {
extract_ts_paths(paths, &mut merged);
}
}
}
let mut mappings: Vec<TsPathMapping> = merged
.into_iter()
.map(|(alias, targets)| TsPathMapping {
alias_pattern: alias,
target_patterns: targets,
})
.collect();
mappings.sort_by(|a, b| a.alias_pattern.cmp(&b.alias_pattern));
mappings
}
#[derive(Debug, Clone)]
struct TsConfig {
path: PathBuf,
json: JsonValue,
}
fn load_tsconfig_chain(path: PathBuf) -> Vec<TsConfig> {
let mut visited = HashSet::new();
load_tsconfig_chain_inner(path, 0, &mut visited)
}
fn load_tsconfig_chain_inner(
path: PathBuf,
depth: usize,
visited: &mut HashSet<PathBuf>,
) -> Vec<TsConfig> {
if depth > 5 {
return Vec::new();
}
let canonical = dunce::canonicalize(&path).unwrap_or(path);
if !visited.insert(canonical.clone()) {
return Vec::new();
}
let json = match read_json_with_comments(canonical.clone()) {
Some(j) => j,
None => return Vec::new(),
};
let mut out = Vec::new();
if let Some(extends) = json.get("extends").and_then(|v| v.as_str()) {
if let Some(ext_path) = resolve_tsconfig_extends(&canonical, extends) {
out.extend(load_tsconfig_chain_inner(ext_path, depth + 1, visited));
}
}
out.push(TsConfig {
path: canonical,
json,
});
out
}
fn resolve_tsconfig_extends(base: &Path, extends: &str) -> Option<PathBuf> {
let ext = extends.trim();
if ext.is_empty() {
return None;
}
if !(ext.starts_with('.') || ext.starts_with('/')) {
return None;
}
let base_dir = base.parent().unwrap_or(Path::new("."));
let mut path = if ext.starts_with('/') {
PathBuf::from(ext)
} else {
base_dir.join(ext)
};
if path.extension().is_none() {
path.set_extension("json");
}
Some(path)
}
fn extract_ts_paths(value: &JsonValue, out: &mut HashMap<String, Vec<String>>) {
let map = match value.as_object() {
Some(m) => m,
None => return,
};
for (alias, targets) in map {
let mut patterns = Vec::new();
if let Some(path) = targets.as_str() {
patterns.push(path.to_string());
} else if let Some(list) = targets.as_array() {
for item in list {
if let Some(path) = item.as_str() {
patterns.push(path.to_string());
}
}
}
if !patterns.is_empty() {
out.insert(alias.to_string(), patterns);
}
}
}
fn detect_php_psr4(root: &Path) -> Vec<(String, PathBuf)> {
let mut mappings = Vec::new();
let json = match read_json_file(root.join("composer.json")) {
Some(j) => j,
None => return mappings,
};
for key in ["autoload", "autoload-dev"] {
if let Some(section) = json.get(key) {
if let Some(psr4) = section.get("psr-4") {
extract_psr4_mappings(root, psr4, &mut mappings);
}
}
}
mappings
}
fn extract_psr4_mappings(root: &Path, value: &JsonValue, out: &mut Vec<(String, PathBuf)>) {
let map = match value.as_object() {
Some(m) => m,
None => return,
};
for (prefix, paths) in map {
if let Some(path) = paths.as_str() {
out.push((prefix.to_string(), root.join(path)));
} else if let Some(list) = paths.as_array() {
for item in list {
if let Some(path) = item.as_str() {
out.push((prefix.to_string(), root.join(path)));
}
}
}
}
}
fn read_json_file(path: PathBuf) -> Option<JsonValue> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn read_json_with_comments(path: PathBuf) -> Option<JsonValue> {
let content = std::fs::read_to_string(path).ok()?;
let stripped = strip_json_comments(&content);
serde_json::from_str(&stripped).ok()
}
fn strip_json_comments(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut in_string = false;
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '"' {
out.push(ch);
in_string = !in_string;
continue;
}
if !in_string && ch == '/' {
if let Some('/') = chars.peek().copied() {
chars.next();
for c in chars.by_ref() {
if c == '\n' {
out.push('\n');
break;
}
}
continue;
}
if let Some('*') = chars.peek().copied() {
chars.next();
while let Some(c) = chars.next() {
if c == '*' {
if let Some('/') = chars.peek().copied() {
chars.next();
break;
}
}
}
continue;
}
}
out.push(ch);
}
out
}
const JAVA_PREFIXES: [&str; 5] = ["src/main/java/", "src/test/java/", "src/", "lib/", "app/"];
const KOTLIN_PREFIXES: [&str; 5] = [
"src/main/kotlin/",
"src/test/kotlin/",
"src/",
"lib/",
"app/",
];
const SCALA_PREFIXES: [&str; 5] = ["src/main/scala/", "src/test/scala/", "src/", "lib/", "app/"];
const CSHARP_PREFIXES: [&str; 3] = ["src/", "lib/", "app/"];
const PHP_PREFIXES: [&str; 5] = ["src/", "lib/", "app/", "public/", "includes/"];
const RUBY_PREFIXES: [&str; 3] = ["lib/", "src/", "app/"];
const LUA_PREFIXES: [&str; 3] = ["src/", "lib/", "scripts/"];
const SWIFT_PREFIXES: [&str; 2] = ["src/", "lib/"];
const OCAML_PREFIXES: [&str; 3] = ["src/", "lib/", "app/"];
const TS_EXTENSIONS: [&str; 6] = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
fn normalize_relative_str(path: &Path) -> String {
let mut rel = path.to_string_lossy().replace('\\', "/");
if let Some(stripped) = rel.strip_prefix("./") {
rel = stripped.to_string();
}
rel.trim_start_matches('/').to_string()
}
fn is_ts_index_file(path: &Path) -> bool {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
matches!(
file_name,
"index.ts" | "index.tsx" | "index.js" | "index.jsx" | "index.mjs" | "index.cjs"
)
}
fn ts_path_aliases_for_file(
path: &Path,
root: &Path,
base_url: Option<&PathBuf>,
mappings: &[TsPathMapping],
) -> Vec<String> {
if mappings.is_empty() {
return Vec::new();
}
let relative = match path.strip_prefix(root) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let rel_str = normalize_relative_str(relative);
let rel_no_ext = strip_extension_any(&rel_str, &TS_EXTENSIONS);
let mut candidates = Vec::new();
if !rel_no_ext.is_empty() {
candidates.push(rel_no_ext.to_string());
}
if is_ts_index_file(path) {
if let Some(parent) = Path::new(rel_no_ext).parent() {
let parent_str = parent.to_string_lossy().to_string();
if !parent_str.is_empty() {
candidates.push(parent_str);
}
}
}
let base_prefix = base_url
.and_then(|p| p.strip_prefix(root).ok())
.map(normalize_relative_str)
.filter(|s| !s.is_empty());
let mut aliases = Vec::new();
for candidate in candidates {
for mapping in mappings {
for target_pattern in &mapping.target_patterns {
if let Some(alias) = ts_alias_for_pattern(
&candidate,
target_pattern,
base_prefix.as_deref(),
&mapping.alias_pattern,
) {
aliases.push(alias);
}
}
}
}
aliases
}
fn ts_alias_for_pattern(
candidate: &str,
target_pattern: &str,
base_prefix: Option<&str>,
alias_pattern: &str,
) -> Option<String> {
let mut pattern = target_pattern.trim().replace('\\', "/");
if let Some(stripped) = pattern.strip_prefix("./") {
pattern = stripped.to_string();
}
if let Some(base) = base_prefix {
if !pattern.starts_with("../") && !pattern.starts_with('/') {
let base = base.trim_end_matches('/');
if !base.is_empty() {
pattern = format!("{}/{}", base, pattern);
}
}
}
let pattern = strip_extension_any(&pattern, &TS_EXTENSIONS);
let capture = match_ts_path_pattern(candidate, pattern)?;
let mut alias = alias_pattern.replace('*', &capture);
if alias.ends_with('/') {
alias = alias.trim_end_matches('/').to_string();
}
if alias.is_empty() {
None
} else {
Some(alias)
}
}
fn match_ts_path_pattern(candidate: &str, pattern: &str) -> Option<String> {
if let Some(star_pos) = pattern.find('*') {
let (prefix, rest) = pattern.split_at(star_pos);
let suffix = &rest[1..];
if candidate.starts_with(prefix) && candidate.ends_with(suffix) {
let mid_end = candidate.len().saturating_sub(suffix.len());
let mid = &candidate[prefix.len()..mid_end];
return Some(mid.to_string());
}
return None;
}
if candidate == pattern {
return Some(String::new());
}
None
}
fn strip_known_prefixes<'a>(path: &'a str, prefixes: &[&str]) -> &'a str {
let mut best_end: Option<usize> = None;
let mut best_prefix_len: usize = 0;
for prefix in prefixes {
if let Some(pos) = path.find(prefix) {
if (pos == 0 || path.as_bytes()[pos - 1] == b'/')
&& prefix.len() > best_prefix_len {
best_prefix_len = prefix.len();
best_end = Some(pos + prefix.len());
}
}
}
if let Some(end) = best_end {
&path[end..]
} else {
path
}
}
fn strip_extension_any<'a>(path: &'a str, extensions: &[&str]) -> &'a str {
for ext in extensions {
if let Some(stripped) = path.strip_suffix(ext) {
return stripped;
}
}
path
}
fn compute_dot_module_name(
path: &Path,
root: &Path,
prefixes: &[&str],
extensions: &[&str],
) -> String {
let relative = match path.strip_prefix(root) {
Ok(r) => r,
Err(_) => return String::new(),
};
let rel_str = normalize_relative_str(relative);
let rel_str = strip_known_prefixes(&rel_str, prefixes);
let rel_str = strip_extension_any(rel_str, extensions);
if rel_str.is_empty() {
return String::new();
}
rel_str
.split('/')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(".")
}
fn compute_separator_module_name(
path: &Path,
root: &Path,
prefixes: &[&str],
extensions: &[&str],
separator: char,
) -> String {
let relative = match path.strip_prefix(root) {
Ok(r) => r,
Err(_) => return String::new(),
};
let rel_str = normalize_relative_str(relative);
let rel_str = strip_known_prefixes(&rel_str, prefixes);
let rel_str = strip_extension_any(rel_str, extensions);
if rel_str.is_empty() {
return String::new();
}
if separator == '/' {
rel_str.to_string()
} else {
rel_str.replace('/', &separator.to_string())
}
}
fn compute_path_module_name(path: &Path, root: &Path) -> String {
let relative = match path.strip_prefix(root) {
Ok(r) => r,
Err(_) => return String::new(),
};
normalize_relative_str(relative)
}
fn snake_to_camel(segment: &str) -> String {
segment
.split(['_', '-'])
.filter(|s| !s.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => {
let mut out = String::new();
out.push(first.to_ascii_uppercase());
out.push_str(chars.as_str());
out
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join("")
}
fn swift_module_from_sources(rest: &str) -> String {
let rest = strip_extension_any(rest, &[".swift"]);
let mut parts = rest.split('/').filter(|s| !s.is_empty());
let module = parts.next().unwrap_or("");
if module.is_empty() {
return String::new();
}
let remainder: Vec<&str> = parts.collect();
if remainder.is_empty() {
module.to_string()
} else {
format!("{}.{}", module, remainder.join("."))
}
}
fn swift_module_root(path: &Path, root: &Path) -> Option<String> {
let relative = path.strip_prefix(root).ok()?;
let rel_str = normalize_relative_str(relative);
if let Some(rest) = rel_str.strip_prefix("Sources/") {
return rest.split('/').next().map(|s| s.to_string());
}
if let Some(rest) = rel_str.strip_prefix("Tests/") {
return rest.split('/').next().map(|s| s.to_string());
}
None
}
fn ruby_module_alias_from_path(path: &Path, root: &Path) -> Option<String> {
let relative = path.strip_prefix(root).ok()?;
let rel_str = normalize_relative_str(relative);
let rel_str = strip_known_prefixes(&rel_str, &RUBY_PREFIXES);
let rel_str = strip_extension_any(rel_str, &[".rb"]);
if rel_str.is_empty() {
return None;
}
let parts: Vec<String> = rel_str
.split('/')
.filter(|s| !s.is_empty())
.map(snake_to_camel)
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join("::"))
}
}
fn parse_java_like_package(path: &Path) -> Option<String> {
let source = fs::read_to_string(path).ok()?;
lazy_static! {
static ref RE_PACKAGE: Regex =
Regex::new(r"(?m)^\\s*package\\s+([A-Za-z_][\\w\\.]*)\\s*;?").unwrap();
}
RE_PACKAGE.captures(&source).map(|caps| caps[1].to_string())
}
fn parse_csharp_namespace(path: &Path) -> Option<String> {
let source = fs::read_to_string(path).ok()?;
lazy_static! {
static ref RE_NAMESPACE: Regex =
Regex::new(r"(?m)^\\s*namespace\\s+([A-Za-z_][\\w\\.]*)").unwrap();
}
RE_NAMESPACE
.captures(&source)
.map(|caps| caps[1].to_string())
}
fn simple_module_name(module: &str) -> &str {
module
.rsplit(['.', '/', '\\'])
.next()
.unwrap_or(module)
}
fn should_skip_directory(name: &str) -> bool {
matches!(
name,
"__pycache__"
| ".git"
| ".svn"
| ".hg"
| "node_modules"
| "venv"
| ".venv"
| "env"
| ".env"
| ".tox"
| ".pytest_cache"
| ".mypy_cache"
| ".ruff_cache"
| "__pypackages__"
| "target"
| "build"
| "dist"
| ".idea"
| ".vscode"
)
}
fn get_language_extensions(language: &str) -> Vec<&'static str> {
match language.to_lowercase().as_str() {
"python" => vec!["py"],
"typescript" => vec!["ts", "tsx"],
"javascript" => vec!["js", "jsx", "mjs", "cjs"],
"rust" => vec!["rs"],
"go" => vec!["go"],
"java" => vec!["java"],
"c" => vec!["c", "h"],
"cpp" | "c++" => vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx", "h"],
"ruby" => vec!["rb"],
"php" => vec!["php"],
"kotlin" => vec!["kt", "kts"],
"scala" => vec!["scala"],
"swift" => vec!["swift"],
"csharp" | "c#" => vec!["cs"],
"lua" => vec!["lua"],
"luau" => vec!["lua", "luau"],
"elixir" => vec!["ex", "exs"],
"ocaml" => vec!["ml", "mli"],
_ => vec!["py"], }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_build_simple_package() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
fs::write(dir.path().join("pkg/core.py"), "def foo(): pass").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("pkg").is_some());
assert!(index.lookup("pkg.core").is_some());
}
#[test]
fn test_build_indexes_init_as_package() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
let pkg_path = index.lookup("pkg");
assert!(pkg_path.is_some());
assert!(pkg_path.unwrap().ends_with("__init__.py"));
}
#[test]
fn test_build_package_wins_over_module() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "# package").unwrap();
fs::write(dir.path().join("pkg.py"), "# module").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
let pkg_path = index.lookup("pkg");
assert!(pkg_path.is_some());
assert!(pkg_path.unwrap().ends_with("__init__.py"));
}
#[test]
fn test_build_namespace_package() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/module.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.is_namespace_package("pkg"));
assert!(index.lookup("pkg.module").is_some());
}
#[test]
fn test_build_skips_pycache() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("__pycache__")).unwrap();
fs::write(dir.path().join("__pycache__/module.cpython-311.pyc"), "").unwrap();
fs::write(dir.path().join("module.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("module").is_some());
for (module, _) in index.iter() {
assert!(!module.contains("__pycache__"));
}
}
#[test]
fn test_build_deeply_nested_package() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("a/b/c/d/e")).unwrap();
for pkg in ["a", "a/b", "a/b/c", "a/b/c/d", "a/b/c/d/e"] {
fs::write(dir.path().join(format!("{}/__init__.py", pkg)), "").unwrap();
}
fs::write(dir.path().join("a/b/c/d/e/f.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("a.b.c.d.e.f").is_some());
assert!(index.lookup("a.b.c").is_some());
}
#[test]
fn test_lookup_returns_file_path() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
fs::write(dir.path().join("pkg/core.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
let path = index.lookup("pkg.core");
assert!(path.is_some());
assert!(path.unwrap().ends_with("core.py"));
}
#[test]
fn test_lookup_returns_none_for_nonexistent() {
let dir = tempdir().unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("nonexistent.module").is_none());
}
#[test]
fn test_reverse_lookup_returns_module_path() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
fs::write(dir.path().join("pkg/core.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
let module = index.reverse_lookup(&dir.path().join("pkg/core.py"));
assert_eq!(module, Some("pkg.core"));
}
#[test]
fn test_reverse_lookup_returns_none_for_unknown() {
let dir = tempdir().unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index
.reverse_lookup(Path::new("/unknown/path.py"))
.is_none());
}
#[test]
fn test_is_project_module_returns_true_for_indexed() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
fs::write(dir.path().join("pkg/core.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.is_project_module("pkg"));
assert!(index.is_project_module("pkg.core"));
}
#[test]
fn test_is_project_module_returns_false_for_stdlib() {
let dir = tempdir().unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(!index.is_project_module("os"));
assert!(!index.is_project_module("sys"));
assert!(!index.is_project_module("json.decoder"));
}
#[test]
fn test_empty_directory() {
let dir = tempdir().unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert_eq!(index.len(), 0);
}
#[test]
fn test_single_file_no_package() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("script.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("script").is_some());
}
#[test]
fn test_mixed_extensions() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("module.py"), "").unwrap();
fs::write(dir.path().join("config.json"), "").unwrap();
fs::write(dir.path().join("README.md"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("module").is_some());
assert_eq!(index.len(), 1); }
#[test]
fn test_dunder_names() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
fs::write(dir.path().join("pkg/__main__.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("pkg.__main__").is_some());
}
#[test]
fn test_private_modules() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg/_internal")).unwrap();
fs::write(dir.path().join("pkg/__init__.py"), "").unwrap();
fs::write(dir.path().join("pkg/_private.py"), "").unwrap();
fs::write(dir.path().join("pkg/_internal/__init__.py"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "python").unwrap();
assert!(index.lookup("pkg._private").is_some());
assert!(index.lookup("pkg._internal").is_some());
}
#[test]
fn test_typescript_index_file() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("utils")).unwrap();
fs::write(dir.path().join("utils/index.ts"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "typescript").unwrap();
assert!(index.lookup("./utils").is_some());
}
#[test]
fn test_rust_lib_rs() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/lib.rs"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "rust").unwrap();
assert!(index.lookup("crate").is_some());
}
#[test]
fn test_rust_mod_rs() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/utils")).unwrap();
fs::write(dir.path().join("src/lib.rs"), "").unwrap();
fs::write(dir.path().join("src/utils/mod.rs"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "rust").unwrap();
assert!(index.lookup("crate::utils").is_some());
}
#[test]
fn test_go_package() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("pkg/utils")).unwrap();
fs::write(dir.path().join("pkg/utils/helpers.go"), "").unwrap();
let index = ModuleIndex::build(dir.path(), "go").unwrap();
assert!(index.lookup("pkg/utils").is_some());
}
}