use super::CodeTool;
use super::ToolError;
use dashmap::DashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tantivy::Index;
use tantivy::IndexReader;
use tantivy::IndexWriter;
use tantivy::collector::TopDocs;
use tantivy::doc;
use tantivy::query::Query;
use tantivy::query::QueryParser;
use tantivy::schema::Field;
use tantivy::schema::INDEXED;
use tantivy::schema::STORED;
use tantivy::schema::STRING;
use tantivy::schema::Schema;
use tantivy::schema::TEXT;
use tantivy::schema::Value;
use tokio::process::Command;
#[derive(Debug)]
pub struct MultiLayerSearchEngine {
symbol_index: Arc<DashMap<String, Vec<Symbol>>>,
tantivy_index: Option<TantivySearchEngine>,
ast_cache: Arc<DashMap<PathBuf, CachedAst>>,
query_cache: Arc<DashMap<String, CachedResult>>,
config: SearchConfig,
}
pub struct TantivySearchEngine {
index: Index,
reader: IndexReader,
writer: Arc<tokio::sync::Mutex<IndexWriter>>,
schema: TantivySchema,
}
#[derive(Debug, Clone)]
pub struct TantivySchema {
pub path: Field,
pub content: Field,
pub symbols: Field,
pub ast: Field,
pub language: Field,
pub line_number: Field,
pub function_name: Field,
pub class_name: Field,
}
#[derive(Debug, Clone)]
pub struct SearchConfig {
pub max_cache_size: usize,
pub cache_ttl: Duration,
pub enable_symbol_index: bool,
pub enable_tantivy: bool,
pub enable_ast_cache: bool,
pub enable_ripgrep_fallback: bool,
pub max_results: usize,
pub timeout: Duration,
}
#[derive(Debug, Clone)]
pub struct SearchQuery {
pub pattern: String,
pub query_type: QueryType,
pub file_filters: Vec<String>,
pub language_filters: Vec<String>,
pub context_lines: usize,
pub limit: Option<usize>,
pub fuzzy: bool,
pub case_sensitive: bool,
pub scope: SearchScope,
}
#[derive(Debug, Clone, PartialEq)]
pub enum QueryType {
Symbol,
FullText,
Definition,
References,
Semantic,
General,
}
#[derive(Debug, Clone)]
pub enum SearchScope {
Workspace,
Directory(PathBuf),
Files(Vec<PathBuf>),
GitRepository,
}
#[derive(Debug, Clone)]
pub struct ToolOutput<T> {
pub result: T,
pub context: Context,
pub changes: Vec<Change>,
pub metadata: Metadata,
pub summary: String,
}
#[derive(Debug, Clone)]
pub struct Context {
pub before: String,
pub after: String,
pub surrounding: Vec<Line>,
pub location: Location,
pub scope: Scope,
}
#[derive(Debug, Clone)]
pub struct Line {
pub number: usize,
pub content: String,
pub is_match: bool,
}
#[derive(Debug, Clone)]
pub struct Location {
pub file: PathBuf,
pub line: usize,
pub column: usize,
pub byte_offset: usize,
}
#[derive(Debug, Clone)]
pub struct Scope {
pub function: Option<String>,
pub class: Option<String>,
pub module: Option<String>,
pub namespace: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Change {
pub change_type: ChangeType,
pub description: String,
pub location: Location,
}
#[derive(Debug, Clone)]
pub enum ChangeType {
Addition,
Modification,
Deletion,
}
#[derive(Debug, Clone)]
pub struct Metadata {
pub search_layer: SearchLayer,
pub duration: Duration,
pub total_results: usize,
pub strategy: SearchStrategy,
pub language: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SearchLayer {
SymbolIndex,
Tantivy,
AstCache,
RipgrepFallback,
Combined,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SearchStrategy {
FastSymbolLookup,
FullTextIndex,
SemanticAnalysis,
PatternMatching,
Hybrid,
}
pub type SearchResult = ToolOutput<Vec<Match>>;
#[derive(Debug, Clone)]
pub struct Match {
pub file: PathBuf,
pub line: usize,
pub column: usize,
pub content: String,
pub score: f32,
}
#[derive(Debug, Clone)]
pub struct Symbol {
pub name: String,
pub kind: SymbolKind,
pub location: Location,
pub scope: Scope,
pub visibility: Visibility,
}
#[derive(Debug, Clone)]
pub enum SymbolKind {
Function,
Method,
Class,
Interface,
Struct,
Enum,
Variable,
Constant,
Module,
Namespace,
}
#[derive(Debug, Clone)]
pub enum Visibility {
Public,
Private,
Protected,
Internal,
}
#[derive(Debug, Clone)]
pub struct CachedAst {
pub file_path: PathBuf,
pub language: String,
pub symbols: Vec<Symbol>,
pub dependencies: Vec<String>,
pub last_modified: std::time::SystemTime,
pub parse_duration: Duration,
}
#[derive(Debug, Clone)]
pub struct CachedResult {
pub result: SearchResult,
pub timestamp: Instant,
pub ttl: Duration,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
max_cache_size: 1000,
cache_ttl: Duration::from_secs(300), enable_symbol_index: true,
enable_tantivy: true,
enable_ast_cache: true,
enable_ripgrep_fallback: true,
max_results: 100,
timeout: Duration::from_secs(10),
}
}
}
impl Default for SearchQuery {
fn default() -> Self {
Self {
pattern: String::new(),
query_type: QueryType::General,
file_filters: Vec::new(),
language_filters: Vec::new(),
context_lines: 3,
limit: Some(50),
fuzzy: false,
case_sensitive: true,
scope: SearchScope::Workspace,
}
}
}
impl MultiLayerSearchEngine {
pub fn new(config: SearchConfig) -> Result<Self, ToolError> {
let symbol_index = Arc::new(DashMap::new());
let ast_cache = Arc::new(DashMap::new());
let query_cache = Arc::new(DashMap::new());
let tantivy_index = if config.enable_tantivy {
Some(TantivySearchEngine::new()?)
} else {
None
};
Ok(Self {
symbol_index,
tantivy_index,
ast_cache,
query_cache,
config,
})
}
pub async fn search(&self, query: SearchQuery) -> Result<SearchResult, ToolError> {
let start_time = Instant::now();
if let Some(cached) = self.get_cached_result(&query) {
return Ok(cached);
}
let strategy = self.select_strategy(&query);
let layer = match strategy {
SearchStrategy::FastSymbolLookup if self.config.enable_symbol_index => {
SearchLayer::SymbolIndex
}
SearchStrategy::FullTextIndex if self.config.enable_tantivy => SearchLayer::Tantivy,
SearchStrategy::SemanticAnalysis if self.config.enable_ast_cache => {
SearchLayer::AstCache
}
SearchStrategy::PatternMatching if self.config.enable_ripgrep_fallback => {
SearchLayer::RipgrepFallback
}
SearchStrategy::Hybrid => SearchLayer::Combined,
_ => {
if self.config.enable_symbol_index {
SearchLayer::SymbolIndex
} else if self.config.enable_ast_cache {
SearchLayer::AstCache
} else if self.config.enable_tantivy {
SearchLayer::Tantivy
} else if self.config.enable_ripgrep_fallback {
SearchLayer::RipgrepFallback
} else {
SearchLayer::SymbolIndex
}
}
};
let matches = match layer {
SearchLayer::SymbolIndex => self.search_symbol_index(&query).await?,
SearchLayer::Tantivy => self.search_tantivy(&query).await?,
SearchLayer::AstCache => self.search_ast_cache(&query).await?,
SearchLayer::RipgrepFallback => self.search_ripgrep(&query).await?,
SearchLayer::Combined => self.search_combined(&query).await?,
};
let enhanced_matches = self.enhance_matches_with_context(matches, &query).await?;
let duration = start_time.elapsed();
let result = ToolOutput {
result: enhanced_matches.clone(),
context: self
.build_overall_context(&enhanced_matches, &query)
.await?,
changes: Vec::new(), metadata: Metadata {
search_layer: layer,
duration,
total_results: enhanced_matches.len(),
strategy,
language: self.detect_language(&query),
},
summary: self.generate_summary(&enhanced_matches, &query),
};
self.cache_result(&query, &result);
Ok(result)
}
async fn search_symbol_index(&self, query: &SearchQuery) -> Result<Vec<Match>, ToolError> {
let _start = Instant::now();
let mut matches = Vec::new();
if let Some(symbols) = self.symbol_index.get(&query.pattern) {
for symbol in symbols.iter() {
if self.matches_filters(symbol, query) {
matches.push(Match {
file: symbol.location.file.clone(),
line: symbol.location.line,
column: symbol.location.column,
content: symbol.name.clone(),
score: 1.0, });
}
}
}
if query.fuzzy && matches.is_empty() {
for entry in self.symbol_index.iter() {
let similarity = self.calculate_similarity(&query.pattern, entry.key());
if similarity > 0.7 {
for symbol in entry.value().iter() {
if self.matches_filters(symbol, query) {
matches.push(Match {
file: symbol.location.file.clone(),
line: symbol.location.line,
column: symbol.location.column,
content: symbol.name.clone(),
score: similarity,
});
}
}
}
}
}
matches.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
if let Some(limit) = query.limit {
matches.truncate(limit);
}
Ok(matches)
}
async fn search_tantivy(&self, query: &SearchQuery) -> Result<Vec<Match>, ToolError> {
if let Some(ref tantivy) = self.tantivy_index {
tantivy.search(query).await
} else {
Err(ToolError::NotImplemented("Tantivy search not enabled"))
}
}
async fn search_ast_cache(&self, query: &SearchQuery) -> Result<Vec<Match>, ToolError> {
let mut matches = Vec::new();
for entry in self.ast_cache.iter() {
let ast = entry.value();
for symbol in &ast.symbols {
if self.matches_semantic_query(symbol, query) {
matches.push(Match {
file: symbol.location.file.clone(),
line: symbol.location.line,
column: symbol.location.column,
content: format!("{} {}", symbol.kind.as_str(), symbol.name),
score: self.calculate_semantic_score(symbol, query),
});
}
}
}
matches.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
if let Some(limit) = query.limit {
matches.truncate(limit);
}
Ok(matches)
}
async fn search_ripgrep(&self, query: &SearchQuery) -> Result<Vec<Match>, ToolError> {
let mut cmd = Command::new("rg");
cmd.arg("--json")
.arg("--with-filename")
.arg("--line-number")
.arg("--column");
if !query.case_sensitive {
cmd.arg("--ignore-case");
}
if let Some(limit) = query.limit {
cmd.arg("--max-count").arg(limit.to_string());
}
for filter in &query.file_filters {
cmd.arg("--glob").arg(filter);
}
cmd.arg(&query.pattern);
match &query.scope {
SearchScope::Workspace => {
cmd.arg(".");
}
SearchScope::Directory(path) => {
cmd.arg(path);
}
SearchScope::Files(files) => {
for file in files {
cmd.arg(file);
}
}
SearchScope::GitRepository => {
cmd.arg("--type-add")
.arg("git:*.{rs,py,js,ts,go,java,c,cpp,h}")
.arg("--type")
.arg("git")
.arg(".");
}
}
let output = tokio::time::timeout(self.config.timeout, cmd.output())
.await
.map_err(|_| ToolError::InvalidQuery("Search timeout".to_string()))?
.map_err(ToolError::Io)?;
if !output.status.success() {
return Err(ToolError::InvalidQuery(format!(
"ripgrep failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
self.parse_ripgrep_output(&output.stdout)
}
async fn search_combined(&self, query: &SearchQuery) -> Result<Vec<Match>, ToolError> {
let mut all_matches = Vec::new();
let mut any_layer_enabled = false;
if self.config.enable_symbol_index {
any_layer_enabled = true;
if let Ok(matches) = self.search_symbol_index(query).await {
all_matches.extend(matches);
}
}
if self.config.enable_tantivy && all_matches.len() < query.limit.unwrap_or(50) {
any_layer_enabled = true;
if let Ok(matches) = self.search_tantivy(query).await {
all_matches.extend(matches);
}
}
if self.config.enable_ast_cache && all_matches.len() < query.limit.unwrap_or(50) {
any_layer_enabled = true;
if let Ok(matches) = self.search_ast_cache(query).await {
all_matches.extend(matches);
}
}
if self.config.enable_ripgrep_fallback && all_matches.len() < query.limit.unwrap_or(50) {
any_layer_enabled = true;
if let Ok(matches) = self.search_ripgrep(query).await {
all_matches.extend(matches);
}
}
if !any_layer_enabled {
return Ok(Vec::new());
}
if !all_matches.is_empty() {
all_matches.sort_by(|a, b| {
match b.score.partial_cmp(&a.score).unwrap() {
std::cmp::Ordering::Equal => match a.file.cmp(&b.file) {
std::cmp::Ordering::Equal => a.line.cmp(&b.line),
other => other,
},
other => other,
}
});
all_matches
.dedup_by(|a, b| a.file == b.file && a.line == b.line && a.column == b.column);
}
if let Some(limit) = query.limit {
all_matches.truncate(limit);
}
Ok(all_matches)
}
fn select_strategy(&self, query: &SearchQuery) -> SearchStrategy {
match query.query_type {
QueryType::Symbol => {
if self.symbol_index.contains_key(&query.pattern) {
SearchStrategy::FastSymbolLookup
} else {
SearchStrategy::FullTextIndex
}
}
QueryType::Definition | QueryType::References => SearchStrategy::SemanticAnalysis,
QueryType::FullText => SearchStrategy::FullTextIndex,
QueryType::Semantic => SearchStrategy::SemanticAnalysis,
QueryType::General => {
if self.is_likely_symbol(&query.pattern) {
SearchStrategy::FastSymbolLookup
} else if query.pattern.len() < 50 && !query.pattern.contains(' ') {
SearchStrategy::FullTextIndex
} else {
SearchStrategy::Hybrid
}
}
}
}
fn is_likely_symbol(&self, pattern: &str) -> bool {
pattern
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == ':')
&& !pattern.contains(' ')
&& pattern.len() < 100
}
fn calculate_similarity(&self, a: &str, b: &str) -> f32 {
let distance = self.levenshtein_distance(a, b);
let max_len = a.len().max(b.len()) as f32;
if max_len == 0.0 {
1.0
} else {
1.0 - (distance as f32 / max_len)
}
}
fn levenshtein_distance(&self, a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let a_len = a_chars.len();
let b_len = b_chars.len();
let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
for i in 0..=a_len {
matrix[i][0] = i;
}
for j in 0..=b_len {
matrix[0][j] = j;
}
for i in 1..=a_len {
for j in 1..=b_len {
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
matrix[i][j] = (matrix[i - 1][j] + 1)
.min(matrix[i][j - 1] + 1)
.min(matrix[i - 1][j - 1] + cost);
}
}
matrix[a_len][b_len]
}
fn matches_filters(&self, symbol: &Symbol, query: &SearchQuery) -> bool {
if !query.file_filters.is_empty() {
let file_str = symbol.location.file.to_string_lossy();
if !query
.file_filters
.iter()
.any(|filter| file_str.contains(filter))
{
return false;
}
}
if !query.language_filters.is_empty()
&& let Some(ext) = symbol.location.file.extension()
{
let ext_str = ext.to_string_lossy().to_lowercase();
if !query.language_filters.contains(&ext_str) {
return false;
}
}
true
}
fn matches_semantic_query(&self, symbol: &Symbol, query: &SearchQuery) -> bool {
match query.query_type {
QueryType::Definition => {
matches!(
symbol.kind,
SymbolKind::Function | SymbolKind::Class | SymbolKind::Struct
)
}
QueryType::References => {
symbol.name.contains(&query.pattern)
}
_ => symbol.name.contains(&query.pattern),
}
}
fn calculate_semantic_score(&self, symbol: &Symbol, query: &SearchQuery) -> f32 {
let mut score = 0.0;
if symbol.name == query.pattern {
score += 1.0;
} else if symbol.name.contains(&query.pattern) {
score += 0.8;
} else {
score += self.calculate_similarity(&symbol.name, &query.pattern) * 0.6;
}
if query.query_type == QueryType::Definition {
match symbol.kind {
SymbolKind::Function | SymbolKind::Method => score += 0.2,
SymbolKind::Class | SymbolKind::Struct => score += 0.3,
_ => {}
}
}
if matches!(symbol.visibility, Visibility::Public) {
score += 0.1;
}
score.min(1.0)
}
fn parse_ripgrep_output(&self, output: &[u8]) -> Result<Vec<Match>, ToolError> {
let output_str = String::from_utf8_lossy(output);
let mut matches = Vec::new();
for line in output_str.lines() {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
&& json["type"] == "match"
&& let Some(data) = json["data"].as_object()
{
let file = PathBuf::from(data["path"]["text"].as_str().unwrap_or("").to_string());
let line_num = data["line_number"].as_u64().unwrap_or(0) as usize;
let column = data["submatches"][0]["start"].as_u64().unwrap_or(0) as usize + 1; let content = data["lines"]["text"].as_str().unwrap_or("").to_string();
matches.push(Match {
file,
line: line_num,
column,
content,
score: 0.8, });
}
}
Ok(matches)
}
async fn enhance_matches_with_context(
&self,
matches: Vec<Match>,
_query: &SearchQuery,
) -> Result<Vec<Match>, ToolError> {
Ok(matches)
}
async fn build_overall_context(
&self,
_matches: &[Match],
_query: &SearchQuery,
) -> Result<Context, ToolError> {
Ok(Context {
before: String::new(),
after: String::new(),
surrounding: Vec::new(),
location: Location {
file: PathBuf::new(),
line: 0,
column: 0,
byte_offset: 0,
},
scope: Scope {
function: None,
class: None,
module: None,
namespace: None,
},
})
}
fn detect_language(&self, query: &SearchQuery) -> Option<String> {
for filter in &query.file_filters {
if filter.ends_with(".rs") {
return Some("rust".to_string());
} else if filter.ends_with(".py") {
return Some("python".to_string());
} else if filter.ends_with(".js") || filter.ends_with(".ts") {
return Some("javascript".to_string());
}
}
None
}
fn generate_summary(&self, matches: &[Match], query: &SearchQuery) -> String {
format!(
"Found {} matches for '{}' using {} search",
matches.len(),
query.pattern,
match query.query_type {
QueryType::Symbol => "symbol",
QueryType::FullText => "full-text",
QueryType::Definition => "definition",
QueryType::References => "reference",
QueryType::Semantic => "semantic",
QueryType::General => "general",
}
)
}
fn get_cached_result(&self, query: &SearchQuery) -> Option<SearchResult> {
let cache_key = self.generate_cache_key(query);
if let Some(cached) = self.query_cache.get(&cache_key) {
if cached.timestamp.elapsed() < cached.ttl {
return Some(cached.result.clone());
}
self.query_cache.remove(&cache_key);
}
None
}
fn cache_result(&self, query: &SearchQuery, result: &SearchResult) {
if self.query_cache.len() >= self.config.max_cache_size {
if let Some(entry) = self.query_cache.iter().next() {
let key = entry.key().clone();
drop(entry);
self.query_cache.remove(&key);
}
}
let cache_key = self.generate_cache_key(query);
self.query_cache.insert(
cache_key,
CachedResult {
result: result.clone(),
timestamp: Instant::now(),
ttl: self.config.cache_ttl,
},
);
}
fn generate_cache_key(&self, query: &SearchQuery) -> String {
format!(
"{}:{}:{}:{}",
query.pattern,
query.query_type.as_str(),
query.file_filters.join(","),
query.language_filters.join(",")
)
}
pub fn add_symbol(&self, symbol: Symbol) {
self.symbol_index
.entry(symbol.name.clone())
.or_default()
.push(symbol);
}
pub fn add_ast_cache(&self, ast: CachedAst) {
self.ast_cache.insert(ast.file_path.clone(), ast);
}
pub async fn find_references(&self, symbol_name: &str) -> Result<SearchResult, ToolError> {
let query = SearchQuery {
pattern: symbol_name.to_string(),
query_type: QueryType::References,
..Default::default()
};
self.search(query).await
}
pub async fn find_definition(&self, symbol_name: &str) -> Result<SearchResult, ToolError> {
let query = SearchQuery {
pattern: symbol_name.to_string(),
query_type: QueryType::Definition,
..Default::default()
};
self.search(query).await
}
}
impl TantivySearchEngine {
pub fn new() -> Result<Self, ToolError> {
let mut schema_builder = Schema::builder();
let path = schema_builder.add_text_field("path", TEXT | STORED);
let content = schema_builder.add_text_field("content", TEXT);
let symbols = schema_builder.add_text_field("symbols", TEXT | STORED);
let ast = schema_builder.add_bytes_field("ast", STORED);
let language = schema_builder.add_text_field("language", STRING | STORED);
let line_number = schema_builder.add_u64_field("line_number", INDEXED | STORED);
let function_name = schema_builder.add_text_field("function_name", TEXT | STORED);
let class_name = schema_builder.add_text_field("class_name", TEXT | STORED);
let schema = schema_builder.build();
let index = Index::create_in_ram(schema.clone());
let reader = index
.reader()
.map_err(|e| ToolError::InvalidQuery(format!("Failed to create reader: {}", e)))?;
let writer = index
.writer(50_000_000) .map_err(|e| ToolError::InvalidQuery(format!("Failed to create writer: {}", e)))?;
Ok(Self {
index,
reader,
writer: Arc::new(tokio::sync::Mutex::new(writer)),
schema: TantivySchema {
path,
content,
symbols,
ast,
language,
line_number,
function_name,
class_name,
},
})
}
pub async fn search(&self, query: &SearchQuery) -> Result<Vec<Match>, ToolError> {
let searcher = self.reader.searcher();
let schema = &self.schema;
let tantivy_query: Box<dyn Query> = match query.query_type {
QueryType::Symbol => {
let query_parser = QueryParser::for_index(&self.index, vec![schema.symbols]);
query_parser
.parse_query(&query.pattern)
.map_err(|e| ToolError::InvalidQuery(format!("Parse error: {}", e)))?
}
QueryType::FullText => {
let query_parser = QueryParser::for_index(&self.index, vec![schema.content]);
query_parser
.parse_query(&query.pattern)
.map_err(|e| ToolError::InvalidQuery(format!("Parse error: {}", e)))?
}
_ => {
let query_parser =
QueryParser::for_index(&self.index, vec![schema.content, schema.symbols]);
query_parser
.parse_query(&query.pattern)
.map_err(|e| ToolError::InvalidQuery(format!("Parse error: {}", e)))?
}
};
let top_docs = searcher
.search(
&tantivy_query,
&TopDocs::with_limit(query.limit.unwrap_or(50)),
)
.map_err(|e| ToolError::InvalidQuery(format!("Search error: {}", e)))?;
let mut matches = Vec::new();
for (_score, doc_address) in top_docs {
let retrieved_doc: tantivy::TantivyDocument = searcher
.doc(doc_address)
.map_err(|e| ToolError::InvalidQuery(format!("Doc retrieval error: {}", e)))?;
let path = retrieved_doc
.get_first(schema.path)
.and_then(|f| f.as_str())
.unwrap_or("")
.to_string();
let line_num = retrieved_doc
.get_first(schema.line_number)
.and_then(|f| f.as_u64())
.unwrap_or(1) as usize;
let content = retrieved_doc
.get_first(schema.content)
.and_then(|f| f.as_str())
.unwrap_or("")
.to_string();
matches.push(Match {
file: PathBuf::from(path),
line: line_num,
column: 1,
content,
score: 0.9, });
}
Ok(matches)
}
pub async fn add_document(
&self,
path: &Path,
content: &str,
symbols: &[Symbol],
language: &str,
) -> Result<(), ToolError> {
let mut writer = self.writer.lock().await;
let schema = &self.schema;
let symbols_text = symbols
.iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
.join(" ");
let doc = doc!(
schema.path => path.to_string_lossy().to_string(),
schema.content => content,
schema.symbols => symbols_text,
schema.language => language,
schema.line_number => 1u64,
);
writer
.add_document(doc)
.map_err(|e| ToolError::InvalidQuery(format!("Add document error: {}", e)))?;
writer
.commit()
.map_err(|e| ToolError::InvalidQuery(format!("Commit error: {}", e)))?;
Ok(())
}
}
impl std::fmt::Debug for TantivySearchEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TantivySearchEngine")
.field("schema", &self.schema)
.finish_non_exhaustive()
}
}
impl CodeTool for MultiLayerSearchEngine {
type Query = SearchQuery;
type Output = SearchResult;
fn search(&self, query: Self::Query) -> Result<Self::Output, ToolError> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.search(query))
})
}
}
impl QueryType {
const fn as_str(&self) -> &'static str {
match self {
QueryType::Symbol => "symbol",
QueryType::FullText => "fulltext",
QueryType::Definition => "definition",
QueryType::References => "references",
QueryType::Semantic => "semantic",
QueryType::General => "general",
}
}
}
impl SymbolKind {
const fn as_str(&self) -> &'static str {
match self {
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Class => "class",
SymbolKind::Interface => "interface",
SymbolKind::Struct => "struct",
SymbolKind::Enum => "enum",
SymbolKind::Variable => "variable",
SymbolKind::Constant => "constant",
SymbolKind::Module => "module",
SymbolKind::Namespace => "namespace",
}
}
}
impl SearchQuery {
pub fn new(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
..Default::default()
}
}
pub fn symbol(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
query_type: QueryType::Symbol,
..Default::default()
}
}
pub fn full_text(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
query_type: QueryType::FullText,
..Default::default()
}
}
pub fn definition(symbol: impl Into<String>) -> Self {
Self {
pattern: symbol.into(),
query_type: QueryType::Definition,
..Default::default()
}
}
pub fn references(symbol: impl Into<String>) -> Self {
Self {
pattern: symbol.into(),
query_type: QueryType::References,
..Default::default()
}
}
pub fn with_file_filters(mut self, filters: Vec<String>) -> Self {
self.file_filters = filters;
self
}
pub fn with_language_filters(mut self, filters: Vec<String>) -> Self {
self.language_filters = filters;
self
}
pub const fn with_context_lines(mut self, lines: usize) -> Self {
self.context_lines = lines;
self
}
pub const fn with_limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
pub const fn fuzzy(mut self) -> Self {
self.fuzzy = true;
self
}
pub const fn case_insensitive(mut self) -> Self {
self.case_sensitive = false;
self
}
pub fn in_directory(mut self, path: impl Into<PathBuf>) -> Self {
self.scope = SearchScope::Directory(path.into());
self
}
pub fn in_files(mut self, files: Vec<PathBuf>) -> Self {
self.scope = SearchScope::Files(files);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_symbol_search() {
let config = SearchConfig {
enable_tantivy: false, enable_ripgrep_fallback: false, ..Default::default()
};
let engine = MultiLayerSearchEngine::new(config).unwrap();
let symbol = Symbol {
name: "test_function".to_string(),
kind: SymbolKind::Function,
location: Location {
file: PathBuf::from("test.rs"),
line: 10,
column: 5,
byte_offset: 100,
},
scope: Scope {
function: None,
class: None,
module: Some("test_module".to_string()),
namespace: None,
},
visibility: Visibility::Public,
};
engine.add_symbol(symbol);
let query = SearchQuery::symbol("test_function");
let result = engine.search(query).await.unwrap();
assert_eq!(result.result.len(), 1);
assert_eq!(result.result[0].file, PathBuf::from("test.rs"));
assert_eq!(result.result[0].line, 10);
assert_eq!(result.metadata.search_layer, SearchLayer::SymbolIndex);
}
#[tokio::test]
async fn test_fuzzy_search() {
let config = SearchConfig {
enable_tantivy: false, enable_ripgrep_fallback: false, enable_ast_cache: false, ..Default::default()
};
let engine = MultiLayerSearchEngine::new(config).unwrap();
let symbol = Symbol {
name: "calculateSum".to_string(),
kind: SymbolKind::Function,
location: Location {
file: PathBuf::from("math.js"),
line: 5,
column: 1,
byte_offset: 50,
},
scope: Scope {
function: None,
class: None,
module: None,
namespace: None,
},
visibility: Visibility::Public,
};
engine.add_symbol(symbol);
let query = SearchQuery::symbol("calculaeSum").fuzzy();
let result = engine.search(query).await.unwrap();
assert!(
!result.result.is_empty(),
"Fuzzy search should find similar symbols"
);
assert!(
result.result[0].score > 0.7,
"Score should be above 0.7 for similar match"
);
}
#[tokio::test]
async fn test_search_strategy_selection() {
let config = SearchConfig {
enable_tantivy: false, enable_ripgrep_fallback: false, ..Default::default()
};
let engine = MultiLayerSearchEngine::new(config).unwrap();
let query = SearchQuery::new("function_name");
let strategy = engine.select_strategy(&query);
assert_eq!(strategy, SearchStrategy::FastSymbolLookup);
let query = SearchQuery::new("this is a long text search query");
let strategy = engine.select_strategy(&query);
assert_eq!(strategy, SearchStrategy::Hybrid);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "Temporarily disabled due to async deadlock issue"]
async fn test_cache_functionality() {
let config = SearchConfig {
max_cache_size: 2,
cache_ttl: Duration::from_millis(100),
enable_symbol_index: true, enable_tantivy: false, enable_ripgrep_fallback: false, enable_ast_cache: false, max_results: 10,
timeout: Duration::from_secs(2), };
let engine = MultiLayerSearchEngine::new(config).unwrap();
let symbol = Symbol {
name: "test_cache_function".to_string(),
kind: SymbolKind::Function,
location: Location {
file: PathBuf::from("test.rs"),
line: 1,
column: 1,
byte_offset: 0,
},
scope: Scope {
function: None,
class: None,
module: None,
namespace: None,
},
visibility: Visibility::Public,
};
engine.add_symbol(symbol);
let query = SearchQuery::symbol("test_cache_function");
let timeout_duration = Duration::from_secs(3);
let result1 = tokio::time::timeout(timeout_duration, engine.search(query.clone()))
.await
.expect("First search timed out")
.unwrap();
assert!(!result1.result.is_empty(), "Should find the test symbol");
let result2 = tokio::time::timeout(timeout_duration, engine.search(query.clone()))
.await
.expect("Second search timed out")
.unwrap();
assert_eq!(result1.result.len(), result2.result.len());
tokio::time::sleep(Duration::from_millis(150)).await;
let result3 = tokio::time::timeout(timeout_duration, engine.search(query))
.await
.expect("Third search timed out")
.unwrap();
assert_eq!(result1.result.len(), result3.result.len());
}
#[tokio::test]
async fn test_similarity_calculation() {
let config = SearchConfig {
enable_tantivy: false, enable_ripgrep_fallback: false, ..Default::default()
};
let engine = MultiLayerSearchEngine::new(config).unwrap();
assert_eq!(engine.calculate_similarity("hello", "hello"), 1.0);
assert_eq!(engine.calculate_similarity("", ""), 1.0);
let similarity = engine.calculate_similarity("hello", "helo");
assert!(similarity >= 0.8);
let similarity = engine.calculate_similarity("test", "completely_different");
assert!(similarity < 0.3);
}
#[tokio::test]
async fn test_query_builder() {
let query = SearchQuery::symbol("test_function")
.with_file_filters(vec!["*.rs".to_string()])
.with_language_filters(vec!["rust".to_string()])
.with_context_lines(5)
.with_limit(10)
.fuzzy()
.case_insensitive()
.in_directory("/path/to/project");
assert_eq!(query.pattern, "test_function");
assert_eq!(query.query_type, QueryType::Symbol);
assert_eq!(query.file_filters, vec!["*.rs"]);
assert_eq!(query.language_filters, vec!["rust"]);
assert_eq!(query.context_lines, 5);
assert_eq!(query.limit, Some(10));
assert!(query.fuzzy);
assert!(!query.case_sensitive);
match query.scope {
SearchScope::Directory(path) => assert_eq!(path, PathBuf::from("/path/to/project")),
_ => panic!("Expected Directory scope"),
}
}
}