feat: add semantic search tool for code symbol indexing and searching
- Implemented a new tool for semantic code search that indexes Rust code symbols (functions, structs, enums, traits, modules) and allows searching by name, concept, or meaning. - Introduced a symbol index structure with methods for rebuilding the index and searching symbols. - Added regex patterns for extracting various code symbols from Rust source files. - Implemented scoring logic for search results based on exact matches, prefix matches, and context relevance. - Created a web search tool that interacts with a SearXNG instance to fetch documentation and API information based on user queries. - Added a diff preview overlay for rendering git diff output with color-coded additions and deletions in a TUI interface.
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
//! Semantic code search tool — indexes all code symbols (functions, structs,
|
||||
//! enums, traits, modules) in a project and allows searching by name,
|
||||
//! concept, or meaning.
|
||||
//!
|
||||
//! Flow: walk workspace files → parse Rust source for symbol declarations →
|
||||
//! build in-memory index → search by fuzzy/prefix match on symbol names and
|
||||
//! doc comments.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symbol index types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The kind of a code symbol.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SymbolKind {
|
||||
Function,
|
||||
Struct,
|
||||
Enum,
|
||||
Trait,
|
||||
Module,
|
||||
Impl,
|
||||
Type,
|
||||
Constant,
|
||||
Macro,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SymbolKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SymbolKind::Function => write!(f, "fn"),
|
||||
SymbolKind::Struct => write!(f, "struct"),
|
||||
SymbolKind::Enum => write!(f, "enum"),
|
||||
SymbolKind::Trait => write!(f, "trait"),
|
||||
SymbolKind::Module => write!(f, "mod"),
|
||||
SymbolKind::Impl => write!(f, "impl"),
|
||||
SymbolKind::Type => write!(f, "type"),
|
||||
SymbolKind::Constant => write!(f, "const"),
|
||||
SymbolKind::Macro => write!(f, "macro"),
|
||||
SymbolKind::Other => write!(f, "symbol"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single code symbol entry in the index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodeSymbol {
|
||||
/// Symbol name (e.g. "run_agent", "AppStateRest").
|
||||
pub name: String,
|
||||
/// Kind of symbol.
|
||||
pub kind: SymbolKind,
|
||||
/// File path relative to workspace root.
|
||||
pub file: String,
|
||||
/// Line number (1-indexed).
|
||||
pub line: usize,
|
||||
/// Parent symbol (e.g. struct name for impl methods).
|
||||
pub parent: Option<String>,
|
||||
/// Doc comment text, if any.
|
||||
pub doc_comment: Option<String>,
|
||||
/// Short context (the declaration line).
|
||||
pub context: String,
|
||||
}
|
||||
|
||||
/// The in-memory symbol index, shared via a global static.
|
||||
static SYMBOL_INDEX: Mutex<Option<SymbolIndex>> = Mutex::new(None);
|
||||
|
||||
/// Lazily compile regexes for symbol extraction.
|
||||
fn compiled_regexes() -> (
|
||||
Regex,
|
||||
Regex,
|
||||
Regex,
|
||||
Regex,
|
||||
Regex,
|
||||
Regex,
|
||||
Regex,
|
||||
Regex,
|
||||
Regex,
|
||||
) {
|
||||
(
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?async\s+)?fn\s+(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?)?trait\s+(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:for\s+)?(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?type\s+(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)").unwrap(),
|
||||
Regex::new(r"(?m)^\s*(?:pub\s+)?macro_rules!\s*\(\s*(\w+)").unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A symbol index cache.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SymbolIndex {
|
||||
symbols: Vec<CodeSymbol>,
|
||||
workspace_path: Option<String>,
|
||||
}
|
||||
|
||||
impl SymbolIndex {
|
||||
pub fn new() -> Self {
|
||||
SymbolIndex {
|
||||
symbols: Vec::new(),
|
||||
workspace_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.symbols.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.symbols.len()
|
||||
}
|
||||
|
||||
pub fn rebuild(&mut self, workspace: &str) -> Result<usize> {
|
||||
let path = std::path::Path::new(workspace);
|
||||
if !path.exists() {
|
||||
anyhow::bail!("workspace path does not exist: {workspace}");
|
||||
}
|
||||
|
||||
let mut symbols = Vec::new();
|
||||
let walker = ignore::Walk::new(path);
|
||||
|
||||
for entry in walker.flatten() {
|
||||
let file_path = entry.path();
|
||||
if !file_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only index Rust files
|
||||
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if ext != "rs" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rel_path = file_path
|
||||
.strip_prefix(path)
|
||||
.unwrap_or(file_path)
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
match std::fs::read_to_string(file_path) {
|
||||
Ok(content) => {
|
||||
let file_symbols = extract_symbols(&content, &rel_path);
|
||||
symbols.extend(file_symbols);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(file = %rel_path, error = %e, "failed to read file for indexing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
symbols.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.symbols = symbols;
|
||||
self.workspace_path = Some(workspace.to_string());
|
||||
|
||||
let count = self.symbols.len();
|
||||
info!(symbol_count = count, workspace = %workspace, "symbol index rebuilt");
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<&CodeSymbol> {
|
||||
if self.symbols.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_words: Vec<String> = query_lower.split_whitespace().map(|s| s.to_string()).collect();
|
||||
|
||||
let mut scored: Vec<(i32, &CodeSymbol)> = self
|
||||
.symbols
|
||||
.iter()
|
||||
.filter_map(|sym| {
|
||||
let score = score_symbol(sym, &query_lower, &query_words);
|
||||
if score > 0 {
|
||||
Some((score, sym))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by score descending, then by name ascending
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
|
||||
|
||||
scored
|
||||
.into_iter()
|
||||
.take(max_results)
|
||||
.map(|(_, sym)| sym)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SymbolIndex {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a symbol against a search query.
|
||||
fn score_symbol(sym: &CodeSymbol, query_lower: &str, query_words: &[String]) -> i32 {
|
||||
let name_lower = sym.name.to_lowercase();
|
||||
let mut score: i32 = 0;
|
||||
|
||||
// Exact match = highest score
|
||||
if name_lower == *query_lower {
|
||||
score += 1000;
|
||||
}
|
||||
|
||||
// Prefix match
|
||||
if name_lower.starts_with(query_lower) {
|
||||
score += 500;
|
||||
}
|
||||
|
||||
// Contains
|
||||
if name_lower.contains(query_lower) {
|
||||
score += 200;
|
||||
}
|
||||
|
||||
// Word-by-word matching
|
||||
for word in query_words {
|
||||
if name_lower.contains(word) {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
|
||||
// Doc comment match
|
||||
if let Some(ref doc) = sym.doc_comment {
|
||||
let doc_lower = doc.to_lowercase();
|
||||
if doc_lower.contains(query_lower) {
|
||||
score += 30;
|
||||
}
|
||||
for word in query_words {
|
||||
if doc_lower.contains(word) {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context match
|
||||
let context_lower = sym.context.to_lowercase();
|
||||
if context_lower.contains(query_lower) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Extract code symbols from Rust source content.
|
||||
fn extract_symbols(content: &str, rel_path: &str) -> Vec<CodeSymbol> {
|
||||
let (fn_re, struct_re, enum_re, trait_re, mod_re, impl_re, type_re, const_re, macro_re) =
|
||||
compiled_regexes();
|
||||
|
||||
let mut symbols = Vec::new();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
// Extract doc comments that precede declarations
|
||||
let doc_comments = extract_doc_comments(&lines);
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let line_num = i + 1;
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Check for function declarations
|
||||
if let Some(caps) = fn_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
let doc = doc_comments.get(&line_num).cloned();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Function,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: doc,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for struct declarations
|
||||
if let Some(caps) = struct_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
let doc = doc_comments.get(&line_num).cloned();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Struct,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: doc,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for enum declarations
|
||||
if let Some(caps) = enum_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
let doc = doc_comments.get(&line_num).cloned();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Enum,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: doc,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for trait declarations
|
||||
if let Some(caps) = trait_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
let doc = doc_comments.get(&line_num).cloned();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Trait,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: doc,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for module declarations
|
||||
if let Some(caps) = mod_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Module,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: None,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for type alias declarations
|
||||
if let Some(caps) = type_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
let doc = doc_comments.get(&line_num).cloned();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Type,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: doc,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for const declarations
|
||||
if let Some(caps) = const_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
let doc = doc_comments.get(&line_num).cloned();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Constant,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: doc,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for macro declarations
|
||||
if let Some(caps) = macro_re.captures(trimmed) {
|
||||
let name = caps.get(1).unwrap().as_str().to_string();
|
||||
symbols.push(CodeSymbol {
|
||||
name,
|
||||
kind: SymbolKind::Macro,
|
||||
file: rel_path.to_string(),
|
||||
line: line_num,
|
||||
parent: None,
|
||||
doc_comment: None,
|
||||
context: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse impl blocks for method-level indexing
|
||||
if let Some(caps) = impl_re.captures(trimmed) {
|
||||
let impl_for = caps.get(1).unwrap().as_str().to_string();
|
||||
// Look for methods inside this impl block
|
||||
let mut brace_depth: i32 = 0;
|
||||
let mut started = false;
|
||||
for (j, l) in lines[i..].iter().enumerate() {
|
||||
for ch in l.chars() {
|
||||
match ch {
|
||||
'{' => {
|
||||
brace_depth += 1;
|
||||
started = true;
|
||||
}
|
||||
'}' => {
|
||||
brace_depth -= 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if started && brace_depth <= 0 && j > 1 {
|
||||
break; // End of impl block
|
||||
}
|
||||
if j > 0 {
|
||||
let inner_line = l.trim();
|
||||
if let Some(mcaps) = fn_re.captures(inner_line) {
|
||||
let method_name = mcaps.get(1).unwrap().as_str().to_string();
|
||||
let abs_line = i + j + 1;
|
||||
let doc = doc_comments.get(&abs_line).cloned();
|
||||
symbols.push(CodeSymbol {
|
||||
name: format!("{impl_for}::{method_name}"),
|
||||
kind: SymbolKind::Function,
|
||||
file: rel_path.to_string(),
|
||||
line: abs_line,
|
||||
parent: Some(impl_for.clone()),
|
||||
doc_comment: doc,
|
||||
context: inner_line.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
symbols
|
||||
}
|
||||
|
||||
/// Extract doc comments (/// or //!) that precede each line.
|
||||
fn extract_doc_comments(lines: &[&str]) -> HashMap<usize, String> {
|
||||
let mut map = HashMap::new();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.starts_with("///") {
|
||||
let mut doc = String::new();
|
||||
while i < lines.len() {
|
||||
let l = lines[i].trim();
|
||||
if l.starts_with("///") {
|
||||
if !doc.is_empty() {
|
||||
doc.push(' ');
|
||||
}
|
||||
doc.push_str(l.trim_start_matches("///").trim());
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Associate doc with the next non-empty, non-doc, non-attribute line
|
||||
let target_line = find_next_declaration_line(lines, i);
|
||||
if let Some(tl) = target_line {
|
||||
map.insert(tl + 1, doc);
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Find the next line that looks like a declaration (not doc, not attr).
|
||||
fn find_next_declaration_line(lines: &[&str], start: usize) -> Option<usize> {
|
||||
for i in start..lines.len() {
|
||||
let trimmed = lines[i].trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.starts_with("///")
|
||||
|| trimmed.starts_with("//!")
|
||||
|| trimmed.starts_with('#')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some(i);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool: SemanticSearch — search the symbol index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Search for code symbols by name, concept, or semantic meaning.
|
||||
///
|
||||
/// Flow: ensure index is built → search by query → return formatted results.
|
||||
pub struct SemanticSearch;
|
||||
|
||||
impl Tool for SemanticSearch {
|
||||
fn name(&self) -> &'static str {
|
||||
"semantic_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search for code symbols (functions, structs, enums, traits) by name, concept, or meaning"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query — function name, struct name, or concept (e.g. 'payment handler', 'auth middleware', 'user repository')"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["fn", "struct", "enum", "trait", "mod", "all"],
|
||||
"description": "Filter by symbol kind (default: all)",
|
||||
"default": "all"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results (default 10, max 30)",
|
||||
"default": 10
|
||||
},
|
||||
"rebuild_index": {
|
||||
"type": "boolean",
|
||||
"description": "Force rebuild the symbol index before searching (default false)",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, ctx, args))]
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let query = crate::tools::arg_str(args, "query")?;
|
||||
let kind_filter = args
|
||||
.get("kind")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("all");
|
||||
let max_results = args
|
||||
.get("max_results")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10)
|
||||
.min(30) as usize;
|
||||
let rebuild = args
|
||||
.get("rebuild_index")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let workspace = ctx
|
||||
.workspaces
|
||||
.first()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
|
||||
info!(query = %query, kind = %kind_filter, max_results, rebuild, "semantic search");
|
||||
|
||||
// Get or rebuild the index
|
||||
let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?;
|
||||
let index = guard.get_or_insert_with(SymbolIndex::new);
|
||||
|
||||
if rebuild || index.is_empty() {
|
||||
let count = index.rebuild(&workspace)?;
|
||||
debug!(symbol_count = count, "symbol index rebuilt");
|
||||
}
|
||||
|
||||
let results = index.search(&query, max_results * 2); // Get extra for filtering
|
||||
|
||||
// Apply kind filter
|
||||
let filtered: Vec<&&CodeSymbol> = if kind_filter != "all" {
|
||||
let target_kind = match kind_filter {
|
||||
"fn" => SymbolKind::Function,
|
||||
"struct" => SymbolKind::Struct,
|
||||
"enum" => SymbolKind::Enum,
|
||||
"trait" => SymbolKind::Trait,
|
||||
"mod" => SymbolKind::Module,
|
||||
_ => SymbolKind::Other,
|
||||
};
|
||||
results
|
||||
.iter()
|
||||
.filter(|s| s.kind == target_kind)
|
||||
.take(max_results)
|
||||
.collect()
|
||||
} else {
|
||||
results.iter().take(max_results).collect()
|
||||
};
|
||||
|
||||
if filtered.is_empty() {
|
||||
return Ok(format!(
|
||||
"No symbols found matching '{query}'.\n\
|
||||
Try a different query, or use `rebuild_index: true` to rebuild the index first."
|
||||
));
|
||||
}
|
||||
|
||||
let total = index.len();
|
||||
info!(matched = filtered.len(), total_indexed = total, "semantic search completed");
|
||||
|
||||
// Group results by file for cleaner output
|
||||
let mut by_file: std::collections::BTreeMap<String, Vec<&&CodeSymbol>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for sym in &filtered {
|
||||
by_file.entry(sym.file.clone()).or_default().push(*sym);
|
||||
}
|
||||
|
||||
let mut output = format!(
|
||||
"## Semantic Search Results\n\n**Query:** {query}\n**Index size:** {total} symbols\n**Matches:** {}\n\n",
|
||||
filtered.len()
|
||||
);
|
||||
|
||||
for (file, symbols) in &by_file {
|
||||
output.push_str(&format!("### `{file}`\n\n"));
|
||||
for sym in symbols {
|
||||
let kind_str = sym.kind.to_string();
|
||||
let parent_str = sym
|
||||
.parent
|
||||
.as_ref()
|
||||
.map(|p| format!(" [{p}]"))
|
||||
.unwrap_or_default();
|
||||
let doc_str = sym
|
||||
.doc_comment
|
||||
.as_ref()
|
||||
.map(|d| {
|
||||
let truncated: String = d.chars().take(100).collect();
|
||||
format!(" — {truncated}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let context_trimmed = sym.context.trim();
|
||||
let context_ellipsis = if context_trimmed.len() > 80 { "…" } else { "" };
|
||||
|
||||
output.push_str(&format!(
|
||||
"- `{kind_str}` **{}**{} at line {} `{}`{}{}\n",
|
||||
sym.name,
|
||||
parent_str,
|
||||
sym.line,
|
||||
context_trimmed,
|
||||
doc_str,
|
||||
context_ellipsis
|
||||
));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
output.push_str(&format!(
|
||||
"---\n*{} symbols indexed. Use `rebuild_index: true` to refresh.*\n",
|
||||
total
|
||||
));
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: Rebuild the symbol index explicitly.
|
||||
pub struct RebuildIndex;
|
||||
|
||||
impl Tool for RebuildIndex {
|
||||
fn name(&self) -> &'static str {
|
||||
"rebuild_index"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Rebuild the code symbol index for semantic search"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, ctx, _args))]
|
||||
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
let workspace = ctx
|
||||
.workspaces
|
||||
.first()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
|
||||
info!("rebuilding symbol index");
|
||||
|
||||
let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?;
|
||||
let index = guard.get_or_insert_with(SymbolIndex::new);
|
||||
let count = index.rebuild(&workspace)?;
|
||||
|
||||
Ok(format!(
|
||||
"Symbol index rebuilt successfully. {} symbols indexed.",
|
||||
count
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user