use std::ops::Bound;
use rmcp::ErrorData as McpError;
use rmcp::model::CallToolResult;
use super::MapCache;
use super::cursor::{Cursor, prefix_upper_bound};
use super::helpers::{SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX, json_result, kind_to_str, parse_kind};
pub(crate) use super::helpers_calls_scan::InRamCallIndex;
use super::helpers_calls_scan::{CallScanPage, budget_call_page, scan_calls};
use super::types::ReferenceHit;
use crate::extract::Call;
use crate::index::IndexDb;
use crate::path::RelPath;
pub(super) enum RefsSource<'a> {
Local(&'a crate::store::Store),
#[cfg(all(feature = "comms", any(unix, windows)))]
Daemon {
client: std::sync::Arc<tokio::sync::Mutex<crate::comms::client::CommsClient>>,
root: std::path::PathBuf,
},
}
impl RefsSource<'_> {
async fn references_to(&self, def_path: &RelPath, def_start: u32) -> Vec<(RelPath, u32)> {
match self {
RefsSource::Local(store) => crate::query::resolved_references(store, def_path, def_start),
#[cfg(all(feature = "comms", any(unix, windows)))]
RefsSource::Daemon { client, root } => {
use crate::comms::resolved_proto::{ResolvedRefQuery, ResolvedRefResult};
let query = ResolvedRefQuery::ReferencesTo {
def_path: def_path.clone(),
def_start,
};
match client.lock().await.resolved_refs(root.clone(), query).await {
Ok(ResolvedRefResult::References(uses)) => uses,
Ok(_) => Vec::new(),
Err(error) => {
tracing::debug!(%error, "find_callers: daemon resolved-refs forward failed; name-based fallback");
Vec::new()
}
}
}
}
}
}
pub(super) fn for_each_call_in_file<F: FnMut(&str, u32) -> bool>(
idx: Option<&IndexDb>,
cache: &MapCache,
path: &RelPath,
mut f: F,
) -> Result<(), McpError> {
match idx {
Some(idx) => {
let prefix = crate::index::keys::calls_by_path_prefix(path);
let upper: Bound<Vec<u8>> = match prefix_upper_bound(&prefix) {
Some(b) => Bound::Excluded(b),
None => Bound::Unbounded,
};
for guard in idx.calls_by_path.range::<Vec<u8>, _>((Bound::Included(prefix), upper)) {
let (_, v) = guard
.into_inner()
.map_err(|e| McpError::internal_error(format!("index iter: {e}"), None))?;
let call: Call = match rmp_serde::from_slice(&v) {
Ok(c) => c,
Err(_) => continue,
};
if !f(&call.callee, call.start_byte) {
return Ok(());
}
}
}
None => {
if let Some(calls) = cache.calls.as_ref() {
for cref in calls.calls_in_file(path) {
if !f(&cref.callee, cref.start_byte) {
return Ok(());
}
}
}
}
}
Ok(())
}
pub(super) fn resolve_call_line_col(
idx: &crate::index::IndexDb,
rel: &crate::path::RelPath,
start_byte: u32,
) -> (u32, u32) {
let key = crate::index::keys::call_by_path(rel, start_byte);
let value = match idx.calls_by_path.get(key) {
Ok(Some(v)) => v,
_ => return (0, 0),
};
let call: crate::extract::Call = match rmp_serde::from_slice(&value) {
Ok(c) => c,
Err(_) => return (0, 0),
};
(call.start_row + 1, call.start_col)
}
pub(super) fn run_find_references(
idx: Option<&crate::index::IndexDb>,
params: super::types::FindReferencesParams,
cache: &super::MapCache,
notice: Option<super::types::LifecycleNotice>,
started: std::time::Instant,
) -> Result<CallToolResult, McpError> {
use super::types::FindReferencesResponse;
let format = super::toon::ResponseFormat::parse(params.format.as_deref());
let limit = params.limit.unwrap_or(SEARCH_LIMIT_DEFAULT).min(SEARCH_LIMIT_MAX) as usize;
let cursor_bytes = params.cursor.as_ref().map(|c| c.decode_fjall()).transpose()?;
let scan = scan_calls(idx, cache, ¶ms.name, limit, cursor_bytes.as_deref())?;
let total = scan.total;
let total_is_partial = scan.total_is_partial;
let budgeted = budget_call_page(scan, params.max_tokens);
super::toon::format_result(
&FindReferencesResponse {
name: params.name,
total,
total_is_partial,
budgeted: budgeted.budgeted,
hits: budgeted.hits,
next_cursor: budgeted.next_cursor,
notice,
elapsed_us: super::helpers::elapsed_us(started),
},
format,
)
}
pub(super) async fn run_find_callers(
store: &crate::store::Store,
refs: RefsSource<'_>,
root: &std::path::Path,
cache: &super::MapCache,
params: super::types::FindCallersParams,
notice: Option<super::types::LifecycleNotice>,
started: std::time::Instant,
) -> Result<CallToolResult, McpError> {
use super::types::{DefinitionView, FindCallersResponse};
let limit = params.limit.unwrap_or(SEARCH_LIMIT_DEFAULT).min(SEARCH_LIMIT_MAX) as usize;
let kind_filter = params.kind.as_deref().map(parse_kind).transpose()?;
let symbol = cache.by_path.get(¶ms.path).and_then(|l1| {
l1.symbols
.iter()
.find(|s| s.name == params.name && kind_filter.is_none_or(|k| s.kind == k))
.cloned()
});
let definition: Option<DefinitionView> = symbol.as_ref().map(|sym| DefinitionView {
path: params.path.clone(),
name: sym.name.clone(),
kind: kind_to_str(sym.kind),
start_row: sym.start_row,
start_col: sym.start_col,
});
let cursor_bytes = params.cursor.as_ref().map(|c| c.decode_fjall()).transpose()?;
let scan = scan_calls(
store.index_db.as_ref(),
cache,
¶ms.name,
limit,
cursor_bytes.as_deref(),
)?;
let resolved = match symbol.as_ref() {
Some(sym) => resolved_callers(store, &refs, root, cache, ¶ms.path, sym, ¶ms.name, limit).await,
None => None,
};
let resolved_total = resolved.as_ref().map_or(0, |r| r.total);
let page = merge_resolved(scan, resolved, limit, cursor_bytes.as_deref());
let total = page.total;
let total_is_partial = page.total_is_partial;
let budgeted = budget_call_page(page, params.max_tokens);
json_result(&FindCallersResponse {
definition,
resolved_total,
total,
total_is_partial,
budgeted: budgeted.budgeted,
hits: budgeted.hits,
next_cursor: budgeted.next_cursor,
notice,
elapsed_us: super::helpers::elapsed_us(started),
})
}
struct ResolvedCallers {
sites: ahash::AHashMap<crate::path::RelPath, ahash::AHashSet<u32>>,
aliased: Vec<(Vec<u8>, ReferenceHit)>,
total: u32,
}
struct CallSite {
callee: String,
line: u32,
column: u32,
}
fn call_sites_in_file(
idx: Option<&IndexDb>,
cache: &MapCache,
path: &RelPath,
) -> Result<ahash::AHashMap<u32, CallSite>, McpError> {
let mut sites: ahash::AHashMap<u32, CallSite> = ahash::AHashMap::new();
match idx {
Some(idx) => {
let prefix = crate::index::keys::calls_by_path_prefix(path);
let upper: Bound<Vec<u8>> = match prefix_upper_bound(&prefix) {
Some(b) => Bound::Excluded(b),
None => Bound::Unbounded,
};
for guard in idx.calls_by_path.range::<Vec<u8>, _>((Bound::Included(prefix), upper)) {
let (_, v) = guard
.into_inner()
.map_err(|e| McpError::internal_error(format!("index iter: {e}"), None))?;
let call: Call = match rmp_serde::from_slice(&v) {
Ok(c) => c,
Err(_) => continue,
};
sites.insert(
call.start_byte,
CallSite {
callee: call.callee,
line: call.start_row + 1,
column: call.start_col,
},
);
}
}
None => {
if let Some(calls) = cache.calls.as_ref() {
for cref in calls.calls_in_file(path) {
sites.insert(
cref.start_byte,
CallSite {
callee: cref.callee.clone(),
line: cref.line,
column: cref.column,
},
);
}
}
}
}
Ok(sites)
}
#[allow(clippy::too_many_arguments)]
async fn resolved_callers(
store: &crate::store::Store,
ref_source: &RefsSource<'_>,
root: &std::path::Path,
cache: &MapCache,
def_path: &crate::path::RelPath,
symbol: &crate::extract::Symbol,
name: &str,
limit: usize,
) -> Option<ResolvedCallers> {
let entry = store.lookup(def_path)?;
let refs = store.read_resolved_by_hex(&entry.hash_hex).ok().flatten()?;
let def_source = std::fs::read(root.join(def_path.to_path_buf())).ok()?;
let mut def_starts: Vec<u32> = Vec::new();
let push_candidate = |byte: u32, def_starts: &mut Vec<u32>| {
if byte >= symbol.start_byte
&& byte < symbol.end_byte
&& super::helpers_intel::identifier_at(&def_source, byte) == symbol.name.as_str()
&& !def_starts.contains(&byte)
{
def_starts.push(byte);
}
};
for edge in &refs.intra {
push_candidate(edge.def_start, &mut def_starts);
}
for export in &refs.exports {
push_candidate(export.name_start, &mut def_starts);
}
if def_starts.is_empty() {
return None;
}
let mut seen: ahash::AHashSet<(crate::path::RelPath, u32)> = ahash::AHashSet::new();
let mut uses: Vec<(crate::path::RelPath, u32)> = Vec::new();
for def_start in def_starts {
for use_ref in ref_source.references_to(def_path, def_start).await {
if seen.insert(use_ref.clone()) {
uses.push(use_ref);
}
}
}
if uses.is_empty() {
return None;
}
let probe_cap = limit.saturating_mul(8).max(2_000);
let finder = memchr::memmem::Finder::new(name.as_bytes());
let mut sites: ahash::AHashMap<crate::path::RelPath, ahash::AHashSet<u32>> = ahash::AHashMap::new();
let mut aliased: Vec<(Vec<u8>, ReferenceHit)> = Vec::new();
let mut total: u32 = 0;
uses.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let mut probed = 0usize;
let mut file_calls: Option<(&crate::path::RelPath, ahash::AHashMap<u32, CallSite>)> = None;
for (use_path, use_start) in &uses {
if file_calls.as_ref().is_none_or(|(p, _)| *p != use_path) {
if probed >= probe_cap {
break;
}
probed += 1;
file_calls = Some((
use_path,
call_sites_in_file(store.index_db.as_ref(), cache, use_path).ok()?,
));
}
let Some((_, calls)) = file_calls.as_ref() else {
continue;
};
let Some(site) = calls.get(use_start) else {
continue;
};
total += 1;
sites.entry(use_path.clone()).or_default().insert(*use_start);
if finder.find(site.callee.as_bytes()).is_none()
&& let Some(key) = crate::index::keys::call_by_callee(&site.callee, use_path, *use_start)
{
aliased.push((
key,
ReferenceHit {
path: use_path.clone(),
line: site.line,
column: site.column,
callee: site.callee.clone(),
resolved: Some(true),
},
));
}
}
if total == 0 {
return None;
}
Some(ResolvedCallers { sites, aliased, total })
}
fn merge_resolved(
mut page: CallScanPage,
resolved: Option<ResolvedCallers>,
limit: usize,
cursor_after: Option<&[u8]>,
) -> CallScanPage {
let Some(resolved) = resolved else {
return page;
};
for (hit, start) in page.hits.iter_mut().zip(page.hit_starts.iter()) {
let proven = resolved.sites.get(&hit.path).is_some_and(|s| s.contains(start));
hit.resolved = Some(proven);
}
if resolved.aliased.is_empty() {
return page;
}
let total = page.total.saturating_add(resolved.aliased.len() as u32);
let scan_had_more = page.next_cursor.is_some();
let mut merged: Vec<(Vec<u8>, ReferenceHit, u32)> = Vec::with_capacity(page.hits.len() + resolved.aliased.len());
for ((key, hit), start) in page
.hit_keys
.drain(..)
.zip(page.hits.drain(..))
.zip(page.hit_starts.drain(..))
{
merged.push((key, hit, start));
}
for (key, hit) in resolved.aliased {
if cursor_after.is_some_and(|cursor| key.as_slice() <= cursor) {
continue;
}
merged.push((key, hit, 0));
}
merged.sort_unstable_by(|a, b| a.0.cmp(&b.0));
let has_more = merged.len() > limit || scan_had_more;
merged.truncate(limit);
let next_cursor = if has_more {
merged.last().map(|(key, _, _)| Cursor::encode_fjall(key))
} else {
None
};
let (hit_keys, hits, hit_starts) = merged.into_iter().fold(
(Vec::new(), Vec::new(), Vec::new()),
|(mut keys, mut hits, mut starts), (key, hit, start)| {
keys.push(key);
hits.push(hit);
starts.push(start);
(keys, hits, starts)
},
);
CallScanPage {
total,
total_is_partial: page.total_is_partial,
hits,
next_cursor,
hit_keys,
hit_starts,
}
}
#[cfg(test)]
mod tests {
use super::super::helpers_calls_scan::{InRamCallIndex, scan_calls_in_ram};
use crate::config::ConfigV1;
use crate::scanner::{ScanSource, scan};
use crate::store::{Store, VIEW_WORKING};
fn decode(result: &rmcp::model::CallToolResult) -> serde_json::Value {
use rmcp::model::ContentBlock;
let text = result
.content
.iter()
.find_map(|c| match c {
ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.unwrap_or_default();
serde_json::from_str(&text).expect("tool response is JSON")
}
fn scan_fixture(root: &std::path::Path) -> (Store, crate::mcp::MapCache) {
let mut store = Store::open(root, VIEW_WORKING).expect("open");
scan(
root,
&mut store,
&ConfigV1::with_defaults(),
ScanSource::WorkingTree,
crate::scanner::EmbedMode::Inline,
)
.expect("scan");
let cache = crate::mcp::MapCache::build(&store);
(store, cache)
}
#[test]
fn find_callers_reports_cross_file_callers_resolution_cannot_see() {
use crate::mcp::types::{FindCallersParams, FindReferencesParams};
use crate::path::RelPath;
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir(root.join("pkg")).expect("pkg dir");
std::fs::write(root.join("pkg/__init__.py"), b"").expect("__init__.py");
std::fs::write(
root.join("pkg/mod.py"),
b"def target():\n return 1\n\n\ndef seed():\n return target()\n",
)
.expect("mod.py");
std::fs::write(
root.join("caller_a.py"),
b"from pkg import mod\n\n\ndef go():\n return mod.target()\n",
)
.expect("caller_a.py");
std::fs::write(
root.join("caller_b.py"),
b"from pkg import mod\n\n\ndef go2():\n return mod.target() + mod.target()\n",
)
.expect("caller_b.py");
let (store, cache) = scan_fixture(root);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let references = decode(
&super::run_find_references(
store.index_db.as_ref(),
FindReferencesParams {
name: "target".to_string(),
limit: Some(500),
max_tokens: None,
format: None,
cursor: None,
},
&cache,
None,
std::time::Instant::now(),
)
.expect("find_references"),
);
assert_eq!(
references.get("total").and_then(serde_json::Value::as_u64),
Some(4),
"ground truth: find_references sees all 4 target() call sites: {references}"
);
let callers = decode(
&runtime
.block_on(super::run_find_callers(
&store,
super::RefsSource::Local(&store),
root,
&cache,
FindCallersParams {
path: RelPath::from("pkg/mod.py".as_bytes()),
name: "target".to_string(),
kind: None,
limit: Some(500),
max_tokens: None,
cursor: None,
},
None,
std::time::Instant::now(),
))
.expect("find_callers"),
);
assert_eq!(
callers.get("total").and_then(serde_json::Value::as_u64),
Some(4),
"find_callers must NOT shrink to the resolution-visible subset: {callers}"
);
let hits = callers.get("hits").and_then(serde_json::Value::as_array).expect("hits");
let paths: Vec<&str> = hits
.iter()
.filter_map(|h| h.get("path").and_then(serde_json::Value::as_str))
.collect();
assert!(
paths.contains(&"caller_a.py") && paths.contains(&"caller_b.py"),
"the module-import callers resolution cannot see must still be reported: {paths:?}"
);
assert_eq!(
callers.get("total").and_then(serde_json::Value::as_u64),
references.get("total").and_then(serde_json::Value::as_u64),
"find_callers and find_references must agree on an unambiguous name"
);
let resolved_total = callers
.get("resolved_total")
.and_then(serde_json::Value::as_u64)
.expect("resolved_total is always reported");
assert!(
resolved_total <= 4,
"resolved_total is a lower bound on the truth, never above total: {callers}"
);
}
#[cfg(feature = "code-intel-js")]
#[test]
fn find_callers_reports_namespace_import_callers_the_js_resolver_cannot_bind() {
use crate::mcp::types::FindCallersParams;
use crate::path::RelPath;
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(
root.join("util.ts"),
b"export function target() { return 1; }\ntarget();\n",
)
.expect("util.ts");
std::fs::write(
root.join("consumer.ts"),
b"import * as util from './util';\nutil.target();\nutil.target();\n",
)
.expect("consumer.ts");
let (store, cache) = scan_fixture(root);
let body = decode(
&tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(super::run_find_callers(
&store,
super::RefsSource::Local(&store),
root,
&cache,
FindCallersParams {
path: RelPath::from("util.ts".as_bytes()),
name: "target".to_string(),
kind: None,
limit: Some(500),
max_tokens: None,
cursor: None,
},
None,
std::time::Instant::now(),
))
.expect("find_callers"),
);
assert_eq!(
body.get("total").and_then(serde_json::Value::as_u64),
Some(3),
"all 3 call sites — the resolvable self-call AND the 2 namespace-import callers: {body}"
);
assert_eq!(
body.get("resolved_total").and_then(serde_json::Value::as_u64),
Some(1),
"resolution can only PROVE the intra-file self-call — it must not pass that off as the total"
);
let hits = body.get("hits").and_then(serde_json::Value::as_array).expect("hits");
let consumer_hits = hits
.iter()
.filter(|h| h.get("path").and_then(serde_json::Value::as_str) == Some("consumer.ts"))
.count();
assert_eq!(
consumer_hits, 2,
"the namespace-import callers resolution cannot see must still be reported: {body}"
);
assert!(
hits.iter().all(|h| h.get("resolved").is_some()),
"every hit is annotated so the agent can tell proven from unproven: {body}"
);
}
#[test]
fn in_ram_call_index_resolves_references() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(root.join("a.rs"), b"pub fn alpha() {}\n").expect("a.rs");
std::fs::write(root.join("b.rs"), b"fn beta() { alpha(); alpha(); }\n").expect("b.rs");
let mut store = Store::open(root, VIEW_WORKING).expect("open");
scan(
root,
&mut store,
&ConfigV1::with_defaults(),
ScanSource::WorkingTree,
crate::scanner::EmbedMode::Inline,
)
.expect("scan");
let index = InRamCallIndex::build(&store);
let page = scan_calls_in_ram(&index, "alpha", 100, None);
assert_eq!(page.total, 2, "two alpha() call sites in b.rs");
assert_eq!(page.hits.len(), 2);
assert!(page.hits.iter().all(|h| h.callee == "alpha"));
assert!(
page.hits.iter().all(|h| h.path.as_str() == Some("b.rs")),
"both references live in b.rs"
);
}
#[cfg(feature = "code-intel-js")]
#[test]
fn find_callers_annotates_resolved_edges_without_dropping_same_name_sites() {
use crate::mcp::types::FindCallersParams;
use crate::path::RelPath;
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(
root.join("util.ts"),
b"export function target() { return 1; }\ntarget();\ntarget();\n",
)
.expect("util.ts");
std::fs::write(root.join("other.ts"), b"function target() { return 3; }\ntarget();\n").expect("other.ts");
let (store, cache) = scan_fixture(root);
let def_path = RelPath::from("util.ts".as_bytes());
let l1 = crate::query::file_outline(&store, &def_path).expect("outline");
let sym = l1
.symbols
.iter()
.find(|s| s.name == "target" && s.kind == crate::extract::SymbolKind::Function)
.cloned()
.expect("util.ts target function symbol");
let entry = store.lookup(&def_path).expect("indexed");
let refs = store
.read_resolved_by_hex(&entry.hash_hex)
.expect("read blob")
.expect("resolution facts present");
assert!(
!refs.intra.iter().any(|e| e.def_start == sym.start_byte),
"L1 node start_byte must differ from the resolver's def identifier byte"
);
let body = decode(
&tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(super::run_find_callers(
&store,
super::RefsSource::Local(&store),
root,
&cache,
FindCallersParams {
path: def_path.clone(),
name: "target".to_string(),
kind: None,
limit: Some(100),
max_tokens: None,
cursor: None,
},
None,
std::time::Instant::now(),
))
.expect("find_callers"),
);
assert_eq!(
body.get("resolved_total").and_then(serde_json::Value::as_u64),
Some(2),
"exactly the two util.ts callers are PROVEN to bind to util.ts target: {body}"
);
assert_eq!(
body.get("total").and_then(serde_json::Value::as_u64),
Some(3),
"the other.ts same-name call site is still reported, not dropped: {body}"
);
let hits = body.get("hits").and_then(serde_json::Value::as_array).expect("hits");
let proven: Vec<&str> = hits
.iter()
.filter(|h| h.get("resolved").and_then(serde_json::Value::as_bool) == Some(true))
.filter_map(|h| h.get("path").and_then(serde_json::Value::as_str))
.collect();
assert_eq!(
proven,
vec!["util.ts", "util.ts"],
"only the util.ts sites are proven — other.ts is never conflated INTO the proven set"
);
let unproven: Vec<&str> = hits
.iter()
.filter(|h| h.get("resolved").and_then(serde_json::Value::as_bool) == Some(false))
.filter_map(|h| h.get("path").and_then(serde_json::Value::as_str))
.collect();
assert_eq!(
unproven,
vec!["other.ts"],
"the same-name site is surfaced as unproven, so a precision-seeking caller can filter it"
);
}
}