use crate::language::Language;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub fn extract_raw_imports(source: &str, language: Language) -> Vec<String> {
match language {
Language::Rust => extract_rust_imports(source),
Language::Go => extract_go_imports(source),
Language::Python => extract_python_imports(source),
Language::Java => extract_java_imports(source),
Language::TypeScript
| Language::TypeScriptReact
| Language::JavaScript
| Language::JavaScriptReact
| Language::Vue => extract_ecmascript_imports(source),
Language::CSharp => extract_csharp_imports(source),
Language::C | Language::CHeader => vec![], }
}
fn extract_rust_imports(source: &str) -> Vec<String> {
let file = match syn::parse_file(source) {
Ok(f) => f,
Err(_) => return vec![],
};
let mut paths = Vec::new();
for item in &file.items {
if let syn::Item::Use(use_item) = item {
let raw = flatten_use_tree(&use_item.tree, "");
paths.extend(raw);
}
}
dedup(paths)
}
fn flatten_use_tree(tree: &syn::UseTree, prefix: &str) -> Vec<String> {
match tree {
syn::UseTree::Path(p) => {
flatten_use_tree(&p.tree, &join_path(prefix, &p.ident.to_string()))
}
syn::UseTree::Name(n) => vec![join_path(prefix, &n.ident.to_string())],
syn::UseTree::Rename(r) => vec![join_path(prefix, &r.ident.to_string())],
syn::UseTree::Glob(_) => {
if prefix.is_empty() {
vec![]
} else {
vec![prefix.to_string()]
}
}
syn::UseTree::Group(g) => g
.items
.iter()
.flat_map(|item| flatten_use_tree(item, prefix))
.collect(),
}
}
fn join_path(prefix: &str, seg: &str) -> String {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{}::{}", prefix, seg)
}
}
fn extract_go_imports(source: &str) -> Vec<String> {
use regex::Regex;
static BLOCK_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let block_re = BLOCK_RE.get_or_init(|| Regex::new(r#"import\s*\(([^)]*)\)"#).unwrap());
static PATH_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let path_re = PATH_RE.get_or_init(|| Regex::new(r#"(?:\w+\s+)?"([^"]+)""#).unwrap());
let mut imports = Vec::new();
let mut block_ranges = Vec::new();
for cap in block_re.captures_iter(source) {
let m = cap.get(0).unwrap();
block_ranges.push(m.range());
let block = &cap[1];
for pc in path_re.captures_iter(block) {
imports.push(pc[1].to_string());
}
}
static SINGLE_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let single_re =
SINGLE_RE.get_or_init(|| Regex::new(r#"(?m)^import\s+(?:\w+\s+)?"([^"]+)""#).unwrap());
for cap in single_re.captures_iter(source) {
let m = cap.get(0).unwrap();
if block_ranges.iter().any(|r| r.contains(&m.start())) {
continue;
}
imports.push(cap[1].to_string());
}
dedup(imports)
}
fn extract_python_imports(source: &str) -> Vec<String> {
use regex::Regex;
static IMPORT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let import_re = IMPORT_RE.get_or_init(|| Regex::new(r"(?m)^\s*import\s+(\S+)").unwrap());
static FROM_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let from_re = FROM_RE.get_or_init(|| Regex::new(r"(?m)^\s*from\s+(\S+)\s+import").unwrap());
let mut imports = Vec::new();
for cap in import_re.captures_iter(source) {
imports.push(cap[1].trim_end_matches(',').to_string());
}
for cap in from_re.captures_iter(source) {
imports.push(cap[1].to_string());
}
dedup(imports)
}
fn extract_java_imports(source: &str) -> Vec<String> {
use regex::Regex;
static IMPORT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let import_re = IMPORT_RE.get_or_init(|| Regex::new(r"(?m)^\s*import\s+([\w.]+)\s*;").unwrap());
let imports: Vec<_> = import_re
.captures_iter(source)
.map(|c| c[1].to_string())
.collect();
dedup(imports)
}
fn extract_csharp_imports(source: &str) -> Vec<String> {
use regex::Regex;
static USING_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let using_re =
USING_RE.get_or_init(|| Regex::new(r"(?m)^\s*using\s+(?:static\s+)?([\w.]+)\s*;").unwrap());
let imports: Vec<_> = using_re
.captures_iter(source)
.map(|c| c[1].to_string())
.collect();
dedup(imports)
}
fn extract_ecmascript_imports(source: &str) -> Vec<String> {
use regex::Regex;
static FROM_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let from_re =
FROM_RE.get_or_init(|| Regex::new(r#"(?:import|from)\s+['"]([^'"]+)['"]"#).unwrap());
static REQUIRE_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let require_re =
REQUIRE_RE.get_or_init(|| Regex::new(r#"require\(['"]([^'"]+)['"]\)"#).unwrap());
let mut imports = Vec::new();
for cap in from_re.captures_iter(source) {
imports.push(cap[1].to_string());
}
for cap in require_re.captures_iter(source) {
imports.push(cap[1].to_string());
}
dedup(imports)
}
fn to_abs_dir(file: &str, repo_root: &Path) -> PathBuf {
let parent = Path::new(file).parent().unwrap_or(Path::new("."));
if parent.is_absolute() {
parent.to_path_buf()
} else {
repo_root.join(parent)
}
}
fn find_crate_root(file_path: &Path) -> Option<PathBuf> {
let mut dir = file_path.parent()?;
loop {
if dir.join("Cargo.toml").exists() {
return Some(dir.to_path_buf());
}
match dir.parent() {
Some(p) if p != dir => dir = p,
_ => return None,
}
}
}
fn extract_cargo_package_name(toml: &str) -> Option<String> {
use regex::Regex;
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r#"(?m)^\s*name\s*=\s*"([^"]+)""#).unwrap());
re.captures(toml).map(|c| c[1].to_string())
}
fn build_crate_map(all_files: &[&str], repo_root: &Path) -> HashMap<String, PathBuf> {
let mut cargo_dirs: HashSet<PathBuf> = HashSet::new();
for &file in all_files {
let path = if Path::new(file).is_absolute() {
PathBuf::from(file)
} else {
repo_root.join(file)
};
let mut dir = match path.parent() {
Some(d) => d.to_path_buf(),
None => continue,
};
loop {
if dir.join("Cargo.toml").exists() {
cargo_dirs.insert(dir.clone());
break;
}
match dir.parent() {
Some(p) if p != dir.as_path() => dir = p.to_path_buf(),
_ => break,
}
}
}
let mut map = HashMap::new();
for dir in cargo_dirs {
let cargo_toml = dir.join("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
if let Some(name) = extract_cargo_package_name(&content) {
let crate_name = name.replace('-', "_");
let src_dir = dir.join("src");
map.insert(crate_name, src_dir);
}
}
}
map
}
fn resolve_import(
raw: &str,
importing_file: &str,
all_files_set: &HashSet<String>,
language: Language,
repo_root: &Path,
crate_map: &HashMap<String, PathBuf>,
) -> Option<String> {
match language {
Language::TypeScript
| Language::TypeScriptReact
| Language::JavaScript
| Language::JavaScriptReact
| Language::Vue => resolve_ecmascript(raw, importing_file, all_files_set, repo_root),
Language::Rust => resolve_rust(raw, importing_file, all_files_set, repo_root, crate_map),
Language::Go => resolve_go(raw, all_files_set),
Language::Python => resolve_python(raw, importing_file, all_files_set, repo_root),
Language::Java => resolve_java(raw, all_files_set),
Language::CSharp => resolve_java(raw, all_files_set), Language::C | Language::CHeader => None, }
}
fn resolve_ecmascript(
raw: &str,
importing_file: &str,
all_files_set: &HashSet<String>,
repo_root: &Path,
) -> Option<String> {
if !raw.starts_with("./") && !raw.starts_with("../") {
return None; }
let base = normalize_path_lexically(&to_abs_dir(importing_file, repo_root).join(raw));
find_ecmascript_file(&base, all_files_set, repo_root)
}
fn find_ecmascript_file(
base: &Path,
all_files_set: &HashSet<String>,
repo_root: &Path,
) -> Option<String> {
for ext in &["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"] {
if let Some(hit) = check_in_set(&base.with_extension(ext), all_files_set, repo_root) {
return Some(hit);
}
}
for ext in &["ts", "js", "tsx", "jsx"] {
if let Some(hit) = check_in_set(
&base.join(format!("index.{}", ext)),
all_files_set,
repo_root,
) {
return Some(hit);
}
}
None
}
fn resolve_rust(
raw: &str,
importing_file: &str,
all_files_set: &HashSet<String>,
repo_root: &Path,
crate_map: &HashMap<String, PathBuf>,
) -> Option<String> {
let module_paths = rust_module_paths(raw);
let importing_path = Path::new(importing_file);
let abs_file = if importing_path.is_absolute() {
importing_path.to_path_buf()
} else {
repo_root.join(importing_path)
};
for module_path in module_paths {
if let Some(hit) =
try_resolve_rust_module(&module_path, &abs_file, all_files_set, repo_root, crate_map)
{
return Some(hit);
}
}
None
}
fn rust_module_paths(raw: &str) -> Vec<String> {
let mut paths = vec![raw.to_string()];
let mut current = raw.to_string();
while let Some(pos) = current.rfind("::") {
current = current[..pos].to_string();
paths.push(current.clone());
}
paths
}
fn try_resolve_rust_module(
module_path: &str,
importing_abs: &Path,
all_files_set: &HashSet<String>,
repo_root: &Path,
crate_map: &HashMap<String, PathBuf>,
) -> Option<String> {
if let Some(sub) = module_path.strip_prefix("crate::") {
return resolve_crate_path(sub, importing_abs, all_files_set, repo_root);
}
if let Some(sub) = module_path.strip_prefix("super::") {
return resolve_super_path(sub, importing_abs, all_files_set, repo_root);
}
resolve_cross_crate_path(module_path, all_files_set, repo_root, crate_map)
}
fn resolve_crate_path(
sub: &str,
importing_abs: &Path,
all_files_set: &HashSet<String>,
repo_root: &Path,
) -> Option<String> {
let src_dir = find_crate_root(importing_abs)?.join("src");
try_rs_candidates(&src_dir, &sub.replace("::", "/"), all_files_set, repo_root)
}
fn resolve_super_path(
sub: &str,
importing_abs: &Path,
all_files_set: &HashSet<String>,
repo_root: &Path,
) -> Option<String> {
let parent_dir = importing_abs.parent()?.parent()?;
try_rs_candidates(
parent_dir,
&sub.replace("::", "/"),
all_files_set,
repo_root,
)
}
fn resolve_cross_crate_path(
module_path: &str,
all_files_set: &HashSet<String>,
repo_root: &Path,
crate_map: &HashMap<String, PathBuf>,
) -> Option<String> {
let sep = module_path.find("::")?;
let src_dir = crate_map.get(&module_path[..sep])?;
try_rs_candidates(
src_dir,
&module_path[sep + 2..].replace("::", "/"),
all_files_set,
repo_root,
)
}
fn try_rs_candidates(
src_dir: &Path,
path: &str,
all_files_set: &HashSet<String>,
repo_root: &Path,
) -> Option<String> {
if path.is_empty() {
return None;
}
let candidates = [
src_dir.join(format!("{}.rs", path)),
src_dir.join(format!("{}/mod.rs", path)),
];
for c in &candidates {
if let Some(hit) = check_in_set(c, all_files_set, repo_root) {
return Some(hit);
}
}
None
}
fn resolve_go(raw: &str, all_files_set: &HashSet<String>) -> Option<String> {
let last_seg = raw.split('/').next_back()?;
if last_seg.is_empty() {
return None;
}
let mut candidate: Option<&str> = None;
for file in all_files_set {
if !file.ends_with(".go") {
continue;
}
let parent_name = Path::new(file)
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str());
if parent_name == Some(last_seg) {
match candidate {
None => candidate = Some(file.as_str()),
Some(prev) if file.as_str() < prev => candidate = Some(file.as_str()),
_ => {}
}
}
}
candidate.map(|s| s.to_string())
}
fn resolve_python(
raw: &str,
importing_file: &str,
all_files_set: &HashSet<String>,
repo_root: &Path,
) -> Option<String> {
let abs_parent = to_abs_dir(importing_file, repo_root);
let dots = raw.chars().take_while(|c| *c == '.').count();
let module_name = &raw[dots..];
let base_dir = if dots > 0 {
let mut dir = abs_parent.clone();
for _ in 1..dots {
if let Some(p) = dir.parent() {
dir = p.to_path_buf();
}
}
dir
} else {
abs_parent.clone()
};
let module_path = module_name.replace('.', "/");
let candidates: Vec<PathBuf> = if module_name.is_empty() {
vec![base_dir.join("__init__.py")]
} else {
vec![
base_dir.join(format!("{}.py", module_path)),
base_dir.join(format!("{}/__init__.py", module_path)),
repo_root.join(format!("{}.py", module_path)),
]
};
for c in candidates {
if let Some(hit) = check_in_set(&c, all_files_set, repo_root) {
return Some(hit);
}
}
None
}
fn resolve_java(raw: &str, all_files_set: &HashSet<String>) -> Option<String> {
let stdlib = [
"java.",
"javax.",
"org.junit.",
"org.springframework.",
"com.google.",
"android.",
"kotlin.",
"scala.",
];
if stdlib.iter().any(|p| raw.starts_with(p)) {
return None;
}
let java_file = format!("{}.java", raw.replace('.', "/"));
for file in all_files_set {
if file.ends_with(&java_file) {
return Some(file.clone());
}
}
None
}
fn check_in_set(
candidate: &Path,
all_files_set: &HashSet<String>,
repo_root: &Path,
) -> Option<String> {
let abs_str = candidate.to_string_lossy().replace('\\', "/");
if all_files_set.contains(&abs_str) {
return Some(abs_str);
}
if let Ok(rel) = candidate.strip_prefix(repo_root) {
let rel_str = rel.to_string_lossy().replace('\\', "/");
if all_files_set.contains(&rel_str) {
return Some(rel_str);
}
}
None
}
fn normalize_path_lexically(path: &Path) -> PathBuf {
let mut components = Vec::new();
for c in path.components() {
match c {
std::path::Component::ParentDir => {
if matches!(components.last(), Some(std::path::Component::Normal(_))) {
components.pop();
} else {
components.push(c);
}
}
std::path::Component::CurDir => {}
other => components.push(other),
}
}
components.iter().collect()
}
fn dedup(mut v: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
v.retain(|s| seen.insert(s.clone()));
v
}
pub fn resolve_file_deps(source_files: &[&str], repo_root: &Path) -> Vec<(String, String)> {
let all_files_set: HashSet<String> = source_files.iter().map(|s| s.to_string()).collect();
let crate_map = build_crate_map(source_files, repo_root);
let mut edges = Vec::new();
let mut seen_edges: HashSet<(String, String)> = HashSet::new();
for &file in source_files {
for edge in extract_file_import_edges(file, &all_files_set, repo_root, &crate_map) {
if seen_edges.insert(edge.clone()) {
edges.push(edge);
}
}
}
edges
}
fn extract_file_import_edges(
file: &str,
all_files_set: &HashSet<String>,
repo_root: &Path,
crate_map: &HashMap<String, PathBuf>,
) -> Vec<(String, String)> {
let lang = match Language::from_path(Path::new(file)) {
Some(l) => l,
None => return vec![],
};
let abs_path = if Path::new(file).is_absolute() {
PathBuf::from(file)
} else {
repo_root.join(file)
};
let source = match std::fs::read_to_string(&abs_path) {
Ok(s) => s,
Err(_) => return vec![],
};
extract_raw_imports(&source, lang)
.into_iter()
.filter_map(|raw| resolve_import(&raw, file, all_files_set, lang, repo_root, crate_map))
.filter(|to_file| to_file.as_str() != file)
.map(|to_file| (file.to_string(), to_file))
.collect()
}
fn parse_workspace_members(workspace_toml: &str) -> Vec<String> {
use regex::Regex;
if !workspace_toml.contains("[workspace]") {
return Vec::new();
}
static MEMBERS_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let members_re =
MEMBERS_RE.get_or_init(|| Regex::new(r#"members\s*=\s*\[([^\]]+)\]"#).unwrap());
let members_block = match members_re.captures(workspace_toml) {
Some(c) => c[1].to_string(),
None => return Vec::new(),
};
static QUOTED_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let quoted_re = QUOTED_RE.get_or_init(|| Regex::new(r#""([^"]+)""#).unwrap());
quoted_re
.captures_iter(&members_block)
.map(|c| c[1].to_string())
.collect()
}
fn resolve_member_dep_libs(repo_root: &Path, member: &str) -> Vec<String> {
use regex::Regex;
static PATH_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let path_re = PATH_RE.get_or_init(|| Regex::new(r#"path\s*=\s*"([^"]+)""#).unwrap());
let member_toml = match std::fs::read_to_string(repo_root.join(member).join("Cargo.toml")) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let mut dep_libs = Vec::new();
for cap in path_re.captures_iter(&member_toml) {
let dep_dir = normalize_path_lexically(&repo_root.join(member).join(&cap[1]));
let lib_rs = dep_dir.join("src").join("lib.rs");
if lib_rs.exists() {
if let Ok(rel) = lib_rs.strip_prefix(repo_root) {
dep_libs.push(rel.to_string_lossy().into_owned());
}
}
}
dep_libs
}
pub fn resolve_cargo_workspace_edges(
repo_root: &Path,
source_files: &[&str],
) -> Vec<(String, String)> {
let members = load_workspace_members(repo_root);
if members.is_empty() {
return Vec::new();
}
let prefix_to_libs = build_prefix_lib_map(&members, repo_root);
if prefix_to_libs.is_empty() {
return Vec::new();
}
collect_workspace_edges(source_files, repo_root, &prefix_to_libs)
}
fn load_workspace_members(repo_root: &Path) -> Vec<String> {
match std::fs::read_to_string(repo_root.join("Cargo.toml")) {
Ok(toml) => parse_workspace_members(&toml),
Err(_) => Vec::new(),
}
}
fn collect_workspace_edges(
source_files: &[&str],
repo_root: &Path,
prefix_to_libs: &[(String, Vec<String>)],
) -> Vec<(String, String)> {
let mut edges: Vec<(String, String)> = Vec::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
for &file in source_files {
let rel_file = to_repo_relative(file, repo_root);
for edge in workspace_edges_for_file(file, &rel_file, prefix_to_libs) {
if seen.insert(edge.clone()) {
edges.push(edge);
}
}
}
edges
}
fn workspace_edges_for_file(
file: &str,
rel_file: &str,
prefix_to_libs: &[(String, Vec<String>)],
) -> Vec<(String, String)> {
let mut edges = Vec::new();
for (prefix, dep_libs) in prefix_to_libs {
if rel_file.starts_with(prefix.as_str()) {
for dep_lib in dep_libs {
if rel_file != dep_lib.as_str() {
edges.push((file.to_string(), dep_lib.clone()));
}
}
}
}
edges
}
fn build_prefix_lib_map(members: &[String], repo_root: &Path) -> Vec<(String, Vec<String>)> {
members
.iter()
.filter_map(|member| {
let dep_libs = resolve_member_dep_libs(repo_root, member);
if dep_libs.is_empty() {
None
} else {
Some((format!("{}/", member), dep_libs))
}
})
.collect()
}
fn to_repo_relative(file: &str, repo_root: &Path) -> String {
if Path::new(file).is_absolute() {
match Path::new(file).strip_prefix(repo_root) {
Ok(r) => r.to_string_lossy().into_owned(),
Err(_) => file.to_string(),
}
} else {
file.to_string()
}
}
#[cfg(test)]
mod parse_workspace_members_tests {
use super::parse_workspace_members;
#[test]
fn non_workspace_toml_returns_empty() {
let toml = r#"
[package]
name = "example"
"#;
assert!(parse_workspace_members(toml).is_empty());
}
#[test]
fn workspace_without_members_returns_empty() {
let toml = r#"
[workspace]
resolver = "2"
"#;
assert!(parse_workspace_members(toml).is_empty());
}
#[test]
fn workspace_with_members_parses_names() {
let toml = r#"
[workspace]
members = [
"core",
"utils",
"cli"
]
"#;
assert_eq!(
parse_workspace_members(toml),
vec!["core".to_string(), "utils".to_string(), "cli".to_string()]
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_ecmascript_imports() {
let src = r#"
import React from 'react';
import { foo } from './utils';
import type { Bar } from '../types';
const x = require('./helper');
"#;
let imports = extract_ecmascript_imports(src);
assert!(imports.contains(&"react".to_string()));
assert!(imports.contains(&"./utils".to_string()));
assert!(imports.contains(&"../types".to_string()));
assert!(imports.contains(&"./helper".to_string()));
}
#[test]
fn test_extract_python_imports() {
let src = "import os\nfrom utils import foo\nfrom .helper import bar\n";
let imports = extract_python_imports(src);
assert!(imports.contains(&"os".to_string()));
assert!(imports.contains(&"utils".to_string()));
assert!(imports.contains(&".helper".to_string()));
}
#[test]
fn test_extract_java_imports() {
let src = "import java.util.List;\nimport com.example.Foo;\n";
let imports = extract_java_imports(src);
assert!(imports.contains(&"java.util.List".to_string()));
assert!(imports.contains(&"com.example.Foo".to_string()));
}
#[test]
fn test_extract_go_imports_block() {
let src = r#"
import (
"fmt"
"github.com/user/project/pkg/utils"
)
"#;
let imports = extract_go_imports(src);
assert!(imports.contains(&"fmt".to_string()));
assert!(imports.contains(&"github.com/user/project/pkg/utils".to_string()));
}
#[test]
fn test_extract_rust_imports() {
let src = r#"
use crate::aggregates::SnapshotAggregates;
use crate::git;
use std::collections::HashMap;
"#;
let imports = extract_rust_imports(src);
assert!(imports.iter().any(|s| s.contains("aggregates")));
assert!(imports.iter().any(|s| s.contains("git")));
}
#[test]
fn test_flatten_use_tree_glob() {
let src = "use crate::foo::*;";
let imports = extract_rust_imports(src);
assert!(imports
.iter()
.any(|s| s == "crate::foo::*" || s == "crate::foo"));
}
#[test]
fn test_resolve_java_drops_stdlib() {
let set: HashSet<String> = HashSet::new();
assert!(resolve_java("java.util.List", &set).is_none());
assert!(resolve_java("javax.servlet.Servlet", &set).is_none());
}
#[test]
fn test_normalize_path_lexically() {
let p = Path::new("/foo/bar/../baz/./qux");
let normalized = normalize_path_lexically(p);
assert_eq!(normalized, Path::new("/foo/baz/qux"));
}
}