use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use super::builder_v2::{build_project_call_graph_v2, BuildConfig, BuildError};
use super::cross_file_types::{
CallGraphIR, FileIR, FuncDef, ImportDef, ProjectCallGraphV2,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FunctionInfo {
pub name: String,
pub file: String,
pub start_line: u32,
pub end_line: u32,
pub is_method: bool,
pub class_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImportInfo {
pub module: String,
pub names: Vec<String>,
pub is_from: bool,
pub alias: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CallEdge {
pub caller: String,
pub callee: String,
pub file: String,
pub line: u32,
pub src_file: PathBuf,
pub src_func: String,
pub dst_file: PathBuf,
pub dst_func: String,
}
impl CallEdge {
pub fn new(
src_file: PathBuf,
src_func: String,
dst_file: PathBuf,
dst_func: String,
line: u32,
) -> Self {
let caller = format!("{}:{}", src_file.display(), src_func);
let callee = format!("{}:{}", dst_file.display(), dst_func);
let file = src_file.display().to_string();
Self {
caller,
callee,
file,
line,
src_file,
src_func,
dst_file,
dst_func,
}
}
}
pub fn funcdef_to_functioninfo(func: &FuncDef, file: &str) -> FunctionInfo {
FunctionInfo {
name: func.name.clone(),
file: file.to_string(),
start_line: func.line,
end_line: func.end_line,
is_method: func.is_method,
class_name: func.class_name.clone(),
}
}
pub fn importdef_to_importinfo(imp: &ImportDef) -> ImportInfo {
ImportInfo {
module: imp.module.clone(),
names: imp.names.clone(),
is_from: imp.is_from,
alias: imp.alias.clone(),
}
}
pub fn project_graph_to_edges(
graph: &ProjectCallGraphV2,
file_irs: &HashMap<String, FileIR>,
) -> Vec<CallEdge> {
graph
.edges()
.map(|edge| {
let line = file_irs
.get(&edge.src_file.to_string_lossy().to_string())
.and_then(|ir| {
ir.calls.get(&edge.src_func).and_then(|calls| {
calls
.iter()
.find(|c| c.target == edge.dst_func)
.and_then(|c| c.line)
})
})
.unwrap_or(0);
CallEdge::new(
edge.src_file.clone(),
edge.src_func.clone(),
edge.dst_file.clone(),
edge.dst_func.clone(),
line,
)
})
.collect()
}
pub fn callgraph_ir_to_v1(ir: &CallGraphIR, root: &Path) -> crate::types::ProjectCallGraph {
let mut graph = crate::types::ProjectCallGraph::new();
let should_make_absolute = root.is_absolute();
for edge in &ir.edges {
let src_file = if should_make_absolute && edge.src_file.is_relative() {
root.join(&edge.src_file)
} else if !should_make_absolute {
edge.src_file.clone()
} else {
edge.src_file.clone()
};
let dst_file = if should_make_absolute && edge.dst_file.is_relative() {
root.join(&edge.dst_file)
} else if !should_make_absolute {
edge.dst_file.clone()
} else {
edge.dst_file.clone()
};
let v1_edge = crate::types::CallEdge {
src_file,
src_func: edge.src_func.clone(),
dst_file,
dst_func: edge.dst_func.clone(),
};
graph.add_edge(v1_edge);
}
graph
}
pub fn callgraph_ir_to_old(ir: &CallGraphIR) -> crate::types::ProjectCallGraph {
callgraph_ir_to_v1(ir, &ir.root)
}
pub enum CallGraphOutput {
V1(crate::types::ProjectCallGraph),
V2(Box<CallGraphIR>),
}
impl CallGraphOutput {
pub fn into_v1(self, root: &Path) -> crate::types::ProjectCallGraph {
match self {
CallGraphOutput::V1(g) => g,
CallGraphOutput::V2(ir) => callgraph_ir_to_v1(&ir, root),
}
}
pub fn edges(&self) -> Box<dyn Iterator<Item = crate::types::CallEdge> + '_> {
match self {
CallGraphOutput::V1(g) => Box::new(g.edges().cloned()),
CallGraphOutput::V2(ir) => {
let root = ir.root.clone();
let v1 = callgraph_ir_to_v1(ir, &root);
Box::new(v1.edges().cloned().collect::<Vec<_>>().into_iter())
}
}
}
pub fn is_v2(&self) -> bool {
matches!(self, CallGraphOutput::V2(_))
}
}
pub fn build_call_graph(
root: &Path,
config: &BuildConfig,
use_experimental: bool,
) -> Result<CallGraphOutput, BuildError> {
if use_experimental {
let language = config.language.to_lowercase();
let supported = matches!(
language.as_str(),
"python"
| "typescript"
| "tsx"
| "javascript"
| "js"
| "go"
| "rust"
| "java"
| "c"
| "cpp"
| "csharp"
| "kotlin"
| "scala"
| "php"
| "ruby"
| "lua"
| "luau"
| "elixir"
| "ocaml"
);
if !supported && !config.language.is_empty() {
return Err(BuildError::UnsupportedLanguage(config.language.clone()));
}
let ir = build_project_call_graph_v2(root, config.clone())?;
return Ok(CallGraphOutput::V2(Box::new(ir)));
}
let language = match config.language.to_lowercase().as_str() {
"python" => crate::types::Language::Python,
"typescript" | "tsx" => crate::types::Language::TypeScript,
"javascript" | "js" => crate::types::Language::JavaScript,
"go" => crate::types::Language::Go,
"rust" => crate::types::Language::Rust,
"java" => crate::types::Language::Java,
"c" => crate::types::Language::C,
"cpp" => crate::types::Language::Cpp,
"csharp" => crate::types::Language::CSharp,
"kotlin" => crate::types::Language::Kotlin,
"scala" => crate::types::Language::Scala,
"swift" => crate::types::Language::Swift,
"php" => crate::types::Language::Php,
"ruby" => crate::types::Language::Ruby,
"lua" => crate::types::Language::Lua,
"luau" => crate::types::Language::Luau,
"elixir" => crate::types::Language::Elixir,
"ocaml" => crate::types::Language::Ocaml,
_ => return Err(BuildError::UnsupportedLanguage(config.language.clone())),
};
let v1_graph = crate::callgraph::build_project_call_graph(root, language, None, true)
.map_err(|e| BuildError::WorkspaceConfig(e.to_string()))?;
Ok(CallGraphOutput::V1(v1_graph))
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct NormalizedEdge {
pub src_file: String,
pub src_func: String,
pub dst_file: String,
pub dst_func: String,
}
impl NormalizedEdge {
pub fn new(src_file: String, src_func: String, dst_file: String, dst_func: String) -> Self {
Self {
src_file,
src_func,
dst_file,
dst_func,
}
}
}
pub fn normalize_edge(edge: &crate::types::CallEdge, root: &Path) -> NormalizedEdge {
let strip_root = |p: &Path| -> String {
p.strip_prefix(root)
.unwrap_or(p)
.to_string_lossy()
.replace('\\', "/")
};
NormalizedEdge {
src_file: strip_root(&edge.src_file),
src_func: edge.src_func.clone(),
dst_file: strip_root(&edge.dst_file),
dst_func: edge.dst_func.clone(),
}
}
#[derive(Debug, Default)]
pub struct ComparisonResult {
pub only_in_old: HashSet<NormalizedEdge>,
pub only_in_new: HashSet<NormalizedEdge>,
pub in_both: HashSet<NormalizedEdge>,
}
pub fn compare_builders(root: &Path, config: &BuildConfig) -> Result<ComparisonResult, BuildError> {
let v2_ir = build_project_call_graph_v2(root, config.clone())?;
let v2_graph = callgraph_ir_to_v1(&v2_ir, root);
let language = match config.language.to_lowercase().as_str() {
"python" => crate::types::Language::Python,
"typescript" | "tsx" => crate::types::Language::TypeScript,
"javascript" | "js" => crate::types::Language::JavaScript,
"go" => crate::types::Language::Go,
"rust" => crate::types::Language::Rust,
"java" => crate::types::Language::Java,
"c" => crate::types::Language::C,
"cpp" => crate::types::Language::Cpp,
"csharp" => crate::types::Language::CSharp,
"kotlin" => crate::types::Language::Kotlin,
"scala" => crate::types::Language::Scala,
"swift" => crate::types::Language::Swift,
"php" => crate::types::Language::Php,
"ruby" => crate::types::Language::Ruby,
"lua" => crate::types::Language::Lua,
"luau" => crate::types::Language::Luau,
"elixir" => crate::types::Language::Elixir,
"ocaml" => crate::types::Language::Ocaml,
_ => return Err(BuildError::UnsupportedLanguage(config.language.clone())),
};
let v1_graph = crate::callgraph::build_project_call_graph(root, language, None, true)
.map_err(|e| BuildError::WorkspaceConfig(e.to_string()))?;
let v1_edges: HashSet<NormalizedEdge> =
v1_graph.edges().map(|e| normalize_edge(e, root)).collect();
let v2_edges: HashSet<NormalizedEdge> =
v2_graph.edges().map(|e| normalize_edge(e, root)).collect();
let only_in_old: HashSet<_> = v1_edges.difference(&v2_edges).cloned().collect();
let only_in_new: HashSet<_> = v2_edges.difference(&v1_edges).cloned().collect();
let in_both: HashSet<_> = v1_edges.intersection(&v2_edges).cloned().collect();
Ok(ComparisonResult {
only_in_old,
only_in_new,
in_both,
})
}
pub fn format_edges_compatible(edges: &[(String, String, String, String)]) -> String {
let mut lines: Vec<String> = edges
.iter()
.map(|(s_f, s_fn, d_f, d_fn)| format!("{}:{} -> {}:{}", s_f, s_fn, d_f, d_fn))
.collect();
lines.sort();
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_funcdef_to_functioninfo_basic() {
let func = FuncDef::function("test_func", 1, 10);
let info = funcdef_to_functioninfo(&func, "test.py");
assert_eq!(info.name, "test_func");
assert_eq!(info.file, "test.py");
assert_eq!(info.start_line, 1);
assert_eq!(info.end_line, 10);
assert!(!info.is_method);
assert_eq!(info.class_name, None);
}
#[test]
fn test_funcdef_to_functioninfo_method() {
let func = FuncDef::method("my_method", "MyClass", 5, 15);
let info = funcdef_to_functioninfo(&func, "module.py");
assert_eq!(info.name, "my_method");
assert!(info.is_method);
assert_eq!(info.class_name, Some("MyClass".to_string()));
}
#[test]
fn test_importdef_to_importinfo_from() {
let import = ImportDef::from_import("mymodule", vec!["MyClass".to_string()]);
let info = importdef_to_importinfo(&import);
assert_eq!(info.module, "mymodule");
assert!(info.is_from);
assert_eq!(info.names, vec!["MyClass"]);
}
#[test]
fn test_importdef_to_importinfo_simple() {
let import = ImportDef::simple_import("json");
let info = importdef_to_importinfo(&import);
assert_eq!(info.module, "json");
assert!(!info.is_from);
assert!(info.names.is_empty());
}
#[test]
fn test_importdef_to_importinfo_alias() {
let import = ImportDef::import_as("numpy", "np");
let info = importdef_to_importinfo(&import);
assert_eq!(info.module, "numpy");
assert_eq!(info.alias, Some("np".to_string()));
}
#[test]
fn test_format_edges_compatible_basic() {
let edges = vec![(
"a.py".to_string(),
"foo".to_string(),
"b.py".to_string(),
"bar".to_string(),
)];
let output = format_edges_compatible(&edges);
assert_eq!(output, "a.py:foo -> b.py:bar");
}
#[test]
fn test_format_edges_compatible_sorted() {
let edges = vec![
(
"z.py".to_string(),
"z".to_string(),
"a.py".to_string(),
"a".to_string(),
),
(
"a.py".to_string(),
"a".to_string(),
"b.py".to_string(),
"b".to_string(),
),
];
let output = format_edges_compatible(&edges);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "a.py:a -> b.py:b");
assert_eq!(lines[1], "z.py:z -> a.py:a");
}
#[test]
fn test_format_edges_compatible_empty() {
let edges: Vec<(String, String, String, String)> = vec![];
let output = format_edges_compatible(&edges);
assert!(output.is_empty());
}
#[test]
fn test_comparison_result_default() {
let result = ComparisonResult::default();
assert!(result.only_in_old.is_empty());
assert!(result.only_in_new.is_empty());
assert!(result.in_both.is_empty());
}
#[test]
fn test_normalized_edge_new() {
let edge = NormalizedEdge::new(
"src/main.py".to_string(),
"main".to_string(),
"src/helper.py".to_string(),
"process".to_string(),
);
assert_eq!(edge.src_file, "src/main.py");
assert_eq!(edge.src_func, "main");
assert_eq!(edge.dst_file, "src/helper.py");
assert_eq!(edge.dst_func, "process");
}
#[test]
fn test_normalize_edge_strips_root() {
let root = Path::new("/project");
let edge = crate::types::CallEdge {
src_file: PathBuf::from("/project/src/main.py"),
src_func: "main".to_string(),
dst_file: PathBuf::from("/project/src/helper.py"),
dst_func: "process".to_string(),
};
let normalized = normalize_edge(&edge, root);
assert_eq!(normalized.src_file, "src/main.py");
assert_eq!(normalized.dst_file, "src/helper.py");
}
#[test]
fn test_normalize_edge_forward_slashes() {
let root = Path::new("/project");
let edge = crate::types::CallEdge {
src_file: PathBuf::from("/project/src/main.py"),
src_func: "main".to_string(),
dst_file: PathBuf::from("/project/src/helper.py"),
dst_func: "process".to_string(),
};
let normalized = normalize_edge(&edge, root);
assert!(!normalized.src_file.contains('\\'));
assert!(!normalized.dst_file.contains('\\'));
}
#[test]
fn test_normalize_edge_hash_equality() {
let edge1 = NormalizedEdge::new(
"main.py".to_string(),
"func".to_string(),
"helper.py".to_string(),
"helper".to_string(),
);
let edge2 = NormalizedEdge::new(
"main.py".to_string(),
"func".to_string(),
"helper.py".to_string(),
"helper".to_string(),
);
assert_eq!(edge1, edge2);
let mut set = HashSet::new();
set.insert(edge1.clone());
assert!(set.contains(&edge2));
}
#[test]
fn test_build_call_graph_v1_routing() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("test.py"), "def foo(): pass").unwrap();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_call_graph(dir.path(), &config, false);
assert!(result.is_ok(), "V1 build should succeed");
let output = result.unwrap();
assert!(!output.is_v2(), "Should return V1 format");
}
#[test]
fn test_build_call_graph_v2_routing() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("test.py"), "def foo(): pass").unwrap();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let result = build_call_graph(dir.path(), &config, true);
assert!(result.is_ok(), "V2 build should succeed");
let output = result.unwrap();
assert!(output.is_v2(), "Should return V2 format");
}
#[test]
fn test_build_call_graph_unsupported_language() {
let dir = tempfile::TempDir::new().unwrap();
let config = BuildConfig {
language: "brainfuck".to_string(), ..Default::default()
};
let result_v1 = build_call_graph(dir.path(), &config, false);
assert!(
result_v1.is_err(),
"V1 should fail for unsupported language"
);
let result_v2 = build_call_graph(dir.path(), &config, true);
assert!(
result_v2.is_err(),
"V2 should fail for unsupported language"
);
}
#[test]
fn test_build_call_graph_output_conversion() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("test.py"), "def foo(): pass").unwrap();
let config = BuildConfig {
language: "python".to_string(),
..Default::default()
};
let output = build_call_graph(dir.path(), &config, true).unwrap();
let v1_graph = output.into_v1(dir.path());
let _ = v1_graph;
}
}