use std::collections::{HashMap, HashSet};
use std::path::Path;
use tracing::warn;
use crate::ir::ExtractResult;
use crate::model::{ConfidenceTier, Edge, EdgeType, Graph, Language, NodeLabel};
const CONFIDENCE_IMPORTS: f32 = 0.95;
const EXTENSION_PROBES: &[&str] = &[
"ts", "tsx", "js", "jsx", "rs", "go", "py", "java", "c", "h", "cpp", "cc",
];
pub struct ImportResolver<'a> {
project: &'a str,
}
impl<'a> ImportResolver<'a> {
#[must_use]
pub fn new(project: &'a str) -> Self {
Self { project }
}
pub fn resolve_imports(&self, results: &[ExtractResult], graph: &mut Graph) -> Vec<Edge> {
let file_index = build_file_index(graph);
let mut edges = Vec::new();
let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
for result in results {
#[cfg(feature = "lang-cpp")]
if result.language == Language::Cpp {
continue;
}
let (source_file_id, importer_rel_path) = match find_file_in_index(
&file_index,
&result.file_path,
) {
Some((id, rel)) => (id, rel),
None => {
warn!(file = %result.file_path, "IMPORTS source File node not found in graph; skipping imports for this file");
continue;
}
};
for import in &result.imports {
if import.source_file.is_empty() {
continue;
}
let target_file_id = match resolve_import_target(
&import.source_file,
&importer_rel_path,
&file_index,
) {
Some(id) => id,
None => {
warn!(import = %import.source_file, importer = %result.file_path, line = import.line, "IMPORTS target unresolved (external module or missing file); skipping");
continue;
}
};
let pair_key = (source_file_id.clone(), target_file_id.clone());
if !seen_pairs.insert(pair_key) {
continue;
}
let edge = Edge::builder(
source_file_id.clone(),
target_file_id,
EdgeType::Imports,
self.project,
)
.confidence(CONFIDENCE_IMPORTS)
.confidence_tier(ConfidenceTier::ImportScoped)
.start_line(import.line)
.build();
graph.add_edge(edge.clone());
edges.push(edge);
}
}
edges
}
}
fn build_file_index(graph: &Graph) -> HashMap<String, String> {
let mut index = HashMap::new();
for node in graph.nodes_by_label(NodeLabel::File) {
if let Some(fp) = &node.file_path {
index.entry(fp.clone()).or_insert_with(|| node.id.clone());
}
index
.entry(node.name.clone())
.or_insert_with(|| node.id.clone());
}
index
}
fn find_file_in_index(
file_index: &HashMap<String, String>,
path: &str,
) -> Option<(String, String)> {
if let Some(id) = file_index.get(path) {
return Some((id.clone(), path.to_string()));
}
let path_norm = path.replace('\\', "/");
let mut best: Option<(&String, &String)> = None;
for (rel, id) in file_index {
let rel_norm = rel.replace('\\', "/");
if path_norm.ends_with(rel_norm.as_str()) {
let prefix_len = path_norm.len() - rel_norm.len();
if (prefix_len == 0 || path_norm.as_bytes()[prefix_len - 1] == b'/')
&& best.as_ref().is_none_or(|(r, _)| rel.len() > r.len())
{
best = Some((rel, id));
}
}
}
best.map(|(rel, id)| (id.clone(), rel.clone()))
}
fn resolve_import_target(
source_file: &str,
importer_path: &str,
file_index: &HashMap<String, String>,
) -> Option<String> {
if let Some(id) = file_index.get(source_file) {
return Some(id.clone());
}
if let Some(id) = resolve_rust_module_path(source_file, importer_path, file_index) {
return Some(id);
}
let is_relative = source_file.starts_with('.') || source_file.starts_with('/');
if !is_relative {
if source_file.contains('.') {
if let Some(id) = resolve_include_suffix(source_file, importer_path, file_index) {
return Some(id);
}
}
if let Some(id) = resolve_java_class_import(source_file, importer_path, file_index) {
return Some(id);
}
return None;
}
let normalised = normalise_relative(source_file, importer_path);
let normalised = strip_js_style_extension(&normalised);
if let Some(id) = file_index.get(&normalised) {
return Some(id.clone());
}
for ext in EXTENSION_PROBES {
let candidate = format!("{normalised}.{ext}");
if let Some(id) = file_index.get(&candidate) {
return Some(id.clone());
}
}
for ext in EXTENSION_PROBES {
let candidate = format!("{normalised}/index.{ext}");
if let Some(id) = file_index.get(&candidate) {
return Some(id.clone());
}
}
None
}
fn resolve_include_suffix(
include_path: &str,
importer_path: &str,
file_index: &HashMap<String, String>,
) -> Option<String> {
let path_norm = include_path.replace('\\', "/");
let importer_dir = importer_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
let mut same_dir: Option<(&String, &String)> = None;
let mut other: Option<(&String, &String)> = None;
for (rel, id) in file_index {
let rel_norm = rel.replace('\\', "/");
if rel_norm.ends_with(path_norm.as_str()) {
let prefix_len = rel_norm.len() - path_norm.len();
if prefix_len == 0 || rel_norm.as_bytes()[prefix_len - 1] == b'/' {
let match_dir = rel_norm.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
if match_dir == importer_dir {
if same_dir.as_ref().is_none_or(|(r, _)| r.len() > rel.len()) {
same_dir = Some((rel, id));
}
} else {
if other.as_ref().is_none_or(|(r, _)| r.len() > rel.len()) {
other = Some((rel, id));
}
}
}
}
}
same_dir.or(other).map(|(_, id)| id.clone())
}
fn resolve_java_class_import(
source_file: &str,
importer_path: &str,
file_index: &HashMap<String, String>,
) -> Option<String> {
if source_file.contains('/') || !source_file.contains('.') {
return None;
}
let mapped = format!("{}.java", source_file.replace('.', "/"));
if let Some(id) = resolve_include_suffix(&mapped, importer_path, file_index) {
return Some(id);
}
if let Some((parent, _)) = source_file.rsplit_once('.') {
let mapped = format!("{}.java", parent.replace('.', "/"));
if let Some(id) = resolve_include_suffix(&mapped, importer_path, file_index) {
return Some(id);
}
}
None
}
fn strip_js_style_extension(path: &str) -> String {
for ext in [".js", ".jsx", ".mjs", ".cjs"] {
if let Some(stripped) = path.strip_suffix(ext) {
return stripped.to_string();
}
}
path.to_string()
}
fn normalise_relative(source_file: &str, importer_path: &str) -> String {
let specifier = source_file.replace('\\', "/");
let importer_normalised = importer_path.replace('\\', "/");
let importer_dir = Path::new(&importer_normalised)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("");
let combined = if importer_dir.is_empty() {
specifier
} else {
format!("{importer_dir}/{specifier}")
};
let mut segments: Vec<&str> = Vec::new();
for seg in combined.split('/') {
match seg {
"" | "." => continue,
".." => {
segments.pop();
}
other => segments.push(other),
}
}
segments.join("/")
}
fn resolve_rust_module_path(
source_file: &str,
importer_path: &str,
file_index: &HashMap<String, String>,
) -> Option<String> {
if let Some(module_path) = source_file.strip_prefix("crate::") {
let path = module_path.replace("::", "/");
return try_candidates(&rust_crate_candidates(&path), file_index);
}
if let Some(module_path) = source_file.strip_prefix("self::") {
let path = module_path.replace("::", "/");
let relative = format!("./{path}");
let normalised = normalise_relative(&relative, importer_path);
return try_candidates(&rust_relative_candidates(&normalised), file_index);
}
if let Some(module_path) = source_file.strip_prefix("super::") {
let path = module_path.replace("::", "/");
let relative = format!("../{path}");
let normalised = normalise_relative(&relative, importer_path);
return try_candidates(&rust_relative_candidates(&normalised), file_index);
}
None
}
fn try_candidates(candidates: &[String], file_index: &HashMap<String, String>) -> Option<String> {
for candidate in candidates {
if let Some(id) = file_index.get(candidate) {
return Some(id.clone());
}
}
None
}
fn rust_crate_candidates(path: &str) -> Vec<String> {
let mut candidates = Vec::new();
candidates.push(format!("src/{path}.rs"));
candidates.push(format!("src/{path}/mod.rs"));
if let Some((parent, _)) = path.rsplit_once('/') {
candidates.push(format!("src/{parent}.rs"));
candidates.push(format!("src/{parent}/mod.rs"));
}
candidates
}
fn rust_relative_candidates(normalised: &str) -> Vec<String> {
let mut candidates = Vec::new();
candidates.push(format!("{normalised}.rs"));
candidates.push(format!("{normalised}/mod.rs"));
if let Some((parent, _)) = normalised.rsplit_once('/') {
candidates.push(format!("{parent}.rs"));
candidates.push(format!("{parent}/mod.rs"));
}
candidates
}
#[cfg(all(test, feature = "lang-cpp", feature = "lang-typescript"))]
mod tests {
use super::*;
use crate::model::{Language, Node, NodeLabel};
fn make_file_node(path: &str, project: &str) -> Node {
Node::builder(NodeLabel::File, path, path)
.id(path)
.project(project)
.file_path(path)
.language(Language::TypeScript)
.build()
}
fn make_result(file_path: &str) -> ExtractResult {
ExtractResult::new(file_path, Language::TypeScript)
}
fn make_result_cpp(file_path: &str) -> ExtractResult {
ExtractResult::new(file_path, Language::Cpp)
}
#[test]
fn resolve_imports_creates_edge_for_explicit_import() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec!["foo".to_string()],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
graph.add_node(make_file_node("b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "should create 1 IMPORTS edge");
let edge = &edges[0];
assert_eq!(edge.edge_type, EdgeType::Imports);
assert_eq!(edge.source, "a.ts");
assert_eq!(edge.target, "b.ts");
assert!((edge.confidence - 0.95).abs() < 1e-6);
assert_eq!(edge.confidence_tier, ConfidenceTier::ImportScoped);
assert_eq!(edge.start_line, Some(1));
assert_eq!(graph.edge_count(), 1);
}
#[test]
fn resolve_imports_handles_empty_imports() {
let result = make_result("a.ts");
let results = vec![result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(edges.is_empty(), "no imports → no edges");
assert_eq!(graph.edge_count(), 0);
}
#[test]
fn resolve_imports_empty_results_returns_empty() {
let mut graph = Graph::new();
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&[], &mut graph);
assert!(edges.is_empty());
}
#[test]
fn resolve_imports_skips_unresolved_imports() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "react".to_string(),
imported_names: vec!["useState".to_string()],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(edges.is_empty(), "unresolved import → no edge");
assert_eq!(graph.edge_count(), 0);
}
#[test]
fn resolve_imports_skips_when_source_file_node_missing() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(edges.is_empty());
}
#[test]
fn resolve_imports_skips_empty_source_file() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: String::new(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(edges.is_empty());
}
#[test]
fn resolve_imports_deduplicates_edges() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec!["foo".to_string()],
line: 1,
});
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec!["bar".to_string()],
line: 2,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
graph.add_node(make_file_node("b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "duplicate (source, target) → 1 edge");
assert_eq!(graph.edge_count(), 1);
}
#[test]
fn resolve_imports_resolves_extensionless_relative_import() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./utils".to_string(),
imported_names: vec!["helper".to_string()],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
graph.add_node(make_file_node("utils.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "extensionless import should resolve");
assert_eq!(edges[0].target, "utils.ts");
}
#[test]
fn resolve_imports_resolves_subdirectory_relative_import() {
let mut a_result = make_result("src/a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./helpers/b".to_string(),
imported_names: vec!["foo".to_string()],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/a.ts", "proj"));
graph.add_node(make_file_node("src/helpers/b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].source, "src/a.ts");
assert_eq!(edges[0].target, "src/helpers/b.ts");
}
#[test]
fn resolve_imports_resolves_parent_directory_import() {
let mut a_result = make_result("src/sub/a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "../b".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/sub/a.ts", "proj"));
graph.add_node(make_file_node("src/b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].target, "src/b.ts");
}
#[test]
fn resolve_imports_resolves_barrel_import() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./utils".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
graph.add_node(make_file_node("utils/index.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "barrel import should resolve");
assert_eq!(edges[0].target, "utils/index.ts");
}
#[test]
fn resolve_imports_handles_multiple_files() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec![],
line: 1,
});
let mut c_result = make_result("c.ts");
c_result.imports.push(crate::ir::ImportInfo {
source_file: "./d.ts".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result, c_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
graph.add_node(make_file_node("b.ts", "proj"));
graph.add_node(make_file_node("c.ts", "proj"));
graph.add_node(make_file_node("d.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 2);
assert_eq!(graph.edge_count(), 2);
}
#[test]
fn resolve_imports_adds_edges_to_graph_for_traversal() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
graph.add_node(make_file_node("b.ts", "proj"));
let resolver = ImportResolver::new("proj");
resolver.resolve_imports(&results, &mut graph);
let neighbors = graph.neighbors(&"a.ts".to_string(), Some(EdgeType::Imports));
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].id, "b.ts");
}
#[test]
fn normalise_relative_dot_slash() {
let n = normalise_relative("./b", "src/a.ts");
assert_eq!(n, "src/b");
}
#[test]
fn normalise_relative_dot_dot_slash() {
let n = normalise_relative("../b", "src/sub/a.ts");
assert_eq!(n, "src/b");
}
#[test]
fn normalise_relative_strips_leading_dot() {
let n = normalise_relative("./utils", "a.ts");
assert_eq!(n, "utils");
}
#[test]
fn normalise_relative_handles_backslashes() {
let n = normalise_relative(".\\b", "src\\a.ts");
assert_eq!(n, "src/b");
}
#[test]
fn resolve_imports_resolves_direct_match_strategy() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "b.ts".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
graph.add_node(make_file_node("b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "direct match should resolve");
assert_eq!(edges[0].target, "b.ts");
}
#[test]
fn resolve_imports_relative_unresolvable_returns_none() {
let mut a_result = make_result("a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./nonexistent".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("a.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(edges.is_empty(), "unresolvable relative import → no edge");
}
#[test]
fn resolve_imports_skips_when_source_file_not_in_graph() {
let mut orphan_result = make_result("orphan.ts");
orphan_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![orphan_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(edges.is_empty(), "source file not in graph → no edges");
}
#[test]
fn resolve_imports_resolves_rust_crate_prefix_to_src_file() {
let mut lib_result = make_result("src/lib.rs");
lib_result.imports.push(crate::ir::ImportInfo {
source_file: "crate::model".to_string(),
imported_names: vec!["Node".to_string()],
line: 1,
});
let results = vec![lib_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/lib.rs", "proj"));
graph.add_node(make_file_node("src/model.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"crate:: prefix should resolve to src/model.rs"
);
assert_eq!(edges[0].source, "src/lib.rs");
assert_eq!(edges[0].target, "src/model.rs");
}
#[test]
fn resolve_imports_resolves_rust_crate_prefix_with_symbol_name() {
let mut lib_result = make_result("src/lib.rs");
lib_result.imports.push(crate::ir::ImportInfo {
source_file: "crate::model::Node".to_string(),
imported_names: vec!["Node".to_string()],
line: 1,
});
let results = vec![lib_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/lib.rs", "proj"));
graph.add_node(make_file_node("src/model.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"symbol name should be stripped, resolving to src/model.rs"
);
assert_eq!(edges[0].target, "src/model.rs");
}
#[test]
fn resolve_imports_resolves_rust_crate_prefix_to_mod_file() {
let mut lib_result = make_result("src/lib.rs");
lib_result.imports.push(crate::ir::ImportInfo {
source_file: "crate::model".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![lib_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/lib.rs", "proj"));
graph.add_node(make_file_node("src/model/mod.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"crate:: prefix should resolve to src/model/mod.rs"
);
assert_eq!(edges[0].target, "src/model/mod.rs");
}
#[test]
fn resolve_imports_resolves_rust_crate_nested_module() {
let mut lib_result = make_result("src/lib.rs");
lib_result.imports.push(crate::ir::ImportInfo {
source_file: "crate::parse::parser".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![lib_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/lib.rs", "proj"));
graph.add_node(make_file_node("src/parse/parser.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "nested crate:: path should resolve");
assert_eq!(edges[0].target, "src/parse/parser.rs");
}
#[test]
fn resolve_imports_resolves_rust_self_prefix() {
let mut lib_result = make_result("src/lib.rs");
lib_result.imports.push(crate::ir::ImportInfo {
source_file: "self::model".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![lib_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/lib.rs", "proj"));
graph.add_node(make_file_node("src/model.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "self:: prefix should resolve");
assert_eq!(edges[0].target, "src/model.rs");
}
#[test]
fn resolve_imports_resolves_rust_super_prefix() {
let mut sub_result = make_result("src/sub/mod.rs");
sub_result.imports.push(crate::ir::ImportInfo {
source_file: "super::model".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![sub_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/sub/mod.rs", "proj"));
graph.add_node(make_file_node("src/model.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(edges.len(), 1, "super:: prefix should resolve");
assert_eq!(edges[0].target, "src/model.rs");
}
#[test]
fn resolve_imports_resolves_rust_super_prefix_with_symbol() {
let mut sub_result = make_result("src/sub/mod.rs");
sub_result.imports.push(crate::ir::ImportInfo {
source_file: "super::model::Node".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![sub_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/sub/mod.rs", "proj"));
graph.add_node(make_file_node("src/model.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"super:: with symbol should strip last component"
);
assert_eq!(edges[0].target, "src/model.rs");
}
#[test]
fn resolve_imports_skips_rust_external_crate() {
let mut lib_result = make_result("src/lib.rs");
lib_result.imports.push(crate::ir::ImportInfo {
source_file: "std::io".to_string(),
imported_names: vec!["Read".to_string()],
line: 1,
});
let results = vec![lib_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/lib.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(edges.is_empty(), "external crate (std::io) → no edge");
}
#[test]
fn resolve_rust_module_path_crate_prefix_resolves_src_file() {
let mut index = HashMap::new();
index.insert("src/model.rs".to_string(), "id-model".to_string());
let result = resolve_rust_module_path("crate::model", "src/lib.rs", &index);
assert_eq!(result, Some("id-model".to_string()));
}
#[test]
fn resolve_rust_module_path_crate_prefix_with_symbol_strips_last() {
let mut index = HashMap::new();
index.insert("src/model.rs".to_string(), "id-model".to_string());
let result = resolve_rust_module_path("crate::model::Node", "src/lib.rs", &index);
assert_eq!(result, Some("id-model".to_string()));
}
#[test]
fn resolve_rust_module_path_crate_prefix_resolves_mod_file() {
let mut index = HashMap::new();
index.insert("src/model/mod.rs".to_string(), "id-model-mod".to_string());
let result = resolve_rust_module_path("crate::model", "src/lib.rs", &index);
assert_eq!(result, Some("id-model-mod".to_string()));
}
#[test]
fn resolve_rust_module_path_external_returns_none() {
let mut index = HashMap::new();
index.insert("src/model.rs".to_string(), "id-model".to_string());
let result = resolve_rust_module_path("std::io", "src/lib.rs", &index);
assert_eq!(result, None);
}
#[test]
fn resolve_rust_module_path_non_rust_returns_none() {
let index = HashMap::new();
let result = resolve_rust_module_path("react", "src/lib.ts", &index);
assert_eq!(result, None);
}
#[test]
fn resolve_rust_module_path_no_match_returns_none() {
let mut index = HashMap::new();
index.insert("src/other.rs".to_string(), "id-other".to_string());
let result = resolve_rust_module_path("crate::nonexistent", "src/lib.rs", &index);
assert_eq!(result, None);
}
#[test]
fn resolve_rust_module_path_self_prefix_resolves() {
let mut index = HashMap::new();
index.insert("src/model.rs".to_string(), "id-model".to_string());
let result = resolve_rust_module_path("self::model", "src/lib.rs", &index);
assert_eq!(result, Some("id-model".to_string()));
}
#[test]
fn resolve_rust_module_path_super_prefix_resolves() {
let mut index = HashMap::new();
index.insert("src/model.rs".to_string(), "id-model".to_string());
let result = resolve_rust_module_path("super::model", "src/sub/mod.rs", &index);
assert_eq!(result, Some("id-model".to_string()));
}
#[test]
fn find_file_in_index_direct_match_relative() {
let mut index = HashMap::new();
index.insert("src/lib.rs".to_string(), "id-lib".to_string());
let result = find_file_in_index(&index, "src/lib.rs");
assert_eq!(
result,
Some(("id-lib".to_string(), "src/lib.rs".to_string()))
);
}
#[test]
fn find_file_in_index_absolute_path_suffix_match() {
let mut index = HashMap::new();
index.insert("src/lib.rs".to_string(), "id-lib".to_string());
let result = find_file_in_index(&index, "/home/dev/projects/CodeNexus/src/lib.rs");
assert_eq!(
result,
Some(("id-lib".to_string(), "src/lib.rs".to_string()))
);
}
#[test]
fn find_file_in_index_suffix_boundary_check() {
let mut index = HashMap::new();
index.insert("xsrc/lib.rs".to_string(), "id-xlib".to_string());
let result = find_file_in_index(&index, "/home/dev/projects/CodeNexus/src/lib.rs");
assert_eq!(
result, None,
"xsrc/lib.rs should not suffix-match src/lib.rs"
);
}
#[test]
fn find_file_in_index_no_match_returns_none() {
let mut index = HashMap::new();
index.insert("src/other.rs".to_string(), "id-other".to_string());
let result = find_file_in_index(&index, "/home/dev/projects/CodeNexus/src/lib.rs");
assert_eq!(result, None);
}
#[test]
fn find_file_in_index_multiple_suffix_matches_picks_longest() {
let mut index = HashMap::new();
index.insert("index.ts".to_string(), "id-root".to_string());
index.insert("src/index.ts".to_string(), "id-src".to_string());
let result = find_file_in_index(&index, "/home/dev/proj/src/index.ts");
assert_eq!(
result,
Some(("id-src".to_string(), "src/index.ts".to_string())),
"longest suffix match should win for determinism"
);
}
#[test]
fn find_file_in_index_windows_backslash_boundary() {
let mut index = HashMap::new();
index.insert("src/lib.rs".to_string(), "id-lib".to_string());
let result = find_file_in_index(&index, r"C:\Users\dev\proj\src\lib.rs");
assert_eq!(
result,
Some(("id-lib".to_string(), "src/lib.rs".to_string())),
"Windows backslash separator should be accepted in boundary check"
);
}
#[test]
fn resolve_imports_handles_absolute_importer_path() {
let mut lib_result = make_result("/home/dev/projects/CodeNexus/src/lib.rs");
lib_result.imports.push(crate::ir::ImportInfo {
source_file: "crate::model".to_string(),
imported_names: vec!["Node".to_string()],
line: 1,
});
let results = vec![lib_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/lib.rs", "proj"));
graph.add_node(make_file_node("src/model.rs", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"absolute importer path should resolve via suffix match"
);
assert_eq!(edges[0].target, "src/model.rs");
}
#[test]
fn resolve_imports_absolute_path_with_relative_ts_import() {
let mut a_result = make_result("/home/dev/projects/subno.ts/src/a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./b.ts".to_string(),
imported_names: vec!["foo".to_string()],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/a.ts", "proj"));
graph.add_node(make_file_node("src/b.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"absolute importer + relative TS import should resolve"
);
assert_eq!(edges[0].source, "src/a.ts");
assert_eq!(edges[0].target, "src/b.ts");
}
#[test]
fn resolve_imports_strips_js_extension_for_ts_esm() {
let mut client_result = make_result("sdk/typescript/src/client.ts");
client_result.imports.push(crate::ir::ImportInfo {
source_file: "./types/api.js".to_string(),
imported_names: vec!["ClientOptions".to_string()],
line: 1,
});
let results = vec![client_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("sdk/typescript/src/client.ts", "proj"));
graph.add_node(make_file_node("sdk/typescript/src/types/api.ts", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
".js extension should be stripped, resolving to .ts file"
);
assert_eq!(edges[0].target, "sdk/typescript/src/types/api.ts");
}
#[test]
fn resolve_imports_strips_jsx_extension() {
let mut app_result = make_result("src/app.tsx");
app_result.imports.push(crate::ir::ImportInfo {
source_file: "./Button.jsx".to_string(),
imported_names: vec!["Button".to_string()],
line: 1,
});
let results = vec![app_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/app.tsx", "proj"));
graph.add_node(make_file_node("src/Button.tsx", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
".jsx extension should be stripped, resolving to .tsx file"
);
assert_eq!(edges[0].target, "src/Button.tsx");
}
#[test]
fn resolve_imports_strips_mjs_cjs_extensions() {
let mut a_result = make_result("src/a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./config.mjs".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/a.ts", "proj"));
graph.add_node(make_file_node("src/config.js", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
".mjs extension should be stripped, resolving to .js file"
);
assert_eq!(edges[0].target, "src/config.js");
}
#[test]
fn resolve_imports_strips_cjs_extension() {
let mut a_result = make_result("src/a.ts");
a_result.imports.push(crate::ir::ImportInfo {
source_file: "./config.cjs".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![a_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/a.ts", "proj"));
graph.add_node(make_file_node("src/config.js", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
".cjs extension should be stripped, resolving to .js file"
);
assert_eq!(edges[0].target, "src/config.js");
}
#[test]
fn resolve_imports_skips_cpp_include_by_basename() {
let mut std_result = make_result_cpp("include/fmt/std.h");
std_result.imports.push(crate::ir::ImportInfo {
source_file: "format.h".to_string(),
imported_names: vec![],
line: 11,
});
let results = vec![std_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("include/fmt/std.h", "proj"));
graph.add_node(make_file_node("include/fmt/format.h", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(
edges.is_empty(),
"C++ #include should NOT produce IMPORTS edges (scheme C: handled by ResolvePhase)"
);
}
#[test]
fn resolve_imports_skips_cpp_include_by_partial_path() {
let mut top_result = make_result_cpp("src/main.cpp");
top_result.imports.push(crate::ir::ImportInfo {
source_file: "fmt/format.h".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![top_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj"));
graph.add_node(make_file_node("include/fmt/format.h", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(
edges.is_empty(),
"C++ partial-path #include should NOT produce IMPORTS edges (scheme C)"
);
}
#[test]
fn resolve_imports_skips_cpp_system_include() {
let mut main_result = make_result_cpp("src/main.cpp");
main_result.imports.push(crate::ir::ImportInfo {
source_file: "iostream".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(
edges.is_empty(),
"C++ system header #include should NOT produce IMPORTS edges (scheme C)"
);
}
#[test]
fn resolve_imports_skips_cpp_include_even_when_target_exists() {
let mut main_result = make_result_cpp("src/main.cpp");
main_result.imports.push(crate::ir::ImportInfo {
source_file: "foo.h".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj"));
graph.add_node(make_file_node("src/foo.h", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(
edges.is_empty(),
"C++ #include must NOT produce IMPORTS edges even when target file exists (scheme C)"
);
}
#[test]
fn resolve_imports_resolves_java_class_import() {
let mut importer_result = make_result("src/com/google/gson/GsonBuilder.java");
importer_result.imports.push(crate::ir::ImportInfo {
source_file: "com.google.gson.Gson".to_string(),
imported_names: vec!["Gson".to_string()],
line: 3,
});
let results = vec![importer_result];
let mut graph = Graph::new();
graph.add_node(make_file_node(
"src/com/google/gson/GsonBuilder.java",
"proj",
));
graph.add_node(make_file_node("src/com/google/gson/Gson.java", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"Java class import should resolve via dotted-path mapping"
);
assert_eq!(edges[0].source, "src/com/google/gson/GsonBuilder.java");
assert_eq!(edges[0].target, "src/com/google/gson/Gson.java");
}
#[test]
fn resolve_imports_resolves_java_class_in_maven_layout() {
let mut importer_result = make_result("gson/src/main/java/com/google/gson/Gson.java");
importer_result.imports.push(crate::ir::ImportInfo {
source_file: "com.google.gson.JsonElement".to_string(),
imported_names: vec!["JsonElement".to_string()],
line: 1,
});
let results = vec![importer_result];
let mut graph = Graph::new();
graph.add_node(make_file_node(
"gson/src/main/java/com/google/gson/Gson.java",
"proj",
));
graph.add_node(make_file_node(
"gson/src/main/java/com/google/gson/JsonElement.java",
"proj",
));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"Java class import should resolve in Maven standard layout"
);
assert_eq!(
edges[0].target,
"gson/src/main/java/com/google/gson/JsonElement.java"
);
}
#[test]
fn resolve_imports_skips_java_stdlib_import() {
let mut importer_result = make_result("src/Main.java");
importer_result.imports.push(crate::ir::ImportInfo {
source_file: "java.util.List".to_string(),
imported_names: vec!["List".to_string()],
line: 1,
});
let results = vec![importer_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/Main.java", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert!(
edges.is_empty(),
"JDK import (java.util.List) should not resolve to a local file"
);
}
#[test]
fn resolve_imports_resolves_java_static_import() {
let mut importer_result = make_result("src/Test.java");
importer_result.imports.push(crate::ir::ImportInfo {
source_file: "com.google.gson.Gson.fromJson".to_string(),
imported_names: vec!["fromJson".to_string()],
line: 1,
});
let results = vec![importer_result];
let mut graph = Graph::new();
graph.add_node(make_file_node("src/Test.java", "proj"));
graph.add_node(make_file_node("src/com/google/gson/Gson.java", "proj"));
let resolver = ImportResolver::new("proj");
let edges = resolver.resolve_imports(&results, &mut graph);
assert_eq!(
edges.len(),
1,
"Java static import should resolve by stripping the member name"
);
assert_eq!(edges[0].target, "src/com/google/gson/Gson.java");
}
#[test]
fn build_file_index_handles_node_without_file_path() {
let mut graph = Graph::new();
let node = Node::builder(NodeLabel::File, "name_only.ts", "name_only.ts")
.id("name_only.ts")
.project("proj")
.language(Language::TypeScript)
.build();
graph.add_node(node);
let index = build_file_index(&graph);
assert_eq!(index.get("name_only.ts"), Some(&"name_only.ts".to_string()));
}
#[test]
fn strip_js_style_extension_preserves_non_js_extensions() {
assert_eq!(strip_js_style_extension("src/a.ts"), "src/a.ts");
assert_eq!(strip_js_style_extension("src/a.rs"), "src/a.rs");
assert_eq!(strip_js_style_extension("src/a.go"), "src/a.go");
}
#[test]
fn resolve_java_class_import_with_slash_returns_none() {
let index = HashMap::new();
assert_eq!(
resolve_java_class_import("src/Main.java", "src/App.java", &index),
None
);
}
#[test]
fn resolve_java_class_import_without_dot_returns_none() {
let index = HashMap::new();
assert_eq!(
resolve_java_class_import("react", "src/App.java", &index),
None
);
}
#[test]
fn resolve_include_suffix_prefers_same_directory() {
let mut index = HashMap::new();
index.insert("src/format.h".to_string(), "id-same".to_string());
index.insert("include/fmt/format.h".to_string(), "id-other".to_string());
let result = resolve_include_suffix("format.h", "src/main.cpp", &index);
assert_eq!(result, Some("id-same".to_string()));
}
#[test]
fn resolve_include_suffix_falls_back_to_other_directory() {
let mut index = HashMap::new();
index.insert("include/fmt/format.h".to_string(), "id-other".to_string());
let result = resolve_include_suffix("format.h", "src/main.cpp", &index);
assert_eq!(result, Some("id-other".to_string()));
}
#[test]
fn resolve_include_suffix_returns_none_when_no_match() {
let mut index = HashMap::new();
index.insert("src/utils.h".to_string(), "id-utils".to_string());
let result = resolve_include_suffix("format.h", "src/main.cpp", &index);
assert!(result.is_none());
}
#[test]
fn resolve_include_suffix_exact_match_with_no_importer_directory() {
let mut index = HashMap::new();
index.insert("format.h".to_string(), "id-exact".to_string());
let result = resolve_include_suffix("format.h", "main.cpp", &index);
assert_eq!(result, Some("id-exact".to_string()));
}
#[test]
fn normalise_relative_dot_dot_from_root_pops_empty() {
let n = normalise_relative("../b", "a.ts");
assert_eq!(n, "b");
}
#[test]
fn resolve_include_suffix_picks_shortest_other_dir() {
let mut index = HashMap::new();
index.insert("lib/fmt/format.h".to_string(), "id-short".to_string());
index.insert("include/fmt/format.h".to_string(), "id-long".to_string());
let result = resolve_include_suffix("format.h", "src/main.cpp", &index);
assert_eq!(result, Some("id-short".to_string()));
}
#[test]
fn strip_js_style_extension_strips_all_js_variants() {
assert_eq!(strip_js_style_extension("src/a.js"), "src/a");
assert_eq!(strip_js_style_extension("src/a.jsx"), "src/a");
assert_eq!(strip_js_style_extension("src/a.mjs"), "src/a");
assert_eq!(strip_js_style_extension("src/a.cjs"), "src/a");
}
}