//! FTS5-backed search with a simple own ranking (exact > prefix > fulltext). use std::collections::HashSet; use anyhow::Result; use rusqlite::{Connection, params}; use serde::Serialize; #[derive(Debug, Serialize)] pub struct SymbolHit { pub id: i64, pub name: String, pub qualified_name: String, pub kind: String, #[serde(skip_serializing_if = "Option::is_none")] pub signature: Option, pub file: String, pub line_start: i64, pub line_end: i64, pub match_type: String, } #[derive(Debug, Serialize)] pub struct TextHit { pub file: String, pub snippet: String, } const SYMBOL_COLS: &str = "s.id, s.name, s.qualified_name, s.kind, s.signature, f.path, s.line_start, s.line_end"; pub fn search_symbols(conn: &Connection, query: &str) -> Result> { let q = query.trim(); let mut seen: HashSet = HashSet::new(); let mut out: Vec = Vec::new(); if q.is_empty() { return Ok(out); } // 1) exact name (case-insensitive) { let mut stmt = conn.prepare(&format!( "SELECT {SYMBOL_COLS} FROM symbols s JOIN files f ON f.id = s.file_id \ WHERE s.name = ?1 COLLATE NOCASE ORDER BY s.id LIMIT 200" ))?; let rows = stmt.query_map(params![q], |r| row_to_hit(r, "exact"))?; push_hits(&mut out, &mut seen, rows)?; } // 2) prefix { let pat = format!("{}%", q); let mut stmt = conn.prepare(&format!( "SELECT {SYMBOL_COLS} FROM symbols s JOIN files f ON f.id = s.file_id \ WHERE s.name LIKE ?1 COLLATE NOCASE ORDER BY s.id LIMIT 200" ))?; let rows = stmt.query_map(params![pat], |r| row_to_hit(r, "prefix"))?; push_hits(&mut out, &mut seen, rows)?; } // 3) fulltext over name/qualified_name/kind/signature { let phrase = fts_phrase(q); let mut stmt = conn.prepare(&format!( "SELECT {SYMBOL_COLS} FROM symbols_fts \ JOIN symbols s ON s.id = symbols_fts.rowid \ JOIN files f ON f.id = s.file_id \ WHERE symbols_fts MATCH ?1 ORDER BY rank LIMIT 200" ))?; let rows = stmt.query_map(params![phrase], |r| row_to_hit(r, "fulltext"))?; push_hits(&mut out, &mut seen, rows)?; } Ok(out) } pub fn search_text(conn: &Connection, query: &str) -> Result> { let q = query.trim(); if q.is_empty() { return Ok(Vec::new()); } let phrase = fts_phrase(q); let mut stmt = conn.prepare( "SELECT path, snippet(content_fts, 1, '«', '»', '…', 24) \ FROM content_fts WHERE content_fts MATCH ?1 ORDER BY rank LIMIT 100", )?; let rows = stmt.query_map(params![phrase], |r| { Ok(TextHit { file: r.get(0)?, snippet: r.get(1)?, }) })?; Ok(rows.collect::, _>>()?) } fn row_to_hit( r: &rusqlite::Row<'_>, match_type: &str, ) -> std::result::Result { Ok(SymbolHit { id: r.get(0)?, name: r.get(1)?, qualified_name: r.get(2)?, kind: r.get(3)?, signature: r.get(4)?, file: r.get(5)?, line_start: r.get(6)?, line_end: r.get(7)?, match_type: match_type.to_string(), }) } fn push_hits( out: &mut Vec, seen: &mut HashSet, rows: impl Iterator>, ) -> Result<()> { for r in rows { let hit = r?; if seen.insert(hit.id) { out.push(hit); } } Ok(()) } /// Wrap a user query as a safe FTS5 phrase. fn fts_phrase(q: &str) -> String { format!("\"{}\"", q.replace('"', "\"\"")) }