use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use lazy_static::lazy_static;
use regex::Regex;
use super::cross_file_types::{FileIR, ImportDef, ResolvedImport};
use super::import_resolver::{ImportResolver, ReExportTracer, DEFAULT_MAX_DEPTH};
use super::languages::base::{get_node_text, walk_tree};
use super::types::{parse_source, FuncIndex};
pub type ImportMap = HashMap<String, (String, String)>;
pub type ModuleImports = HashMap<String, String>;
pub fn build_import_map(resolved_imports: &[ResolvedImport]) -> (ImportMap, ModuleImports) {
let mut import_map = ImportMap::new();
let mut module_imports = ModuleImports::new();
for resolved in resolved_imports {
if resolved.is_external {
continue;
}
let resolved_name = match &resolved.resolved_name {
Some(name) => name.clone(),
None => continue,
};
let original = &resolved.original;
let raw_module_path = original
.resolved_module
.as_ref()
.unwrap_or(&original.module)
.clone();
let module_path = raw_module_path
.strip_suffix(".js")
.or_else(|| raw_module_path.strip_suffix(".jsx"))
.or_else(|| raw_module_path.strip_suffix(".ts"))
.or_else(|| raw_module_path.strip_suffix(".tsx"))
.or_else(|| raw_module_path.strip_suffix(".mjs"))
.or_else(|| raw_module_path.strip_suffix(".cjs"))
.unwrap_or(&raw_module_path)
.to_string();
if original.is_from {
for name in &original.names {
if name == "*" {
let local_name = resolved_name.clone();
import_map.insert(local_name.clone(), (module_path.clone(), local_name));
} else {
let alias_name = original
.aliases
.as_ref()
.and_then(|aliases| aliases.iter().find(|(_, v)| *v == name))
.map(|(alias, _)| alias.clone());
if let Some(alias) = alias_name {
import_map.insert(alias, (module_path.clone(), name.clone()));
}
import_map.insert(name.clone(), (module_path.clone(), name.clone()));
}
}
} else {
let local_name = original.alias.as_ref().unwrap_or(&original.module).clone();
module_imports.insert(local_name.clone(), module_path.clone());
if (original.is_default || (original.alias.is_some() && !original.is_namespace))
&& original.names.is_empty()
{
if let Some(resolved_file) = &resolved.resolved_file {
if let Some(default_name) = find_js_ts_default_export_name(resolved_file) {
import_map.insert(local_name, (module_path.clone(), default_name));
}
}
}
}
}
(import_map, module_imports)
}
pub fn augment_go_module_imports(
imports: &[ImportDef],
module_imports: &mut ModuleImports,
func_index: &FuncIndex,
) {
let known_modules: HashSet<&str> = func_index.iter().map(|((module, _), _)| module).collect();
for import_def in imports {
let module_path = &import_def.module;
if module_path.is_empty() {
continue;
}
let alias = match &import_def.alias {
Some(a) if a == "_" || a == "." => continue, Some(a) => a.clone(),
None => {
match module_path.rsplit('/').next() {
Some(last) if !last.is_empty() => last.to_string(),
_ => continue,
}
}
};
if module_imports.contains_key(&alias) {
continue;
}
let mut best_match: Option<&str> = None;
let mut best_len = 0;
for known in &known_modules {
if known.is_empty() {
continue;
}
if *known == module_path.as_str() {
best_match = Some(known);
break; }
let suffix = format!("/{}", known);
if module_path.ends_with(&suffix) && known.len() > best_len {
best_match = Some(known);
best_len = known.len();
}
}
if let Some(matched) = best_match {
module_imports.insert(alias, matched.to_string());
}
}
}
pub(crate) fn find_js_ts_default_export_name(path: &Path) -> Option<String> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs") {
return None;
}
let source = fs::read_to_string(path).ok()?;
lazy_static! {
static ref RE_EXPORT_DEFAULT_FN: Regex =
Regex::new(r"(?m)^\\s*export\\s+default\\s+function\\s+([A-Za-z_$][\\w$]*)").unwrap();
static ref RE_EXPORT_DEFAULT_CLASS: Regex =
Regex::new(r"(?m)^\\s*export\\s+default\\s+class\\s+([A-Za-z_$][\\w$]*)").unwrap();
static ref RE_EXPORT_DEFAULT_IDENT: Regex =
Regex::new(r"(?m)^\\s*export\\s+default\\s+([A-Za-z_$][\\w$]*)").unwrap();
static ref RE_EXPORTS_DEFAULT: Regex =
Regex::new(r"(?m)^\\s*exports\\.default\\s*=\\s*([A-Za-z_$][\\w$]*)").unwrap();
static ref RE_MODULE_EXPORTS: Regex =
Regex::new(r"(?m)^\\s*module\\.exports\\s*=\\s*([A-Za-z_$][\\w$]*)").unwrap();
}
if let Some(caps) = RE_EXPORT_DEFAULT_FN.captures(&source) {
return Some(caps[1].to_string());
}
if let Some(caps) = RE_EXPORT_DEFAULT_CLASS.captures(&source) {
return Some(caps[1].to_string());
}
if let Some(caps) = RE_EXPORT_DEFAULT_IDENT.captures(&source) {
let ident = caps[1].to_string();
if ident != "function" && ident != "class" {
return Some(ident);
}
}
if let Some(caps) = RE_EXPORTS_DEFAULT.captures(&source) {
return Some(caps[1].to_string());
}
if let Some(caps) = RE_MODULE_EXPORTS.captures(&source) {
return Some(caps[1].to_string());
}
None
}
pub fn resolve_imports_for_file<'a>(
file_ir: &FileIR,
resolver: &mut ImportResolver<'a>,
root: &Path,
) -> Vec<ResolvedImport> {
let current_file = root.join(&file_ir.path);
let mut resolved_imports = Vec::new();
for import_def in &file_ir.imports {
let resolved = resolver.resolve(import_def, ¤t_file);
for r in resolved {
if let Some(ref resolved_file) = r.resolved_file {
let resolved_canonical: Option<PathBuf> = resolved_file.canonicalize().ok();
let current_canonical: Option<PathBuf> = current_file.canonicalize().ok();
if resolved_canonical == current_canonical {
continue;
}
}
resolved_imports.push(r);
}
}
resolved_imports
}
pub fn extract_python_imports(source: &str, file_ir: &mut FileIR) -> usize {
let tree = match parse_source(source, "python") {
Ok(t) => t,
Err(_) => return 0,
};
let source_bytes = source.as_bytes();
let root = tree.root_node();
let mut import_count = 0;
for node in walk_tree(root) {
match node.kind() {
"import_statement" => {
if let Some(import_def) = parse_python_import_statement(&node, source_bytes) {
file_ir.imports.push(import_def);
import_count += 1;
}
}
"import_from_statement" => {
if let Some(import_def) = parse_python_from_import(&node, source_bytes) {
file_ir.imports.push(import_def);
import_count += 1;
}
}
_ => {}
}
}
drop(tree);
import_count
}
pub(crate) fn parse_python_import_statement(
node: &tree_sitter::Node,
source: &[u8],
) -> Option<ImportDef> {
let mut module = String::new();
let mut alias = None;
for i in 0..node.named_child_count() {
if let Some(child) = node.named_child(i) {
match child.kind() {
"dotted_name" => {
module = get_node_text(&child, source).to_string();
}
"aliased_import" => {
if let Some(name_node) = child.child_by_field_name("name") {
module = get_node_text(&name_node, source).to_string();
}
if let Some(alias_node) = child.child_by_field_name("alias") {
alias = Some(get_node_text(&alias_node, source).to_string());
}
}
_ => {}
}
}
}
if module.is_empty() {
return None;
}
let mut import_def = ImportDef::simple_import(&module);
import_def.alias = alias;
Some(import_def)
}
pub(crate) fn parse_python_from_import(
node: &tree_sitter::Node,
source: &[u8],
) -> Option<ImportDef> {
let mut module = String::new();
let mut level = 0u8;
let mut names = Vec::new();
let mut aliases = HashMap::new();
let mut is_wildcard = false;
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
if child.kind() == "relative_import" {
let text = get_node_text(&child, source);
for c in text.chars() {
if c == '.' {
level += 1;
} else {
break;
}
}
let module_part: String = text.chars().skip_while(|&c| c == '.').collect();
if !module_part.is_empty() {
module = module_part;
}
break;
}
}
}
if level == 0 {
if let Some(module_node) = node.child_by_field_name("module_name") {
module = get_node_text(&module_node, source).to_string();
}
}
for i in 0..node.named_child_count() {
if let Some(child) = node.named_child(i) {
match child.kind() {
"dotted_name"
if child != node.child_by_field_name("module_name").unwrap_or(child) =>
{
let name = get_node_text(&child, source).to_string();
names.push(name);
}
"aliased_import" => {
if let Some(name_node) = child.child_by_field_name("name") {
let name = get_node_text(&name_node, source).to_string();
names.push(name.clone());
if let Some(alias_node) = child.child_by_field_name("alias") {
let alias = get_node_text(&alias_node, source).to_string();
aliases.insert(alias, name);
}
}
}
"wildcard_import" => {
is_wildcard = true;
names.push("*".to_string());
}
_ => {}
}
}
}
if names.is_empty() && !is_wildcard {
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
if child.kind() == "import_list" {
for j in 0..child.named_child_count() {
if let Some(name_child) = child.named_child(j) {
if name_child.kind() == "dotted_name"
|| name_child.kind() == "identifier"
{
names.push(get_node_text(&name_child, source).to_string());
} else if name_child.kind() == "aliased_import" {
if let Some(name_node) = name_child.child_by_field_name("name") {
let name = get_node_text(&name_node, source).to_string();
names.push(name.clone());
if let Some(alias_node) =
name_child.child_by_field_name("alias")
{
let alias = get_node_text(&alias_node, source).to_string();
aliases.insert(alias, name);
}
}
}
}
}
}
}
}
}
if names.is_empty() && !is_wildcard {
return None;
}
let mut import_def = if level > 0 {
ImportDef::relative_import(&module, names, level)
} else {
ImportDef::from_import(&module, names)
};
if !aliases.is_empty() {
import_def.aliases = Some(aliases);
}
Some(import_def)
}
pub fn trace_reexport_with_cycle_detection(
tracer: &mut ReExportTracer<'_>,
module: &str,
name: &str,
visited: &mut HashSet<(String, String)>,
) -> Option<(PathBuf, String)> {
let key = (module.to_string(), name.to_string());
if visited.contains(&key) {
return None;
}
visited.insert(key);
if visited.len() > DEFAULT_MAX_DEPTH {
return None;
}
tracer
.trace(module, name, DEFAULT_MAX_DEPTH - visited.len())
.map(|traced| (traced.definition_file, traced.qualified_name))
}
#[cfg(test)]
mod tests {
use super::super::types::{FuncEntry, FuncIndex};
use super::*;
use crate::callgraph::cross_file_types::{FileIR, ImportDef, ResolvedImport};
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn test_build_import_map_from_import() {
let import_def = ImportDef::from_import("pkg.module", vec!["MyClass".to_string()]);
let resolved = ResolvedImport {
original: import_def,
resolved_file: Some(PathBuf::from("pkg/module.py")),
resolved_name: Some("MyClass".to_string()),
is_external: false,
confidence: 1.0,
};
let (import_map, module_imports) = build_import_map(&[resolved]);
assert!(
import_map.contains_key("MyClass"),
"Should have MyClass in import_map"
);
assert_eq!(
import_map.get("MyClass"),
Some(&("pkg.module".to_string(), "MyClass".to_string()))
);
assert!(
module_imports.is_empty(),
"module_imports should be empty for from-imports"
);
}
#[test]
fn test_build_import_map_with_alias() {
let mut import_def = ImportDef::from_import("pkg", vec!["MyClass".to_string()]);
let mut aliases = HashMap::new();
aliases.insert("MC".to_string(), "MyClass".to_string());
import_def.aliases = Some(aliases);
let resolved = ResolvedImport {
original: import_def,
resolved_file: Some(PathBuf::from("pkg/__init__.py")),
resolved_name: Some("MyClass".to_string()),
is_external: false,
confidence: 1.0,
};
let (import_map, _) = build_import_map(&[resolved]);
assert!(
import_map.contains_key("MC"),
"Should have alias MC in import_map"
);
assert_eq!(
import_map.get("MC"),
Some(&("pkg".to_string(), "MyClass".to_string()))
);
}
#[test]
fn test_build_import_map_module_import() {
let import_def = ImportDef::simple_import("json");
let resolved = ResolvedImport {
original: import_def,
resolved_file: None, resolved_name: Some("json".to_string()),
is_external: true, confidence: 1.0,
};
let (import_map, module_imports) = build_import_map(&[resolved]);
assert!(import_map.is_empty());
assert!(module_imports.is_empty());
}
#[test]
fn test_build_import_map_import_as() {
let mut import_def = ImportDef::simple_import("mylib.core");
import_def.alias = Some("mc".to_string());
let resolved = ResolvedImport {
original: import_def,
resolved_file: Some(PathBuf::from("mylib/core.py")),
resolved_name: Some("mylib.core".to_string()),
is_external: false,
confidence: 1.0,
};
let (import_map, module_imports) = build_import_map(&[resolved]);
assert!(
import_map.is_empty(),
"import_map should be empty for module imports"
);
assert!(
module_imports.contains_key("mc"),
"Should have alias in module_imports"
);
assert_eq!(module_imports.get("mc"), Some(&"mylib.core".to_string()));
}
#[test]
fn test_build_import_map_filters_external() {
let external_import = ImportDef::from_import("os.path", vec!["join".to_string()]);
let resolved = ResolvedImport {
original: external_import,
resolved_file: None,
resolved_name: Some("join".to_string()),
is_external: true,
confidence: 1.0,
};
let (import_map, module_imports) = build_import_map(&[resolved]);
assert!(import_map.is_empty(), "External imports should be filtered");
assert!(module_imports.is_empty());
}
#[test]
fn test_extract_python_imports_simple() {
let source = r#"
import json
import os
"#;
let mut file_ir = FileIR::new(PathBuf::from("test.py"));
let count = extract_python_imports(source, &mut file_ir);
assert_eq!(count, 2, "Should extract 2 imports");
assert_eq!(file_ir.imports.len(), 2);
}
#[test]
fn test_extract_python_imports_from() {
let source = r#"
from pkg.module import MyClass
from os import path
"#;
let mut file_ir = FileIR::new(PathBuf::from("test.py"));
let count = extract_python_imports(source, &mut file_ir);
assert_eq!(count, 2, "Should extract 2 from-imports");
assert!(file_ir.imports.iter().any(|i| i.module == "pkg.module"));
assert!(file_ir
.imports
.iter()
.any(|i| i.names.contains(&"MyClass".to_string())));
}
#[test]
fn test_extract_python_imports_alias() {
let source = r#"
import numpy as np
from typing import List as L
"#;
let mut file_ir = FileIR::new(PathBuf::from("test.py"));
let count = extract_python_imports(source, &mut file_ir);
assert_eq!(count, 2, "Should extract 2 imports with aliases");
let np_import = file_ir.imports.iter().find(|i| i.module == "numpy");
assert!(np_import.is_some());
assert_eq!(np_import.unwrap().alias, Some("np".to_string()));
}
#[test]
fn test_extract_python_imports_relative() {
let source = r#"
from . import types
from ..utils import helper
from ...core.base import Base
"#;
let mut file_ir = FileIR::new(PathBuf::from("pkg/sub/module.py"));
let count = extract_python_imports(source, &mut file_ir);
assert!(count >= 2, "Should extract relative imports");
let level1_import = file_ir.imports.iter().find(|i| i.level == 1);
assert!(level1_import.is_some(), "Should have level 1 import");
let level2_import = file_ir.imports.iter().find(|i| i.level == 2);
assert!(level2_import.is_some(), "Should have level 2 import");
let level3_import = file_ir.imports.iter().find(|i| i.level == 3);
assert!(level3_import.is_some(), "Should have level 3 import");
}
#[test]
fn test_import_map_type() {
let mut map: ImportMap = HashMap::new();
map.insert(
"MC".to_string(),
("pkg.module".to_string(), "MyClass".to_string()),
);
assert_eq!(map.get("MC").unwrap().0, "pkg.module");
assert_eq!(map.get("MC").unwrap().1, "MyClass");
}
#[test]
fn test_module_imports_type() {
let mut imports: ModuleImports = HashMap::new();
imports.insert("np".to_string(), "numpy".to_string());
assert_eq!(imports.get("np"), Some(&"numpy".to_string()));
}
#[test]
fn test_augment_go_module_imports_basic() {
let mut func_index = FuncIndex::new();
func_index.insert(
"pkg/models",
"NewUser",
FuncEntry::function(PathBuf::from("pkg/models/user.go"), 12, 14),
);
let imports = vec![ImportDef::simple_import("go-callgraph-test/pkg/models")];
let mut module_imports = ModuleImports::new();
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert_eq!(
module_imports.get("models"),
Some(&"pkg/models".to_string()),
"Should map 'models' alias to 'pkg/models' func_index key"
);
}
#[test]
fn test_augment_go_module_imports_explicit_alias() {
let mut func_index = FuncIndex::new();
func_index.insert(
"pkg/service",
"NewUserService",
FuncEntry::function(PathBuf::from("pkg/service/service.go"), 10, 13),
);
let mut import_def = ImportDef::simple_import("go-callgraph-test/pkg/service");
import_def.alias = Some("svc".to_string());
let imports = vec![import_def];
let mut module_imports = ModuleImports::new();
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert_eq!(
module_imports.get("svc"),
Some(&"pkg/service".to_string()),
"Should map explicit alias 'svc' to 'pkg/service'"
);
assert!(
!module_imports.contains_key("service"),
"Should NOT map default alias when explicit alias is given"
);
}
#[test]
fn test_augment_go_module_imports_skip_blank() {
let mut func_index = FuncIndex::new();
func_index.insert(
"pkg/effects",
"Init",
FuncEntry::function(PathBuf::from("pkg/effects/init.go"), 1, 5),
);
let mut import_def = ImportDef::simple_import("pkg/effects");
import_def.alias = Some("_".to_string());
let imports = vec![import_def];
let mut module_imports = ModuleImports::new();
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert!(
module_imports.is_empty(),
"Blank imports (_) should be skipped"
);
}
#[test]
fn test_augment_go_module_imports_skip_dot() {
let mut func_index = FuncIndex::new();
func_index.insert(
"pkg/utils",
"Helper",
FuncEntry::function(PathBuf::from("pkg/utils/utils.go"), 1, 5),
);
let mut import_def = ImportDef::simple_import("pkg/utils");
import_def.alias = Some(".".to_string());
let imports = vec![import_def];
let mut module_imports = ModuleImports::new();
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert!(
module_imports.is_empty(),
"Dot imports (.) should be skipped"
);
}
#[test]
fn test_augment_go_module_imports_no_overwrite() {
let mut func_index = FuncIndex::new();
func_index.insert(
"pkg/models",
"NewUser",
FuncEntry::function(PathBuf::from("pkg/models/user.go"), 12, 14),
);
let imports = vec![ImportDef::simple_import("go-callgraph-test/pkg/models")];
let mut module_imports = ModuleImports::new();
module_imports.insert("models".to_string(), "already/resolved".to_string());
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert_eq!(
module_imports.get("models"),
Some(&"already/resolved".to_string()),
"Should NOT overwrite existing module_imports entries"
);
}
#[test]
fn test_augment_go_module_imports_external() {
let func_index = FuncIndex::new();
let imports = vec![
ImportDef::simple_import("github.com/gin-gonic/gin"),
ImportDef::simple_import("fmt"),
];
let mut module_imports = ModuleImports::new();
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert!(
module_imports.is_empty(),
"External/stdlib imports should not be added"
);
}
#[test]
fn test_augment_go_module_imports_multiple() {
let mut func_index = FuncIndex::new();
func_index.insert(
"pkg/models",
"NewUser",
FuncEntry::function(PathBuf::from("pkg/models/user.go"), 12, 14),
);
func_index.insert(
"pkg/service",
"NewUserService",
FuncEntry::function(PathBuf::from("pkg/service/service.go"), 10, 13),
);
let imports = vec![
ImportDef::simple_import("myapp/pkg/models"),
ImportDef::simple_import("myapp/pkg/service"),
ImportDef::simple_import("fmt"), ];
let mut module_imports = ModuleImports::new();
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert_eq!(
module_imports.get("models"),
Some(&"pkg/models".to_string()),
);
assert_eq!(
module_imports.get("service"),
Some(&"pkg/service".to_string()),
);
assert!(!module_imports.contains_key("fmt"));
assert_eq!(module_imports.len(), 2);
}
#[test]
fn test_augment_go_module_imports_longest_match() {
let mut func_index = FuncIndex::new();
func_index.insert(
"models",
"Func1",
FuncEntry::function(PathBuf::from("models/m.go"), 1, 5),
);
func_index.insert(
"internal/models",
"Func2",
FuncEntry::function(PathBuf::from("internal/models/m.go"), 1, 5),
);
let imports = vec![ImportDef::simple_import("myapp/internal/models")];
let mut module_imports = ModuleImports::new();
augment_go_module_imports(&imports, &mut module_imports, &func_index);
assert_eq!(
module_imports.get("models"),
Some(&"internal/models".to_string()),
"Should prefer longest suffix match"
);
}
}