use anyhow::{Context, Result};
use std::collections::HashMap;
use tracing::{debug, trace, warn};
use tree_sitter::{Node, Parser};
use super::common::{build_symbol_table, find_enclosing_chunk};
use super::{ChunkWithId, Edge, EdgeType};
pub fn extract_calls(source: &str, chunks: &[ChunkWithId]) -> Result<Vec<Edge>> {
let mut parser = Parser::new();
let language = tree_sitter_typescript::language_typescript();
parser
.set_language(&language)
.context("Failed to set TypeScript language")?;
let tree = match parser.parse(source, None) {
Some(t) => t,
None => {
warn!("Failed to parse TypeScript file for edge extraction");
return Ok(Vec::new());
}
};
let symbol_table = build_symbol_table(chunks);
let mut edges = Vec::new();
let root = tree.root_node();
find_call_expressions(&root, source, chunks, &symbol_table, &mut edges);
debug!("Extracted {} call edges from TypeScript file", edges.len());
Ok(edges)
}
fn find_call_expressions(
node: &Node,
source: &str,
chunks: &[ChunkWithId],
symbol_table: &HashMap<String, i64>,
edges: &mut Vec<Edge>,
) {
if node.kind() == "call_expression" {
process_call_expression(node, source, chunks, symbol_table, edges);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
find_call_expressions(&child, source, chunks, symbol_table, edges);
}
}
fn process_call_expression(
node: &Node,
source: &str,
chunks: &[ChunkWithId],
symbol_table: &HashMap<String, i64>,
edges: &mut Vec<Edge>,
) {
let callee_name = match extract_function_identifier(node, source) {
Some(name) => name,
None => {
trace!(
"Could not extract function identifier from call at line {}",
node.start_position().row + 1
);
return;
}
};
let callee_id = match symbol_table.get(&callee_name) {
Some(&id) => id,
None => {
trace!(
"Unresolved call: {} (may be cross-file or built-in)",
callee_name
);
return;
}
};
let call_line = node.start_position().row as i32 + 1; let caller_chunk = match find_enclosing_chunk(chunks, call_line) {
Some(chunk) => chunk,
None => {
trace!("Call at line {} not in any chunk", call_line);
return;
}
};
edges.push(Edge {
src_chunk_id: caller_chunk.id,
dst_chunk_id: callee_id,
edge_type: EdgeType::Calls,
});
trace!(
"Call edge: {} (chunk {}) → {} (chunk {})",
caller_chunk.symbol_name.as_deref().unwrap_or("<anonymous>"),
caller_chunk.id,
callee_name,
callee_id
);
}
fn extract_function_identifier(node: &Node, source: &str) -> Option<String> {
let function_node = node.child_by_field_name("function")?;
match function_node.kind() {
"identifier" => {
Some(function_node.utf8_text(source.as_bytes()).ok()?.to_string())
}
"member_expression" => {
let property = function_node.child_by_field_name("property")?;
Some(property.utf8_text(source.as_bytes()).ok()?.to_string())
}
_ => {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_simple_call() {
let source = r#"
function foo() { return 42; }
function bar() {
const x = foo();
return x;
}
"#;
let chunks = vec![
ChunkWithId {
id: 1,
symbol_name: Some("foo".to_string()),
kind: "function".to_string(),
start_line: 2,
end_line: 2,
file_id: 100,
},
ChunkWithId {
id: 2,
symbol_name: Some("bar".to_string()),
kind: "function".to_string(),
start_line: 3,
end_line: 6,
file_id: 100,
},
];
let edges = extract_calls(source, &chunks).unwrap();
assert_eq!(edges.len(), 1, "Should find one call edge");
assert_eq!(edges[0].src_chunk_id, 2, "Caller should be bar");
assert_eq!(edges[0].dst_chunk_id, 1, "Callee should be foo");
assert_eq!(edges[0].edge_type, EdgeType::Calls);
}
#[test]
fn test_extract_method_call() {
let source = r#"
class Calculator {
add(a, b) { return a + b; }
multiply(a, b) {
return this.add(a, a) * b;
}
}
"#;
let chunks = vec![
ChunkWithId {
id: 1,
symbol_name: Some("add".to_string()),
kind: "method".to_string(),
start_line: 3,
end_line: 3,
file_id: 100,
},
ChunkWithId {
id: 2,
symbol_name: Some("multiply".to_string()),
kind: "method".to_string(),
start_line: 4,
end_line: 6,
file_id: 100,
},
];
let edges = extract_calls(source, &chunks).unwrap();
assert!(edges.len() >= 1, "Should find at least one method call");
let add_call = edges.iter().find(|e| e.dst_chunk_id == 1);
assert!(add_call.is_some(), "Should find call to add method");
}
#[test]
fn test_unresolved_call_skipped() {
let source = r#"
function foo() {
console.log("test"); // console.log is not in chunks
}
"#;
let chunks = vec![ChunkWithId {
id: 1,
symbol_name: Some("foo".to_string()),
kind: "function".to_string(),
start_line: 2,
end_line: 4,
file_id: 100,
}];
let edges = extract_calls(source, &chunks).unwrap();
assert_eq!(edges.len(), 0, "Should skip unresolved calls");
}
#[test]
fn test_parse_error_returns_empty() {
let invalid_source = "function foo(";
let chunks = vec![];
let result = extract_calls(invalid_source, &chunks);
assert!(result.is_ok(), "Should not fail on parse error");
assert_eq!(result.unwrap().len(), 0, "Should return empty vec");
}
#[test]
fn test_multiple_calls() {
let source = r#"
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
function calculate() {
const x = add(1, 2);
const y = subtract(5, 3);
return x + y;
}
"#;
let chunks = vec![
ChunkWithId {
id: 1,
symbol_name: Some("add".to_string()),
kind: "function".to_string(),
start_line: 2,
end_line: 2,
file_id: 100,
},
ChunkWithId {
id: 2,
symbol_name: Some("subtract".to_string()),
kind: "function".to_string(),
start_line: 3,
end_line: 3,
file_id: 100,
},
ChunkWithId {
id: 3,
symbol_name: Some("calculate".to_string()),
kind: "function".to_string(),
start_line: 4,
end_line: 8,
file_id: 100,
},
];
let edges = extract_calls(source, &chunks).unwrap();
assert_eq!(edges.len(), 2, "Should find two calls");
assert!(edges.iter().any(|e| e.dst_chunk_id == 1), "Should call add");
assert!(
edges.iter().any(|e| e.dst_chunk_id == 2),
"Should call subtract"
);
}
}