use std::collections::VecDeque;
use std::ops::Bound;
use ahash::{AHashMap, AHashSet};
use rmcp::ErrorData as McpError;
use rmcp::model::CallToolResult;
use super::MapCache;
use super::cursor::prefix_upper_bound;
use super::helpers::{json_result, kind_to_str};
use super::types_graph::{CallGraphNode, CallGraphParams, CallGraphResponse, CallGraphSite};
use crate::extract::{Call, FileMapL1, Symbol, SymbolKind};
use crate::path::RelPath;
const MAX_DEPTH_CEILING: u32 = 6;
const MAX_NODES_CEILING: u32 = 500;
const DEFAULT_MAX_DEPTH: u32 = 3;
const DEFAULT_MAX_NODES: u32 = 100;
fn is_function_like(kind: SymbolKind) -> bool {
matches!(
kind,
SymbolKind::Function
| SymbolKind::Method
| SymbolKind::Constructor
| SymbolKind::Getter
| SymbolKind::Setter
)
}
pub(super) fn run_call_graph(
idx: Option<&crate::index::IndexDb>,
params: CallGraphParams,
cache: &MapCache,
) -> Result<CallToolResult, McpError> {
let direction = params.direction.as_str();
let direction_owned = match direction {
"callers" | "callees" => direction.to_string(),
other => {
return Err(McpError::invalid_params(
format!("direction must be \"callers\" or \"callees\", got {other:?}"),
None,
));
}
};
let max_depth = params
.max_depth
.unwrap_or(DEFAULT_MAX_DEPTH)
.min(MAX_DEPTH_CEILING);
let max_nodes = params
.max_nodes
.unwrap_or(DEFAULT_MAX_NODES)
.min(MAX_NODES_CEILING) as usize;
let Some(idx) = idx else {
return json_result(&CallGraphResponse {
root: params.name.clone(),
direction: direction_owned,
nodes: vec![CallGraphNode {
name: params.name,
depth: 0,
edges_to: Vec::new(),
sites: Vec::new(),
}],
truncated: false,
truncation_reason: None,
});
};
let outcome = if direction == "callers" {
bfs_callers(
idx,
cache,
¶ms.name,
params.path.as_ref(),
max_depth,
max_nodes,
)?
} else {
bfs_callees(
idx,
cache,
¶ms.name,
params.path.as_ref(),
max_depth,
max_nodes,
)?
};
json_result(&CallGraphResponse {
root: params.name,
direction: direction_owned,
nodes: outcome.nodes,
truncated: outcome.truncated,
truncation_reason: outcome.truncation_reason,
})
}
struct BfsOutcome {
nodes: Vec<CallGraphNode>,
truncated: bool,
truncation_reason: Option<&'static str>,
}
fn build_root(name: &str, cache: &MapCache, path_filter: Option<&RelPath>) -> CallGraphNode {
let mut sites: Vec<CallGraphSite> = Vec::new();
let iter: Box<dyn Iterator<Item = (&RelPath, &FileMapL1)>> = match path_filter {
Some(p) => match cache.by_path.get(p) {
Some(l1) => Box::new(std::iter::once((p, l1))),
None => Box::new(std::iter::empty()),
},
None => Box::new(cache.by_path.iter()),
};
for (path, l1) in iter {
for sym in &l1.symbols {
if sym.name == name && is_function_like(sym.kind) {
sites.push(CallGraphSite {
path: path.clone(),
kind: kind_to_str(sym.kind).to_string(),
start_row: sym.start_row,
start_col: sym.start_col,
});
}
}
}
CallGraphNode {
name: name.to_string(),
depth: 0,
edges_to: Vec::new(),
sites,
}
}
fn containing_function(l1: &FileMapL1, start_byte: u32) -> Option<&Symbol> {
let mut best: Option<&Symbol> = None;
for sym in &l1.symbols {
if !is_function_like(sym.kind) {
continue;
}
if sym.start_byte <= start_byte && start_byte < sym.end_byte {
let span = sym.end_byte.saturating_sub(sym.start_byte);
let best_span = best
.map(|s| s.end_byte.saturating_sub(s.start_byte))
.unwrap_or(u32::MAX);
if span < best_span {
best = Some(sym);
}
}
}
best
}
fn bfs_callers(
idx: &crate::index::IndexDb,
cache: &MapCache,
root_name: &str,
path_filter: Option<&RelPath>,
max_depth: u32,
max_nodes: usize,
) -> Result<BfsOutcome, McpError> {
let mut nodes: Vec<CallGraphNode> = vec![build_root(root_name, cache, path_filter)];
let mut index_of: AHashMap<String, u32> = AHashMap::new();
index_of.insert(root_name.to_string(), 0);
let mut frontier: VecDeque<(String, u32)> = VecDeque::new();
frontier.push_back((root_name.to_string(), 0));
let scan_cap = max_nodes.saturating_mul(8).max(2_000);
let mut truncated = false;
let mut truncation_reason: Option<&'static str> = None;
let mut hit_scan_cap = false;
let mut depth_gated = false;
while let Some((current_name, depth)) = frontier.pop_front() {
if depth >= max_depth {
depth_gated = true;
continue;
}
let parents = match collect_callers(idx, cache, ¤t_name, scan_cap) {
Ok(p) => p,
Err(CallerScanError::ScanCap) => {
hit_scan_cap = true;
AHashMap::new()
}
Err(CallerScanError::Other(e)) => return Err(e),
};
let current_idx = *index_of
.get(¤t_name)
.expect("frontier entry must be indexed");
for (parent_name, parent_sites) in parents {
if parent_name == current_name {
if !nodes[current_idx as usize].edges_to.contains(¤t_idx) {
nodes[current_idx as usize].edges_to.push(current_idx);
}
continue;
}
let already = index_of.get(&parent_name).copied();
let parent_idx = match already {
Some(i) => i,
None => {
if nodes.len() >= max_nodes {
truncated = true;
truncation_reason = Some("max_nodes");
break;
}
let new_idx = nodes.len() as u32;
nodes.push(CallGraphNode {
name: parent_name.clone(),
depth: depth + 1,
edges_to: Vec::new(),
sites: parent_sites,
});
index_of.insert(parent_name.clone(), new_idx);
frontier.push_back((parent_name, depth + 1));
new_idx
}
};
if !nodes[parent_idx as usize].edges_to.contains(¤t_idx) {
nodes[parent_idx as usize].edges_to.push(current_idx);
}
}
if truncation_reason == Some("max_nodes") {
break;
}
}
if truncation_reason.is_none() && hit_scan_cap {
truncated = true;
truncation_reason = Some("scan_cap");
}
if truncation_reason.is_none() && depth_gated {
truncated = true;
truncation_reason = Some("max_depth");
}
Ok(BfsOutcome {
nodes,
truncated,
truncation_reason,
})
}
enum CallerScanError {
ScanCap,
Other(McpError),
}
fn collect_callers(
idx: &crate::index::IndexDb,
cache: &MapCache,
name: &str,
scan_cap: usize,
) -> Result<AHashMap<String, Vec<CallGraphSite>>, CallerScanError> {
let prefix = crate::index::keys::calls_by_callee_prefix(name);
let upper = prefix_upper_bound(&prefix);
let lower = Bound::Included(prefix.clone());
let upper_bound: Bound<Vec<u8>> = match upper {
Some(b) => Bound::Excluded(b),
None => Bound::Unbounded,
};
let mut parents: AHashMap<String, Vec<CallGraphSite>> = AHashMap::new();
let mut seen_parents: AHashSet<String> = AHashSet::new();
let mut scanned: usize = 0;
for guard in idx
.calls_by_callee
.range::<Vec<u8>, _>((lower, upper_bound))
{
scanned += 1;
if scanned > scan_cap {
return Err(CallerScanError::ScanCap);
}
let (k, _) = guard.into_inner().map_err(|e| {
CallerScanError::Other(McpError::internal_error(format!("index iter: {e}"), None))
})?;
let Some((callee, rel, start_byte)) = crate::index::keys::parse_call_by_callee(&k) else {
continue;
};
if callee != name {
continue;
}
let Some(l1) = cache.by_path.get(&rel) else {
continue;
};
let Some(parent_sym) = containing_function(l1, start_byte) else {
continue;
};
let entry_key = parent_sym.name.clone();
let site = CallGraphSite {
path: rel.clone(),
kind: kind_to_str(parent_sym.kind).to_string(),
start_row: parent_sym.start_row,
start_col: parent_sym.start_col,
};
let dedupe_key = format!(
"{}\0{}\0{}\0{}",
entry_key,
rel.as_bstr(),
site.start_row,
site.start_col,
);
if seen_parents.insert(dedupe_key) {
parents.entry(entry_key).or_default().push(site);
}
}
Ok(parents)
}
fn bfs_callees(
idx: &crate::index::IndexDb,
cache: &MapCache,
root_name: &str,
path_filter: Option<&RelPath>,
max_depth: u32,
max_nodes: usize,
) -> Result<BfsOutcome, McpError> {
let mut nodes: Vec<CallGraphNode> = vec![build_root(root_name, cache, path_filter)];
let mut index_of: AHashMap<String, u32> = AHashMap::new();
index_of.insert(root_name.to_string(), 0);
let mut frontier: VecDeque<(String, u32)> = VecDeque::new();
frontier.push_back((root_name.to_string(), 0));
let scan_cap = max_nodes.saturating_mul(8).max(2_000);
let mut truncated = false;
let mut truncation_reason: Option<&'static str> = None;
let mut hit_scan_cap = false;
let mut depth_gated = false;
while let Some((current_name, depth)) = frontier.pop_front() {
if depth >= max_depth {
depth_gated = true;
continue;
}
let callees = match collect_callees_for_name(
idx,
cache,
¤t_name,
if depth == 0 { path_filter } else { None },
scan_cap,
) {
Ok(c) => c,
Err(CallerScanError::ScanCap) => {
hit_scan_cap = true;
AHashSet::new()
}
Err(CallerScanError::Other(e)) => return Err(e),
};
let current_idx = *index_of
.get(¤t_name)
.expect("frontier entry must be indexed");
for callee in callees {
if callee == current_name {
if !nodes[current_idx as usize].edges_to.contains(¤t_idx) {
nodes[current_idx as usize].edges_to.push(current_idx);
}
continue;
}
let already = index_of.get(&callee).copied();
let child_idx = match already {
Some(i) => i,
None => {
if nodes.len() >= max_nodes {
truncated = true;
truncation_reason = Some("max_nodes");
break;
}
let new_idx = nodes.len() as u32;
let mut sites: Vec<CallGraphSite> = Vec::new();
for (path, l1) in &cache.by_path {
for sym in &l1.symbols {
if sym.name == callee && is_function_like(sym.kind) {
sites.push(CallGraphSite {
path: path.clone(),
kind: kind_to_str(sym.kind).to_string(),
start_row: sym.start_row,
start_col: sym.start_col,
});
}
}
}
nodes.push(CallGraphNode {
name: callee.clone(),
depth: depth + 1,
edges_to: Vec::new(),
sites,
});
index_of.insert(callee.clone(), new_idx);
frontier.push_back((callee, depth + 1));
new_idx
}
};
if !nodes[current_idx as usize].edges_to.contains(&child_idx) {
nodes[current_idx as usize].edges_to.push(child_idx);
}
}
if truncation_reason == Some("max_nodes") {
break;
}
}
if truncation_reason.is_none() && hit_scan_cap {
truncated = true;
truncation_reason = Some("scan_cap");
}
if truncation_reason.is_none() && depth_gated {
truncated = true;
truncation_reason = Some("max_depth");
}
Ok(BfsOutcome {
nodes,
truncated,
truncation_reason,
})
}
fn collect_callees_for_name(
idx: &crate::index::IndexDb,
cache: &MapCache,
name: &str,
path_filter: Option<&RelPath>,
scan_cap: usize,
) -> Result<AHashSet<String>, CallerScanError> {
let mut callees: AHashSet<String> = AHashSet::new();
let mut scanned: usize = 0;
let iter: Box<dyn Iterator<Item = (&RelPath, &FileMapL1)>> = match path_filter {
Some(p) => match cache.by_path.get(p) {
Some(l1) => Box::new(std::iter::once((p, l1))),
None => Box::new(std::iter::empty()),
},
None => Box::new(cache.by_path.iter()),
};
for (path, l1) in iter {
let matching: Vec<&Symbol> = l1
.symbols
.iter()
.filter(|s| s.name == name && is_function_like(s.kind))
.collect();
if matching.is_empty() {
continue;
}
let prefix = crate::index::keys::calls_by_path_prefix(path);
let upper = prefix_upper_bound(&prefix);
let lower = Bound::Included(prefix.clone());
let upper_bound: Bound<Vec<u8>> = match upper {
Some(b) => Bound::Excluded(b),
None => Bound::Unbounded,
};
for guard in idx.calls_by_path.range::<Vec<u8>, _>((lower, upper_bound)) {
scanned += 1;
if scanned > scan_cap {
return Err(CallerScanError::ScanCap);
}
let (_, v) = guard.into_inner().map_err(|e| {
CallerScanError::Other(McpError::internal_error(format!("index iter: {e}"), None))
})?;
let call: Call = match rmp_serde::from_slice(&v) {
Ok(c) => c,
Err(_) => continue,
};
if matching
.iter()
.any(|s| s.start_byte <= call.start_byte && call.start_byte < s.end_byte)
{
callees.insert(call.callee.clone());
}
}
}
Ok(callees)
}