use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use rmcp::model::Tool;
use scryer_engine::EngineService;
use scryer_engine::search::{SearchQuery, SearchScope};
use super::admin::{make_tool, read_only};
use crate::context::ProjectContextResolver;
const DEFAULT_LIMIT: usize = 8;
const SNIPPET_LINES: usize = 10;
const CONFIDENT_RATIO: f32 = 1.5;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct SearchSymbolsParams {
pub query: Option<String>,
pub like_symbol: Option<String>,
pub kinds: Option<Vec<String>>,
pub file_filter: Option<String>,
pub scope_level: Option<super::enums::SearchScopeLevel>,
pub crate_name: Option<String>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub compact: Option<bool>,
pub project: Option<String>,
pub format: Option<crate::session::ResponseFormat>,
pub max_tokens: Option<usize>,
pub no_truncate: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SymbolSearchMatch {
pub name: String,
pub qualified_name: String,
pub kind: String,
pub file_path: String,
pub start_line: u32,
pub end_line: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
pub score: f64,
pub matched_terms: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub crate_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SearchSymbolsResult {
#[serde(flatten, default)]
pub next: crate::tools::budget::Continuation,
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub like_symbol: Option<String>,
pub scope_level: String,
pub query_terms: Vec<String>,
pub total: usize,
pub returned: usize,
pub offset: usize,
pub has_more: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub confident: bool,
pub symbols: Vec<SymbolSearchMatch>,
}
pub async fn handle_search_symbols(
context: &ProjectContextResolver,
engine: &EngineService,
params: SearchSymbolsParams,
) -> anyhow::Result<SearchSymbolsResult> {
let query = params.query.filter(|q| !q.trim().is_empty());
let like_symbol = params.like_symbol.filter(|s| !s.trim().is_empty());
anyhow::ensure!(
query.is_some() != like_symbol.is_some(),
"Provide exactly one of `query` or `like_symbol`"
);
use super::enums::SearchScopeLevel;
let level = params.scope_level.unwrap_or(SearchScopeLevel::Project);
let scope_level = level.as_str().to_string();
let scope = match level {
SearchScopeLevel::Project => {
anyhow::ensure!(
params.crate_name.is_none(),
"`crate_name` requires scope_level 'dependencies'"
);
SearchScope::Project
}
SearchScopeLevel::Dependencies => SearchScope::Dependencies {
crate_name: params.crate_name.clone(),
},
};
let (project, _) = context
.resolve_project(None, params.project.as_deref())
.await?;
let offset = params.offset.unwrap_or(0);
let results = engine
.search_symbols(
project.id,
SearchQuery {
text: query.clone(),
like_symbol: like_symbol.clone(),
kinds: params.kinds.unwrap_or_default(),
file_filter: params.file_filter,
scope,
offset,
limit: params.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 50),
},
)
.await?;
let compact = params.compact.unwrap_or(false);
let confident = like_symbol.is_none()
&& offset == 0
&& results.hits.first().is_some_and(|top| {
top.matched_terms.len() >= results.query_terms.len()
&& results
.hits
.get(1)
.is_none_or(|second| top.score >= CONFIDENT_RATIO * second.score)
});
let mut symbols: Vec<SymbolSearchMatch> = results
.hits
.into_iter()
.map(|h| SymbolSearchMatch {
name: h.name,
qualified_name: h.qualified_name,
kind: h.kind,
file_path: h.file_path,
start_line: h.start_line,
end_line: h.end_line,
signature: (!compact).then_some(h.signature),
doc: (!compact && !h.doc_snippet.is_empty())
.then(|| super::inspect::first_sentence(&h.doc_snippet)),
snippet: None,
score: (f64::from(h.score) * 100.0).round() / 100.0,
matched_terms: h.matched_terms,
crate_name: h.crate_name,
version: h.version,
})
.collect();
if query.is_some()
&& offset == 0
&& !compact
&& level == SearchScopeLevel::Project
&& let Some(top) = symbols.first_mut()
{
top.snippet = top_hit_snippet(engine, &project, top).await;
}
Ok(SearchSymbolsResult {
confident,
next: Default::default(),
query,
like_symbol: results.like_symbol.or(like_symbol),
scope_level,
query_terms: results.query_terms,
total: results.total,
returned: symbols.len(),
offset: results.offset,
has_more: results.has_more,
symbols,
})
}
async fn top_hit_snippet(
engine: &EngineService,
project: &scryer_db::Project,
hit: &SymbolSearchMatch,
) -> Option<String> {
use scryer_db::SourceFile;
let hash = {
let mut guard = engine.db().lock().await;
SourceFile::filter(
SourceFile::fields()
.project_id()
.eq(project.id)
.and(SourceFile::fields().path().eq(&hit.file_path)),
)
.first()
.exec(&mut *guard)
.await
.ok()??
.content_hash
};
let end = hit.end_line.min(hit.start_line + SNIPPET_LINES as u32 - 1);
super::source_lines::LineCache::default()
.range(
std::path::Path::new(&project.root_path),
&hit.file_path,
&hash,
hit.start_line,
end,
SNIPPET_LINES,
)
.map(|(text, _)| text)
}
pub fn tool_definitions() -> Vec<Tool> {
vec![make_tool::<SearchSymbolsParams>(
"search_symbols",
"Use when you don't know a symbol's exact name, instead of grepping: ranks functions, types and traits by concept keywords (e.g. 'retry backoff') over names, paths, signatures and docstrings. Hits carry signature and doc summary; a query's best hit includes its first lines, and confident means stop looking. like_symbol finds similar code; scope_level 'dependencies' searches third-party crates.",
read_only(),
)]
}
impl crate::tools::budget::Paged for SearchSymbolsResult {
const TOOL: &'static str = "search_symbols";
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 = 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::search_symbols(self)
}
}