use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use rmcp::model::Tool;
use scryer_db::Symbol;
use scryer_engine::EngineService;
use scryer_engine::hasher::hash_bytes;
use super::admin::{make_tool, read_only};
use super::adr_rank::{InvariantTarget, NEIGHBOUR_EDGE_TYPES, rank_invariants_for_symbol};
use super::dependency::{
ResolvedSymbol, SymbolCandidate, TYPE_KINDS, lookup_symbol_candidates, narrow_by_file,
};
use super::graph::{EdgeDirection, one_hop_edges, source_file_path};
use super::navigation::count_references;
use crate::context::ProjectContextResolver;
const DEFAULT_SNIPPET_LINES: usize = 25;
const DEFAULT_LIMIT: usize = 5;
const MAX_CANDIDATES: usize = 10;
const MAX_INVARIANTS: usize = 3;
const MAX_MEMBERS: usize = 8;
const DOC_LINES: usize = 3;
const DOC_CHARS: usize = 400;
const SNIPPET_LINE_CHARS: usize = 200;
const DEFAULT_SNIPPET_CHARS: usize = 1200;
const SECTIONS: [&str; 6] = [
"snippet",
"callers",
"callees",
"references",
"invariants",
"members",
];
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct InspectSymbolParams {
pub symbol: String,
pub file_path: Option<String>,
pub include: Option<Vec<String>>,
pub snippet_lines: Option<usize>,
pub limit: Option<usize>,
pub project: Option<String>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LineRange {
pub file_path: String,
pub start_line: u32,
pub end_line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InvariantSummary {
pub adr_number: Option<u32>,
pub title: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct InspectSymbolResult {
pub symbol: String,
pub found: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ambiguous: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidates: Vec<SymbolCandidate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub candidate_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub qualified_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub docstring: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub docstring_truncated: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_external: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub crate_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub snippet_truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet_remaining: Option<LineRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caller_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callees: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callee_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invariants: Option<Vec<InvariantSummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub members: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_count: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub see_also: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
pub async fn handle_inspect_symbol(
context: &ProjectContextResolver,
engine: &EngineService,
params: InspectSymbolParams,
) -> anyhow::Result<InspectSymbolResult> {
let file_arg = params.file_path.as_deref().map(Path::new);
let (project, rel_hint) = context
.resolve_project(file_arg, params.project.as_deref())
.await?;
let root = PathBuf::from(&project.root_path);
let mut out = InspectSymbolResult {
symbol: params.symbol.clone(),
..Default::default()
};
let wants = |section: &str| {
params
.include
.as_ref()
.is_none_or(|inc| inc.iter().any(|s| s.eq_ignore_ascii_case(section)))
};
for s in params.include.iter().flatten() {
if !SECTIONS.iter().any(|known| known.eq_ignore_ascii_case(s)) {
out.notes.push(format!(
"unknown include section '{s}' (valid: {})",
SECTIONS.join(", ")
));
}
}
let mut guard = engine.db().lock().await;
let mut candidates = lookup_symbol_candidates(&mut guard, &project, ¶ms.symbol).await?;
if let Some(fp) = ¶ms.file_path {
let hint = rel_hint
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| fp.trim_start_matches("./").to_string());
match narrow_by_file(&candidates, &[&hint, fp]) {
Some(hinted) => candidates = hinted,
None => out.notes.push(format!(
"file_path '{fp}' matched no definition of '{}'; ignoring it",
params.symbol
)),
}
}
if candidates.is_empty() {
out.see_also.push(format!(
"search_symbols(query: \"{}\") to find symbols by concept or partial name",
params.symbol
));
return Ok(out);
}
if candidates.len() > 1 {
out.found = true;
out.ambiguous = true;
out.candidate_count = Some(candidates.len());
out.candidates = candidates
.iter()
.take(MAX_CANDIDATES)
.map(ResolvedSymbol::candidate)
.collect();
out.see_also
.push("pass file_path or a qualified name to pick one definition".to_string());
return Ok(out);
}
let target = candidates.remove(0);
let sym = &target.symbol;
let external = target.is_external();
let is_type = TYPE_KINDS.contains(&sym.kind.as_str());
let display_path = target.display_path();
out.found = true;
out.qualified_name = Some(sym.qualified_name.clone());
out.kind = Some(sym.kind.clone());
out.file_path = Some(display_path.clone());
out.start_line = Some(sym.start_line);
out.end_line = Some(sym.end_line);
out.signature = Some(sym.signature.clone());
if let Some(doc) = &sym.docstring {
let (cut, truncated) = cut_docstring(doc);
out.docstring = Some(cut);
out.docstring_truncated = truncated;
}
out.is_external = external;
if let Some(pkg) = &target.package {
out.crate_name = Some(pkg.name.clone());
out.version = Some(pkg.version.clone());
}
if wants("snippet") {
let char_budget = (params.snippet_lines.is_none()
&& params.max_tokens.is_none()
&& !params.no_truncate.unwrap_or(false))
.then_some(DEFAULT_SNIPPET_CHARS);
fill_snippet(&mut out, &target, params.snippet_lines, char_budget);
}
let limit = params.limit.unwrap_or(DEFAULT_LIMIT);
if !external && !is_type {
for (direction, section) in [
(EdgeDirection::Inbound, "callers"),
(EdgeDirection::Outbound, "callees"),
] {
if !wants(section) {
continue;
}
let neighbours = neighbours(&mut guard, sym, direction).await?;
let count = neighbours.len();
if count > limit {
let dir = if direction == EdgeDirection::Inbound {
"inbound"
} else {
"outbound"
};
out.see_also.push(format!(
"trace_call_hierarchy(symbol: \"{}\", direction: \"{dir}\") for all {count} {section}",
sym.name
));
}
let shown: Vec<String> = neighbours.into_iter().take(limit).collect();
if direction == EdgeDirection::Inbound {
out.callers = Some(shown);
out.caller_count = Some(count);
} else {
out.callees = Some(shown);
out.callee_count = Some(count);
}
}
}
if wants("references") {
let count = count_references(&mut guard, sym).await?;
out.reference_count = Some(count);
if count > 0 {
let scope = if external {
", scope_level: \"dependencies\""
} else {
""
};
out.see_also.push(format!(
"find_references(symbol: \"{}\"{scope}) for the {count} reference locations",
if external {
&sym.qualified_name
} else {
&sym.name
}
));
}
}
drop(guard);
if external && (wants("callers") || wants("callees") || wants("invariants")) && !is_type {
out.notes.push(
"dependency symbol: the workspace call graph and ADRs don't cover it, so callers, callees and invariants are omitted".to_string(),
);
}
if is_type && wants("members") {
let contract = engine
.get_type_contract(project.id, &root, &sym.qualified_name, None, None)
.await;
match contract {
Ok(c) if c.found => {
let count = c.members.len();
if count > MAX_MEMBERS {
out.see_also.push(format!(
"get_type_contract(type_name: \"{}\") for all {count} members",
if external {
&sym.qualified_name
} else {
&sym.name
}
));
}
let mut members = c.members;
if external && sym.kind != "trait" {
rank_public_api_first(&mut members);
}
out.members = Some(members.into_iter().take(MAX_MEMBERS).collect());
out.member_count = Some(count);
}
Ok(_) => out.notes.push("type members unavailable".to_string()),
Err(e) => out.notes.push(format!("type members unavailable: {e}")),
}
}
if !external && wants("invariants") {
let ranking = rank_invariants_for_symbol(
context,
engine,
InvariantTarget {
symbol: Some(sym.name.clone()),
file_path: target
.abs_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
project: Some(project.id.to_string()),
},
)
.await?;
let total = ranking.ranked.len();
if total > MAX_INVARIANTS {
out.see_also.push(format!(
"query_adrs for the full text of all {total} related decisions"
));
}
out.invariants = Some(
ranking
.ranked
.iter()
.take(MAX_INVARIANTS)
.map(|r| {
let adr = &ranking.corpus.entries[r.idx].adr;
InvariantSummary {
adr_number: adr.adr_number,
title: adr.title.clone(),
reason: r.match_reasons.first().cloned().unwrap_or_default(),
}
})
.collect(),
);
}
Ok(out)
}
pub fn fit_snippet_to_budget(
out: &mut InspectSymbolResult,
max_tokens: Option<usize>,
no_truncate: bool,
count_tokens: impl Fn(&str) -> usize,
) {
let budget = max_tokens.unwrap_or(crate::telemetry::MAX_PAYLOAD_TOKENS);
if no_truncate || budget == 0 {
return;
}
let (Some(snippet), Some(path), Some(start), Some(end)) = (
out.snippet.clone(),
out.file_path.clone(),
out.start_line,
out.end_line,
) else {
return;
};
let fits = |r: &InspectSymbolResult| {
serde_json::to_string_pretty(r).is_ok_and(|json| count_tokens(&json) <= budget)
};
if fits(out) {
return;
}
let lines: Vec<&str> = snippet.lines().collect();
let with_lines = |n: usize| {
let mut r = out.clone();
r.snippet = Some(lines[..n].join("\n"));
let shown_end = start + n as u32 - 1;
r.see_also.retain(|s| !s.starts_with("rest of body"));
r.see_also.push(format!(
"rest of body: lines {}-{end} in {path}",
shown_end + 1
));
r.snippet_truncated = true;
r.snippet_remaining = Some(LineRange {
file_path: path.clone(),
start_line: shown_end + 1,
end_line: end,
});
r.notes.push(
"snippet shortened to fit the token budget; raise max_tokens or pass no_truncate for more"
.to_string(),
);
r
};
let (mut lo, mut hi) = (1, lines.len());
while lo < hi {
let mid = (lo + hi).div_ceil(2);
if fits(&with_lines(mid)) {
lo = mid;
} else {
hi = mid - 1;
}
}
*out = with_lines(lo);
}
const MARKER_TRAITS: &[&str] = &["Send", "Sync", "Unpin", "UnwindSafe", "RefUnwindSafe"];
pub(crate) fn rank_public_api_first(members: &mut [String]) {
fn is_pub(decl: &str) -> bool {
decl.starts_with("pub ")
}
members.sort_by_key(|m| {
let (kind, decl) = m.split_once(": ").unwrap_or(("", m));
match kind {
"method" if is_pub(decl) => 0,
"variant" => 1,
"field" | "type" | "const" if is_pub(decl) => 1,
"impl" if !MARKER_TRAITS.contains(&decl.rsplit("::").next().unwrap_or(decl)) => 2,
_ => 3,
}
});
}
pub(crate) fn cut_docstring(doc: &str) -> (String, bool) {
let doc = doc.trim();
let mut lines: Vec<&str> = doc.lines().collect();
let mut truncated = lines.len() > DOC_LINES;
lines.truncate(DOC_LINES);
let mut text = lines.join("\n");
if text.chars().count() > DOC_CHARS {
text = text.chars().take(DOC_CHARS).collect();
truncated = true;
}
(text, truncated)
}
fn fill_snippet(
out: &mut InspectSymbolResult,
target: &ResolvedSymbol,
snippet_lines: Option<usize>,
char_budget: Option<usize>,
) {
let sym = &target.symbol;
let Some(abs) = &target.abs_path else {
out.notes.push(
"snippet unavailable: the defining file is not recorded in the index".to_string(),
);
return;
};
let bytes = match fs::read(abs) {
Ok(b) => b,
Err(e) => {
out.notes.push(format!(
"snippet unavailable: cannot read {}: {e}",
abs.display()
));
return;
}
};
if target
.content_hash
.as_deref()
.is_some_and(|h| h != hash_bytes(&bytes))
{
out.notes.push(
"file changed since index: line numbers and snippet may be off; run index_workspace"
.to_string(),
);
}
let source = String::from_utf8_lossy(&bytes);
let max_lines = snippet_lines.unwrap_or(DEFAULT_SNIPPET_LINES).max(1);
let start = sym.start_line.max(1);
let end = sym.end_line.max(start);
let max_lines = u32::try_from(max_lines).unwrap_or(u32::MAX);
let shown_end = end.min(start.saturating_add(max_lines - 1));
let mut long_lines = false;
let mut used_chars = 0usize;
let mut lines: Vec<String> = Vec::new();
for l in source
.lines()
.skip(start as usize - 1)
.take((shown_end - start + 1) as usize)
{
let line = if l.chars().count() > SNIPPET_LINE_CHARS {
long_lines = true;
let mut cut: String = l.chars().take(SNIPPET_LINE_CHARS).collect();
cut.push('…');
cut
} else {
l.to_string()
};
used_chars += line.chars().count() + 1;
if !lines.is_empty() && char_budget.is_some_and(|b| used_chars > b) {
break;
}
lines.push(line);
}
let shown_end = start + lines.len().saturating_sub(1) as u32;
if lines.is_empty() {
out.notes.push(format!(
"snippet unavailable: lines {start}-{end} are past the end of the file"
));
return;
}
if long_lines {
out.notes.push(format!(
"snippet lines longer than {SNIPPET_LINE_CHARS} characters are cut (marked …)"
));
}
out.snippet = Some(lines.join("\n"));
if shown_end < end {
let path = target.display_path();
out.snippet_truncated = true;
out.see_also.push(format!(
"rest of body: lines {}-{end} in {path}",
shown_end + 1
));
out.snippet_remaining = Some(LineRange {
file_path: path,
start_line: shown_end + 1,
end_line: end,
});
}
}
async fn neighbours(
db: &mut toasty::Db,
sym: &Symbol,
direction: EdgeDirection,
) -> anyhow::Result<Vec<String>> {
let edges = one_hop_edges(
db,
sym.project_id,
sym.id,
direction,
Some(&NEIGHBOUR_EDGE_TYPES),
)
.await?;
let mut ids: Vec<u64> = edges.iter().map(|e| direction.neighbour(e)).collect();
ids.sort_unstable();
ids.dedup();
let mut found: Vec<(String, u32, String)> = Vec::new();
for id in ids {
let Some(n) = Symbol::filter(
Symbol::fields()
.project_id()
.eq(sym.project_id)
.and(Symbol::fields().id().eq(id)),
)
.first()
.exec(&mut *db)
.await?
else {
continue;
};
let path = source_file_path(db, sym.project_id, n.file_id)
.await?
.unwrap_or_else(|| "unknown".to_string());
found.push((path, n.start_line, n.name));
}
found.sort();
Ok(found
.into_iter()
.map(|(path, line, name)| format!("{name} — {path}:{line}"))
.collect())
}
pub fn tool_definitions() -> Vec<Tool> {
vec![make_tool::<InspectSymbolParams>(
"inspect_symbol",
"Use when you know a symbol's name and want to understand it before reading or editing: one call returns its definition (kind, file:lines, signature, docstring), a source snippet, 1-hop callers and callees, a reference count, and related ADR invariants (types get a member summary instead of callers). Replaces grep + Read for \"what is X?\". Works for workspace symbols and indexed Cargo dependencies; ambiguous names return a candidate list.",
read_only(),
)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rank_public_api_first_puts_public_methods_before_private_details() {
let mut members: Vec<String> = [
"field: resource_span: tracing::Span",
"field: s: semaphore::Semaphore",
"impl: std::fmt::Debug",
"impl: Sync",
"impl: std::marker::Send",
"method: pub fn new(t: T) -> Mutex<T>",
"method: fn acquire(&self)",
"method: pub async fn lock(&self) -> MutexGuard<'_, T>",
"field: pub(crate) c: UnsafeCell<T>",
]
.map(String::from)
.to_vec();
rank_public_api_first(&mut members);
assert_eq!(
members,
[
"method: pub fn new(t: T) -> Mutex<T>",
"method: pub async fn lock(&self) -> MutexGuard<'_, T>",
"impl: std::fmt::Debug",
"field: resource_span: tracing::Span",
"field: s: semaphore::Semaphore",
"impl: Sync",
"impl: std::marker::Send",
"method: fn acquire(&self)",
"field: pub(crate) c: UnsafeCell<T>",
]
);
}
#[test]
fn rank_public_api_first_keeps_variants_and_public_fields_ahead_of_impls() {
let mut members: Vec<String> = ["impl: Clone", "variant: Closed", "field: pub len: usize"]
.map(String::from)
.to_vec();
rank_public_api_first(&mut members);
assert_eq!(
members,
["variant: Closed", "field: pub len: usize", "impl: Clone"]
);
}
}