use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use rmcp::model::Tool;
use scryer_db::{DependencyPackage, Scope, SourceFile, Symbol, SymbolReference};
use scryer_engine::{EngineService, find_symbol_for_target, reference_target_at};
use super::admin::{make_tool, read_only};
use super::dependency::{
MAX_CANDIDATES, SymbolCandidate, TYPE_KINDS, crate_root, follow_import, import_path,
imports_symbol, last_segment, lookup_symbol_candidates, narrow_by_file,
};
use super::enums::ReferenceView;
use super::inspect::{cut_docstring, first_sentence};
use super::source_lines::LineCache;
use crate::context::ProjectContextResolver;
// --- Input & Output Types ---
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ResolveDefinitionParams {
/// File containing the reference.
pub file_path: String,
/// 1-based.
pub line: u32,
/// 0-based, on the identifier.
pub col: u32,
/// Custom token budget for this response.
pub max_tokens: Option<usize>,
/// If true, bypasses token budget truncation.
pub no_truncate: Option<bool>,
/// Optional project slug or ID override.
pub project: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ResolvedDefinitionResult {
pub found: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbol_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
/// First 3 lines (400 chars) unless `max_tokens` or `no_truncate` is set.
#[serde(default, 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 = "Option::is_none")]
pub is_external: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crate_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notice: Option<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct FindReferencesParams {
/// Name or qualified path, e.g. tokio::sync::Mutex.
pub symbol: String,
/// Picks one definition when several share the name.
pub file_path: Option<String>,
/// Path substring filter.
pub file_filter: Option<String>,
/// Keep one kind of use.
pub role: Option<super::enums::ReferenceRole>,
/// exact (resolved), name (by name only) or macro.
pub via: Option<super::enums::ReferenceVia>,
/// project (default), workspace_all, file (needs file_path) or dependencies (usages of external crate symbols).
pub scope_level: Option<super::enums::ScopeLevel>,
/// callers, files or sites (see description).
pub view: Option<super::enums::ReferenceView>,
/// Pagination offset.
pub offset: Option<usize>,
/// Maximum items (default 40).
pub limit: Option<usize>,
/// json (default) or text: a header line, then one line per item.
pub format: Option<crate::session::ResponseFormat>,
/// Custom token budget for this response.
pub max_tokens: Option<usize>,
/// If true, bypasses the token budget cap and returns full results.
pub no_truncate: Option<bool>,
/// Optional project slug or ID override.
pub project: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReferenceItem {
pub file_path: String,
pub line_number: u32,
pub role: String,
/// Internal: locates the enclosing symbol. Agents never use byte offsets, so not serialised.
#[serde(skip)]
pub start_byte: usize,
#[serde(skip)]
pub end_byte: usize,
/// How the target was chosen: exact, name (by name only: may be another symbol of the
/// same name) or macro.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<String>,
/// Qualified name of the function, method or type that contains this reference, so a
/// caller list needs no follow-up lookup per site.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enclosing_symbol: Option<String>,
/// The trimmed source line, as `grep -n` would show it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
/// One caller of a symbol: the function that contains its usages.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CallerItem {
/// Qualified name of the enclosing function, method or type.
pub symbol: String,
/// File of its first usage.
pub file_path: String,
/// Line of its first usage.
pub line_number: u32,
/// Usages inside this caller.
pub count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
/// A file that uses a symbol, with how often.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FileUsage {
pub file_path: String,
pub count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FindReferencesResult {
/// Which list is filled: `references` (sites), `callers` or `files`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view: Option<super::enums::ReferenceView>,
/// Set when the list was cut to fit the budget: how to get the rest.
#[serde(flatten, default)]
pub next: crate::tools::budget::Continuation,
pub symbol: String,
pub total_references: usize,
pub returned_references: usize,
pub offset: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
pub has_more: bool,
/// Usage sites in files (the whole answer, not just this page).
#[serde(default, skip_serializing_if = "crate::tools::budget::is_zero")]
pub total_files: usize,
/// Every usage is in a Rust file (the language the recall gate covers). Internal.
#[serde(skip)]
pub all_rust: bool,
/// Usages matched by name only (via name or macro), which may belong to another symbol of
/// the same name.
#[serde(default, skip_serializing_if = "crate::tools::budget::is_zero")]
pub name_matched: usize,
/// Distinct callers, when the view computed them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_callers: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<ReferenceItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub callers: Vec<CallerItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FileUsage>,
/// Set when several definitions share the name and have usages: the answer is the list of
/// definitions, not a merged list of their usages.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ambiguous: bool,
#[serde(default, skip_serializing_if = "crate::tools::budget::is_zero")]
pub definition_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub definitions: Vec<SymbolCandidate>,
/// Why the result is empty, when it is.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetEnclosingScopeParams {
/// Source file, with line or lines.
#[serde(default)]
pub file_path: String,
/// 1-based line.
pub line: Option<u32>,
/// Up to 25 lines.
pub lines: Option<Vec<u32>>,
/// Up to 25 `path:line` entries across files (grep -n hits).
pub at: Option<Vec<String>>,
/// Custom token budget for this response.
pub max_tokens: Option<usize>,
/// If true, bypasses token budget truncation.
pub no_truncate: Option<bool>,
/// Optional project slug or ID override.
pub project: Option<String>,
}
/// The innermost lexical scope (block, closure, function body, ...) at a line.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LexicalScope {
pub scope_kind: String,
/// Names an unnamed block or closure by its parent, e.g. `crate::run::closure@L12`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub start_line: u32,
pub end_line: u32,
}
/// One requested line of a batch, with its source text.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ScopeLine {
pub line: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// The unnamed block or closure the line is in, named by its parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block: Option<String>,
}
/// The requested lines of a batch that one symbol contains.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ScopeGroup {
/// The file, in a batch across files (`at`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
/// Absent for lines no symbol contains.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qualified_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
/// First sentence of the doc comment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
pub lines: Vec<ScopeLine>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct EnclosingScopeResult {
pub found: bool,
/// Empty (omitted) for a batch across files: each group names its file.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub file_path: String,
/// Name of the smallest named symbol (function, method, type, module, ...) containing the line.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[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 signature: Option<String>,
/// Line span of the enclosing named symbol.
#[serde(skip_serializing_if = "Option::is_none")]
pub start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
/// The innermost lexical scope at the line, which may be an unnamed block or closure
/// inside the named symbol.
#[serde(skip_serializing_if = "Option::is_none")]
pub innermost_scope: Option<LexicalScope>,
/// First sentence of the doc comment of the enclosing symbol.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
/// The enclosing symbol's code: about 40 lines around the requested line.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
/// Line of the symbol's file where `snippet` starts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet_start_line: Option<u32>,
/// The snippet was shortened to fit the budget.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub snippet_truncated: bool,
/// Batch answer (`lines`): one group per containing symbol.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<ScopeGroup>,
/// Why nothing was found, when nothing was.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetFileOutlineParams {
/// Source file.
pub file_path: String,
/// Substring filter on symbol names.
pub query: Option<String>,
/// Kinds to keep: fn, struct, enum, trait, type, mod, const, reexport (Rust); class, method, interface (Python/TypeScript). Aliases like function and class work.
pub kinds: Option<Vec<String>>,
/// First line of the region.
pub start_line: Option<u32>,
/// Last line of the region.
pub end_line: Option<u32>,
/// Pagination offset.
pub offset: Option<usize>,
/// Maximum symbols.
pub limit: Option<usize>,
/// Omit doc summaries.
pub compact: Option<bool>,
/// json (default) or text: a header line, then one line per item.
pub format: Option<crate::session::ResponseFormat>,
/// Custom token budget for this response.
pub max_tokens: Option<usize>,
/// If true, bypasses the token budget cap and returns full outline.
pub no_truncate: Option<bool>,
/// Optional project slug or ID override.
pub project: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct OutlineSymbol {
/// Empty (and omitted) when the outline shed it: it is the last segment of `qualified_name`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
pub qualified_name: String,
pub kind: String,
/// Empty (and omitted) when the outline shed it: the signature carries `pub`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub visibility: String,
pub signature: String,
pub start_line: u32,
pub end_line: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docstring: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FileOutlineResult {
/// Set when the list was cut to fit the budget: how to get the rest.
#[serde(flatten, default)]
pub next: crate::tools::budget::Continuation,
pub file_path: String,
pub total_symbols: usize,
pub returned_symbols: usize,
pub offset: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
pub has_more: bool,
pub symbols: Vec<OutlineSymbol>,
/// Detail dropped to fit the budget (doc summaries, parameter lists, private items); every
/// public declaration is still listed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shed: Vec<String>,
/// Hints when filters matched nothing (e.g. the kinds present in the file).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GetTypeContractParams {
/// Struct, enum, trait or alias name.
pub type_name: String,
/// Picks one of several same-named types.
pub file_path: Option<String>,
/// Substring filter on fields, variants or methods.
pub member_query: Option<String>,
/// Custom token budget for this response.
pub max_tokens: Option<usize>,
/// If true, bypasses token budget truncation.
pub no_truncate: Option<bool>,
/// Optional project slug or ID override.
pub project: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TypeContractResult {
pub found: bool,
pub type_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
/// Listed before the docstring so a long docstring can't push members past the budget.
pub members: Vec<String>,
/// First 3 lines (400 chars) unless `max_tokens` or `no_truncate` is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docstring: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub docstring_truncated: bool,
/// Why nothing was found, when nothing was.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
// --- Handlers ---
pub async fn handle_resolve_definition(
context: &ProjectContextResolver,
engine: &EngineService,
params: ResolveDefinitionParams,
) -> anyhow::Result<ResolvedDefinitionResult> {
let full_doc = params.max_tokens.is_some() || params.no_truncate.unwrap_or(false);
let mut res = resolve_definition_full(context, engine, params).await?;
(res.docstring, res.docstring_truncated) = response_docstring(res.docstring, full_doc);
Ok(res)
}
/// Docstring for a response: dependency docstrings can run to ~1,000 tokens on one line,
/// which the line-based budget would cut together with every field after it, so keep the
/// first 3 lines unless the caller lifted the budget (`full`).
fn response_docstring(doc: Option<String>, full: bool) -> (Option<String>, bool) {
match doc {
Some(doc) if !full => {
let (cut, truncated) = cut_docstring(&doc);
(Some(cut), truncated)
}
other => (other, false),
}
}
async fn resolve_definition_full(
context: &ProjectContextResolver,
engine: &EngineService,
params: ResolveDefinitionParams,
) -> anyhow::Result<ResolvedDefinitionResult> {
anyhow::ensure!(
params.line >= 1,
"`line` is 1-based (the first line is 1); `col` is 0-based"
);
let path = PathBuf::from(¶ms.file_path);
let (project, rel_path) = context
.resolve_project(Some(&path), params.project.as_deref())
.await?;
// A path no project contains is only usable if it is relative to the project root and
// stays inside it; never read an arbitrary absolute or `..` path.
let rel = match rel_path {
Some(rel) => rel,
None => {
if path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
anyhow::bail!(
"file_path '{}' is outside project '{}'",
params.file_path,
project.slug
);
}
path
}
};
let root = Path::new(&project.root_path);
// Name the symbol under the cursor; nothing to resolve if it isn't an identifier.
let content = std::fs::read_to_string(root.join(&rel)).unwrap_or_default();
let Some(target) = reference_target_at(&rel, &content, params.line, params.col) else {
let reason = if content.is_empty() {
format!(
"could not read '{}' in project '{}'; check file_path",
rel.display(),
project.slug
)
} else {
format!(
"no identifier at line {}, col {} (line is 1-based, col is 0-based); point at the name of the reference, e.g. use get_file_outline or find_references to locate it",
params.line, params.col
)
};
return Ok(ResolvedDefinitionResult::not_found_because(reason));
};
let mut guard = engine.db().lock().await;
let rel_str = rel.to_string_lossy().to_string();
let current_file = SourceFile::filter(
SourceFile::fields()
.project_id()
.eq(project.id)
.and(SourceFile::fields().path().eq(rel_str)),
)
.first()
.exec(&mut *guard)
.await?;
// 1. The reference stored at the cursor, when indexing resolved it to one symbol. That
// beats a name lookup: it knows which of several same-named symbols is meant.
let current_file_id = current_file.as_ref().map(|f| f.id);
let stored = match (
current_file_id,
byte_offset(&content, params.line, params.col),
) {
(Some(file_id), Some(offset)) => stored_target_at(&mut guard, file_id, offset).await?,
_ => None,
};
// 2. Otherwise match the target name against project symbols, preferring definitions in
// the current file.
let symbol = match stored {
Some(symbol) => Some(symbol),
None => find_symbol_for_target(&mut *guard, project.id, &target, current_file_id).await?,
};
let Some(mut sym) = symbol else {
return Ok(ResolvedDefinitionResult::not_found_because(format!(
"'{target}' is an identifier but matches no indexed definition; try search_symbols(query: \"{target}\"), or index_workspace if the code is new"
)));
};
// A file's `use` stub shares the imported item's name and wins the name lookup; jump
// through it to the definition it imports.
if sym.kind == "reexport"
&& let Some(def) = follow_import(&mut guard, &project, &sym).await?
{
sym = def;
}
let file = SourceFile::filter(
SourceFile::fields()
.project_id()
.eq(sym.project_id)
.and(SourceFile::fields().id().eq(sym.file_id)),
)
.first()
.exec(&mut *guard)
.await?;
let is_external = sym.project_id == 0;
// Dependency files are stored relative to their package root, so report the absolute
// path into the Cargo cache and the package's crate name.
let package = match sym.dependency_package_id {
Some(pkg_id) if is_external => {
DependencyPackage::filter(DependencyPackage::fields().id().eq(pkg_id))
.first()
.exec(&mut *guard)
.await?
}
_ => None,
};
let crate_name = package.as_ref().map(|p| p.name.clone());
let res_file_path = file.map(|f| match &package {
Some(pkg) => Path::new(&pkg.root_path)
.join(&f.path)
.to_string_lossy()
.into_owned(),
None => f.path,
});
let notice = if is_external
&& res_file_path
.as_ref()
.map(|p| !Path::new(p).exists())
.unwrap_or(true)
{
Some("Source file not found on disk; signature returned from index.".to_string())
} else {
None
};
Ok(ResolvedDefinitionResult {
found: true,
symbol_name: Some(sym.name),
file_path: res_file_path,
start_line: Some(sym.start_line),
end_line: Some(sym.end_line),
signature: Some(sym.signature),
docstring: sym.docstring,
docstring_truncated: false,
is_external: Some(is_external),
crate_name,
notice,
})
}
impl ResolvedDefinitionResult {
fn not_found_because(reason: String) -> Self {
Self {
notice: Some(reason),
..Self::not_found()
}
}
fn not_found() -> Self {
Self {
found: false,
symbol_name: None,
file_path: None,
start_line: None,
end_line: None,
signature: None,
docstring: None,
docstring_truncated: false,
is_external: None,
crate_name: None,
notice: None,
}
}
}
/// Byte offset of `(line, col)` (1-based line, 0-based byte column) in `content`.
fn byte_offset(content: &str, line: u32, col: u32) -> Option<usize> {
let start: usize = content
.split_inclusive('\n')
.take(line.checked_sub(1)? as usize)
.map(str::len)
.sum();
let offset = start + col as usize;
(offset <= content.len()).then_some(offset)
}
/// The symbol that indexing resolved the reference covering `offset` in file `file_id` to,
/// when it did so exactly (`via = exact`); the narrowest reference wins.
async fn stored_target_at(
db: &mut toasty::Db,
file_id: u64,
offset: usize,
) -> anyhow::Result<Option<Symbol>> {
// By file only: the engine scans when a lookup combines two columns.
let hit = SymbolReference::filter(SymbolReference::fields().file_id().eq(file_id))
.exec(&mut *db)
.await?
.into_iter()
.filter(|r| r.via == "exact" && r.start_byte <= offset && offset <= r.end_byte)
.min_by_key(|r| r.end_byte - r.start_byte);
let Some(reference) = hit else {
return Ok(None);
};
Ok(
Symbol::filter(Symbol::fields().id().eq(reference.symbol_id))
.first()
.exec(&mut *db)
.await?,
)
}
pub async fn handle_find_references(
context: &ProjectContextResolver,
engine: &EngineService,
params: FindReferencesParams,
) -> anyhow::Result<FindReferencesResult> {
use super::enums::ScopeLevel;
let is_workspace_all = params.scope_level == Some(ScopeLevel::WorkspaceAll);
let is_dependencies = params.scope_level == Some(ScopeLevel::Dependencies);
let is_file_scope = params.scope_level == Some(ScopeLevel::File);
anyhow::ensure!(
!is_file_scope || params.file_path.is_some(),
"scope_level 'file' needs `file_path` (the file whose references to list)"
);
// Validate an explicit `project` even for scopes that don't otherwise resolve one.
if params.project.is_some() {
context
.resolve_project(None, params.project.as_deref())
.await?;
}
let mut guard = engine.db().lock().await;
// Where quoted source lines are read from: only the single resolved project has one root.
let mut text_root: Option<PathBuf> = None;
// Resolve matching symbols
let mut symbols = if is_dependencies {
let external_symbols = Symbol::filter(Symbol::fields().project_id().eq(0))
.exec(&mut *guard)
.await?;
let ws_reexports = Symbol::filter(Symbol::fields().kind().eq("reexport"))
.exec(&mut *guard)
.await?;
let query = params.symbol.as_str();
let matches_query = |s: &Symbol| {
s.name == query
|| s.qualified_name == query
|| s.qualified_name.ends_with(&format!("::{query}"))
};
let mut definitions: Vec<Symbol> = external_symbols
.iter()
.filter(|s| matches_query(s))
.cloned()
.collect();
// Public paths often differ from definition paths (`tokio::sync::Mutex` is defined at
// `tokio::sync::mutex::Mutex`), so within a named crate fall back to the bare name.
if definitions.is_empty() && query.contains("::") {
let name = last_segment(query);
definitions = external_symbols
.iter()
.filter(|s| {
s.kind != "reexport"
&& s.name == name
&& crate_root(&s.qualified_name) == crate_root(query)
})
.cloned()
.collect();
}
// Workspace code references the `use` stubs that import a dependency item, not the
// item itself, so include the stubs that import a matched definition.
let mut matched = definitions.clone();
matched.extend(ws_reexports.into_iter().filter(|s| {
matches_query(s)
|| import_path(s).is_some_and(|p| p == query || p.ends_with(&format!("::{query}")))
|| definitions
.iter()
.any(|d| d.kind != "reexport" && imports_symbol(s, d))
}));
let mut seen = HashSet::new();
matched.retain(|s| seen.insert(s.id));
matched
} else if is_workspace_all {
let all = Symbol::all().exec(&mut *guard).await?;
all.into_iter()
.filter(|s| matches_symbol_query(s, ¶ms.symbol))
.collect::<Vec<_>>()
} else {
let file_path = params.file_path.as_deref().map(Path::new);
let (project, _) = context
.resolve_project(file_path, params.project.as_deref())
.await?;
text_root = Some(PathBuf::from(&project.root_path));
let proj_symbols = Symbol::filter(Symbol::fields().project_id().eq(project.id))
.exec(&mut *guard)
.await?;
proj_symbols
.into_iter()
.filter(|s| matches_symbol_query(s, ¶ms.symbol))
.collect::<Vec<_>>()
};
let mut notes = Vec::new();
let mut files: HashMap<(u64, u64), (String, String)> = HashMap::new();
// `file_path` picks one definition when several share the name.
if let Some(fp) = ¶ms.file_path
&& !is_file_scope
&& !is_dependencies
&& symbols.len() > 1
{
let hint = fp.trim_start_matches("./");
let mut keep = Vec::new();
for s in &symbols {
let (path, _) = file_info(&mut guard, &mut files, s.project_id, s.file_id).await?;
if path == hint
|| hint.ends_with(&format!("/{path}"))
|| path.ends_with(&format!("/{hint}"))
{
keep.push(s.clone());
}
}
if keep.is_empty() {
notes.push(format!(
"file_path '{fp}' matched no definition of '{}'; ignoring it",
params.symbol
));
} else {
symbols = keep;
}
}
let mut references = Vec::new();
// (project_id, file_id) of each reference, parallel to `references`.
let mut origins: Vec<(u64, u64)> = Vec::new();
// Index into `symbols` of the definition each reference belongs to.
let mut def_of: Vec<usize> = Vec::new();
let symbol_found = !symbols.is_empty();
for (si, sym) in symbols.iter().enumerate() {
let refs = if is_dependencies || is_workspace_all {
SymbolReference::filter(SymbolReference::fields().symbol_id().eq(sym.id))
.exec(&mut *guard)
.await?
} else {
SymbolReference::filter(
SymbolReference::fields()
.project_id()
.eq(sym.project_id)
.and(SymbolReference::fields().symbol_id().eq(sym.id)),
)
.exec(&mut *guard)
.await?
};
for r in refs {
let (file_path, _) = file_info(&mut guard, &mut files, r.project_id, r.file_id).await?;
if is_file_scope
&& let Some(scope_fp) = ¶ms.file_path
&& !file_path.ends_with(scope_fp)
&& file_path != *scope_fp
{
continue;
}
if let Some(ff) = ¶ms.file_filter
&& !file_path.to_lowercase().contains(&ff.to_lowercase())
{
continue;
}
if let Some(rf) = params.role
&& !r.role.eq_ignore_ascii_case(rf.as_str())
{
continue;
}
if let Some(via) = params.via
&& !r.via.eq_ignore_ascii_case(via.as_str())
{
continue;
}
references.push(ReferenceItem {
file_path,
line_number: r.line_number,
role: r.role,
start_byte: r.start_byte,
end_byte: r.end_byte,
via: Some(r.via),
enclosing_symbol: None,
text: None,
});
origins.push((r.project_id, r.file_id));
def_of.push(si);
}
if is_dependencies {
let reexport_edges = scryer_db::CodeGraphEdge::filter(
scryer_db::CodeGraphEdge::fields()
.target_symbol_id()
.eq(sym.id)
.and(
scryer_db::CodeGraphEdge::fields()
.edge_type()
.eq("reexports"),
),
)
.exec(&mut *guard)
.await?;
for edge in reexport_edges {
let source_sym = Symbol::filter(Symbol::fields().id().eq(edge.source_symbol_id))
.first()
.exec(&mut *guard)
.await?;
if let Some(src_s) = source_sym {
let (file_path, _) =
file_info(&mut guard, &mut files, src_s.project_id, src_s.file_id).await?;
if let Some(ff) = ¶ms.file_filter
&& !file_path.to_lowercase().contains(&ff.to_lowercase())
{
continue;
}
if let Some(rf) = params.role
&& rf != super::enums::ReferenceRole::Reexport
{
continue;
}
// A `pub use` names its target by path.
if params
.via
.is_some_and(|v| v != super::enums::ReferenceVia::Exact)
{
continue;
}
references.push(ReferenceItem {
file_path,
line_number: src_s.start_line,
role: "reexport".to_string(),
start_byte: src_s.start_byte,
end_byte: src_s.end_byte,
via: Some("exact".to_string()),
enclosing_symbol: None,
text: None,
});
origins.push((src_s.project_id, src_s.file_id));
def_of.push(si);
}
}
}
}
let total_references = references.len();
let all_rust =
!references.is_empty() && references.iter().all(|r| r.file_path.ends_with(".rs"));
let name_matched = references
.iter()
.filter(|r| r.via.as_deref().is_some_and(|v| v != "exact"))
.count();
let total_files = references
.iter()
.map(|r| r.file_path.as_str())
.collect::<HashSet<_>>()
.len();
let offset = params.offset.unwrap_or(0);
// Several definitions share the name and are used: counting their usages together answers
// no question, so say which definitions there are and let the caller pick one.
let mut def_counts = vec![0usize; symbols.len()];
for &d in &def_of {
def_counts[d] += 1;
}
let used_defs: Vec<usize> = (0..symbols.len())
.filter(|&i| def_counts[i] > 0 && symbols[i].kind != "reexport")
.collect();
let mut ambiguity: Option<(usize, Vec<SymbolCandidate>)> = None;
if !is_dependencies && used_defs.len() > 1 {
let mut definitions = Vec::new();
for i in used_defs {
let s = &symbols[i];
let (path, _) = file_info(&mut guard, &mut files, s.project_id, s.file_id).await?;
definitions.push(SymbolCandidate {
qualified_name: s.qualified_name.clone(),
kind: s.kind.clone(),
location: format!("{path}:{}", s.start_line),
usages: Some(def_counts[i]),
});
}
definitions.sort_by(|a, b| {
b.usages
.cmp(&a.usages)
.then_with(|| a.location.cmp(&b.location))
});
let definition_count = definitions.len();
definitions.truncate(MAX_CANDIDATES);
let first = definitions
.first()
.map_or("", |d| d.qualified_name.as_str());
// The files view is the union by design ("which files use any `new`"); the other
// views would mix unrelated usages, so they stop at the list of definitions.
if params.view == Some(ReferenceView::Files) {
notes.push(format!(
"{definition_count} definitions named '{}' are used; these files are the union. Pass file_path or a qualified name such as '{first}' for one definition's files",
params.symbol
));
ambiguity = Some((definition_count, definitions));
} else {
notes.push(format!(
"{definition_count} definitions named '{}' are used. Pass file_path (the definition's file) or a qualified name such as '{first}' to list one definition's usages",
params.symbol
));
return Ok(FindReferencesResult {
next: Default::default(),
view: None,
symbol: params.symbol,
total_references,
returned_references: 0,
offset,
limit: params.limit,
has_more: false,
total_files,
name_matched,
all_rust,
total_callers: None,
references: Vec::new(),
callers: Vec::new(),
files: Vec::new(),
ambiguous: true,
definition_count,
definitions,
notes,
});
}
}
// Default shape from the question where it is unambiguous: who calls a function, which
// files import something.
let real_kinds: Vec<&str> = symbols
.iter()
.filter(|s| s.kind != "reexport")
.map(|s| s.kind.as_str())
.collect();
let function_target = !real_kinds.is_empty()
&& real_kinds
.iter()
.all(|k| matches!(*k, "fn" | "method" | "function"));
let view = params.view.unwrap_or({
if is_file_scope {
ReferenceView::Sites
} else if params.role == Some(super::enums::ReferenceRole::Import) {
ReferenceView::Files
} else if function_target {
ReferenceView::Callers
} else {
ReferenceView::Sites
}
});
let limit = params.limit.unwrap_or(match view {
ReferenceView::Files => 100,
_ => 40,
});
let mut lines = LineCache::default();
let mut file_symbols: HashMap<(u64, u64), Vec<Symbol>> = HashMap::new();
let mut quote = |item: &mut ReferenceItem, key: (u64, u64)| {
if let (Some(root), Some((_, hash))) = (&text_root, files.get(&key)) {
item.text = lines.line(root, &item.file_path, hash, item.line_number);
}
};
let mut callers = Vec::new();
let mut file_usage = Vec::new();
let mut sites = Vec::new();
let mut total_callers = None;
let page_total;
match view {
ReferenceView::Sites => {
page_total = total_references;
let end = (offset + limit).min(total_references);
let start = offset.min(end);
sites = references[start..end].to_vec();
fill_enclosing(
&mut guard,
&mut sites,
&origins[start..end],
&mut file_symbols,
)
.await?;
for (i, item) in sites.iter_mut().enumerate() {
quote(item, origins[start + i]);
}
}
ReferenceView::Callers => {
fill_enclosing(&mut guard, &mut references, &origins, &mut file_symbols).await?;
// caller -> (first site index, usages); the first site is the earliest by file and line.
let mut groups: HashMap<String, (usize, usize)> = HashMap::new();
for (i, r) in references.iter().enumerate() {
let key = if r.role == "reexport" {
"(reexport)".to_string()
} else {
r.enclosing_symbol
.clone()
.unwrap_or_else(|| "(top level)".to_string())
};
let entry = groups.entry(key).or_insert((i, 0));
entry.1 += 1;
let (first, cur) = (&references[entry.0], r);
if (&cur.file_path, cur.line_number) < (&first.file_path, first.line_number) {
entry.0 = i;
}
}
let mut ordered: Vec<(String, usize, usize)> =
groups.into_iter().map(|(k, (i, n))| (k, i, n)).collect();
ordered.sort_by(|a, b| {
let (x, y) = (&references[a.1], &references[b.1]);
(&x.file_path, x.line_number, &a.0).cmp(&(&y.file_path, y.line_number, &b.0))
});
total_callers = Some(ordered.len());
page_total = ordered.len();
let end = (offset + limit).min(ordered.len());
for (symbol, first, count) in &ordered[offset.min(end)..end] {
let mut site = references[*first].clone();
quote(&mut site, origins[*first]);
callers.push(CallerItem {
symbol: symbol.clone(),
file_path: site.file_path,
line_number: site.line_number,
count: *count,
text: site.text,
});
}
}
ReferenceView::Files => {
let mut counts: HashMap<&str, usize> = HashMap::new();
for r in &references {
*counts.entry(r.file_path.as_str()).or_default() += 1;
}
let mut ordered: Vec<(&str, usize)> = counts.into_iter().collect();
ordered.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
page_total = ordered.len();
let end = (offset + limit).min(ordered.len());
file_usage = ordered[offset.min(end)..end]
.iter()
.map(|(path, count)| FileUsage {
file_path: (*path).to_string(),
count: *count,
})
.collect();
}
}
let returned_references = match view {
ReferenceView::Sites => sites.len(),
ReferenceView::Callers => callers.len(),
ReferenceView::Files => file_usage.len(),
};
let has_more = offset + returned_references < page_total;
if lines.stale_files() > 0 {
notes.push(format!(
"{} file(s) changed since indexing, so their lines are not quoted; run index_workspace",
lines.stale_files()
));
}
if !symbol_found {
notes.push(format!(
"no indexed symbol named '{0}'; this is not the same as \"no usages\". Try search_symbols(query: \"{0}\"), or index_workspace if the code is new",
params.symbol
));
} else if total_references == 0 {
notes.push(match params.role {
Some(role) => format!(
"'{}' is indexed but has no references with role '{}'; drop `role` to see every kind",
params.symbol,
role.as_str()
),
None => format!("'{}' is indexed but has no recorded references", params.symbol),
});
}
let mut next = crate::tools::budget::Continuation::default();
if has_more {
let next_offset = offset + returned_references;
next.complete = Some(false);
next.next_offset = Some(next_offset);
next.see_also.push(format!(
"find_references(same arguments, offset: {next_offset}) for the rest"
));
if view != ReferenceView::Files {
next.see_also
.push("view: \"files\" lists just the files".to_string());
}
}
Ok(FindReferencesResult {
next,
view: Some(view),
symbol: params.symbol,
total_references,
returned_references,
offset,
limit: Some(limit),
has_more,
total_files,
name_matched,
all_rust,
total_callers,
references: sites,
callers,
files: file_usage,
ambiguous: ambiguity.is_some(),
definition_count: ambiguity.as_ref().map_or(0, |(n, _)| *n),
definitions: ambiguity.map(|(_, d)| d).unwrap_or_default(),
notes,
})
}
/// `(path, content hash)` of an indexed file, cached for the call.
async fn file_info(
db: &mut toasty::Db,
cache: &mut HashMap<(u64, u64), (String, String)>,
project_id: u64,
file_id: u64,
) -> anyhow::Result<(String, String)> {
if let Some(known) = cache.get(&(project_id, file_id)) {
return Ok(known.clone());
}
let file = SourceFile::filter(
SourceFile::fields()
.project_id()
.eq(project_id)
.and(SourceFile::fields().id().eq(file_id)),
)
.first()
.exec(db)
.await?;
let info = file
.map(|f| (f.path, f.content_hash))
.unwrap_or_else(|| ("unknown".to_string(), String::new()));
cache.insert((project_id, file_id), info.clone());
Ok(info)
}
/// Name the function each reference sits in. `origins` is parallel to `items`.
async fn fill_enclosing(
db: &mut toasty::Db,
items: &mut [ReferenceItem],
origins: &[(u64, u64)],
file_symbols: &mut HashMap<(u64, u64), Vec<Symbol>>,
) -> anyhow::Result<()> {
for (item, key) in items.iter_mut().zip(origins) {
if item.role == "reexport" {
continue;
}
if let std::collections::hash_map::Entry::Vacant(slot) = file_symbols.entry(*key) {
let syms = Symbol::filter(
Symbol::fields()
.project_id()
.eq(key.0)
.and(Symbol::fields().file_id().eq(key.1)),
)
.exec(db)
.await?;
slot.insert(syms);
}
item.enclosing_symbol = file_symbols[key]
.iter()
.filter(|s| {
s.kind != "reexport"
&& s.start_byte <= item.start_byte
&& item.end_byte <= s.end_byte
})
.min_by_key(|s| s.end_byte - s.start_byte)
.map(|s| s.qualified_name.clone())
// Top-level code (a script's statements) is held by no symbol: name the module.
.or_else(|| scryer_engine::module_name_for(Path::new(&item.file_path)));
}
Ok(())
}
/// Whether `query` names `symbol`: its bare name, its full qualified name, or a trailing part
/// of the qualified name (`Config::new`, `Holder.attribute`), which tells apart the same
/// method name on different types.
pub(crate) fn matches_symbol_query(symbol: &Symbol, query: &str) -> bool {
symbol.name == query
|| symbol.qualified_name == query
|| ((query.contains("::") || query.contains('.'))
&& (symbol.qualified_name.ends_with(&format!("::{query}"))
|| symbol.qualified_name.ends_with(&format!(".{query}"))))
}
/// Number of references to one symbol definition, without materialising reference items.
///
/// Workspace symbols count references inside their own project. Dependency symbols
/// (project 0) count references from every project, workspace re-exports, and references to
/// the workspace `use` stubs that import them, matching `find_references` with
/// `scope_level: "dependencies"`.
pub(crate) async fn count_references(db: &mut toasty::Db, sym: &Symbol) -> anyhow::Result<usize> {
if sym.project_id != 0 {
let refs = SymbolReference::filter(
SymbolReference::fields()
.project_id()
.eq(sym.project_id)
.and(SymbolReference::fields().symbol_id().eq(sym.id)),
)
.exec(&mut *db)
.await?;
return Ok(refs.len());
}
let refs = SymbolReference::filter(SymbolReference::fields().symbol_id().eq(sym.id))
.exec(&mut *db)
.await?;
let reexports = scryer_db::CodeGraphEdge::filter(
scryer_db::CodeGraphEdge::fields()
.target_symbol_id()
.eq(sym.id)
.and(
scryer_db::CodeGraphEdge::fields()
.edge_type()
.eq("reexports"),
),
)
.exec(&mut *db)
.await?;
let stubs = Symbol::filter(
Symbol::fields()
.kind()
.eq("reexport")
.and(Symbol::fields().name().eq(&sym.name)),
)
.exec(&mut *db)
.await?;
let mut stub_refs = 0;
for stub in stubs
.iter()
.filter(|s| s.project_id != 0 && imports_symbol(s, sym))
{
stub_refs += SymbolReference::filter(SymbolReference::fields().symbol_id().eq(stub.id))
.exec(&mut *db)
.await?
.len();
}
Ok(refs.len() + reexports.len() + stub_refs)
}
/// Lines of the enclosing symbol quoted in a single-line answer.
const SCOPE_SNIPPET_LINES: usize = 40;
/// Most lines one batch call accepts.
const MAX_BATCH_LINES: usize = 25;
/// `parent::kind@L12`: a name for a block or closure that has none.
fn block_label(parent: Option<&str>, scope: &Scope) -> String {
let parent = parent.unwrap_or("(file)");
format!("{parent}::{}@L{}", scope.scope_kind, scope.start_line)
}
/// `path:line` entries (as `grep -n` prints them, with or without trailing text), grouped by
/// file in the order the files first appear.
fn parse_locations(at: &[String]) -> anyhow::Result<Vec<(String, Vec<u32>)>> {
let mut groups: Vec<(String, Vec<u32>)> = Vec::new();
for entry in at {
let mut parts = entry.trim().split(':');
let (path, line) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
let line: u32 = line.trim().parse().map_err(|_| {
anyhow::anyhow!("`at` entries look like 'src/lib.rs:42'; got '{entry}'")
})?;
anyhow::ensure!(
!path.is_empty() && line >= 1,
"`at` entry '{entry}' needs a path and a 1-based line"
);
match groups.iter_mut().find(|(p, _)| p == path) {
Some((_, lines)) => lines.push(line),
None => groups.push((path.to_string(), vec![line])),
}
}
Ok(groups)
}
pub async fn handle_get_enclosing_scope(
context: &ProjectContextResolver,
engine: &EngineService,
params: GetEnclosingScopeParams,
) -> anyhow::Result<EnclosingScopeResult> {
let Some(at) = params.at.clone() else {
return scope_in_file(context, engine, params).await;
};
anyhow::ensure!(
!at.is_empty() && at.len() <= MAX_BATCH_LINES,
"`at` takes 1 to {MAX_BATCH_LINES} entries; split the list"
);
let mut out = EnclosingScopeResult::default();
for (path, lines) in parse_locations(&at)? {
let one = GetEnclosingScopeParams {
file_path: path.clone(),
line: None,
lines: Some(lines),
at: None,
max_tokens: None,
no_truncate: None,
project: params.project.clone(),
};
let res = scope_in_file(context, engine, one).await?;
out.found |= res.found;
out.notes
.extend(res.notes.into_iter().map(|n| format!("{path}: {n}")));
for mut group in res.scopes {
group.file_path = Some(path.clone());
out.scopes.push(group);
}
}
Ok(out)
}
async fn scope_in_file(
context: &ProjectContextResolver,
engine: &EngineService,
params: GetEnclosingScopeParams,
) -> anyhow::Result<EnclosingScopeResult> {
anyhow::ensure!(
!params.file_path.is_empty(),
"pass `file_path` with `line` or `lines`, or `at` (path:line entries)"
);
let batch = params.lines.is_some();
let mut targets: Vec<u32> = params.lines.clone().unwrap_or_default();
targets.extend(params.line);
targets.sort_unstable();
targets.dedup();
anyhow::ensure!(
!targets.is_empty(),
"pass `line` (one 1-based line) or `lines` (up to {MAX_BATCH_LINES})"
);
anyhow::ensure!(targets[0] >= 1, "`line` is 1-based (the first line is 1)");
anyhow::ensure!(
targets.len() <= MAX_BATCH_LINES,
"`lines` takes at most {MAX_BATCH_LINES} lines; split the list"
);
let path = PathBuf::from(¶ms.file_path);
let (project, rel_path) = context
.resolve_project(Some(&path), params.project.as_deref())
.await?;
let rel = rel_path.unwrap_or(path);
let rel_str = rel.to_string_lossy().to_string();
let mut guard = engine.db().lock().await;
let file = SourceFile::filter(
SourceFile::fields()
.project_id()
.eq(project.id)
.and(SourceFile::fields().path().eq(&rel_str)),
)
.first()
.exec(&mut *guard)
.await?;
let Some(file) = file else {
return Ok(EnclosingScopeResult {
notes: vec![format!(
"'{rel_str}' is not in the index; check the path, or run index_workspace if the file is new"
)],
file_path: rel_str,
..Default::default()
});
};
let mut out = EnclosingScopeResult {
file_path: rel_str.clone(),
..Default::default()
};
let symbols = Symbol::filter(
Symbol::fields()
.project_id()
.eq(project.id)
.and(Symbol::fields().file_id().eq(file.id)),
)
.exec(&mut *guard)
.await?;
let scopes = Scope::filter(
Scope::fields()
.project_id()
.eq(project.id)
.and(Scope::fields().file_id().eq(file.id)),
)
.exec(&mut *guard)
.await?;
drop(guard);
// Smallest named symbol spanning the line; `use` stubs aren't scopes.
let symbol_at = |line: u32| {
symbols
.iter()
.filter(|s| s.kind != "reexport" && s.start_line <= line && s.end_line >= line)
.min_by_key(|s| (s.end_line - s.start_line, std::cmp::Reverse(s.start_line)))
};
let scope_at = |line: u32| {
scopes
.iter()
.filter(|s| s.start_line <= line && s.end_line >= line)
.min_by_key(|s| s.end_line - s.start_line)
};
let root = std::path::Path::new(&project.root_path);
let mut lines = LineCache::default();
if !batch {
let line = targets[0];
if let Some(sym) = symbol_at(line) {
out.found = true;
out.name = Some(sym.name.clone());
out.qualified_name = Some(sym.qualified_name.clone());
out.kind = Some(sym.kind.clone());
out.signature = Some(sym.signature.clone());
out.start_line = Some(sym.start_line);
out.end_line = Some(sym.end_line);
out.doc = sym
.docstring
.as_deref()
.map(first_sentence)
.filter(|d| !d.is_empty());
// A window of the body that contains the requested line.
let start = sym
.start_line
.max(line.saturating_sub(10))
.min(sym.end_line);
let end = sym.end_line.min(start + SCOPE_SNIPPET_LINES as u32 - 1);
if let Some((text, _)) = lines.range(
root,
&out.file_path,
&file.content_hash,
start,
end,
SCOPE_SNIPPET_LINES,
) {
out.snippet = Some(text);
out.snippet_start_line = Some(start);
}
}
if let Some(scope) = scope_at(line) {
out.found = true;
// The function's own body scope is the symbol; anything smaller is a block or
// closure, named by its parent.
let smaller = out
.start_line
.zip(out.end_line)
.is_none_or(|(s, e)| (scope.start_line, scope.end_line) != (s, e));
out.innermost_scope = Some(LexicalScope {
name: smaller.then(|| block_label(out.qualified_name.as_deref(), scope)),
scope_kind: scope.scope_kind.clone(),
start_line: scope.start_line,
end_line: scope.end_line,
});
}
if !out.found {
out.notes.push(format!(
"no symbol or scope contains line {line} of '{}'; the line may be past the end of the file or the index may be stale (run index_workspace)",
out.file_path
));
}
if lines.stale_files() > 0 {
out.notes.push(
"the file changed since indexing, so no code is quoted; run index_workspace"
.to_string(),
);
}
return Ok(out);
}
// Batch: group the lines by the symbol that contains them.
let mut groups: Vec<(Option<u64>, ScopeGroup)> = Vec::new();
for line in targets {
let sym = symbol_at(line);
let key = sym.map(|s| s.id);
let block = scope_at(line).and_then(|scope| {
let same_as_symbol = sym
.is_some_and(|s| (s.start_line, s.end_line) == (scope.start_line, scope.end_line));
(!same_as_symbol).then(|| block_label(sym.map(|s| s.qualified_name.as_str()), scope))
});
let entry = ScopeLine {
line,
text: lines.line(root, &out.file_path, &file.content_hash, line),
block,
};
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, g)) => g.lines.push(entry),
None => groups.push((
key,
ScopeGroup {
file_path: None,
qualified_name: sym.map(|s| s.qualified_name.clone()),
kind: sym.map(|s| s.kind.clone()),
signature: sym.map(|s| s.signature.clone()),
start_line: sym.map(|s| s.start_line),
end_line: sym.map(|s| s.end_line),
doc: sym
.and_then(|s| s.docstring.as_deref())
.map(first_sentence)
.filter(|d| !d.is_empty()),
lines: vec![entry],
},
)),
}
}
out.found = groups.iter().any(|(k, _)| k.is_some());
let missed = groups.iter().find(|(k, _)| k.is_none()).map(|(_, g)| {
g.lines
.iter()
.map(|l| l.line.to_string())
.collect::<Vec<_>>()
});
if let Some(missed) = missed {
out.notes.push(format!(
"no symbol contains line(s) {}; they may be past the end of the file or the index may be stale (run index_workspace)",
missed.join(", ")
));
}
out.scopes = groups.into_iter().map(|(_, g)| g).collect();
if lines.stale_files() > 0 {
out.notes.push(
"the file changed since indexing, so line text is omitted; run index_workspace"
.to_string(),
);
}
Ok(out)
}
/// Fit an enclosing-scope answer to `budget` tokens: shorten the snippet first, and in a batch
/// drop the quoted line text, so the symbol names (the answer) always arrive.
pub fn fit_scope_to_budget(
out: &mut EnclosingScopeResult,
budget: Option<usize>,
no_truncate: bool,
count_tokens: impl Fn(&str) -> usize,
) {
let budget = budget.unwrap_or(crate::telemetry::MAX_PAYLOAD_TOKENS);
let size = |r: &EnclosingScopeResult| serde_json::to_string(r).map_or(0, |j| count_tokens(&j));
if no_truncate || budget == 0 || size(out) <= budget {
return;
}
if let Some(snippet) = out.snippet.clone() {
let all: Vec<&str> = snippet.lines().collect();
let (mut lo, mut hi) = (1, all.len());
while lo < hi {
let mid = (lo + hi).div_ceil(2);
out.snippet = Some(all[..mid].join("\n"));
// Measured with the flag it will carry, so the answer cannot overflow afterwards.
out.snippet_truncated = mid < all.len();
if size(out) <= budget {
lo = mid;
} else {
hi = mid - 1;
}
}
out.snippet = Some(all[..lo].join("\n"));
out.snippet_truncated = lo < all.len();
// A budget too small for even one line leaves just the answer: where the line is.
if size(out) > budget {
out.snippet = None;
out.snippet_start_line = None;
out.snippet_truncated = false;
out.doc = None;
}
}
if !out.scopes.is_empty() {
for g in &mut out.scopes {
for l in &mut g.lines {
l.text = None;
}
g.doc = None;
}
out.notes.push(format!(
"line text and doc summaries were dropped to fit {budget} tokens; pass max_tokens to restore them"
));
}
}
pub async fn handle_get_file_outline(
context: &ProjectContextResolver,
engine: &EngineService,
params: GetFileOutlineParams,
) -> anyhow::Result<FileOutlineResult> {
let path = PathBuf::from(¶ms.file_path);
let (project, rel_path) = context
.resolve_project(Some(&path), params.project.as_deref())
.await?;
let rel = rel_path.unwrap_or(path);
let rel_str = rel.to_string_lossy().to_string();
let mut guard = engine.db().lock().await;
let file = SourceFile::filter(
SourceFile::fields()
.project_id()
.eq(project.id)
.and(SourceFile::fields().path().eq(&rel_str)),
)
.first()
.exec(&mut *guard)
.await?;
let Some(file) = file else {
return Ok(FileOutlineResult {
next: Default::default(),
notes: vec![format!(
"'{rel_str}' is not in the index; check the path, or run index_workspace if the file is new"
)],
file_path: rel_str,
total_symbols: 0,
returned_symbols: 0,
offset: 0,
limit: params.limit,
has_more: false,
symbols: Vec::new(),
shed: Vec::new(),
});
};
let symbols = Symbol::filter(
Symbol::fields()
.project_id()
.eq(project.id)
.and(Symbol::fields().file_id().eq(file.id)),
)
.exec(&mut *guard)
.await?;
let compact = params.compact.unwrap_or(false);
// Doc comments run to paragraphs; the first sentence says what a declaration does. The whole
// comment comes back when the caller asks for more budget.
let full_docs = params.max_tokens.is_some() || params.no_truncate.unwrap_or(false);
let mut kinds_in_file: Vec<String> = symbols.iter().map(|s| s.kind.clone()).collect();
kinds_in_file.sort();
kinds_in_file.dedup();
let mut outline_symbols = Vec::new();
for s in symbols {
// Line range filter
if let Some(start) = params.start_line
&& s.end_line < start
{
continue;
}
if let Some(end) = params.end_line
&& s.start_line > end
{
continue;
}
// Kinds filter
if let Some(kinds) = ¶ms.kinds
&& !kinds.iter().any(|k| kind_matches(k, &s.kind))
{
continue;
}
// Query filter (matches name or qualified_name)
if let Some(q) = ¶ms.query {
let q_lower = q.to_lowercase();
if !s.name.to_lowercase().contains(&q_lower)
&& !s.qualified_name.to_lowercase().contains(&q_lower)
{
continue;
}
}
outline_symbols.push(OutlineSymbol {
name: s.name,
qualified_name: s.qualified_name,
kind: s.kind,
visibility: s.visibility,
signature: s.signature,
start_line: s.start_line,
end_line: s.end_line,
docstring: if compact {
None
} else if full_docs {
s.docstring
} else {
s.docstring
.as_deref()
.map(first_sentence)
.filter(|d| !d.is_empty())
},
});
}
// Sort by line number
outline_symbols.sort_by_key(|s| s.start_line);
let total_symbols = outline_symbols.len();
let offset = params.offset.unwrap_or(0);
let paginated_symbols = if offset >= total_symbols {
Vec::new()
} else {
let remaining = &outline_symbols[offset..];
if let Some(limit) = params.limit {
remaining.iter().take(limit).cloned().collect()
} else {
remaining.to_vec()
}
};
let returned_symbols = paginated_symbols.len();
let has_more = offset + returned_symbols < total_symbols;
let mut notes = Vec::new();
if let Some(kinds) = ¶ms.kinds
&& total_symbols == 0
&& !kinds_in_file.is_empty()
{
notes.push(format!(
"no symbols of kind {kinds:?}; kinds in this file: {}",
kinds_in_file.join(", ")
));
}
Ok(FileOutlineResult {
next: Default::default(),
file_path: rel_str,
total_symbols,
returned_symbols,
offset,
limit: params.limit,
has_more,
symbols: paginated_symbols,
shed: Vec::new(),
notes,
})
}
/// `fn f(a: u32, b: u32) -> u32` becomes `fn f(…) -> u32`.
fn strip_parameters(signature: &str) -> String {
let Some(open) = signature.find('(') else {
return signature.to_string();
};
let mut depth = 0usize;
for (i, c) in signature[open..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
let close = open + i;
return format!("{}(…){}", &signature[..open], &signature[close + 1..]);
}
}
_ => {}
}
}
signature.to_string()
}
/// Make an outline fit `budget` tokens by dropping detail in a fixed order, so the reader still
/// sees every declaration: doc summaries, then parameter lists, then the `name` and `visibility`
/// fields that repeat other fields. What was dropped is named in `shed`, with the parameter that
/// restores it. Private items are never dropped (agents ask an outline for them): whatever still
/// does not fit is paged by the generic list fitter, with `next_offset`, never cut mid-item.
pub fn fit_outline_detail(
out: &mut FileOutlineResult,
budget: Option<usize>,
no_truncate: bool,
format: crate::session::ResponseFormat,
count_tokens: impl Fn(&str) -> usize,
) {
use crate::tools::budget::Paged;
let budget = budget.unwrap_or(crate::telemetry::MAX_PAYLOAD_TOKENS);
let size = |r: &FileOutlineResult| count_tokens(&r.render(format));
if no_truncate || budget == 0 || size(out) <= budget {
return;
}
let note = |shed: &[String]| {
format!(
"to fit {budget} tokens this outline dropped: {}. Pass max_tokens (or no_truncate) to restore it, or narrow with kinds, query or start_line/end_line",
shed.join(", ")
)
};
for level in 0..3 {
match level {
0 => {
if out.symbols.iter().all(|s| s.docstring.is_none()) {
continue;
}
out.symbols.iter_mut().for_each(|s| s.docstring = None);
out.shed.push("doc summaries".to_string());
}
1 => {
out.symbols
.iter_mut()
.for_each(|s| s.signature = strip_parameters(&s.signature));
out.shed.push("parameter lists".to_string());
}
// The text format prints neither field, so shedding them would gain nothing.
2 if format == crate::session::ResponseFormat::Text => continue,
2 => {
out.symbols.iter_mut().for_each(|s| {
s.name.clear();
s.visibility.clear();
});
out.shed
.push("name and visibility (see qualified_name and signature)".to_string());
}
_ => break,
}
// The note is part of the answer, so it is counted when checking the fit.
out.notes.retain(|n| !n.starts_with("to fit "));
out.notes.push(note(&out.shed));
if size(out) <= budget {
break;
}
}
}
/// Whether a requested outline `kind` selects symbols of indexed kind `actual`. Accepts the
/// indexed names plus common aliases, so `function` finds Rust `fn`s and Python `method`s.
fn kind_matches(kind: &str, actual: &str) -> bool {
let aliases: &[&str] = match kind.to_ascii_lowercase().as_str() {
"fn" | "function" | "func" | "def" | "method" => &["fn", "method"],
"struct" | "class" => &["struct", "class"],
"trait" | "interface" | "protocol" => &["trait", "interface"],
"mod" | "module" | "namespace" => &["mod"],
"const" | "constant" | "static" => &["const"],
"type" | "typealias" | "type_alias" | "alias" => &["type"],
"use" | "import" | "reexport" => &["reexport"],
_ => &[],
};
kind.eq_ignore_ascii_case(actual) || aliases.contains(&actual)
}
pub async fn handle_get_type_contract(
context: &ProjectContextResolver,
engine: &EngineService,
params: GetTypeContractParams,
) -> anyhow::Result<TypeContractResult> {
let file_path = params.file_path.as_deref().map(Path::new);
let (project, rel_hint) = context
.resolve_project(file_path, params.project.as_deref())
.await?;
// The engine matches exact names only. Resolve through the shared lookup first so
// qualified names, public dependency paths (`tokio::sync::Mutex`) and the `file_path`
// hint all pick the right definition, then hand the engine its exact qualified name.
let resolved = {
let mut guard = engine.db().lock().await;
let mut candidates: Vec<_> =
lookup_symbol_candidates(&mut guard, &project, ¶ms.type_name)
.await?
.into_iter()
.filter(|c| TYPE_KINDS.contains(&c.symbol.kind.as_str()))
.collect();
if let Some(fp) = ¶ms.file_path {
let hint = rel_hint
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| fp.clone());
if let Some(hinted) = narrow_by_file(&candidates, &[&hint, fp]) {
candidates = hinted;
}
}
candidates
.into_iter()
.next()
.map(|c| c.symbol.qualified_name)
};
let root = Path::new(&project.root_path);
let contract = engine
.get_type_contract(
project.id,
root,
resolved.as_deref().unwrap_or(¶ms.type_name),
params.file_path.as_deref(),
params.member_query.as_deref(),
)
.await?;
let full_doc = params.max_tokens.is_some() || params.no_truncate.unwrap_or(false);
let (docstring, docstring_truncated) = response_docstring(contract.docstring, full_doc);
let notes = if contract.found {
Vec::new()
} else {
vec![format!(
"no struct, enum, trait or type alias named '{0}'; try search_symbols(query: \"{0}\", kinds: [\"struct\", \"enum\", \"trait\"]) or inspect_symbol",
params.type_name
)]
};
Ok(TypeContractResult {
found: contract.found,
type_name: contract.type_name,
kind: contract.kind,
signature: contract.signature,
file_path: contract.file_path,
members: contract.members,
docstring,
docstring_truncated,
notes,
})
}
// --- Tool Definitions ---
pub fn tool_definitions() -> Vec<Tool> {
vec![
make_tool::<ResolveDefinitionParams>(
"resolve_definition",
"Use when you have the file, line and column of a reference (e.g. from a stack trace) and need its definition. Returns path, line span and signature, following the reference across files instead of grepping the name. `line` is 1-based, `col` 0-based and must fall on the identifier; when found is false, `notice` says why.",
read_only(),
),
make_tool::<FindReferencesParams>(
"find_references",
"Use to list where a symbol is used (who calls it), e.g. before renaming or changing a signature. Parsed references skip comments and strings. view: callers (default for a function: each caller once, with first usage line and count), files (default for role import) or sites (every usage, with enclosing_symbol and source line). Same-named definitions are listed with counts: pass file_path or a qualified name. Narrow with file_filter, role, via (exact, name, macro); page with offset/limit. scope_level 'dependencies' finds usages of external crate symbols.",
read_only(),
),
make_tool::<GetEnclosingScopeParams>(
"get_enclosing_scope",
"Use when you know a file and a 1-based line (stack trace, diff, compiler error, grep hits) and need the function, method or type containing it: name, kind, signature, doc summary, line span, about 40 lines of code, and the innermost block or closure. Pass lines (up to 25) or at (path:line entries across files) for one answer per containing symbol.",
read_only(),
),
make_tool::<GetFileOutlineParams>(
"get_file_outline",
"Use instead of reading a whole file to see what it declares (outline): functions, types, traits and signatures with line spans and a doc summary, no bodies. Over budget it sheds docs, parameter lists and repeated fields (see shed), then pages. Filter with kinds, query or start_line/end_line.",
read_only(),
),
make_tool::<GetTypeContractParams>(
"get_type_contract",
"Use when you need a type's fields, variants and methods before changing or constructing it, without reading its implementation. Filter with member_query; works for workspace and Cargo dependency types (e.g. tokio::sync::Mutex).",
read_only(),
),
]
}
impl crate::tools::budget::Paged for FindReferencesResult {
const TOOL: &'static str = "find_references";
fn item_count(&self) -> usize {
match self.view {
Some(super::enums::ReferenceView::Callers) => self.callers.len(),
Some(super::enums::ReferenceView::Files) => self.files.len(),
_ => self.references.len(),
}
}
fn page_offset(&self) -> usize {
self.offset
}
fn keep_first(&mut self, kept: usize) {
self.references.truncate(kept);
self.callers.truncate(kept);
self.files.truncate(kept);
self.returned_references = kept;
self.has_more = true;
}
fn continuation_mut(&mut self) -> &mut crate::tools::budget::Continuation {
&mut self.next
}
fn render_text(&self) -> String {
crate::tools::render::find_references(self)
}
}
impl crate::tools::budget::Paged for FileOutlineResult {
const TOOL: &'static str = "get_file_outline";
fn item_count(&self) -> usize {
self.symbols.len()
}
fn page_offset(&self) -> usize {
self.offset
}
fn keep_first(&mut self, kept: usize) {
self.symbols.truncate(kept);
self.returned_symbols = kept;
self.has_more = true;
}
fn continuation_mut(&mut self) -> &mut crate::tools::budget::Continuation {
&mut self.next
}
fn render_text(&self) -> String {
crate::tools::render::file_outline(self)
}
}