use crate::types::{ImportStatement, TreeNode};
use std::borrow::Borrow;
use std::collections::{HashMap, HashSet};
use std::fmt;
mod resolve_py;
mod resolve_rust;
mod resolve_ts;
pub use resolve_py::resolve_py_import;
pub use resolve_rust::resolve_rust_import;
pub use resolve_ts::resolve_ts_import;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IndexPath(String);
impl IndexPath {
pub fn new(path: &str) -> Self {
IndexPath(normalize_path(path))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl Borrow<str> for IndexPath {
fn borrow(&self) -> &str {
&self.0
}
}
impl fmt::Display for IndexPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<IndexPath> for String {
fn from(p: IndexPath) -> String {
p.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportTarget {
File(usize),
External(String),
Unresolved(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedImport {
pub module_path: String,
pub target: ImportTarget,
pub line_number: u32,
}
#[derive(Debug, Clone, Default)]
pub struct ModuleGraph {
pub forward: Vec<Vec<ResolvedImport>>,
pub reverse: Vec<Vec<usize>>,
}
impl ModuleGraph {
pub fn imports(&self, idx: usize) -> &[ResolvedImport] {
self.forward.get(idx).map(Vec::as_slice).unwrap_or(&[])
}
pub fn importers(&self, idx: usize) -> &[usize] {
self.reverse.get(idx).map(Vec::as_slice).unwrap_or(&[])
}
pub fn transitive_importers(&self, idx: usize) -> Vec<usize> {
let mut seen: HashSet<usize> = HashSet::new();
let mut frontier = vec![idx];
while let Some(node) = frontier.pop() {
for &importer in self.importers(node) {
if seen.insert(importer) {
frontier.push(importer);
}
}
}
seen.remove(&idx);
let mut out: Vec<usize> = seen.into_iter().collect();
out.sort_unstable();
out
}
pub fn file_edges(&self) -> Vec<(usize, usize)> {
let mut edges = Vec::new();
for (from, imports) in self.forward.iter().enumerate() {
for imp in imports {
if let ImportTarget::File(to) = imp.target {
edges.push((from, to));
}
}
}
edges
}
pub fn cycles(&self) -> Vec<Vec<usize>> {
let n = self.forward.len();
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut self_loops: HashSet<usize> = HashSet::new();
for (from, to) in self.file_edges() {
if from == to {
self_loops.insert(from);
} else if to < n {
adj[from].push(to);
}
}
let mut sccs = tarjan_scc(&adj);
sccs.retain(|c| c.len() > 1 || (c.len() == 1 && self_loops.contains(&c[0])));
for c in &mut sccs {
c.sort_unstable();
}
sccs.sort_by_key(|c| c.first().copied().unwrap_or(0));
sccs
}
}
fn tarjan_scc(adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
let n = adj.len();
let mut index = vec![usize::MAX; n];
let mut low = vec![0usize; n];
let mut on_stack = vec![false; n];
let mut stack: Vec<usize> = Vec::new();
let mut next_index = 0usize;
let mut sccs: Vec<Vec<usize>> = Vec::new();
for start in 0..n {
if index[start] != usize::MAX {
continue;
}
let mut work: Vec<(usize, usize)> = vec![(start, 0)];
while let Some((v, ci)) = work.pop() {
if ci == 0 {
index[v] = next_index;
low[v] = next_index;
next_index += 1;
stack.push(v);
on_stack[v] = true;
}
if ci < adj[v].len() {
work.push((v, ci + 1));
let w = adj[v][ci];
if index[w] == usize::MAX {
work.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(index[w]);
}
} else {
if let Some(&(parent, _)) = work.last() {
low[parent] = low[parent].min(low[v]);
}
if low[v] == index[v] {
let mut comp = Vec::new();
while let Some(w) = stack.pop() {
on_stack[w] = false;
comp.push(w);
if w == v {
break;
}
}
sccs.push(comp);
}
}
}
}
sccs
}
pub struct FileSet<'a> {
files: &'a [TreeNode],
by_path: HashMap<IndexPath, usize>,
}
impl<'a> FileSet<'a> {
pub fn new(files: &'a [TreeNode]) -> Self {
let by_path = files
.iter()
.enumerate()
.map(|(i, f)| (IndexPath::new(&f.file_path), i))
.collect();
Self { files, by_path }
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn language(&self, idx: usize) -> Option<&str> {
self.files.get(idx).map(|f| f.language.as_str())
}
pub fn path(&self, idx: usize) -> Option<&str> {
self.files.get(idx).map(|f| f.file_path.as_str())
}
pub fn declares_module(&self, idx: usize, name: &str) -> bool {
self.files
.get(idx)
.is_some_and(|f| f.declared_modules.iter().any(|m| m == name))
}
pub fn dir_of(&self, idx: usize) -> Option<String> {
self.path(idx).map(|p| parent_dir(&normalize_path(p)))
}
pub fn index_of(&self, path: &str) -> Option<usize> {
self.by_path.get(&IndexPath::new(path)).copied()
}
pub fn probe(&self, from_dir: &str, rel: &str) -> Option<usize> {
self.index_of(&join_normalized(from_dir, rel))
}
}
pub fn build_module_graph(files: &[TreeNode]) -> ModuleGraph {
build_with(files, dispatch_resolve)
}
fn dispatch_resolve(import: &ImportStatement, from_file: usize, files: &FileSet) -> ImportTarget {
match files.language(from_file) {
Some("rust") => resolve_rust_import(import, from_file, files),
Some("python") => resolve_py_import(import, from_file, files),
Some("typescript") | Some("tsx") | Some("javascript") | Some("jsx") => {
resolve_ts_import(import, from_file, files)
}
_ => ImportTarget::Unresolved(import.module_path.clone()),
}
}
fn expand_import(language: Option<&str>, import: &ImportStatement) -> Vec<ImportStatement> {
let single = || vec![import.clone()];
if language != Some("rust") {
return single();
}
let raw = import.module_path.trim();
let (open, close) = match (raw.find('{'), raw.rfind('}')) {
(Some(o), Some(c)) if c > o => (o, c),
_ => return single(),
};
let inner = &raw[open + 1..close];
if inner.contains('{') {
return single();
}
let base = raw[..open].trim_end_matches(':').trim();
if base.is_empty() {
return single();
}
let members: Vec<ImportStatement> = inner
.split(',')
.map(str::trim)
.filter(|m| !m.is_empty() && *m != "*")
.map(|m| {
let mut expanded = import.clone();
expanded.module_path = if m == "self" {
base.to_string()
} else {
format!("{base}::{m}")
};
expanded
})
.collect();
if members.is_empty() {
single()
} else {
members
}
}
pub fn build_with<F>(files: &[TreeNode], resolve: F) -> ModuleGraph
where
F: Fn(&ImportStatement, usize, &FileSet) -> ImportTarget,
{
let fs = FileSet::new(files);
let mut forward: Vec<Vec<ResolvedImport>> = Vec::with_capacity(files.len());
for (i, file) in files.iter().enumerate() {
let mut edges: Vec<ResolvedImport> = Vec::with_capacity(file.imports.len());
for import in &file.imports {
for member in expand_import(fs.language(i), import) {
let edge = ResolvedImport {
module_path: import.module_path.clone(),
target: resolve(&member, i, &fs),
line_number: import.line_number,
};
if !edges.contains(&edge) {
edges.push(edge);
}
}
}
forward.push(edges);
}
let mut reverse: Vec<HashSet<usize>> = vec![HashSet::new(); files.len()];
for (importer, edges) in forward.iter().enumerate() {
for edge in edges {
if let ImportTarget::File(target) = edge.target {
if target < files.len() && target != importer {
reverse[target].insert(importer);
}
}
}
}
let reverse = reverse
.into_iter()
.map(|s| {
let mut v: Vec<usize> = s.into_iter().collect();
v.sort_unstable();
v
})
.collect();
ModuleGraph { forward, reverse }
}
pub fn normalize_path(path: &str) -> String {
let path = path.replace('\\', "/");
let leading_slash = path.starts_with('/');
let mut out: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => {
if matches!(out.last(), Some(&s) if s != "..") {
out.pop();
} else if !leading_slash {
out.push("..");
}
}
s => out.push(s),
}
}
let joined = out.join("/");
if leading_slash {
format!("/{joined}")
} else {
joined
}
}
pub fn parent_dir(path: &str) -> String {
match path.rfind('/') {
Some(0) => "/".to_string(),
Some(i) => path[..i].to_string(),
None => String::new(),
}
}
pub fn join_normalized(base_dir: &str, rel: &str) -> String {
if base_dir.is_empty() {
normalize_path(rel)
} else {
normalize_path(&format!("{base_dir}/{rel}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn file(path: &str, lang: &str, imports: &[&str]) -> TreeNode {
let mut node = TreeNode::new(path.to_string(), lang.to_string());
for (n, spec) in imports.iter().enumerate() {
let mut imp = ImportStatement::new(spec.to_string(), path.to_string());
imp.line_number = n as u32 + 1;
node.imports.push(imp);
}
node
}
fn stub(import: &ImportStatement, from: usize, fs: &FileSet) -> ImportTarget {
let spec = &import.module_path;
if let Some(rel) = spec.strip_prefix("./") {
let dir = fs.dir_of(from).unwrap_or_default();
if let Some(idx) = fs.probe(&dir, &format!("{rel}.x")) {
return ImportTarget::File(idx);
}
}
ImportTarget::Unresolved(spec.clone())
}
#[test]
fn forward_and_reverse_are_consistent() {
let files = vec![
file("src/a.x", "stub", &["./b", "vendor"]),
file("src/b.x", "stub", &[]),
];
let g = build_with(&files, stub);
assert_eq!(g.imports(0).len(), 2);
assert_eq!(g.imports(0)[0].target, ImportTarget::File(1));
assert_eq!(
g.imports(0)[1].target,
ImportTarget::Unresolved("vendor".to_string())
);
assert!(g.imports(1).is_empty());
assert_eq!(g.importers(1), &[0]);
assert!(g.importers(0).is_empty());
}
#[test]
fn unresolved_imports_are_carried_never_dropped() {
let files = vec![file("x.x", "stub", &["./missing", "alsoexternal"])];
let g = build_with(&files, stub);
assert_eq!(g.imports(0).len(), 2, "both imports retained");
assert!(
g.imports(0)
.iter()
.all(|e| matches!(e.target, ImportTarget::Unresolved(_)))
);
}
#[test]
fn rebuild_after_file_replace_leaves_no_stale_reverse_edges() {
let g1 = build_with(
&[
file("src/a.x", "stub", &["./b"]),
file("src/b.x", "stub", &[]),
],
stub,
);
assert_eq!(g1.importers(1), &[0]);
let g2 = build_with(
&[file("src/a.x", "stub", &[]), file("src/b.x", "stub", &[])],
stub,
);
assert!(
g2.importers(1).is_empty(),
"stale reverse edge survived rebuild"
);
}
#[test]
fn cycles_reports_sccs_and_self_loops_only() {
let files = vec![
file("a.x", "stub", &["./b"]),
file("b.x", "stub", &["./c"]),
file("c.x", "stub", &["./a"]),
file("d.x", "stub", &["./e"]),
file("e.x", "stub", &[]),
file("f.x", "stub", &["./f"]),
];
let g = build_with(&files, stub);
let cycles = g.cycles();
assert_eq!(cycles.len(), 2);
assert_eq!(cycles[0], vec![0, 1, 2]);
assert_eq!(cycles[1], vec![5]);
}
#[test]
fn transitive_importers_is_cycle_safe() {
let files = vec![
file("a.x", "stub", &["./b"]),
file("b.x", "stub", &["./c"]),
file("c.x", "stub", &["./a"]),
];
let g = build_with(&files, stub);
let mut ti = g.transitive_importers(2);
ti.sort_unstable();
assert_eq!(ti, vec![0, 1]);
}
#[test]
fn rust_brace_group_under_the_root_yields_one_edge_per_module() {
let files = vec![
file("/repo/src/main.rs", "rust", &["crate::{config, storage}"]),
file("/repo/src/config.rs", "rust", &[]),
file("/repo/src/storage.rs", "rust", &[]),
];
let g = build_module_graph(&files);
assert_eq!(g.imports(0).len(), 2);
assert_eq!(g.importers(1), &[0]);
assert_eq!(g.importers(2), &[0]);
assert!(
g.imports(0)
.iter()
.all(|i| i.module_path == "crate::{config, storage}")
);
}
#[test]
fn rust_item_group_stays_a_single_edge() {
let files = vec![
file("/repo/src/main.rs", "rust", &["crate::a::{X, Y}"]),
file("/repo/src/a.rs", "rust", &[]),
];
let g = build_module_graph(&files);
assert_eq!(g.imports(0).len(), 1);
assert_eq!(g.imports(0)[0].target, ImportTarget::File(1));
}
#[test]
fn rust_group_with_self_member_resolves_the_base_module() {
let files = vec![
file("/repo/src/main.rs", "rust", &["crate::a::{self, X}"]),
file("/repo/src/a.rs", "rust", &[]),
];
let g = build_module_graph(&files);
assert_eq!(g.imports(0)[0].target, ImportTarget::File(1));
}
#[test]
fn unknown_language_yields_all_unresolved() {
let files = vec![file("f.weird", "cobol", &["SOMELIB"])];
let g = build_module_graph(&files);
assert_eq!(
g.imports(0)[0].target,
ImportTarget::Unresolved("SOMELIB".to_string())
);
}
#[test]
fn normalize_and_join_collapse_segments() {
assert_eq!(normalize_path("./src/./a.rs"), "src/a.rs");
assert_eq!(normalize_path("src/x/../a.rs"), "src/a.rs");
assert_eq!(join_normalized("src/pkg", "../a"), "src/a");
assert_eq!(join_normalized("src", "./b"), "src/b");
assert_eq!(parent_dir("src/a/b.rs"), "src/a");
assert_eq!(parent_dir("top.rs"), "");
}
}