Skip to main content

code_kb_core/
queries.rs

1use rusqlite::{Connection, Row, ToSql, params};
2use rust_stemmers::{Algorithm, Stemmer};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5
6use crate::db::local_variable_predicate;
7use crate::models::{
8    BlastRadiusResult, FileFact, ImpactedSymbol, LiteralFact, ReferenceSite, SearchExplain,
9    StructuralFact, Symbol, SymbolSearchResult, TestTarget, TypeFact,
10};
11
12#[derive(Debug, Error)]
13pub enum QueryError {
14    #[error("Database query error: {0}")]
15    Sqlite(#[from] rusqlite::Error),
16    #[error("Symbol '{0}' not found")]
17    SymbolNotFound(String),
18    #[error("Symbol '{0}' not found. Did you mean one of:\n{1}")]
19    SymbolNotFoundWithSuggestions(String, String),
20    #[error(
21        "Ambiguous symbol '{0}': found {1} matching candidates. Specify file_path or qualified name to disambiguate:\n{2}"
22    )]
23    AmbiguousSymbol(String, usize, String),
24    #[error("Invalid direction '{0}': must be 'callers' or 'callees'")]
25    InvalidDirection(String),
26    #[error("Result limit must be between 0 and {MAX_RESULT_LIMIT}, got {0}")]
27    InvalidResultLimit(usize),
28}
29
30pub const MAX_RESULT_LIMIT: usize = 200;
31
32pub fn validate_result_limit(limit: usize) -> Result<(), QueryError> {
33    if limit > MAX_RESULT_LIMIT {
34        return Err(QueryError::InvalidResultLimit(limit));
35    }
36    Ok(())
37}
38
39fn map_symbol(row: &Row) -> rusqlite::Result<Symbol> {
40    Ok(Symbol {
41        symbol_id: row.get("symbol_id")?,
42        file_id: row.get("file_id")?,
43        path: row.get::<_, String>("path")?.replace('\\', "/"),
44        language: row.get("language")?,
45        name: row.get("name")?,
46        kind: row.get("kind")?,
47        signature: row.get("signature")?,
48        doc_comment: row.get("doc_comment")?,
49        visibility: row.get("visibility")?,
50        parent_symbol_id: row.get("parent_symbol_id")?,
51        start_line: row.get::<_, i64>("start_line")? as usize,
52        start_column: row.get::<_, i64>("start_column")? as usize,
53        end_line: row.get::<_, i64>("end_line")? as usize,
54        end_column: row.get::<_, i64>("end_column")? as usize,
55        start_byte: row.get::<_, i64>("start_byte")? as usize,
56        end_byte: row.get::<_, i64>("end_byte")? as usize,
57        body_start_line: row
58            .get::<_, Option<i64>>("body_start_line")?
59            .map(|v| v as usize),
60        body_start_column: row
61            .get::<_, Option<i64>>("body_start_column")?
62            .map(|v| v as usize),
63        body_end_line: row
64            .get::<_, Option<i64>>("body_end_line")?
65            .map(|v| v as usize),
66        body_end_column: row
67            .get::<_, Option<i64>>("body_end_column")?
68            .map(|v| v as usize),
69        body_start_byte: row
70            .get::<_, Option<i64>>("body_start_byte")?
71            .map(|v| v as usize),
72        body_end_byte: row
73            .get::<_, Option<i64>>("body_end_byte")?
74            .map(|v| v as usize),
75        body_hash: row.get("body_hash")?,
76        semantic_group: row.get("semantic_group")?,
77        is_test: row.get::<_, i64>("is_test")? != 0,
78        test_container: row.get::<_, i64>("test_container")? != 0,
79    })
80}
81
82pub(crate) fn escape_like(value: &str) -> String {
83    value
84        .replace('\\', "\\\\")
85        .replace('%', "\\%")
86        .replace('_', "\\_")
87}
88
89/// Retrieve indexed files optionally scoped by path filter, pushed down to SQLite.
90pub fn load_scoped_files(
91    conn: &Connection,
92    path_filter: Option<&str>,
93) -> Result<Vec<FileFact>, QueryError> {
94    let norm = path_filter
95        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
96        .filter(|p| !p.is_empty());
97    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
98    let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
99    let prefix_bs = norm_bs
100        .as_ref()
101        .map(|path| format!("{}\\\\%", escape_like(path)));
102
103    let sql = "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
104               FROM files
105               WHERE (:path IS NULL
106                  OR path = :path COLLATE NOCASE
107                  OR path = :path_bs COLLATE NOCASE
108                  OR path LIKE :path_prefix ESCAPE '\\'
109                  OR path LIKE :path_prefix_bs ESCAPE '\\')
110               ORDER BY (:path IS NOT NULL AND (path = :path OR path = :path_bs)) DESC, path ASC";
111
112    let mut stmt = conn.prepare(sql)?;
113    let files = stmt
114        .query_map(
115            rusqlite::named_params! {
116                ":path": norm.as_deref(),
117                ":path_bs": norm_bs.as_deref(),
118                ":path_prefix": prefix.as_deref(),
119                ":path_prefix_bs": prefix_bs.as_deref(),
120            },
121            |row| {
122                Ok(FileFact {
123                    file_id: row.get(0)?,
124                    path: row.get::<_, String>(1)?.replace('\\', "/"),
125                    language: row.get(2)?,
126                    content_hash: row.get(3)?,
127                    content_bytes: row.get(4)?,
128                    line_count: row.get(5)?,
129                    indexed_at: row.get(6)?,
130                })
131            },
132        )?
133        .collect::<Result<Vec<_>, _>>()?;
134
135    Ok(files)
136}
137
138/// Load up to `limit_per_file` symbols per file for scoped files, directly aggregated in SQLite.
139/// Files deeper than `depth` are filtered out in SQLite to keep memory strictly bounded.
140pub fn load_scoped_outline_symbols(
141    conn: &Connection,
142    path_filter: Option<&str>,
143    depth: usize,
144    limit_per_file: usize,
145) -> Result<HashMap<String, Vec<Symbol>>, QueryError> {
146    let norm = path_filter
147        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
148        .filter(|p| !p.is_empty());
149    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
150    let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
151    let prefix_bs = norm_bs
152        .as_ref()
153        .map(|path| format!("{}\\\\%", escape_like(path)));
154
155    let max_slashes = match &norm {
156        None => {
157            if depth > 0 {
158                (depth - 1) as i64
159            } else {
160                0
161            }
162        }
163        Some(f) => {
164            let filter_slashes = f.chars().filter(|&c| c == '/').count();
165            (filter_slashes + depth) as i64
166        }
167    };
168
169    let sql = "
170        WITH bounded_files AS (
171            SELECT path FROM files
172            WHERE (:path IS NULL
173               OR path = :path COLLATE NOCASE
174               OR path = :path_bs COLLATE NOCASE
175               OR path LIKE :path_prefix ESCAPE '\\'
176               OR path LIKE :path_prefix_bs ESCAPE '\\')
177            ORDER BY path ASC
178            LIMIT 1000
179        ),
180        ranked AS (
181            SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
182                   s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
183                   s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
184                   s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
185                   s.is_test, s.test_container,
186                   ROW_NUMBER() OVER (PARTITION BY s.path ORDER BY s.start_line ASC) as rn
187            FROM symbols s
188            JOIN bounded_files bf ON (s.path = bf.path COLLATE NOCASE OR replace(s.path, '\\', '/') = replace(bf.path, '\\', '/') COLLATE NOCASE)
189            WHERE (length(s.path) - length(replace(replace(s.path, '/', ''), '\\', '')) <= :max_slashes)
190              AND s.kind IN ('function', 'method', 'struct', 'enum', 'trait', 'class', 'interface', 'type')
191              AND s.parent_symbol_id IS NULL
192        )
193        SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
194               visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
195               start_byte, end_byte, body_start_line, body_start_column, body_end_line,
196               body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
197               is_test, test_container
198        FROM ranked
199        WHERE rn <= :limit
200        ORDER BY path ASC, start_line ASC
201    ";
202
203    let mut stmt = conn.prepare(sql)?;
204    let mut rows = stmt.query(rusqlite::named_params! {
205        ":path": norm.as_deref(),
206        ":path_bs": norm_bs.as_deref(),
207        ":path_prefix": prefix.as_deref(),
208        ":path_prefix_bs": prefix_bs.as_deref(),
209        ":max_slashes": max_slashes,
210        ":limit": limit_per_file as i64,
211    })?;
212
213    let mut symbols_by_file: HashMap<String, Vec<Symbol>> = HashMap::new();
214    while let Some(row) = rows.next()? {
215        let sym = map_symbol(row)?;
216        symbols_by_file
217            .entry(sym.path.clone())
218            .or_default()
219            .push(sym);
220    }
221
222    Ok(symbols_by_file)
223}
224
225/// Lookup single file metadata by path with slash-boundary matching.
226pub fn get_file(conn: &Connection, path: &str) -> Result<Option<FileFact>, QueryError> {
227    let normalized = path.replace('\\', "/");
228    let backslash = path.replace('/', "\\");
229
230    // Check exact path match first, prioritizing exact case before case-insensitive fallback
231    let mut stmt = conn.prepare(
232        "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
233         FROM files
234         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
235         ORDER BY (path = ?1 OR path = ?2) DESC
236         LIMIT 1",
237    )?;
238
239    let mut rows = stmt.query(params![normalized, backslash])?;
240    if let Some(row) = rows.next()? {
241        Ok(Some(FileFact {
242            file_id: row.get(0)?,
243            path: row.get::<_, String>(1)?.replace('\\', "/"),
244            language: row.get(2)?,
245            content_hash: row.get(3)?,
246            content_bytes: row.get(4)?,
247            line_count: row.get(5)?,
248            indexed_at: row.get(6)?,
249        }))
250    } else {
251        Ok(None)
252    }
253}
254
255/// Count parse diagnostics recorded for a file, returning 0 when the index has none.
256pub fn count_parse_diagnostics(conn: &Connection, path: &str) -> usize {
257    conn.query_row(
258        "SELECT COUNT(*) FROM parse_diagnostics
259         WHERE path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE",
260        params![path.replace('\\', "/"), path.replace('/', "\\")],
261        |row| row.get::<_, i64>(0),
262    )
263    .map(|count| count as usize)
264    .unwrap_or(0)
265}
266
267/// Count files julie could not parse under a path, returning 0 when the index has none.
268pub fn count_unsupported_files(conn: &Connection, path_filter: Option<&str>) -> usize {
269    let norm = path_filter
270        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
271        .filter(|p| !p.is_empty());
272    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
273    let prefix = norm.as_ref().map(|p| format!("{}/%", escape_like(p)));
274    let prefix_bs = norm_bs.as_ref().map(|p| format!("{}\\\\%", escape_like(p)));
275
276    conn.query_row(
277        "SELECT COUNT(*) FROM files
278         WHERE status = 'unsupported'
279           AND (:path IS NULL
280             OR path = :path COLLATE NOCASE
281             OR path = :path_bs COLLATE NOCASE
282             OR path LIKE :path_prefix ESCAPE '\\'
283             OR path LIKE :path_prefix_bs ESCAPE '\\')",
284        rusqlite::named_params! {
285            ":path": norm.as_deref(),
286            ":path_bs": norm_bs.as_deref(),
287            ":path_prefix": prefix.as_deref(),
288            ":path_prefix_bs": prefix_bs.as_deref(),
289        },
290        |row| row.get::<_, i64>(0),
291    )
292    .map(|count| count as usize)
293    .unwrap_or(0)
294}
295
296/// Load all symbols declared inside a specific file.
297pub fn load_file_symbols(conn: &Connection, file_path: &str) -> Result<Vec<Symbol>, QueryError> {
298    // Normalizing slashes for path matching
299    let normalized = file_path.replace('\\', "/");
300    let backslash = file_path.replace('/', "\\");
301
302    // Try exact case matching first to avoid conflating sibling files on case-sensitive filesystems
303    let mut stmt = conn.prepare(
304        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
305                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
306                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
307                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
308                is_test, test_container
309         FROM symbols
310         WHERE (path = ?1 OR path = ?2)
311         ORDER BY start_line ASC, start_column ASC",
312    )?;
313
314    let rows = stmt
315        .query_map(params![&normalized, &backslash], map_symbol)?
316        .collect::<Result<Vec<_>, _>>()?;
317
318    if !rows.is_empty() {
319        return Ok(rows);
320    }
321
322    // Fall back to case-insensitive match (for Windows or case-variant requests)
323    let mut stmt = conn.prepare(
324        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
325                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
326                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
327                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
328                is_test, test_container
329         FROM symbols
330         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
331         ORDER BY start_line ASC, start_column ASC",
332    )?;
333
334    let rows = stmt
335        .query_map(params![normalized, backslash], map_symbol)?
336        .collect::<Result<Vec<_>, _>>()?;
337
338    Ok(rows)
339}
340
341/// Normalizes common symbol kind aliases to their canonical database representation.
342pub fn normalize_kind(kind: &str) -> String {
343    let lower = kind.trim().to_lowercase();
344    match lower.as_str() {
345        "fn" | "func" | "function" => "function".to_string(),
346        "method" => "method".to_string(),
347        "struct" => "struct".to_string(),
348        "class" => "class".to_string(),
349        "enum" => "enum".to_string(),
350        "trait" => "trait".to_string(),
351        "interface" => "interface".to_string(),
352        "type" | "typedef" => "type".to_string(),
353        "mod" | "module" => "module".to_string(),
354        "const" | "constant" => "constant".to_string(),
355        "var" | "variable" => "variable".to_string(),
356        _ => lower,
357    }
358}
359
360/// Search symbols by name query, kind filter, and test flag.
361pub fn search_symbols(
362    conn: &Connection,
363    query: &str,
364    kind_filter: Option<&str>,
365    include_tests: bool,
366    limit: usize,
367) -> Result<Vec<Symbol>, QueryError> {
368    search_symbols_scoped(conn, query, kind_filter, None, include_tests, limit)
369}
370
371/// Search symbols with optional path scoping filter. Locals and parameters are left out unless
372/// the caller passes `kind = "variable"` or names one explicitly as a qualified name such as
373/// `open_conn::conn`. With `kind = "variable"` they match by name only, because they are not in
374/// the full-text index.
375pub fn search_symbols_scoped(
376    conn: &Connection,
377    query: &str,
378    kind_filter: Option<&str>,
379    path_filter: Option<&str>,
380    include_tests: bool,
381    limit: usize,
382) -> Result<Vec<Symbol>, QueryError> {
383    validate_result_limit(limit)?;
384    if limit == 0 {
385        return Ok(Vec::new());
386    }
387    let norm_kind = kind_filter.map(normalize_kind);
388    if (query.contains("::") || query.contains('.'))
389        && let Some(sym) = get_symbol_by_name(conn, query, path_filter)?
390    {
391        let kind_matches = norm_kind.as_deref().is_none_or(|kind| sym.kind == kind);
392        let test_matches =
393            include_tests || (!sym.is_test && !sym.test_container && !is_test_path(&sym.path));
394        return Ok(if kind_matches && test_matches {
395            vec![sym]
396        } else {
397            Vec::new()
398        });
399    }
400
401    let pattern = format!("%{}%", escape_like(query));
402    let normalized_path = path_filter.map(|p| {
403        p.replace('\\', "/")
404            .trim_start_matches("./")
405            .trim_matches('/')
406            .to_string()
407    });
408    let escaped_path = normalized_path.as_deref().map(escape_like);
409
410    let mut sql = String::from(
411        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
412                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
413                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
414                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
415                is_test, test_container
416         FROM symbols s
417         WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
418           AND (:kind IS NULL OR kind = :kind)
419           AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
420    );
421
422    if norm_kind.as_deref() != Some("variable") {
423        sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
424    }
425
426    if !include_tests {
427        sql.push_str(" AND is_test = 0 AND test_container = 0");
428        sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
429    }
430
431    sql.push_str(
432        " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
433    );
434    sql.push_str(&limit.to_string());
435
436    let mut stmt = conn.prepare(&sql)?;
437
438    let path_val = normalized_path.as_deref();
439    let path_like = escaped_path.as_deref();
440    let kind_val = norm_kind.as_deref();
441    let rows = stmt
442        .query_map(
443            rusqlite::named_params! {
444                ":query": query,
445                ":pattern": pattern,
446                ":kind": kind_val,
447                ":path": path_val,
448                ":path_like": path_like,
449            },
450            map_symbol,
451        )?
452        .collect::<Result<Vec<_>, _>>()?;
453
454    Ok(rows)
455}
456
457/// Sanitizes a free-form user query into `(and_query, or_query)` formatted for SQLite FTS5.
458/// Each alphanumeric/underscore token is quoted and given a prefix wildcard: `"token"*`.
459/// Identifiers are split at case boundaries too (`parseHTTPResponse` -> `parse HTTP Response`),
460/// so a camelCase query finds a snake_case symbol. Each split identifier keeps its unsplit form
461/// as an alternative inside its own required term, so a camelCase symbol, which FTS5 indexes as
462/// one token, still matches. A query of two or three words also tries their concatenation.
463/// English stop words are dropped unless the whole query is stop words, and tokens under three
464/// characters get no prefix wildcard.
465pub fn sanitize_fts5_query(query: &str) -> (String, String) {
466    let raw_words = query_words(query);
467    let split: Vec<(Vec<&str>, Option<&str>)> = raw_words
468        .iter()
469        .map(|raw| {
470            let parts = split_identifier(raw);
471            let whole = (parts.len() > 1).then_some(*raw);
472            (parts, whole)
473        })
474        .collect();
475    let any_content = split
476        .iter()
477        .any(|(parts, _)| parts.iter().any(|p| !is_stop_word(p)));
478
479    let mut and_groups: Vec<String> = Vec::new();
480    let mut or_terms: Vec<String> = Vec::new();
481    for (parts, whole) in split {
482        let parts: Vec<String> = parts
483            .into_iter()
484            .filter(|p| !any_content || !is_stop_word(p))
485            .map(fts5_term)
486            .collect();
487        let whole = whole.map(fts5_term);
488        let group = match (parts.is_empty(), whole.as_deref()) {
489            (true, None) => continue,
490            (true, Some(w)) => w.to_string(),
491            (false, None) => parts.join(" "),
492            (false, Some(w)) => format!("(({}) OR {w})", parts.join(" ")),
493        };
494        and_groups.push(group);
495        or_terms.extend(parts);
496        or_terms.extend(whole);
497    }
498
499    if and_groups.is_empty() {
500        return (String::new(), String::new());
501    }
502
503    let mut and_query = and_groups.join(" AND ");
504    if (2..=3).contains(&raw_words.len()) {
505        let all: String = raw_words.concat();
506        if all.len() <= 64 {
507            let all = fts5_term(&all);
508            and_query = format!("({and_query}) OR {all}");
509            or_terms.push(all);
510        }
511    }
512    (and_query, or_terms.join(" OR "))
513}
514
515const STOP_WORDS: &[&str] = &[
516    "a", "about", "after", "again", "all", "already", "also", "always", "an", "and", "another",
517    "any", "are", "as", "at", "be", "because", "been", "before", "being", "between", "both", "but",
518    "by", "can", "could", "did", "do", "does", "each", "either", "else", "ever", "every", "for",
519    "from", "had", "has", "have", "here", "how", "if", "in", "instead", "is", "it", "its",
520    "itself", "just", "many", "may", "might", "more", "most", "much", "must", "neither", "never",
521    "no", "nor", "not", "of", "on", "once", "one", "only", "or", "other", "our", "per", "rather",
522    "same", "should", "since", "so", "some", "still", "such", "than", "that", "the", "their",
523    "them", "then", "there", "these", "they", "this", "those", "through", "to", "too", "two",
524    "until", "very", "via", "was", "we", "were", "what", "when", "where", "whether", "which",
525    "while", "who", "whom", "why", "will", "with", "within", "without", "would", "yet", "you",
526    "your",
527];
528
529fn is_stop_word(word: &str) -> bool {
530    STOP_WORDS.contains(&word.to_ascii_lowercase().as_str())
531}
532
533/// Splits a query at every character that is not alphanumeric or `_`.
534fn query_words(query: &str) -> Vec<&str> {
535    query
536        .split(|c: char| !c.is_alphanumeric() && c != '_')
537        .filter(|s| !s.is_empty())
538        .collect()
539}
540
541/// Lowercase terms of three or more characters for the trigram name index: every query word
542/// and every identifier part of it (`collapse_name` -> `collapse_name`, `collapse`, `name`),
543/// deduplicated. Stop words are dropped unless every term is a stop word. Empty when no term
544/// qualifies.
545fn trigram_name_terms(query: &str) -> Vec<String> {
546    let mut terms: Vec<String> = Vec::new();
547    for word in query_words(query) {
548        for term in std::iter::once(word).chain(split_identifier(word)) {
549            let lower = term.to_lowercase();
550            if lower.chars().count() >= 3 && !terms.contains(&lower) {
551                terms.push(lower);
552            }
553        }
554    }
555    let any_content = terms.iter().any(|t| !is_stop_word(t));
556    terms.retain(|t| !any_content || !is_stop_word(t));
557    terms
558}
559
560/// Quotes one token for FTS5 with a prefix wildcard, except for tokens under three characters.
561fn fts5_term(token: &str) -> String {
562    if token.chars().count() < 3 {
563        format!("\"{token}\"")
564    } else {
565        format!("\"{token}\"*")
566    }
567}
568
569/// FTS5 query for a symbol name as typed: no case splitting, no stop words.
570/// `isReady` -> `"isReady"*`, so related-test lookup stays as strict as the name.
571fn name_prefix_query(name: &str) -> String {
572    name.split(|c: char| !c.is_alphanumeric() && c != '_')
573        .filter(|s| !s.is_empty())
574        .map(|s| format!("\"{s}\"*"))
575        .collect::<Vec<_>>()
576        .join(" ")
577}
578
579/// Splits one identifier into words at `_`, digit runs, and case boundaries.
580/// `parseHTTPResponse2` -> `["parse", "HTTP", "Response", "2"]`.
581fn split_identifier(word: &str) -> Vec<&str> {
582    let mut out = Vec::new();
583    split_identifier_into(word, &mut out);
584    out
585}
586
587/// `split_identifier` that appends to a caller-owned vector, so tokenizing a whole doc
588/// comment costs one allocation instead of one per word.
589fn split_identifier_into<'a>(word: &'a str, out: &mut Vec<&'a str>) {
590    let mut chars = word.char_indices().peekable();
591    let Some((_, first)) = chars.next() else {
592        return;
593    };
594    let mut prev = char_class(first);
595    let mut start = 0;
596    while let Some((idx, c)) = chars.next() {
597        let cur = char_class(c);
598        let next = chars.peek().map_or(OTHER, |(_, n)| char_class(*n));
599        if identifier_boundary(prev, cur, next) {
600            push_piece(out, &word[start..idx]);
601            start = idx;
602        }
603        prev = cur;
604    }
605    push_piece(out, &word[start..]);
606}
607
608const OTHER: u8 = 0;
609const UNDERSCORE: u8 = 1;
610const UPPER: u8 = 2;
611const LOWER: u8 = 3;
612const DIGIT: u8 = 4;
613
614fn char_class(c: char) -> u8 {
615    if c == '_' {
616        UNDERSCORE
617    } else if c.is_uppercase() {
618        UPPER
619    } else if c.is_lowercase() {
620        LOWER
621    } else if c.is_ascii_digit() {
622        DIGIT
623    } else {
624        OTHER
625    }
626}
627
628fn byte_class(b: u8) -> u8 {
629    match b {
630        b'_' => UNDERSCORE,
631        b'A'..=b'Z' => UPPER,
632        b'a'..=b'z' => LOWER,
633        b'0'..=b'9' => DIGIT,
634        _ => OTHER,
635    }
636}
637
638/// The identifier split rule over character classes: `_` on either side, lower/digit to
639/// upper, the last upper of an acronym before a lower (`HTTPResponse`), and digit runs.
640fn identifier_boundary(prev: u8, cur: u8, next: u8) -> bool {
641    cur == UNDERSCORE
642        || prev == UNDERSCORE
643        || (cur == UPPER && (prev == LOWER || prev == DIGIT))
644        || (cur == UPPER && prev == UPPER && next == LOWER)
645        || ((cur == DIGIT) != (prev == DIGIT))
646}
647
648fn push_piece<'a>(out: &mut Vec<&'a str>, piece: &'a str) {
649    if !piece.is_empty() && piece != "_" {
650        out.push(piece);
651    }
652}
653
654/// Tokens of a signature or doc comment: the text split at non-word characters, each word
655/// split like an identifier. ASCII text is walked byte by byte in one pass; other text takes
656/// the char path with the same boundary rule.
657fn text_tokens_into<'a>(text: &'a str, out: &mut Vec<&'a str>) {
658    if !text.is_ascii() {
659        for word in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
660            split_identifier_into(word, out);
661        }
662        return;
663    }
664    let bytes = text.as_bytes();
665    let mut start: Option<usize> = None;
666    let mut prev = OTHER;
667    for (i, &b) in bytes.iter().enumerate() {
668        let cur = byte_class(b);
669        if cur == OTHER {
670            if let Some(s) = start.take() {
671                push_piece(out, &text[s..i]);
672            }
673            continue;
674        }
675        match start {
676            None => start = Some(i),
677            Some(s) => {
678                let next = bytes.get(i + 1).map_or(OTHER, |n| byte_class(*n));
679                if identifier_boundary(prev, cur, next) {
680                    push_piece(out, &text[s..i]);
681                    start = Some(i);
682                }
683            }
684        }
685        prev = cur;
686    }
687    if let Some(s) = start {
688        push_piece(out, &text[s..]);
689    }
690}
691
692/// One admitted search row with the recall branches that reached it.
693/// `result.score` is the word-branch BM25 for word rows and `0.0` otherwise until the
694/// rerank replaces it.
695pub(crate) struct Candidate {
696    pub result: SymbolSearchResult,
697    pub bm25: Option<f64>,
698    pub exact_name: bool,
699    pub word_match: bool,
700    pub name_match: bool,
701    pub name_terms: Vec<String>,
702    pub documentation: bool,
703}
704
705fn candidate_columns(conn: &Connection) -> String {
706    format!(
707        "s.rowid AS row_id, s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind,
708                s.signature, s.doc_comment, s.visibility, s.parent_symbol_id, s.start_line,
709                s.start_column, s.end_line, s.end_column, s.start_byte, s.end_byte,
710                s.body_start_line, s.body_start_column, s.body_end_line, s.body_end_column,
711                s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group, s.is_test,
712                s.test_container,
713                (s.language IN ({doc_langs}) OR NOT ({not_doc})) AS documentation",
714        doc_langs = documentation_language_list(),
715        not_doc = not_documentation(conn, "s")
716    )
717}
718
719fn candidate_filters(searching_variables: bool, include_tests: bool) -> String {
720    let mut sql = String::from(
721        " AND (:kind IS NULL OR s.kind = :kind)
722          AND (:path IS NULL OR replace(s.path, '\\', '/') = :path COLLATE NOCASE OR replace(s.path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(s.path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
723    );
724    if !searching_variables {
725        sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
726    }
727    if !include_tests {
728        // Unary `+` keeps the planner off the test-flag indexes: without ANALYZE statistics it
729        // would otherwise prefer them over the name index and walk nearly every row.
730        sql.push_str(" AND +s.is_test = 0 AND +s.test_container = 0");
731        sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
732    }
733    sql
734}
735
736/// Runs the word, trigram-name, and exact-name branches with the same filters and merges
737/// them by `rowid`. The word branch admits the rows that match every query word first and
738/// then fills its cap with rows that match any word, so one full match never hides a
739/// better partial one. Word BM25 exists only for word rows and never orders across branches.
740/// A `variable` kind filter adds locals and parameters by name, because the FTS tables
741/// exclude them.
742pub(crate) fn collect_search_candidates(
743    conn: &Connection,
744    query: &str,
745    kind_filter: Option<&str>,
746    path_filter: Option<&str>,
747    include_tests: bool,
748    limit: usize,
749) -> Result<Vec<Candidate>, QueryError> {
750    let (and_q, or_q) = sanitize_fts5_query(query);
751    let terms = trigram_name_terms(query);
752    let normalized_path = path_filter.map(|p| {
753        p.replace('\\', "/")
754            .trim_start_matches("./")
755            .trim_matches('/')
756            .to_string()
757    });
758    let escaped_path = normalized_path.as_deref().map(escape_like);
759    let norm_kind = kind_filter.map(normalize_kind);
760    let searching_variables = norm_kind.as_deref() == Some("variable");
761    let path_val = normalized_path.as_deref();
762    let path_like = escaped_path.as_deref();
763    let kind_val = norm_kind.as_deref();
764    let columns = candidate_columns(conn);
765    let filters = candidate_filters(searching_variables, include_tests);
766    let word_cap = (limit * 4).clamp(40, 160);
767    let name_cap = (limit * 2).clamp(20, 40);
768
769    let new_candidate = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
770        let symbol = map_symbol(row)?;
771        let lower_name = symbol.name.to_lowercase();
772        let name_terms = terms
773            .iter()
774            .filter(|t| lower_name.contains(t.as_str()))
775            .cloned()
776            .collect();
777        let candidate = Candidate {
778            result: SymbolSearchResult {
779                symbol,
780                score: 0.0,
781                snippet: None,
782                explain: None,
783            },
784            bm25: None,
785            exact_name: false,
786            word_match: false,
787            name_match: false,
788            name_terms,
789            documentation: row.get::<_, Option<i64>>("documentation")? == Some(1),
790        };
791        Ok((row.get("row_id")?, candidate))
792    };
793
794    let mut candidates: Vec<Candidate> = Vec::new();
795    let mut by_rowid: HashMap<i64, usize> = HashMap::new();
796    let mut admit = |rowid: i64, incoming: Candidate| match by_rowid.get(&rowid).copied() {
797        Some(i) => {
798            let existing = &mut candidates[i];
799            existing.exact_name |= incoming.exact_name;
800            existing.word_match |= incoming.word_match;
801            existing.name_match |= incoming.name_match;
802            if incoming.bm25.is_some() && existing.bm25.is_none() {
803                existing.bm25 = incoming.bm25;
804                existing.result = incoming.result;
805            }
806        }
807        None => {
808            by_rowid.insert(rowid, candidates.len());
809            candidates.push(incoming);
810        }
811    };
812
813    let has_trigram = has_table(conn, "symbol_names_tri");
814    let exact_query = query.trim();
815    let exact_phrase = format!("\"{}\"", exact_query.replace('"', "\"\""));
816    let exact_via_trigram = has_trigram && exact_query.chars().count() >= 3;
817    let exact_sql = if exact_via_trigram {
818        format!(
819            "SELECT {columns} FROM symbol_names_tri
820             CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
821             WHERE symbol_names_tri MATCH :exact AND length(s.name) = length(:query) {filters}
822             ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
823        )
824    } else {
825        format!(
826            "SELECT {columns} FROM symbols s WHERE s.name = :query {filters}
827             ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
828        )
829    };
830    let mut exact_params: Vec<(&str, &dyn ToSql)> = vec![
831        (":query", &exact_query),
832        (":kind", &kind_val),
833        (":path", &path_val),
834        (":path_like", &path_like),
835    ];
836    if exact_via_trigram {
837        exact_params.push((":exact", &exact_phrase));
838    }
839    let exact_rows = conn
840        .prepare(&exact_sql)?
841        .query_map(exact_params.as_slice(), new_candidate)?
842        .collect::<Result<Vec<_>, _>>()?;
843    for (rowid, mut candidate) in exact_rows {
844        candidate.exact_name = true;
845        admit(rowid, candidate);
846    }
847
848    if searching_variables {
849        let pattern = format!("%{}%", escape_like(exact_query));
850        let local_sql = format!(
851            "SELECT {columns} FROM symbols s
852             WHERE {local} AND (s.name = :query OR s.name LIKE :pattern ESCAPE '\\') {filters}
853             ORDER BY (s.name = :query) DESC, length(s.name) ASC, s.path ASC LIMIT {limit}",
854            local = local_variable_predicate("s")
855        );
856        let local_rows = conn
857            .prepare(&local_sql)?
858            .query_map(
859                rusqlite::named_params! {
860                    ":query": exact_query,
861                    ":pattern": pattern,
862                    ":kind": kind_val,
863                    ":path": path_val,
864                    ":path_like": path_like,
865                },
866                new_candidate,
867            )?
868            .collect::<Result<Vec<_>, _>>()?;
869        for (rowid, mut candidate) in local_rows {
870            candidate.exact_name = candidate.result.symbol.name == exact_query;
871            candidate.name_match = true;
872            admit(rowid, candidate);
873        }
874    }
875
876    let word_sql = format!(
877        "SELECT {columns},
878                bm25(symbols_fts, 10.0, 5.0, 1.0) AS rank_score,
879                snippet(symbols_fts, 2, '[', ']', '...', 12) AS doc_snippet,
880                snippet(symbols_fts, 1, '[', ']', '...', 12) AS sig_snippet,
881                snippet(symbols_fts, 0, '[', ']', '...', 12) AS name_snippet
882         FROM symbols_fts
883         CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
884         WHERE symbols_fts MATCH :match {filters}
885         ORDER BY (s.kind = 'import') ASC, (s.language IN ({doc_langs})) ASC, {not_doc} DESC, (s.name = :query COLLATE NOCASE) DESC, rank_score ASC LIMIT {word_cap}",
886        doc_langs = documentation_language_list(),
887        not_doc = not_documentation(conn, "s")
888    );
889    let word_rows = |match_clause: &str| -> Result<Vec<(i64, Candidate)>, QueryError> {
890        let map_fn = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
891            let (rowid, mut candidate) = new_candidate(row)?;
892            let score: f64 = row.get("rank_score")?;
893            let doc_snip: Option<String> = row.get("doc_snippet").ok();
894            let sig_snip: Option<String> = row.get("sig_snippet").ok();
895            let name_snip: Option<String> = row.get("name_snippet").ok();
896            let highlighted = |s: &Option<String>| s.as_ref().is_some_and(|s| s.contains('['));
897            candidate.result.snippet = if highlighted(&doc_snip) {
898                doc_snip
899            } else if highlighted(&sig_snip) {
900                sig_snip
901            } else if highlighted(&name_snip) {
902                name_snip
903            } else {
904                doc_snip.or(sig_snip).or(name_snip)
905            };
906            candidate.result.score = score;
907            candidate.bm25 = Some(score);
908            candidate.word_match = true;
909            Ok((rowid, candidate))
910        };
911        Ok(conn
912            .prepare(&word_sql)?
913            .query_map(
914                rusqlite::named_params! {
915                    ":match": match_clause,
916                    ":query": query.trim(),
917                    ":kind": kind_val,
918                    ":path": path_val,
919                    ":path_like": path_like,
920                },
921                map_fn,
922            )?
923            .collect::<Result<Vec<_>, _>>()?)
924    };
925    if !and_q.is_empty() {
926        let and_rows = word_rows(&and_q)?;
927        let mut word_admitted: HashSet<i64> = and_rows.iter().map(|(rowid, _)| *rowid).collect();
928        for (rowid, candidate) in and_rows {
929            admit(rowid, candidate);
930        }
931        if and_q != or_q {
932            for (rowid, candidate) in word_rows(&or_q)? {
933                if word_admitted.len() >= word_cap && !word_admitted.contains(&rowid) {
934                    break;
935                }
936                word_admitted.insert(rowid);
937                admit(rowid, candidate);
938            }
939        }
940    }
941
942    if !terms.is_empty() && has_trigram {
943        let match_clause = terms
944            .iter()
945            .map(|t| format!("\"{t}\""))
946            .collect::<Vec<_>>()
947            .join(" OR ");
948        let name_sql = format!(
949            "SELECT {columns} FROM symbol_names_tri
950             CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
951             WHERE symbol_names_tri MATCH :match {filters}
952             ORDER BY bm25(symbol_names_tri) ASC, length(s.name) ASC, s.path ASC LIMIT {name_cap}"
953        );
954        let name_rows = conn
955            .prepare(&name_sql)?
956            .query_map(
957                rusqlite::named_params! {
958                    ":match": match_clause,
959                    ":kind": kind_val,
960                    ":path": path_val,
961                    ":path_like": path_like,
962                },
963                new_candidate,
964            )?
965            .collect::<Result<Vec<_>, _>>()?;
966        for (rowid, mut candidate) in name_rows {
967            candidate.name_match = true;
968            admit(rowid, candidate);
969        }
970    }
971
972    Ok(candidates)
973}
974
975/// Conceptual full-text search with optional path scoping filter.
976pub fn fts_search_symbols_scoped(
977    conn: &Connection,
978    query: &str,
979    kind_filter: Option<&str>,
980    path_filter: Option<&str>,
981    include_tests: bool,
982    limit: usize,
983) -> Result<Vec<SymbolSearchResult>, QueryError> {
984    fts_search_symbols_explained(
985        conn,
986        query,
987        kind_filter,
988        path_filter,
989        include_tests,
990        limit,
991        false,
992    )
993}
994
995/// Conceptual full-text search that also attaches the rerank breakdown to every row when
996/// `explain` is true. Without it, `explain` stays `None` on every row.
997pub fn fts_search_symbols_explained(
998    conn: &Connection,
999    query: &str,
1000    kind_filter: Option<&str>,
1001    path_filter: Option<&str>,
1002    include_tests: bool,
1003    limit: usize,
1004    explain: bool,
1005) -> Result<Vec<SymbolSearchResult>, QueryError> {
1006    validate_result_limit(limit)?;
1007    if limit == 0 {
1008        return Ok(Vec::new());
1009    }
1010    let (and_q, _) = sanitize_fts5_query(query);
1011    if and_q.is_empty() {
1012        return Ok(Vec::new());
1013    }
1014
1015    let normalized_path = path_filter.map(|p| {
1016        p.replace('\\', "/")
1017            .trim_start_matches("./")
1018            .trim_matches('/')
1019            .to_string()
1020    });
1021    let norm_kind = kind_filter.map(normalize_kind);
1022    let escaped_path = normalized_path.as_deref().map(escape_like);
1023    let searching_variables = norm_kind.as_deref() == Some("variable");
1024
1025    let name_search = |local_clause: &str| -> Result<Vec<SymbolSearchResult>, QueryError> {
1026        let pattern = format!("%{}%", escape_like(query));
1027        let mut sql = String::from(
1028            "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
1029                    visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
1030                    start_byte, end_byte, body_start_line, body_start_column, body_end_line,
1031                    body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
1032                    is_test, test_container
1033              FROM symbols s
1034              WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
1035                AND (:kind IS NULL OR kind = :kind)
1036                AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
1037        );
1038        sql.push_str(local_clause);
1039        if !include_tests {
1040            sql.push_str(" AND is_test = 0 AND test_container = 0");
1041            sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
1042        }
1043        sql.push_str(
1044            " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
1045        );
1046        sql.push_str(&limit.to_string());
1047
1048        let mut stmt = conn.prepare(&sql)?;
1049        let path_val = normalized_path.as_deref();
1050        let path_like = escaped_path.as_deref();
1051        let kind_val = norm_kind.as_deref();
1052        let rows = stmt
1053            .query_map(
1054                rusqlite::named_params! {
1055                    ":query": query,
1056                    ":pattern": pattern,
1057                    ":kind": kind_val,
1058                    ":path": path_val,
1059                    ":path_like": path_like,
1060                },
1061                map_symbol,
1062            )?
1063            .collect::<Result<Vec<_>, _>>()?;
1064
1065        Ok(rows
1066            .into_iter()
1067            .map(|s| SymbolSearchResult {
1068                symbol: s,
1069                score: 0.0,
1070                snippet: None,
1071                explain: None,
1072            })
1073            .collect())
1074    };
1075
1076    if !has_table(conn, "symbols_fts") {
1077        let local_clause = if searching_variables {
1078            String::new()
1079        } else {
1080            format!(" AND NOT {}", local_variable_predicate("s"))
1081        };
1082        return name_search(&local_clause);
1083    }
1084
1085    let candidates =
1086        collect_search_candidates(conn, query, kind_filter, path_filter, include_tests, limit)?;
1087    let candidate_count = candidates.len();
1088    let started = std::time::Instant::now();
1089    let idf = idf_weights(conn, &rerank_words(query));
1090    let ranked = rerank_with(candidates, query, include_tests, Some(&idf));
1091    let rerank_us = started.elapsed().as_micros();
1092    Ok(ranked
1093        .into_iter()
1094        .take(limit)
1095        .map(|(mut result, mut breakdown)| {
1096            if explain {
1097                breakdown.candidates = candidate_count;
1098                breakdown.rerank_us = rerank_us;
1099                result.explain = Some(breakdown);
1100            }
1101            result
1102        })
1103        .collect())
1104}
1105
1106const W_NAME_WHOLE: f64 = 100.0;
1107const W_NAME_ALL_WORDS: f64 = 60.0;
1108const W_KIND_DEFINITION: f64 = 4.0;
1109const W_KIND_MEMBER: f64 = 0.0;
1110const W_KIND_IMPORT: f64 = -50.0;
1111const W_PATH_ROLE: f64 = -10.0;
1112const W_DOCUMENTATION_ROW: f64 = -200.0;
1113const W_TEST_INTENT: f64 = 5.0;
1114const W_TERMS: f64 = 52.0;
1115const MAX_TERM_CREDIT: f64 = 3.0;
1116const TEXT_CREDIT: f64 = 1.0;
1117const TEXT_HEAD_BYTES: usize = 400;
1118
1119const DEFINITION_KINDS: &[&str] = &[
1120    "function",
1121    "method",
1122    "class",
1123    "struct",
1124    "trait",
1125    "interface",
1126    "enum",
1127    "type",
1128];
1129const MEMBER_KINDS: &[&str] = &["enum_member", "field", "property", "constant", "variable"];
1130const DEMOTED_PATH_SEGMENTS: &[&str] = &["scripts", "examples", "benchmarks", "fixtures", "vendor"];
1131const TEST_INTENT_WORDS: &[&str] = &["test", "tests", "spec", "specs"];
1132
1133struct QueryWord {
1134    word: String,
1135    stem: String,
1136}
1137
1138/// Per-word hits of one candidate: which query words its name, signature, and capped doc
1139/// cover. The name carries a match strength per word, the others a plain hit.
1140struct Hits {
1141    name: Vec<u8>,
1142    signature: Vec<bool>,
1143    doc: Vec<bool>,
1144}
1145
1146/// Symbols a query word may be counted in before it is called common: the count walks the
1147/// word's postings, so the cap bounds the cost of a word held by most of a million symbols.
1148const DF_CAP: usize = 20_000;
1149
1150/// Global rarity of each lowercase rerank term: `ln(1 + N / (df + 1))`, where `df` is the
1151/// number of indexed symbols holding the term (capped at `DF_CAP`) and `N` an upper bound on
1152/// the index size. The count is an FTS5 `MATCH`, so the term is stemmed by the index's own
1153/// tokenizer and `news` finds the rows indexed as `new`.
1154fn idf_weights(conn: &Connection, words: &[String]) -> Vec<f64> {
1155    let n = conn
1156        .query_row("SELECT max(rowid) FROM symbols", [], |r| {
1157            r.get::<_, Option<i64>>(0)
1158        })
1159        .ok()
1160        .flatten()
1161        .unwrap_or(0) as f64;
1162    let mut count = conn
1163        .prepare(&format!(
1164            "SELECT count(*) FROM (SELECT rowid FROM symbols_fts WHERE symbols_fts MATCH ?1 LIMIT {DF_CAP})"
1165        ))
1166        .ok();
1167    words
1168        .iter()
1169        .map(|word| {
1170            let df = count
1171                .as_mut()
1172                .and_then(|stmt| document_frequency(stmt, &format!("\"{word}\"")))
1173                .unwrap_or(0);
1174            (1.0 + n / (df as f64 + 1.0)).ln()
1175        })
1176        .collect()
1177}
1178
1179fn document_frequency(stmt: &mut rusqlite::Statement<'_>, term: &str) -> Option<i64> {
1180    stmt.query_row([term], |r| r.get(0)).ok()
1181}
1182
1183/// The field that credits each query term and the credit it is worth: a name whole token 3,
1184/// a name stem 2, a signature or doc hit `TEXT_CREDIT`, a name substring 1, nothing 0.
1185fn term_credits(hits: &Hits, words: &[QueryWord]) -> Vec<(String, String, f64)> {
1186    words
1187        .iter()
1188        .enumerate()
1189        .map(|(i, w)| {
1190            let (field, credit) = match hits.name[i] {
1191                3 => ("name", 3.0),
1192                2 => ("name", 2.0),
1193                _ if hits.signature[i] => ("signature", TEXT_CREDIT),
1194                _ if hits.doc[i] => ("doc", TEXT_CREDIT),
1195                1 => ("name", 1.0),
1196                _ => ("none", 0.0),
1197            };
1198            (w.word.clone(), field.to_string(), credit)
1199        })
1200        .collect()
1201}
1202
1203fn term_score(terms: &[(String, String, f64)], weights: &[f64]) -> f64 {
1204    let total: f64 = weights.iter().sum();
1205    if total == 0.0 {
1206        return 0.0;
1207    }
1208    let credited: f64 = terms
1209        .iter()
1210        .zip(weights)
1211        .map(|((_, _, credit), weight)| credit * weight)
1212        .sum();
1213    W_TERMS * credited / (MAX_TERM_CREDIT * total)
1214}
1215
1216/// Lowercase query words for the rerank: every `query_words` token is split like an
1217/// identifier, and stop words are dropped only when a content word remains.
1218fn rerank_words(query: &str) -> Vec<String> {
1219    let words: Vec<String> = query_words(query)
1220        .into_iter()
1221        .flat_map(split_identifier)
1222        .map(str::to_lowercase)
1223        .collect();
1224    let any_content = words.iter().any(|w| !is_stop_word(w));
1225    words
1226        .into_iter()
1227        .filter(|w| !any_content || !is_stop_word(w))
1228        .collect()
1229}
1230
1231fn collapse(text: &str) -> String {
1232    text.chars()
1233        .filter(|c| c.is_alphanumeric())
1234        .flat_map(char::to_lowercase)
1235        .collect()
1236}
1237
1238fn head_bytes(text: &str, bytes: usize) -> &str {
1239    let mut end = bytes.min(text.len());
1240    while !text.is_char_boundary(end) {
1241        end -= 1;
1242    }
1243    &text[..end]
1244}
1245
1246fn token_run_equals(tokens: &[String], word: &str) -> bool {
1247    (0..tokens.len()).any(|start| {
1248        let mut joined = String::new();
1249        for token in &tokens[start..] {
1250            joined.push_str(token);
1251            if joined.len() >= word.len() {
1252                return joined == word;
1253            }
1254        }
1255        false
1256    })
1257}
1258
1259/// Match strength of a name per query word: `3` when a run of its tokens equals the word,
1260/// `2` when a token stem equals the word stem, `1` when the collapsed name contains the word,
1261/// `0` when nothing matches.
1262fn name_hits(name: &str, words: &[QueryWord], stemmer: &Stemmer) -> Vec<u8> {
1263    let tokens: Vec<String> = split_identifier(name)
1264        .into_iter()
1265        .map(str::to_lowercase)
1266        .collect();
1267    let stems: Vec<String> = tokens
1268        .iter()
1269        .map(|t| stemmer.stem(t).into_owned())
1270        .collect();
1271    let collapsed = collapse(name);
1272    words
1273        .iter()
1274        .map(|w| {
1275            if token_run_equals(&tokens, &w.word) {
1276                3
1277            } else if stems.contains(&w.stem) {
1278                2
1279            } else if w.word.chars().count() >= 3 && collapsed.contains(&w.word) {
1280                1
1281            } else {
1282                0
1283            }
1284        })
1285        .collect()
1286}
1287
1288/// True when `token` lowercased starts with `prefix` (or equals it when `exact`).
1289/// `prefix` is already lowercase.
1290fn lowercase_prefix_match(token: &str, prefix: &str, exact: bool) -> bool {
1291    if token.is_ascii() && prefix.is_ascii() {
1292        let Some(head) = token.as_bytes().get(..prefix.len()) else {
1293            return false;
1294        };
1295        return head.eq_ignore_ascii_case(prefix.as_bytes())
1296            && (!exact || token.len() == prefix.len());
1297    }
1298    let mut lower = token.chars().flat_map(char::to_lowercase);
1299    for expected in prefix.chars() {
1300        if lower.next() != Some(expected) {
1301            return false;
1302        }
1303    }
1304    !exact || lower.next().is_none()
1305}
1306
1307/// Token-level coverage of a signature or doc: a word is covered when some token equals it,
1308/// or starts with its stem or with the word itself (three or more characters), so `stemming`
1309/// covers `stemmer` and `stems` but not `system`.
1310fn text_hits<'a>(
1311    text: Option<&'a str>,
1312    words: &[QueryWord],
1313    tokens: &mut Vec<&'a str>,
1314) -> Vec<bool> {
1315    tokens.clear();
1316    text_tokens_into(text.unwrap_or(""), tokens);
1317    words
1318        .iter()
1319        .map(|w| {
1320            let stem_prefix = w.stem.chars().count() >= 3;
1321            let exact_word = w.word.chars().count() < 3;
1322            tokens.iter().any(|t| {
1323                lowercase_prefix_match(t, &w.word, exact_word)
1324                    || (stem_prefix && lowercase_prefix_match(t, &w.stem, false))
1325            })
1326        })
1327        .collect()
1328}
1329
1330fn kind_prior(kind: &str) -> f64 {
1331    let kind = normalize_kind(kind);
1332    match kind.as_str() {
1333        "import" => W_KIND_IMPORT,
1334        k if DEFINITION_KINDS.contains(&k) => W_KIND_DEFINITION,
1335        k if MEMBER_KINDS.contains(&k) => W_KIND_MEMBER,
1336        _ => 0.0,
1337    }
1338}
1339
1340fn path_role(path: &str, words: &[QueryWord], stemmer: &Stemmer) -> f64 {
1341    let Some(segment) = path.split(['/', '\\']).find(|seg| {
1342        DEMOTED_PATH_SEGMENTS
1343            .iter()
1344            .any(|d| d.eq_ignore_ascii_case(seg))
1345    }) else {
1346        return 0.0;
1347    };
1348    let segment = segment.to_lowercase();
1349    let segment_stem = stemmer.stem(&segment);
1350    let named = words.iter().any(|w| {
1351        w.word == segment || w.word == segment_stem || w.stem == segment || w.stem == segment_stem
1352    });
1353    if named { 0.0 } else { W_PATH_ROLE }
1354}
1355
1356fn bracket_longest_term(name: &str, terms: &[String]) -> String {
1357    let lower = name.to_lowercase();
1358    if lower.len() != name.len() {
1359        return name.to_string();
1360    }
1361    let mut best: Option<(usize, usize)> = None;
1362    for term in terms {
1363        if let Some(start) = lower.find(term.as_str()) {
1364            let end = start + term.len();
1365            let longer = best.is_none_or(|(s, e)| end - start > e - s);
1366            if longer && name.is_char_boundary(start) && name.is_char_boundary(end) {
1367                best = Some((start, end));
1368            }
1369        }
1370    }
1371    match best {
1372        Some((start, end)) => {
1373            format!("{}[{}]{}", &name[..start], &name[start..end], &name[end..])
1374        }
1375        None => name.to_string(),
1376    }
1377}
1378
1379fn branch_snippet(candidate: &Candidate) -> Option<String> {
1380    let name = &candidate.result.symbol.name;
1381    if candidate.word_match {
1382        candidate.result.snippet.clone()
1383    } else if candidate.exact_name {
1384        Some(name.clone())
1385    } else {
1386        Some(bracket_longest_term(name, &candidate.name_terms))
1387    }
1388}
1389
1390/// Scores every admitted candidate and returns them best first. Each query term is credited
1391/// once from its strongest field and weighted by `idf`, the term's rarity across the index;
1392/// a weight vector of the wrong length, or none, makes every term count the same. Ties fall
1393/// to name strength, then word BM25 (rows without one last), then name length, path, name.
1394fn rerank_with(
1395    candidates: Vec<Candidate>,
1396    query: &str,
1397    include_tests: bool,
1398    idf: Option<&[f64]>,
1399) -> Vec<(SymbolSearchResult, SearchExplain)> {
1400    let stemmer = Stemmer::create(Algorithm::English);
1401    let words: Vec<QueryWord> = rerank_words(query)
1402        .into_iter()
1403        .map(|word| QueryWord {
1404            stem: stemmer.stem(&word).into_owned(),
1405            word,
1406        })
1407        .collect();
1408    let collapsed_query = collapse(query);
1409    let test_intent = include_tests
1410        && words
1411            .iter()
1412            .any(|w| TEST_INTENT_WORDS.contains(&w.word.as_str()));
1413
1414    let mut tokens: Vec<&str> = Vec::new();
1415    let hits: Vec<Hits> = candidates
1416        .iter()
1417        .map(|candidate| {
1418            let symbol = &candidate.result.symbol;
1419            Hits {
1420                name: name_hits(&symbol.name, &words, &stemmer),
1421                signature: text_hits(
1422                    symbol
1423                        .signature
1424                        .as_deref()
1425                        .map(|signature| head_bytes(signature, TEXT_HEAD_BYTES)),
1426                    &words,
1427                    &mut tokens,
1428                ),
1429                doc: text_hits(
1430                    symbol
1431                        .doc_comment
1432                        .as_deref()
1433                        .map(|doc| head_bytes(doc, TEXT_HEAD_BYTES)),
1434                    &words,
1435                    &mut tokens,
1436                ),
1437            }
1438        })
1439        .collect();
1440    let term_weights: Vec<f64> = match idf {
1441        Some(weights) if weights.len() == words.len() => weights.to_vec(),
1442        _ => vec![1.0; words.len()],
1443    };
1444    let word_weights: Vec<(String, f64)> = words
1445        .iter()
1446        .zip(&term_weights)
1447        .map(|(w, weight)| (w.word.clone(), *weight))
1448        .collect();
1449
1450    let mut scored: Vec<(SymbolSearchResult, SearchExplain)> = candidates
1451        .into_iter()
1452        .zip(hits)
1453        .map(|(candidate, hits)| {
1454            let symbol = &candidate.result.symbol;
1455            let name_strength: u32 = hits.name.iter().map(|s| u32::from(*s)).sum();
1456            let tier = if !collapsed_query.is_empty() && collapse(&symbol.name) == collapsed_query {
1457                "whole"
1458            } else if !hits.name.is_empty() && hits.name.iter().all(|s| *s >= 2) {
1459                "all"
1460            } else if hits.name.iter().any(|s| *s > 0) {
1461                "partial"
1462            } else {
1463                "none"
1464            };
1465            let terms = term_credits(&hits, &words);
1466            let explain = SearchExplain {
1467                bm25: candidate.bm25,
1468                branches: [
1469                    (candidate.exact_name, "exact"),
1470                    (candidate.word_match, "word"),
1471                    (candidate.name_match, "name"),
1472                ]
1473                .into_iter()
1474                .filter(|(hit, _)| *hit)
1475                .map(|(_, branch)| branch.to_string())
1476                .collect(),
1477                name_tier: tier.to_string(),
1478                name_strength,
1479                term_score: term_score(&terms, &term_weights),
1480                name_bonus: match tier {
1481                    "whole"
1482                        if DEFINITION_KINDS.contains(&normalize_kind(&symbol.kind).as_str()) =>
1483                    {
1484                        W_NAME_WHOLE
1485                    }
1486                    "whole" | "all" => W_NAME_ALL_WORDS,
1487                    _ => 0.0,
1488                },
1489                kind_prior: kind_prior(&symbol.kind),
1490                path_role: path_role(&symbol.path, &words, &stemmer),
1491                documentation: if candidate.documentation {
1492                    W_DOCUMENTATION_ROW
1493                } else {
1494                    0.0
1495                },
1496                test_intent: if test_intent && (symbol.is_test || symbol.test_container) {
1497                    W_TEST_INTENT
1498                } else {
1499                    0.0
1500                },
1501                terms,
1502                word_weights: word_weights.clone(),
1503                candidates: 0,
1504                rerank_us: 0,
1505            };
1506            let score = explain.term_score
1507                + explain.name_bonus
1508                + explain.kind_prior
1509                + explain.path_role
1510                + explain.documentation
1511                + explain.test_intent;
1512            let snippet = branch_snippet(&candidate);
1513            let mut result = candidate.result;
1514            result.score = score;
1515            result.snippet = snippet;
1516            (result, explain)
1517        })
1518        .collect();
1519
1520    scored.sort_by(|(a, ea), (b, eb)| {
1521        b.score
1522            .total_cmp(&a.score)
1523            .then_with(|| eb.name_strength.cmp(&ea.name_strength))
1524            .then_with(|| {
1525                a.symbol
1526                    .name
1527                    .starts_with('_')
1528                    .cmp(&b.symbol.name.starts_with('_'))
1529            })
1530            .then_with(|| ea.bm25.is_none().cmp(&eb.bm25.is_none()))
1531            .then_with(|| ea.bm25.unwrap_or(0.0).total_cmp(&eb.bm25.unwrap_or(0.0)))
1532            .then_with(|| a.symbol.name.len().cmp(&b.symbol.name.len()))
1533            .then_with(|| a.symbol.path.cmp(&b.symbol.path))
1534            .then_with(|| a.symbol.name.cmp(&b.symbol.name))
1535    });
1536    scored
1537}
1538
1539/// Find tests related to a target symbol by caller relationships, naming pattern, or FTS matching.
1540pub fn find_related_tests(
1541    conn: &Connection,
1542    target_symbol: &Symbol,
1543    limit: usize,
1544) -> Result<Vec<Symbol>, QueryError> {
1545    if limit == 0 {
1546        return Ok(Vec::new());
1547    }
1548
1549    const COLUMNS: &str = "s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1550            s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1551            s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1552            s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1553            s.is_test, s.test_container";
1554    const IS_TEST: &str = "(s.is_test = 1 OR s.test_container = 1)";
1555    let not_documentation = not_documentation(conn, "s");
1556
1557    let mut tests = Vec::new();
1558    let mut seen_ids = std::collections::HashSet::new();
1559
1560    let callers_sql = format!(
1561        "SELECT {COLUMNS}
1562     FROM symbols s
1563     JOIN relationships r ON r.from_symbol_id = s.symbol_id
1564     WHERE r.to_symbol_id = ?1 AND {IS_TEST} AND {not_documentation}
1565     LIMIT ?2"
1566    );
1567
1568    if let Ok(mut stmt) = conn.prepare(&callers_sql)
1569        && let Ok(rows) = stmt.query_map(params![target_symbol.symbol_id, limit as i64], map_symbol)
1570    {
1571        for row in rows.flatten() {
1572            if seen_ids.insert(row.symbol_id.clone()) {
1573                tests.push(row);
1574                if tests.len() >= limit {
1575                    return Ok(tests);
1576                }
1577            }
1578        }
1579    }
1580
1581    // julie resolves call edges inside one file only; every cross-file caller is a pending edge
1582    let remaining = limit - tests.len();
1583    if remaining > 0 && has_pending_namespace_column(conn) {
1584        let pending_sql = format!(
1585            "SELECT DISTINCT {COLUMNS}
1586     FROM pending_relationships p
1587     JOIN symbols s ON p.from_symbol_id = s.symbol_id
1588     JOIN symbols s_from ON s_from.symbol_id = s.symbol_id
1589     JOIN symbols s_target ON s_target.symbol_id = ?1
1590     LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1591     WHERE p.target_terminal_name = s_target.name
1592       AND {IS_TEST}
1593       AND {not_documentation}
1594       AND {pred}
1595     LIMIT ?2",
1596            pred = pending_target_predicate("s_target", "s_target_parent")
1597        );
1598
1599        if let Ok(mut stmt) = conn.prepare(&pending_sql)
1600            && let Ok(rows) = stmt.query_map(
1601                params![target_symbol.symbol_id, remaining as i64],
1602                map_symbol,
1603            )
1604        {
1605            for row in rows.flatten() {
1606                if seen_ids.insert(row.symbol_id.clone()) {
1607                    tests.push(row);
1608                    if tests.len() >= limit {
1609                        return Ok(tests);
1610                    }
1611                }
1612            }
1613        }
1614    }
1615
1616    let remaining = limit - tests.len();
1617    let name_sql = format!(
1618        "SELECT {COLUMNS}
1619     FROM symbols s
1620     WHERE {IS_TEST}
1621       AND {not_documentation}
1622       AND (s.name LIKE '%' || ?1 || '%' OR s.signature LIKE '%' || ?1 || '%')
1623     ORDER BY (s.name LIKE '%' || ?1 || '%') DESC
1624     LIMIT ?2"
1625    );
1626
1627    if let Ok(mut stmt) = conn.prepare(&name_sql)
1628        && let Ok(rows) = stmt.query_map(
1629            params![target_symbol.name, (remaining * 2) as i64],
1630            map_symbol,
1631        )
1632    {
1633        for row in rows.flatten() {
1634            if seen_ids.insert(row.symbol_id.clone()) {
1635                tests.push(row);
1636                if tests.len() >= limit {
1637                    return Ok(tests);
1638                }
1639            }
1640        }
1641    }
1642
1643    let remaining = limit - tests.len();
1644    let fts_exists: bool = conn
1645        .query_row(
1646            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
1647            [],
1648            |_| Ok(true),
1649        )
1650        .unwrap_or(false);
1651
1652    if remaining > 0 && fts_exists {
1653        let fts_sql = format!(
1654            "SELECT {COLUMNS}
1655         FROM symbols_fts
1656         CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
1657         WHERE symbols_fts MATCH ?1 AND {IS_TEST} AND {not_documentation}
1658         LIMIT ?2"
1659        );
1660
1661        let and_q = name_prefix_query(&target_symbol.name);
1662        if !and_q.is_empty()
1663            && let Ok(mut stmt) = conn.prepare(&fts_sql)
1664            && let Ok(rows) = stmt.query_map(params![and_q, (remaining * 2) as i64], map_symbol)
1665        {
1666            for row in rows.flatten() {
1667                if seen_ids.insert(row.symbol_id.clone()) {
1668                    tests.push(row);
1669                    if tests.len() >= limit {
1670                        break;
1671                    }
1672                }
1673            }
1674        }
1675    }
1676
1677    Ok(tests)
1678}
1679
1680/// Find a specific symbol by name, with an optional path filter for disambiguation.
1681pub fn get_symbol_by_name(
1682    conn: &Connection,
1683    name: &str,
1684    path_filter: Option<&str>,
1685) -> Result<Option<Symbol>, QueryError> {
1686    get_symbol_by_name_internal(conn, name, path_filter, false)
1687}
1688
1689/// Find a specific symbol by name, requiring exact path match (used for atomic edits).
1690pub fn get_symbol_by_name_exact(
1691    conn: &Connection,
1692    name: &str,
1693    exact_path: &str,
1694) -> Result<Option<Symbol>, QueryError> {
1695    get_symbol_by_name_internal(conn, name, Some(exact_path), true)
1696}
1697
1698fn get_symbol_by_name_internal(
1699    conn: &Connection,
1700    name: &str,
1701    path_filter: Option<&str>,
1702    exact_path: bool,
1703) -> Result<Option<Symbol>, QueryError> {
1704    // Check if name is qualified like `Struct::method` or `Class.method`
1705    let (parent_name, terminal_name) = if let Some(idx) = name.rfind("::") {
1706        let parent = &name[..idx];
1707        let term = &name[idx + 2..];
1708        let immediate_parent = if let Some(p_idx) = parent.rfind("::") {
1709            &parent[p_idx + 2..]
1710        } else {
1711            parent
1712        };
1713        (Some(immediate_parent), term)
1714    } else if let Some(idx) = name.rfind('.') {
1715        let parent = &name[..idx];
1716        let term = &name[idx + 1..];
1717        let immediate_parent = if let Some(p_idx) = parent.rfind('.') {
1718            &parent[p_idx + 1..]
1719        } else {
1720            parent
1721        };
1722        (Some(immediate_parent), term)
1723    } else {
1724        (None, name)
1725    };
1726
1727    let sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1728                s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1729                s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1730                s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1731                s.is_test, s.test_container
1732         FROM symbols s
1733         LEFT JOIN symbols p ON s.parent_symbol_id = p.symbol_id
1734         WHERE (s.name = :name OR (s.name = :term AND (:parent IS NULL OR p.name = :parent)))
1735           AND (:path IS NULL OR s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE OR (:exact = 0 AND (s.path LIKE '%/' || :path_like ESCAPE '\\' OR s.path LIKE '%\\\\' || :path_like_bs ESCAPE '\\')))
1736         ORDER BY (s.kind != 'import') DESC,
1737                  (s.kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC,
1738                  (s.name = :name) DESC,
1739                  (:path IS NOT NULL AND (s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE)) DESC,
1740                  s.is_test ASC
1741         LIMIT 25";
1742
1743    let mut stmt = conn.prepare(sql)?;
1744    let normalized_path = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
1745    let backslash_path = normalized_path.as_deref().map(|p| p.replace('/', "\\"));
1746    let path_like = normalized_path.as_deref().map(escape_like);
1747    let path_like_bs = backslash_path.as_deref().map(escape_like);
1748
1749    let mut rows = stmt.query(rusqlite::named_params! {
1750        ":name": name,
1751        ":term": terminal_name,
1752        ":parent": parent_name,
1753        ":path": normalized_path.as_deref(),
1754        ":path_bs": backslash_path.as_deref(),
1755        ":path_like": path_like.as_deref(),
1756        ":path_like_bs": path_like_bs.as_deref(),
1757        ":exact": if exact_path { 1 } else { 0 },
1758    })?;
1759
1760    let mut matches: Vec<Symbol> = Vec::new();
1761    while let Some(row) = rows.next()? {
1762        matches.push(map_symbol(row)?);
1763    }
1764
1765    if matches.is_empty() {
1766        return Ok(None);
1767    }
1768
1769    if matches.len() == 1 {
1770        return Ok(Some(matches.remove(0)));
1771    }
1772
1773    // Exclude imports if non-import candidates exist
1774    let candidates: Vec<Symbol> = if matches.iter().any(|s| s.kind != "import") {
1775        matches.into_iter().filter(|s| s.kind != "import").collect()
1776    } else {
1777        matches
1778    };
1779
1780    if candidates.len() == 1 {
1781        return Ok(Some(candidates.into_iter().next().unwrap()));
1782    }
1783
1784    // Check if there's an exact match on full name among candidates
1785    let exact_name_matches: Vec<_> = candidates
1786        .iter()
1787        .filter(|s| s.name == name)
1788        .cloned()
1789        .collect();
1790    if exact_name_matches.len() == 1 {
1791        return Ok(Some(exact_name_matches.into_iter().next().unwrap()));
1792    }
1793
1794    let definition_candidates = if exact_name_matches.is_empty() {
1795        &candidates
1796    } else {
1797        &exact_name_matches
1798    };
1799    let def_matches: Vec<_> = definition_candidates
1800        .iter()
1801        .filter(|s| {
1802            matches!(
1803                s.kind.as_str(),
1804                "function"
1805                    | "struct"
1806                    | "class"
1807                    | "trait"
1808                    | "method"
1809                    | "enum"
1810                    | "interface"
1811                    | "type"
1812            )
1813        })
1814        .cloned()
1815        .collect();
1816    if def_matches.len() == 1 {
1817        return Ok(Some(def_matches.into_iter().next().unwrap()));
1818    }
1819
1820    let active_pool = if !def_matches.is_empty() {
1821        def_matches
1822    } else if !exact_name_matches.is_empty() {
1823        exact_name_matches
1824    } else {
1825        candidates
1826    };
1827
1828    // If path_filter was given and there's an exact path match
1829    if let Some(ref p) = normalized_path {
1830        let exact_path_matches: Vec<_> = active_pool
1831            .iter()
1832            .filter(|s| s.path == *p)
1833            .cloned()
1834            .collect();
1835        if exact_path_matches.len() == 1 {
1836            return Ok(Some(exact_path_matches.into_iter().next().unwrap()));
1837        }
1838    }
1839
1840    if active_pool.len() == 1 {
1841        return Ok(Some(active_pool.into_iter().next().unwrap()));
1842    }
1843
1844    // Ambiguity detected
1845    let mut candidate_list = String::new();
1846    for s in &active_pool {
1847        candidate_list.push_str(&format!(
1848            "- {} `{}` in {}:{}\n",
1849            s.kind, s.name, s.path, s.start_line
1850        ));
1851    }
1852
1853    Err(QueryError::AmbiguousSymbol(
1854        name.to_string(),
1855        active_pool.len(),
1856        candidate_list,
1857    ))
1858}
1859
1860/// Find callers or callees of a symbol (filters unresolved external stdlib/runtime primitives by default).
1861pub fn find_references(
1862    conn: &Connection,
1863    symbol_name: &str,
1864    direction: &str,
1865    limit: usize,
1866) -> Result<Vec<ReferenceSite>, QueryError> {
1867    find_references_ext(conn, symbol_name, direction, limit, false)
1868}
1869
1870/// Find callers or callees with option to include external runtime/stdlib primitives.
1871pub fn find_references_ext(
1872    conn: &Connection,
1873    symbol_name: &str,
1874    direction: &str,
1875    limit: usize,
1876    include_external: bool,
1877) -> Result<Vec<ReferenceSite>, QueryError> {
1878    find_references_scoped(conn, symbol_name, direction, limit, include_external, None)
1879}
1880
1881/// Find callers or callees with optional file path disambiguation filter and external symbols toggle.
1882pub fn find_references_scoped(
1883    conn: &Connection,
1884    symbol_name: &str,
1885    direction: &str,
1886    limit: usize,
1887    include_external: bool,
1888    path_filter: Option<&str>,
1889) -> Result<Vec<ReferenceSite>, QueryError> {
1890    validate_result_limit(limit)?;
1891    if direction != "callers" && direction != "callees" {
1892        return Err(QueryError::InvalidDirection(direction.to_string()));
1893    }
1894
1895    match get_symbol_by_name(conn, symbol_name, path_filter)? {
1896        Some(target) => find_references_internal(
1897            conn,
1898            &target.name,
1899            direction,
1900            limit,
1901            Some(&target.symbol_id),
1902            include_external,
1903        ),
1904        None => {
1905            let suggestions = search_symbols_scoped(conn, symbol_name, None, path_filter, false, 3)
1906                .unwrap_or_default();
1907            if suggestions.is_empty() {
1908                Err(QueryError::SymbolNotFound(symbol_name.to_string()))
1909            } else {
1910                let list = suggestions
1911                    .into_iter()
1912                    .map(|s| format!("  - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
1913                    .collect::<Vec<_>>()
1914                    .join("\n");
1915                Err(QueryError::SymbolNotFoundWithSuggestions(
1916                    symbol_name.to_string(),
1917                    list,
1918                ))
1919            }
1920        }
1921    }
1922}
1923
1924pub fn find_references_for_symbol(
1925    conn: &Connection,
1926    symbol_name: &str,
1927    direction: &str,
1928    limit: usize,
1929    symbol_id: &str,
1930) -> Result<Vec<ReferenceSite>, QueryError> {
1931    find_references_internal(conn, symbol_name, direction, limit, Some(symbol_id), false)
1932}
1933
1934/// SQL expression ranking a candidate path against the call site `p.path`:
1935/// 2 for the same file, 1 for the same directory, 0 otherwise.
1936fn call_site_proximity(candidate_path: &str) -> String {
1937    let normalized = format!("replace({candidate_path}, '\\', '/')");
1938    let call_site = "replace(p.path, '\\', '/')";
1939    format!(
1940        "CASE WHEN {normalized} = {call_site} THEN 2
1941              WHEN rtrim({normalized}, replace({normalized}, '/', '')) = rtrim({call_site}, replace({call_site}, '/', '')) THEN 1
1942              ELSE 0 END"
1943    )
1944}
1945
1946/// SQL predicate that decides whether a pending call edge `p` (with caller `s_from`) points at
1947/// the candidate definition `target` (whose parent symbol is joined as `parent`).
1948fn pending_target_predicate(target: &str, parent: &str) -> String {
1949    let ns = "json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END)";
1950    let target_path = format!("('/' || replace({target}.path, '\\', '/'))");
1951    let like_value = "replace(replace(replace(value, '\\', '\\\\'), '%', '\\%'), '_', '\\_')";
1952    let closer_rank = call_site_proximity("closer.path");
1953    let target_rank = call_site_proximity(&format!("{target}.path"));
1954    format!(
1955        "(
1956            (
1957                {target}.parent_symbol_id IS NOT NULL
1958                AND {parent}.name IS NOT NULL
1959                AND (
1960                    EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
1961                    OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
1962                        AND s_from.parent_symbol_id = {target}.parent_symbol_id)
1963                    OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver)
1964                    OR EXISTS (
1965                        SELECT 1 FROM symbols receiver
1966                        JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
1967                        WHERE receiver.name = p.target_receiver
1968                          AND receiver.path = p.path
1969                          AND receiver_type.resolved_type = {parent}.name
1970                    )
1971                )
1972                AND NOT EXISTS (
1973                    SELECT 1 FROM {ns}
1974                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
1975                      AND NOT EXISTS (
1976                          WITH RECURSIVE ancestor(symbol_id, depth) AS (
1977                              SELECT {target}.parent_symbol_id, 0
1978                              UNION ALL
1979                              SELECT s.parent_symbol_id, ancestor.depth + 1
1980                              FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
1981                              WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
1982                          )
1983                          SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
1984                          WHERE a.name = value
1985                      )
1986                      AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1987                      AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
1988                )
1989            )
1990            OR (
1991                (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1992                AND (p.target_receiver IS NULL OR p.target_receiver = '')
1993                AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
1994                AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
1995                    SELECT 1 FROM symbols closer
1996                    WHERE closer.name = {target}.name
1997                      AND closer.symbol_id != {target}.symbol_id
1998                      AND closer.parent_symbol_id IS NULL
1999                      AND closer.kind = {target}.kind
2000                      AND {closer_rank} > {target_rank}
2001                ))
2002            )
2003            OR (
2004                {target}.parent_symbol_id IS NULL
2005                AND EXISTS (
2006                    SELECT 1 FROM {ns}
2007                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
2008                      AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2009                )
2010            )
2011        )"
2012    )
2013}
2014
2015/// SQL predicate excluding rows julie marked as documentation, or the always-true `1 = 1` when
2016/// the column is absent, because a bare `1` in ORDER BY means the first result column in SQLite.
2017const DOCUMENTATION_LANGUAGES: &[&str] = &[
2018    "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
2019];
2020
2021fn documentation_language_list() -> String {
2022    DOCUMENTATION_LANGUAGES
2023        .iter()
2024        .map(|l| format!("'{l}'"))
2025        .collect::<Vec<_>>()
2026        .join(", ")
2027}
2028
2029fn not_documentation(conn: &Connection, alias: &str) -> String {
2030    let has_content_type: bool = conn
2031        .query_row(
2032            "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
2033            [],
2034            |_| Ok(true),
2035        )
2036        .unwrap_or(false);
2037    if has_content_type {
2038        format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
2039    } else {
2040        "1 = 1".to_string()
2041    }
2042}
2043
2044pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
2045    conn.query_row(
2046        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
2047        [name],
2048        |_| Ok(true),
2049    )
2050    .unwrap_or(false)
2051}
2052
2053fn has_pending_namespace_column(conn: &Connection) -> bool {
2054    let has_ns: bool = conn
2055        .query_row(
2056            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
2057            [],
2058            |_| Ok(true),
2059        )
2060        .unwrap_or(false);
2061    let has_display: bool = conn
2062        .query_row(
2063            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
2064            [],
2065            |_| Ok(true),
2066        )
2067        .unwrap_or(false);
2068    let has_receiver: bool = conn
2069        .query_row(
2070            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
2071            [],
2072            |_| Ok(true),
2073        )
2074        .unwrap_or(false);
2075    has_ns && has_display && has_receiver
2076}
2077
2078fn find_references_internal(
2079    conn: &Connection,
2080    symbol_name: &str,
2081    direction: &str,
2082    limit: usize,
2083    symbol_id: Option<&str>,
2084    include_external: bool,
2085) -> Result<Vec<ReferenceSite>, QueryError> {
2086    let mut results = Vec::new();
2087
2088    if direction == "callers" {
2089        // Find callers: references pointing to target symbol
2090        let mut stmt = conn.prepare(
2091            "SELECT s_from.name AS from_name,
2092                    r.from_symbol_id,
2093                    s_to.name AS to_name,
2094                    r.kind,
2095                    r.path,
2096                    r.start_line,
2097                    r.start_column
2098             FROM relationships r
2099             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2100             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2101             WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
2102             LIMIT ?2",
2103        )?;
2104
2105        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2106            Ok(ReferenceSite {
2107                from_symbol_name: row.get(0)?,
2108                from_symbol_id: row.get(1)?,
2109                to_symbol_name: row.get(2)?,
2110                kind: row.get(3)?,
2111                path: row.get::<_, String>(4)?.replace('\\', "/"),
2112                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2113                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2114            })
2115        })?;
2116
2117        for r in rows {
2118            results.push(r?);
2119        }
2120
2121        // Also query pending_relationships for callers if results < limit
2122        if results.len() < limit {
2123            let remaining = limit - results.len();
2124            if has_pending_namespace_column(conn) {
2125                if let Some(sid) = symbol_id {
2126                    let mut pending_stmt = conn.prepare(
2127                        &format!("SELECT s_from.name AS from_name,
2128                                p.from_symbol_id,
2129                                p.target_terminal_name AS to_name,
2130                                p.kind,
2131                                p.path,
2132                                p.start_line,
2133                                p.start_column
2134                         FROM pending_relationships p
2135                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2136                         JOIN symbols s_target ON s_target.symbol_id = ?3
2137                         LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2138                          WHERE p.target_terminal_name = ?1
2139                            AND {pred}
2140                          LIMIT ?2", pred = pending_target_predicate("s_target", "s_target_parent")),
2141                    )?;
2142
2143                    let p_rows = pending_stmt.query_map(
2144                        params![symbol_name, remaining as i64, sid],
2145                        |row| {
2146                            Ok(ReferenceSite {
2147                                from_symbol_name: row.get(0)?,
2148                                from_symbol_id: row.get(1)?,
2149                                to_symbol_name: row.get(2)?,
2150                                kind: row.get(3)?,
2151                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2152                                start_line: Some(row.get::<_, i64>(5)? as usize),
2153                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2154                            })
2155                        },
2156                    )?;
2157                    for r in p_rows {
2158                        results.push(r?);
2159                    }
2160                } else {
2161                    let mut pending_stmt = conn.prepare(
2162                        "SELECT s_from.name AS from_name,
2163                                p.from_symbol_id,
2164                                p.target_terminal_name AS to_name,
2165                                p.kind,
2166                                p.path,
2167                                p.start_line,
2168                                p.start_column
2169                         FROM pending_relationships p
2170                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2171                         WHERE p.target_terminal_name = ?1
2172                           AND (
2173                               (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2174                               OR EXISTS (
2175                                   SELECT 1 FROM symbols s_any
2176                                   JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
2177                                   WHERE s_any.name = p.target_terminal_name
2178                                     AND EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END) WHERE value = s_any_parent.name)
2179                               )
2180                           )
2181                         LIMIT ?2",
2182                    )?;
2183
2184                    let p_rows =
2185                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2186                            Ok(ReferenceSite {
2187                                from_symbol_name: row.get(0)?,
2188                                from_symbol_id: row.get(1)?,
2189                                to_symbol_name: row.get(2)?,
2190                                kind: row.get(3)?,
2191                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2192                                start_line: Some(row.get::<_, i64>(5)? as usize),
2193                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2194                            })
2195                        })?;
2196                    for r in p_rows {
2197                        results.push(r?);
2198                    }
2199                }
2200            } else {
2201                let is_nested = if let Some(sid) = symbol_id {
2202                    conn.query_row(
2203                        "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
2204                        params![sid],
2205                        |_| Ok(true),
2206                    )
2207                    .unwrap_or(false)
2208                } else {
2209                    false
2210                };
2211
2212                if !is_nested {
2213                    let mut pending_stmt = conn.prepare(
2214                        "SELECT s_from.name AS from_name,
2215                                p.from_symbol_id,
2216                                p.target_terminal_name AS to_name,
2217                                p.kind,
2218                                p.path,
2219                                p.start_line,
2220                                p.start_column
2221                         FROM pending_relationships p
2222                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2223                         WHERE p.target_terminal_name = ?1
2224                         LIMIT ?2",
2225                    )?;
2226
2227                    let p_rows =
2228                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2229                            Ok(ReferenceSite {
2230                                from_symbol_name: row.get(0)?,
2231                                from_symbol_id: row.get(1)?,
2232                                to_symbol_name: row.get(2)?,
2233                                kind: row.get(3)?,
2234                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2235                                start_line: Some(row.get::<_, i64>(5)? as usize),
2236                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2237                            })
2238                        })?;
2239
2240                    for r in p_rows {
2241                        results.push(r?);
2242                    }
2243                }
2244            }
2245        }
2246
2247        if results.len() < limit && has_table(conn, "identifiers") {
2248            let remaining = limit - results.len();
2249            let mut ident_stmt = conn.prepare(
2250                "SELECT COALESCE(s.name, ''),
2251                        COALESCE(i.containing_symbol_id, ''),
2252                        i.name,
2253                        i.kind,
2254                        i.path,
2255                        i.start_line,
2256                        i.start_column
2257                 FROM identifiers i
2258                 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2259                 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
2260                   AND COALESCE(s.kind, '') != 'import'
2261                   AND (?3 IS NULL OR NOT EXISTS (
2262                       SELECT 1 FROM symbols owner
2263                       JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
2264                       WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
2265                         AND member.name = i.name
2266                         AND owner.name IS NOT (SELECT parent.name FROM symbols target
2267                                                JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
2268                                                WHERE target.symbol_id = ?3)
2269                   ))
2270                 ORDER BY i.path, i.start_line
2271                 LIMIT ?2",
2272            )?;
2273            let rows =
2274                ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
2275                    Ok(ReferenceSite {
2276                        from_symbol_name: row.get(0)?,
2277                        from_symbol_id: row.get(1)?,
2278                        to_symbol_name: row.get(2)?,
2279                        kind: row.get(3)?,
2280                        path: row.get::<_, String>(4)?.replace('\\', "/"),
2281                        start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2282                        start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2283                    })
2284                })?;
2285            for r in rows {
2286                results.push(r?);
2287            }
2288        }
2289    } else {
2290        // Find callees: symbols called by target symbol
2291        let mut stmt = conn.prepare(
2292            "SELECT s_from.name AS from_name,
2293                    r.from_symbol_id,
2294                    s_to.name AS to_name,
2295                    r.kind,
2296                    r.path,
2297                    r.start_line,
2298                    r.start_column
2299             FROM relationships r
2300             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2301             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2302             WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
2303             LIMIT ?2",
2304        )?;
2305
2306        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2307            Ok(ReferenceSite {
2308                from_symbol_name: row.get(0)?,
2309                from_symbol_id: row.get(1)?,
2310                to_symbol_name: row.get(2)?,
2311                kind: row.get(3)?,
2312                path: row.get::<_, String>(4)?.replace('\\', "/"),
2313                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2314                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2315            })
2316        })?;
2317
2318        for r in rows {
2319            results.push(r?);
2320        }
2321
2322        // Also query pending_relationships for callees
2323        if results.len() < limit {
2324            let remaining = limit - results.len();
2325            let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
2326                let sql = if include_external {
2327                    String::from("SELECT DISTINCT s_from.name AS from_name,
2328                            p.from_symbol_id,
2329                            COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
2330                            p.kind,
2331                            p.path,
2332                            p.start_line,
2333                            p.start_column
2334                     FROM pending_relationships p
2335                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2336                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2337                     LIMIT ?2")
2338                } else {
2339                    format!("SELECT DISTINCT s_from.name AS from_name,
2340                            p.from_symbol_id,
2341                            p.target_terminal_name AS to_name,
2342                            p.kind,
2343                            p.path,
2344                            p.start_line,
2345                            p.start_column
2346                     FROM pending_relationships p
2347                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2348                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2349                       AND EXISTS (
2350                           SELECT 1 FROM symbols s_to
2351                           LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
2352                           WHERE s_to.name = p.target_terminal_name
2353                             AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2354                             AND {pred}
2355                       )
2356                     LIMIT ?2", pred = pending_target_predicate("s_to", "s_to_parent"))
2357                };
2358                let mut pending_stmt = conn.prepare(&sql)?;
2359                let rows = pending_stmt.query_map(
2360                    params![symbol_name, remaining as i64, symbol_id],
2361                    |row| {
2362                        Ok(ReferenceSite {
2363                            from_symbol_name: row.get(0)?,
2364                            from_symbol_id: row.get(1)?,
2365                            to_symbol_name: row.get(2)?,
2366                            kind: row.get(3)?,
2367                            path: row.get::<_, String>(4)?.replace('\\', "/"),
2368                            start_line: Some(row.get::<_, i64>(5)? as usize),
2369                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2370                        })
2371                    },
2372                )?;
2373                let mut out = Vec::new();
2374                for r in rows {
2375                    out.push(r?);
2376                }
2377                out
2378            } else {
2379                let sql = if include_external {
2380                    "SELECT DISTINCT s_from.name AS from_name,
2381                            p.from_symbol_id,
2382                            p.target_terminal_name AS to_name,
2383                            p.kind,
2384                            p.path,
2385                            p.start_line,
2386                            p.start_column
2387                     FROM pending_relationships p
2388                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2389                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2390                     LIMIT ?2"
2391                } else {
2392                    "SELECT DISTINCT s_from.name AS from_name,
2393                            p.from_symbol_id,
2394                            p.target_terminal_name AS to_name,
2395                            p.kind,
2396                            p.path,
2397                            p.start_line,
2398                            p.start_column
2399                     FROM pending_relationships p
2400                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2401                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2402                       AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
2403                     LIMIT ?2"
2404                };
2405                let mut pending_stmt = conn.prepare(sql)?;
2406
2407                let rows = pending_stmt.query_map(
2408                    params![symbol_name, remaining as i64, symbol_id],
2409                    |row| {
2410                        Ok(ReferenceSite {
2411                            from_symbol_name: row.get(0)?,
2412                            from_symbol_id: row.get(1)?,
2413                            to_symbol_name: row.get(2)?,
2414                            kind: row.get(3)?,
2415                            path: row.get::<_, String>(4)?.replace('\\', "/"),
2416                            start_line: Some(row.get::<_, i64>(5)? as usize),
2417                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2418                        })
2419                    },
2420                )?;
2421                let mut out = Vec::new();
2422                for r in rows {
2423                    out.push(r?);
2424                }
2425                out
2426            };
2427
2428            for r in p_rows {
2429                results.push(r);
2430            }
2431        }
2432    }
2433
2434    Ok(results)
2435}
2436
2437/// Resolve callee signatures directly in a single joined query, avoiding N+1 queries
2438/// and preserving ambiguous methods across types. Prioritizes functions/methods over enum variants.
2439pub fn find_callee_signatures(
2440    conn: &Connection,
2441    symbol_name: &str,
2442    symbol_id: &str,
2443    limit: usize,
2444    include_external: bool,
2445) -> Result<Vec<String>, QueryError> {
2446    let mut stmt = conn.prepare(
2447        "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2448         FROM relationships r
2449         JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2450         JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2451         WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
2452         LIMIT ?3",
2453    )?;
2454
2455    let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
2456        Ok((
2457            row.get::<_, String>(0)?,
2458            row.get::<_, Option<String>>(1)?,
2459            row.get::<_, String>(2)?.replace('\\', "/"),
2460            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2461            row.get::<_, String>(4)?,
2462        ))
2463    })?;
2464
2465    let mut signatures = Vec::new();
2466    let mut variants = Vec::new();
2467
2468    for r in rows.flatten() {
2469        let (name, sig_opt, path, line, kind) = r;
2470        let sig = sig_opt.unwrap_or(name);
2471        let entry = format!("{sig} ({path}:{line})");
2472        if kind == "variant" {
2473            if !variants.contains(&entry) {
2474                variants.push(entry);
2475            }
2476        } else if !signatures.contains(&entry) {
2477            signatures.push(entry);
2478        }
2479    }
2480
2481    if signatures.len() < limit {
2482        let remaining = (limit - signatures.len()) * 2;
2483        let p_rows: Vec<(String, Option<String>, String, usize, String)> =
2484            if has_pending_namespace_column(conn) {
2485                let mut p_stmt = conn.prepare(
2486                &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2487                 FROM pending_relationships p
2488                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2489                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2490                 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2491                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2492                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2493                    AND {pred}
2494                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
2495            )?;
2496
2497                let rows =
2498                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2499                        Ok((
2500                            row.get::<_, String>(0)?,
2501                            row.get::<_, Option<String>>(1)?,
2502                            row.get::<_, String>(2)?.replace('\\', "/"),
2503                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2504                            row.get::<_, String>(4)?,
2505                        ))
2506                    })?;
2507                rows.flatten().collect()
2508            } else {
2509                let mut p_stmt = conn.prepare(
2510                "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2511                 FROM pending_relationships p
2512                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2513                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2514                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2515                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2516                 LIMIT ?3",
2517            )?;
2518
2519                let rows =
2520                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2521                        Ok((
2522                            row.get::<_, String>(0)?,
2523                            row.get::<_, Option<String>>(1)?,
2524                            row.get::<_, String>(2)?.replace('\\', "/"),
2525                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2526                            row.get::<_, String>(4)?,
2527                        ))
2528                    })?;
2529                rows.flatten().collect()
2530            };
2531
2532        for r in p_rows {
2533            let (name, sig_opt, path, line, kind) = r;
2534            let sig = sig_opt.unwrap_or(name);
2535            let entry = format!("{sig} ({path}:{line})");
2536            if kind == "variant" {
2537                if !variants.contains(&entry) {
2538                    variants.push(entry);
2539                }
2540            } else if !signatures.contains(&entry) {
2541                signatures.push(entry);
2542            }
2543        }
2544    }
2545
2546    if include_external && signatures.len() < limit {
2547        let remaining = (limit - signatures.len()) * 2;
2548        let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
2549            let mut ext_stmt = conn.prepare(
2550                &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
2551                 FROM pending_relationships p
2552                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2553                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2554                   AND NOT EXISTS (
2555                       SELECT 1 FROM symbols s_to
2556                       LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2557                       WHERE s_to.name = p.target_terminal_name
2558                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2559                         AND {pred}
2560                   )
2561                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
2562            )?;
2563
2564            let rows =
2565                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2566                    Ok((
2567                        row.get::<_, String>(0)?,
2568                        row.get::<_, String>(1)?.replace('\\', "/"),
2569                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
2570                    ))
2571                })?;
2572            rows.flatten().collect()
2573        } else {
2574            let mut ext_stmt = conn.prepare(
2575                "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
2576                 FROM pending_relationships p
2577                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2578                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2579                   AND NOT EXISTS (
2580                       SELECT 1 FROM symbols s_to
2581                       WHERE s_to.name = p.target_terminal_name
2582                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2583                   )
2584                 LIMIT ?3",
2585            )?;
2586
2587            let rows =
2588                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2589                    Ok((
2590                        row.get::<_, String>(0)?,
2591                        row.get::<_, String>(1)?.replace('\\', "/"),
2592                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
2593                    ))
2594                })?;
2595            rows.flatten().collect()
2596        };
2597
2598        for r in ext_rows {
2599            let (name, path, line) = r;
2600            let entry = format!("{name} ({path}:{line})");
2601            if !signatures.contains(&entry) {
2602                signatures.push(entry);
2603            }
2604        }
2605    }
2606
2607    for v in variants {
2608        if signatures.len() >= limit {
2609            break;
2610        }
2611        if !signatures.contains(&v) {
2612            signatures.push(v);
2613        }
2614    }
2615
2616    signatures.truncate(limit);
2617    Ok(signatures)
2618}
2619
2620/// Find structural facts by category (e.g. route, query, model, config), optionally scoped by path.
2621pub fn find_structural_facts_scoped(
2622    conn: &Connection,
2623    category: &str,
2624    path_filter: Option<&str>,
2625    limit: usize,
2626) -> Result<Vec<StructuralFact>, QueryError> {
2627    validate_result_limit(limit)?;
2628    let norm_path = path_filter
2629        .map(|p| {
2630            p.replace('\\', "/")
2631                .trim_start_matches("./")
2632                .trim_matches('/')
2633                .to_string()
2634        })
2635        .filter(|p| !p.is_empty());
2636    let dir_prefix = norm_path
2637        .as_deref()
2638        .map(|p| format!("{}/%", escape_like(p)));
2639    let cat_pattern = format!("%{}%", escape_like(category));
2640
2641    let cat_lower = category.trim().to_ascii_lowercase();
2642    let cat_clause = match cat_lower.as_str() {
2643        "config" => {
2644            "(sf.pattern_id LIKE '%.key_value.%' OR sf.pattern_id LIKE '%config%' OR sf.capture_name LIKE '%config%' OR sf.node_kind LIKE '%config%')"
2645        }
2646        "route" | "routes" => {
2647            "(sf.pattern_id LIKE '%.route%' OR sf.pattern_id LIKE '%route%' OR sf.capture_name LIKE '%route%')"
2648        }
2649        "query" | "queries" | "sql" => {
2650            "(sf.pattern_id LIKE '%.sql.%' OR sf.pattern_id LIKE '%query%')"
2651        }
2652        "model" | "models" => "sf.pattern_id LIKE '%.model%'",
2653        _ => {
2654            "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
2655        }
2656    };
2657
2658    let sql = format!(
2659        "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
2660                sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
2661                sf.start_line, sf.end_line, sf.confidence,
2662                COALESCE(
2663                    CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
2664                         THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
2665                         ELSE json_extract(sf.metadata_json, '$.key_path') END,
2666                    json_extract(sf.metadata_json, '$.key'),
2667                    json_extract(sf.metadata_json, '$.normalized_route_template')
2668                ) AS display_key
2669         FROM structural_facts sf
2670         LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
2671         WHERE (:cat IS NOT NULL AND {cat_clause})
2672           AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2673         ORDER BY sf.path ASC, sf.start_line ASC
2674         LIMIT :limit"
2675    );
2676
2677    let mut stmt = conn.prepare(&sql)?;
2678    let rows = stmt.query_map(
2679        rusqlite::named_params! {
2680            ":cat": cat_pattern,
2681            ":path": norm_path.as_deref(),
2682            ":dir_prefix": dir_prefix.as_deref(),
2683            ":limit": limit as i64,
2684        },
2685        |row| {
2686            Ok(StructuralFact {
2687                structural_fact_id: row.get(0)?,
2688                path: row.get::<_, String>(1)?.replace('\\', "/"),
2689                language: row.get(2)?,
2690                pattern_id: row.get(3)?,
2691                capture_name: row.get(4)?,
2692                node_kind: row.get(5)?,
2693                key: row.get(10)?,
2694                containing_symbol_name: row.get(6)?,
2695                start_line: row.get::<_, i64>(7)? as usize,
2696                end_line: row.get::<_, i64>(8)? as usize,
2697                confidence: row.get(9)?,
2698            })
2699        },
2700    )?;
2701
2702    let mut results = Vec::new();
2703    for r in rows {
2704        results.push(r?);
2705    }
2706    Ok(results)
2707}
2708
2709/// Find structural facts by category (e.g. route, query, model, config).
2710pub fn find_structural_facts(
2711    conn: &Connection,
2712    category: &str,
2713    limit: usize,
2714) -> Result<Vec<StructuralFact>, QueryError> {
2715    find_structural_facts_scoped(conn, category, None, limit)
2716}
2717
2718/// Find literals (endpoints, SQL queries, configs) matching category, optionally scoped by path.
2719pub fn find_literals_scoped(
2720    conn: &Connection,
2721    category: &str,
2722    path_filter: Option<&str>,
2723    limit: usize,
2724) -> Result<Vec<LiteralFact>, QueryError> {
2725    validate_result_limit(limit)?;
2726    let norm_path = path_filter
2727        .map(|p| {
2728            p.replace('\\', "/")
2729                .trim_start_matches("./")
2730                .trim_matches('/')
2731                .to_string()
2732        })
2733        .filter(|p| !p.is_empty());
2734    let dir_prefix = norm_path
2735        .as_deref()
2736        .map(|p| format!("{}/%", escape_like(p)));
2737    let cat_pattern = format!("%{}%", escape_like(category));
2738
2739    let cat_lower = category.trim().to_ascii_lowercase();
2740    let cat_clause = match cat_lower.as_str() {
2741        "config" => {
2742            "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
2743        }
2744        "route" | "routes" => "l.kind LIKE '%route%'",
2745        "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
2746        "model" | "models" => "l.kind LIKE '%model%'",
2747        _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
2748    };
2749
2750    let sql = format!(
2751        "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
2752                l.start_line, s.name AS containing_symbol_name
2753         FROM literals l
2754         LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
2755         WHERE (:cat IS NOT NULL AND {cat_clause})
2756           AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2757         ORDER BY l.path ASC, l.start_line ASC
2758         LIMIT :limit"
2759    );
2760
2761    let mut stmt = conn.prepare(&sql)?;
2762    let rows = stmt.query_map(
2763        rusqlite::named_params! {
2764            ":cat": cat_pattern,
2765            ":path": norm_path.as_deref(),
2766            ":dir_prefix": dir_prefix.as_deref(),
2767            ":limit": limit as i64,
2768        },
2769        |row| {
2770            Ok(LiteralFact {
2771                literal_id: row.get(0)?,
2772                path: row.get::<_, String>(1)?.replace('\\', "/"),
2773                literal_text: row.get(2)?,
2774                kind: row.get(3)?,
2775                carrier: row.get(4)?,
2776                start_line: row.get::<_, i64>(5)? as usize,
2777                containing_symbol_name: row.get(6)?,
2778            })
2779        },
2780    )?;
2781
2782    let mut results = Vec::new();
2783    for r in rows {
2784        results.push(r?);
2785    }
2786    Ok(results)
2787}
2788
2789/// Find literals (endpoints, SQL queries, configs) matching category.
2790pub fn find_literals(
2791    conn: &Connection,
2792    category: &str,
2793    limit: usize,
2794) -> Result<Vec<LiteralFact>, QueryError> {
2795    find_literals_scoped(conn, category, None, limit)
2796}
2797
2798/// List available structural fact and literal categories with counts, optionally scoped by path.
2799pub fn list_structural_fact_categories_scoped(
2800    conn: &Connection,
2801    path_filter: Option<&str>,
2802) -> Result<Vec<(String, usize)>, QueryError> {
2803    let norm_path = path_filter
2804        .map(|p| {
2805            p.replace('\\', "/")
2806                .trim_start_matches("./")
2807                .trim_matches('/')
2808                .to_string()
2809        })
2810        .filter(|p| !p.is_empty());
2811    let dir_prefix = norm_path
2812        .as_deref()
2813        .map(|p| format!("{}/%", escape_like(p)));
2814
2815    let mut categories = Vec::new();
2816
2817    let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
2818               WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2819               GROUP BY pattern_id ORDER BY cnt DESC";
2820    let mut stmt = conn.prepare(sql)?;
2821    let rows = stmt.query_map(
2822        rusqlite::named_params! {
2823            ":path": norm_path.as_deref(),
2824            ":dir_prefix": dir_prefix.as_deref(),
2825        },
2826        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
2827    )?;
2828    for r in rows {
2829        categories.push(r?);
2830    }
2831
2832    let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
2833                   WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2834                   GROUP BY kind ORDER BY cnt DESC";
2835    let mut lit_stmt = conn.prepare(lit_sql)?;
2836    let lit_rows = lit_stmt.query_map(
2837        rusqlite::named_params! {
2838            ":path": norm_path.as_deref(),
2839            ":dir_prefix": dir_prefix.as_deref(),
2840        },
2841        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
2842    )?;
2843    for r in lit_rows {
2844        categories.push(r?);
2845    }
2846
2847    Ok(categories)
2848}
2849
2850/// List all available structural fact and literal categories with counts.
2851pub fn list_structural_fact_categories(
2852    conn: &Connection,
2853) -> Result<Vec<(String, usize)>, QueryError> {
2854    list_structural_fact_categories_scoped(conn, None)
2855}
2856
2857/// Find type facts for a symbol.
2858pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
2859    let has_table: bool = conn
2860        .query_row(
2861            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
2862            [],
2863            |_| Ok(true),
2864        )
2865        .unwrap_or(false);
2866    if !has_table {
2867        return Ok(Vec::new());
2868    }
2869
2870    let mut stmt = conn.prepare(
2871        "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
2872         FROM type_facts
2873         WHERE symbol_id = ?1",
2874    )?;
2875
2876    let rows = stmt.query_map(params![symbol_id], |row| {
2877        Ok(TypeFact {
2878            type_fact_id: row.get(0)?,
2879            symbol_id: row.get(1)?,
2880            language: row.get(2)?,
2881            resolved_type: row.get(3)?,
2882            generic_params: row.get(4)?,
2883        })
2884    })?;
2885
2886    let mut results = Vec::new();
2887    for r in rows {
2888        results.push(r?);
2889    }
2890    Ok(results)
2891}
2892
2893/// True when a repository-relative path looks like a test file. Directory rules and file-name
2894/// rules are kept apart: a `test`, `tests`, or `__tests__` directory anywhere including the
2895/// repository root, or a file name that starts with `test_` in Python or Ruby, contains `_test.`,
2896/// `.test.`, or `.spec.`, is exactly `test.rs` or `tests.rs`, or ends with the C# `Tests.cs`
2897/// (case-sensitive, so `Contests.cs` is a production file).
2898pub fn is_test_path(path: &str) -> bool {
2899    let p = path.replace('\\', "/");
2900    let cut = p.rfind('/').map_or(0, |i| i + 1);
2901    let directories = format!("/{}/", p[..cut].to_lowercase());
2902    let file_name = &p[cut..];
2903    let lower_name = file_name.to_lowercase();
2904    directories.contains("/test/")
2905        || directories.contains("/tests/")
2906        || directories.contains("/__tests__/")
2907        || (lower_name.starts_with("test_")
2908            && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
2909        || lower_name.contains("_test.")
2910        || lower_name.contains(".test.")
2911        || lower_name.contains(".spec.")
2912        || lower_name == "test.rs"
2913        || lower_name == "tests.rs"
2914        || file_name.ends_with("Tests.cs")
2915}
2916
2917/// SQL boolean over `alias.path` that mirrors [`is_test_path`] rule for rule.
2918///
2919/// `test_path_rule_and_its_sql_mirror_agree_on_every_path` runs both forms over one path list so
2920/// the two cannot drift apart.
2921///
2922/// Every rule needs the word `test` or `spec` in the path, so a cheap substring test guards the
2923/// rules and lets most rows skip the path split. Without the guard the split costs about seven
2924/// times more over a half-million rows.
2925pub(crate) fn test_path_predicate(alias: &str) -> String {
2926    let guard = format!("(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%')");
2927    let p = format!("replace({alias}.path, '\\', '/')");
2928    let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
2929    let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
2930    let lower_name = format!("lower({file_name})");
2931    let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
2932    let clauses = [
2933        like(&directories, "%/test/%"),
2934        like(&directories, "%/tests/%"),
2935        like(&directories, "%/\\_\\_tests\\_\\_/%"),
2936        like(&lower_name, "test\\_%.py"),
2937        like(&lower_name, "test\\_%.rb"),
2938        like(&lower_name, "%\\_test.%"),
2939        like(&lower_name, "%.test.%"),
2940        like(&lower_name, "%.spec.%"),
2941        format!("{lower_name} = 'test.rs'"),
2942        format!("{lower_name} = 'tests.rs'"),
2943        format!("{file_name} GLOB '*Tests.cs'"),
2944    ]
2945    .join(" OR ");
2946    format!("({guard} AND ({clauses}))")
2947}
2948
2949/// Compute blast radius and likely tests for given seed symbols or seed file paths.
2950/// Recursively walks reverse reachability (transitive callers) up to `max_depth` in SQLite.
2951pub fn compute_blast_radius_scoped(
2952    conn: &Connection,
2953    seed_symbols: &[&str],
2954    symbol_path_filter: Option<&str>,
2955    seed_paths: &[&str],
2956    max_depth: usize,
2957    limit: usize,
2958) -> Result<BlastRadiusResult, QueryError> {
2959    validate_result_limit(limit)?;
2960    let max_depth = max_depth.min(5);
2961    let resolved_seed_symbols = seed_symbols
2962        .iter()
2963        .map(|name| {
2964            get_symbol_by_name(conn, name, symbol_path_filter)?
2965                .ok_or_else(|| QueryError::SymbolNotFound((*name).to_string()))
2966        })
2967        .collect::<Result<Vec<_>, _>>()?;
2968    let mut seeds = Vec::new();
2969    let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
2970        for s in seed_symbols {
2971            seeds.push(s.to_string());
2972        }
2973        for p in seed_paths {
2974            seeds.push(p.to_string());
2975        }
2976        "mixed".to_string()
2977    } else if !seed_symbols.is_empty() {
2978        for s in seed_symbols {
2979            seeds.push(s.to_string());
2980        }
2981        "symbol".to_string()
2982    } else if !seed_paths.is_empty() {
2983        for p in seed_paths {
2984            seeds.push(p.to_string());
2985        }
2986        "file".to_string()
2987    } else {
2988        return Ok(BlastRadiusResult {
2989            seed_type: "none".to_string(),
2990            seeds: Vec::new(),
2991            likely_tests: Vec::new(),
2992            impacted_symbols: Vec::new(),
2993            traversal_ceiling_reached: false,
2994        });
2995    };
2996
2997    let mut where_clauses = Vec::new();
2998    let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
2999
3000    if !resolved_seed_symbols.is_empty() {
3001        let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3002            .map(|i| format!("?{}", i))
3003            .collect();
3004        where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3005        for symbol in &resolved_seed_symbols {
3006            params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3007        }
3008    }
3009
3010    if !seed_paths.is_empty() {
3011        let mut path_conds = Vec::new();
3012        for p in seed_paths.iter() {
3013            let raw = p
3014                .replace('\\', "/")
3015                .trim_start_matches("./")
3016                .trim_matches('/')
3017                .to_string();
3018            let exact_idx = params_vec.len() + 1;
3019            params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3020            let dir_pattern = format!("{}/%", escape_like(&raw));
3021            let like_idx = params_vec.len() + 1;
3022            params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3023            path_conds.push(format!(
3024                "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3025            ));
3026        }
3027        where_clauses.push(format!("({})", path_conds.join(" OR ")));
3028    }
3029
3030    let seed_condition = where_clauses.join(" OR ");
3031    let max_depth_idx = params_vec.len() + 1;
3032    params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3033
3034    let mut traversal_ceiling_reached = false;
3035
3036    let has_relationships: bool = conn
3037        .query_row(
3038            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3039            [],
3040            |_| Ok(true),
3041        )
3042        .unwrap_or(false);
3043
3044    let has_pending: bool = conn
3045        .query_row(
3046            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3047            [],
3048            |_| Ok(true),
3049        )
3050        .unwrap_or(false);
3051
3052    let mut likely_tests = Vec::new();
3053    let mut impacted_symbols = Vec::new();
3054    let mut seen_test_keys = HashSet::new();
3055
3056    let mut recursive_branches = Vec::new();
3057
3058    if has_relationships {
3059        recursive_branches.push(format!(
3060            "SELECT r.from_symbol_id, iw.depth + 1
3061             FROM relationships r
3062             JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3063             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3064             WHERE iw.depth < ?{max_depth_idx}
3065               AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
3066        ));
3067    }
3068
3069    if has_pending {
3070        let (parent_join, ns_condition) = if conn
3071            .query_row(
3072                "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3073                [],
3074                |_| Ok(true),
3075            )
3076            .unwrap_or(false)
3077        {
3078            (
3079                "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3080            LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3081                format!("AND {pred}", pred = pending_target_predicate("s_target", "s_target_parent")),
3082            )
3083        } else {
3084            ("", String::new())
3085        };
3086
3087        recursive_branches.push(format!(
3088            "SELECT p.from_symbol_id, iw.depth + 1
3089             FROM pending_relationships p
3090             JOIN symbols s_target ON p.target_terminal_name = s_target.name
3091             JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3092             {parent_join}
3093             WHERE iw.depth < ?{max_depth_idx}
3094               AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3095               {ns_condition}"
3096        ));
3097    }
3098
3099    if !recursive_branches.is_empty() {
3100        let recursive_sql = recursive_branches.join("\n UNION \n");
3101        let not_documentation = not_documentation(conn, "s");
3102        let sql = format!(
3103            "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3104                SELECT symbol_id, 0
3105                FROM symbols
3106                WHERE ({seed_condition})
3107                  AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3108
3109                UNION
3110
3111                {recursive_sql}
3112            )
3113            SELECT s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container, MIN(iw.depth) as min_depth
3114            FROM impact_walk iw
3115            CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3116            WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3117              AND {not_documentation}
3118            GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3119            HAVING MIN(iw.depth) > 0
3120            ORDER BY min_depth ASC, s.path ASC, s.name ASC
3121            LIMIT 200"
3122        );
3123
3124        let mut stmt = conn.prepare(&sql)?;
3125        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3126            .iter()
3127            .map(|v| v as &dyn rusqlite::ToSql)
3128            .collect();
3129
3130        let rows = stmt.query_map(param_refs.as_slice(), |row| {
3131            Ok((
3132                row.get::<_, String>(0)?,
3133                row.get::<_, String>(1)?,
3134                row.get::<_, String>(2)?,
3135                row.get::<_, String>(3)?,
3136                row.get::<_, i64>(4)? as usize,
3137                row.get::<_, bool>(5)?,
3138                row.get::<_, bool>(6)?,
3139                row.get::<_, i64>(7)? as usize,
3140            ))
3141        })?;
3142
3143        let mut row_count = 0;
3144        for r in rows {
3145            row_count += 1;
3146            let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3147            let path = raw_path.replace('\\', "/");
3148            let is_test_target = is_test || test_container || is_test_path(&path);
3149
3150            if is_test_target {
3151                let key = format!("{}:{}", path, line);
3152                if seen_test_keys.insert(key) {
3153                    likely_tests.push(TestTarget {
3154                        name,
3155                        path,
3156                        line,
3157                        reason: format!("transitive caller [depth {depth}]"),
3158                    });
3159                }
3160            } else {
3161                impacted_symbols.push(ImpactedSymbol {
3162                    name,
3163                    kind,
3164                    path,
3165                    line,
3166                    depth,
3167                });
3168            }
3169        }
3170        traversal_ceiling_reached = row_count >= 200;
3171    }
3172
3173    // 2. Discover stem-matched test files in the workspace
3174    let mut file_stems = Vec::new();
3175    for p in seed_paths {
3176        if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
3177            && stem.len() >= 3
3178            && !file_stems.contains(&stem.to_string())
3179        {
3180            file_stems.push(stem.to_string());
3181        }
3182    }
3183    for symbol in &resolved_seed_symbols {
3184        if let Some(stem) = std::path::Path::new(&symbol.path)
3185            .file_stem()
3186            .and_then(|s| s.to_str())
3187            && stem.len() >= 3
3188            && !file_stems.contains(&stem.to_string())
3189        {
3190            file_stems.push(stem.to_string());
3191        }
3192    }
3193
3194    let has_files: bool = conn
3195        .query_row(
3196            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3197            [],
3198            |_| Ok(true),
3199        )
3200        .unwrap_or(false);
3201
3202    if has_files {
3203        let doc_file = format!(
3204            "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3205            not_documentation(conn, "d")
3206        );
3207        let mut test_files_stmt = conn.prepare(&format!(
3208            "SELECT DISTINCT path FROM files
3209             WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3210               AND NOT {doc_file}
3211             LIMIT 10"
3212        ))?;
3213        for stem in file_stems {
3214            let stem_pattern = format!("%{}%", escape_like(&stem));
3215            let t_rows =
3216                test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
3217            for p in t_rows.flatten() {
3218                let p = p.replace('\\', "/");
3219                let key = format!("{}:1", p);
3220                if seen_test_keys.insert(key) {
3221                    likely_tests.push(TestTarget {
3222                        name: p.clone(),
3223                        path: p,
3224                        line: 1,
3225                        reason: "stem-matched test file".to_string(),
3226                    });
3227                }
3228            }
3229        }
3230    }
3231
3232    // Truncate to limit
3233    if likely_tests.len() > limit {
3234        likely_tests.truncate(limit);
3235    }
3236    if impacted_symbols.len() > limit {
3237        impacted_symbols.truncate(limit);
3238    }
3239
3240    Ok(BlastRadiusResult {
3241        seed_type,
3242        seeds,
3243        likely_tests,
3244        impacted_symbols,
3245        traversal_ceiling_reached,
3246    })
3247}
3248
3249/// Compute blast radius and likely tests for given seed symbols or seed file paths.
3250pub fn compute_blast_radius(
3251    conn: &Connection,
3252    seed_symbols: &[&str],
3253    seed_paths: &[&str],
3254    max_depth: usize,
3255    limit: usize,
3256) -> Result<BlastRadiusResult, QueryError> {
3257    compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3258}
3259
3260#[cfg(test)]
3261mod tests {
3262    #[test]
3263    fn result_limit_rejects_values_above_the_shared_ceiling() {
3264        assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3265        assert!(matches!(
3266            validate_result_limit(usize::MAX),
3267            Err(QueryError::InvalidResultLimit(usize::MAX))
3268        ));
3269    }
3270
3271    #[test]
3272    fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
3273        let conn = Connection::open_in_memory().unwrap();
3274
3275        assert!(matches!(
3276            find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
3277            Err(QueryError::InvalidResultLimit(usize::MAX))
3278        ));
3279    }
3280
3281    use super::*;
3282    use crate::db::{ensure_fts_index, open_read_write};
3283
3284    #[test]
3285    fn count_parse_diagnostics_counts_rows_for_one_file() {
3286        let dir = crate::safe_tempdir();
3287        let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
3288
3289        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
3290
3291        conn.execute_batch(
3292            "CREATE TABLE parse_diagnostics (
3293                diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
3294            );
3295            INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
3296            INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
3297            INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
3298        )
3299        .unwrap();
3300
3301        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
3302        assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
3303        assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
3304    }
3305
3306    #[test]
3307    fn test_sanitize_fts5_query() {
3308        let (and_q, or_q) = sanitize_fts5_query("parse tokens");
3309        assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
3310        assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
3311
3312        let (and_q, or_q) = sanitize_fts5_query("  Option<T>  ");
3313        assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
3314        assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
3315
3316        let (and_q, or_q) = sanitize_fts5_query("   ");
3317        assert!(and_q.is_empty());
3318        assert!(or_q.is_empty());
3319    }
3320
3321    #[test]
3322    fn sanitize_splits_case_boundaries_and_drops_stop_words() {
3323        let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
3324        assert_eq!(
3325            and_q,
3326            "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
3327        );
3328        assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
3329
3330        let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
3331        assert_eq!(
3332            and_q,
3333            "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
3334        );
3335
3336        let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
3337        assert_eq!(
3338            and_q,
3339            "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
3340        );
3341        assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
3342
3343        let (and_q, _) = sanitize_fts5_query("validate_syntax");
3344        assert_eq!(
3345            and_q,
3346            "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
3347        );
3348
3349        let (and_q, _) = sanitize_fts5_query("isReady");
3350        assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
3351
3352        let (and_q, _) = sanitize_fts5_query("before");
3353        assert_eq!(and_q, "\"before\"*");
3354
3355        let (and_q, _) = sanitize_fts5_query("fooBar quux");
3356        assert_eq!(
3357            and_q,
3358            "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
3359        );
3360
3361        let (and_q, _) = sanitize_fts5_query("the for a");
3362        assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
3363    }
3364
3365    fn search_fixture(rows: &str) -> Connection {
3366        let conn = Connection::open_in_memory().unwrap();
3367        conn.execute_batch(&format!(
3368            "CREATE TABLE symbols (
3369                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3370                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
3371                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3372                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3373                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3374                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3375                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
3376            );
3377            INSERT INTO symbols VALUES {rows};"
3378        ))
3379        .unwrap();
3380        ensure_fts_index(&conn).unwrap();
3381        conn
3382    }
3383
3384    fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
3385        format!(
3386            "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
3387              10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
3388        )
3389    }
3390
3391    fn doc_row(id: &str, name: &str, doc: &str) -> String {
3392        format!(
3393            "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
3394              3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
3395        )
3396    }
3397
3398    fn search_names(conn: &Connection, query: &str) -> Vec<String> {
3399        fts_search_symbols_scoped(conn, query, None, None, false, 10)
3400            .unwrap()
3401            .into_iter()
3402            .map(|r| r.symbol.name)
3403            .collect()
3404    }
3405
3406    const TEST_PATH_CASES: &[(&str, bool)] = &[
3407        ("tests/foo.py", true),
3408        ("tests/x.py", true),
3409        ("tests/tools/test_web.py", true),
3410        ("src/tests/x.rs", true),
3411        ("__tests__/a.ts", true),
3412        ("a/__tests__/b.ts", true),
3413        ("test/x.java", true),
3414        ("src/test/Helper.java", true),
3415        ("src/test_utils.py", true),
3416        ("lib/test_helper.rb", true),
3417        ("test_config.py", true),
3418        ("pkg/test_data/x.json", false),
3419        ("src/test_detection.rs", false),
3420        ("crates/julie-index/src/analysis/test_quality.rs", false),
3421        ("x/foo_test.go", true),
3422        ("x/foo.test.ts", true),
3423        ("x/foo.spec.js", true),
3424        ("src/lib_test.rs", true),
3425        ("src/test.rs", true),
3426        ("tests.rs", true),
3427        ("src/tests.rs", true),
3428        ("Foo.Tests.cs", true),
3429        ("x/FooTests.cs", true),
3430        ("src/FooTests.cs", true),
3431        ("src/Foo.Tests.cs", true),
3432        ("tests/Foo.cs", true),
3433        ("x/parser.spec.ts", true),
3434        ("test_x.py", true),
3435        ("test.rs", true),
3436        ("tests\\x.py", true),
3437        ("src/protocol.spec.v1/parser.rs", false),
3438        ("pkg/test_support/runtime.py", false),
3439        ("src/Contests.cs", false),
3440        ("spec/x.rb", false),
3441        ("crates/x/src/impact/likely_tests.rs", false),
3442        ("x/foo_tests.rs", false),
3443        ("src/latest.rs", false),
3444        ("x/latest.go", false),
3445        ("x/manifest.rs", false),
3446        ("src/attest.rs", false),
3447        ("contest/x.py", false),
3448        ("src/testing.rs", false),
3449        ("src/main.rs", false),
3450        ("pkg/service.go", false),
3451    ];
3452
3453    #[test]
3454    fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
3455        let conn = Connection::open_in_memory().unwrap();
3456        let sql = format!(
3457            "SELECT {} FROM (SELECT :path AS path) s",
3458            test_path_predicate("s")
3459        );
3460        let mut stmt = conn.prepare(&sql).unwrap();
3461        for (path, expected) in TEST_PATH_CASES {
3462            assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
3463            let from_sql: bool = stmt
3464                .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
3465                .unwrap();
3466            assert_eq!(from_sql, *expected, "sql mirror: {path}");
3467        }
3468    }
3469
3470    #[test]
3471    fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
3472        let conn = search_fixture(
3473            &[
3474                code_row(
3475                    "a",
3476                    "src/parser.rs",
3477                    "rust",
3478                    "parse_sidecar",
3479                    "Parse a sidecar.",
3480                ),
3481                code_row(
3482                    "b",
3483                    "src/tests/helpers.py",
3484                    "python",
3485                    "parse_sidecar_fixture",
3486                    "Parse a sidecar.",
3487                ),
3488            ]
3489            .join(", "),
3490        );
3491
3492        let default_search: Vec<String> =
3493            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
3494                .unwrap()
3495                .into_iter()
3496                .map(|r| r.symbol.name)
3497                .collect();
3498        assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
3499
3500        let with_tests: Vec<String> =
3501            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
3502                .unwrap()
3503                .into_iter()
3504                .map(|r| r.symbol.name)
3505                .collect();
3506        assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
3507
3508        let default_lookup: Vec<String> =
3509            search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
3510                .unwrap()
3511                .into_iter()
3512                .map(|s| s.name)
3513                .collect();
3514        assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
3515
3516        let lookup_with_tests: Vec<String> =
3517            search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
3518                .unwrap()
3519                .into_iter()
3520                .map(|s| s.name)
3521                .collect();
3522        assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
3523    }
3524
3525    #[test]
3526    fn qualified_lookup_in_a_test_file_needs_include_tests() {
3527        let conn = search_fixture(
3528            &[
3529                "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
3530                  1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
3531                "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
3532                  10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
3533            ]
3534            .join(", "),
3535        );
3536
3537        assert!(
3538            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
3539                .unwrap()
3540                .is_empty()
3541        );
3542        assert_eq!(
3543            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
3544                .unwrap()
3545                .len(),
3546            1
3547        );
3548    }
3549
3550    #[test]
3551    fn concept_query_prefers_partial_code_match_over_full_doc_match() {
3552        let conn = search_fixture(
3553            &[
3554                doc_row(
3555                    "d1",
3556                    "Safety guarantees",
3557                    "Pre-flight syntax validation runs before the edit touches disk",
3558                ),
3559                doc_row(
3560                    "d2",
3561                    "Audit",
3562                    "The syntax validation before an edit is the invariant",
3563                ),
3564                code_row(
3565                    "c1",
3566                    "src/syntax.rs",
3567                    "rust",
3568                    "validate_syntax",
3569                    "Validate the syntax of a file",
3570                ),
3571                code_row(
3572                    "c2",
3573                    "src/edit.rs",
3574                    "rust",
3575                    "replace_symbol_body",
3576                    "Atomic edit with validation",
3577                ),
3578            ]
3579            .join(","),
3580        );
3581
3582        let names = search_names(&conn, "syntax validation before edit");
3583
3584        assert_eq!(names[0], "validate_syntax");
3585        assert!(names.contains(&"replace_symbol_body".to_string()));
3586        assert!(names.contains(&"Safety guarantees".to_string()));
3587    }
3588
3589    #[test]
3590    fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
3591        let conn = search_fixture(
3592            &[
3593                code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
3594                code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
3595            ]
3596            .join(","),
3597        );
3598
3599        let mut camel = search_names(&conn, "ValidateSyntax");
3600        camel.sort();
3601        assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
3602        let mut words = search_names(&conn, "validate syntax");
3603        words.sort();
3604        assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
3605    }
3606
3607    #[test]
3608    fn stop_word_prefixed_camel_case_symbol_is_still_found() {
3609        let conn = search_fixture(
3610            &[
3611                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
3612                code_row("c2", "src/hooks.rs", "rust", "before", ""),
3613                code_row(
3614                    "c3",
3615                    "src/x.rs",
3616                    "rust",
3617                    "fooBar",
3618                    "has fooBar but not the other word",
3619                ),
3620            ]
3621            .join(","),
3622        );
3623
3624        assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
3625        assert_eq!(search_names(&conn, "before"), vec!["before"]);
3626    }
3627
3628    #[test]
3629    fn related_tests_use_the_name_as_typed_without_splitting() {
3630        let conn = search_fixture(
3631            &[
3632                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
3633                "('t1', 'f_t1', 'tests/ready.rs', 'rust', 'test_ready', 'function', 'fn test_ready()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t1', NULL, 1, 0, 'code')".to_string(),
3634                "('t2', 'f_t2', 'tests/state.rs', 'rust', 'isReady_reports_true', 'function', 'fn isReady_reports_true()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t2', NULL, 1, 0, 'code')".to_string(),
3635            ]
3636            .join(","),
3637        );
3638        let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
3639
3640        let names: Vec<String> = find_related_tests(&conn, &target, 5)
3641            .unwrap()
3642            .into_iter()
3643            .map(|t| t.name)
3644            .collect();
3645
3646        assert_eq!(names, vec!["isReady_reports_true"]);
3647    }
3648
3649    #[test]
3650    fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
3651        let conn = search_fixture(
3652            &[
3653                code_row(
3654                    "c1",
3655                    "src/queries.rs",
3656                    "rust",
3657                    "fts_search_symbols_scoped",
3658                    "search symbols scoped with fts",
3659                ),
3660                code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
3661            ]
3662            .join(","),
3663        );
3664
3665        assert_eq!(
3666            search_names(&conn, "search_symbols_scoped")[0],
3667            "search_symbols_scoped"
3668        );
3669    }
3670
3671    fn sidecar_fixture() -> Connection {
3672        search_fixture(
3673            &[
3674                code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
3675                code_row(
3676                    "c2",
3677                    "src/sidecar.rs",
3678                    "rust",
3679                    "parse_sidecar_file",
3680                    "parse the sha256 sidecar file",
3681                ),
3682            ]
3683            .join(","),
3684        )
3685    }
3686
3687    fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
3688        candidates
3689            .iter()
3690            .find(|c| c.result.symbol.name == name)
3691            .unwrap_or_else(|| panic!("{name} is not a candidate"))
3692    }
3693
3694    #[test]
3695    fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
3696        let conn = sidecar_fixture();
3697
3698        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
3699
3700        let target = candidate(&candidates, "parseSha256Sidecar");
3701        assert!(target.name_match);
3702        assert!(!target.word_match);
3703        assert!(!target.exact_name);
3704        assert!(target.name_terms.contains(&"sha256".to_string()));
3705    }
3706
3707    #[test]
3708    fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
3709        let conn = search_fixture(
3710            &[
3711                code_row(
3712                    "c1",
3713                    "examples/demo.rs",
3714                    "rust",
3715                    "demo",
3716                    "restore offline state",
3717                ),
3718                code_row(
3719                    "c2",
3720                    "src/replay.rs",
3721                    "rust",
3722                    "replay",
3723                    "restore offline records",
3724                ),
3725            ]
3726            .join(","),
3727        );
3728
3729        let candidates =
3730            collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
3731                .unwrap();
3732
3733        assert!(candidate(&candidates, "demo").word_match);
3734        assert!(candidate(&candidates, "replay").word_match);
3735        assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
3736    }
3737
3738    #[test]
3739    fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
3740        let mut rows: Vec<String> = (1..=170)
3741            .map(|i| {
3742                code_row(
3743                    &format!("h{i:03}"),
3744                    "src/sidecar.rs",
3745                    "rust",
3746                    &format!("sidecar_helper_{i:03}"),
3747                    "parse sidecar file",
3748                )
3749            })
3750            .collect();
3751        rows.push(code_row(
3752            "c1",
3753            "src/sidecar.rs",
3754            "rust",
3755            "parseSha256Sidecar",
3756            "",
3757        ));
3758        let conn = search_fixture(&rows.join(","));
3759
3760        let candidates = collect_search_candidates(
3761            &conn,
3762            "parse the sha256 sidecar file",
3763            None,
3764            None,
3765            false,
3766            40,
3767        )
3768        .unwrap();
3769
3770        assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
3771        assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
3772    }
3773
3774    #[test]
3775    fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
3776        let mut rows: Vec<String> = (1..=20)
3777            .map(|i| {
3778                code_row(
3779                    &format!("a{i:02}"),
3780                    "src/a.rs",
3781                    "rust",
3782                    &format!("both_{i:02}"),
3783                    "restore offline",
3784                )
3785            })
3786            .collect();
3787        rows.extend((1..=50).map(|i| {
3788            code_row(
3789                &format!("p{i:02}"),
3790                "src/p.rs",
3791                "rust",
3792                &format!("partial_{i:02}"),
3793                "restore records",
3794            )
3795        }));
3796        let conn = search_fixture(&rows.join(","));
3797
3798        let candidates =
3799            collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
3800
3801        let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
3802        assert_eq!(word_rows.len(), 40);
3803        assert_eq!(
3804            word_rows
3805                .iter()
3806                .filter(|c| c.result.symbol.name.starts_with("both_"))
3807                .count(),
3808            20
3809        );
3810    }
3811
3812    #[test]
3813    fn exact_name_is_admitted_regardless_of_case() {
3814        let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
3815
3816        let candidates =
3817            collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
3818        assert!(candidate(&candidates, "xyzzy_q").exact_name);
3819
3820        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
3821        let candidates =
3822            collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
3823        assert!(candidate(&candidates, "xyzzy_q").exact_name);
3824    }
3825
3826    #[test]
3827    fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
3828        let conn = search_fixture(&code_row(
3829            "c1",
3830            "src/say.js",
3831            "javascript",
3832            "say \"hi\"",
3833            "",
3834        ));
3835
3836        let candidates =
3837            collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
3838
3839        let target = candidate(&candidates, "say \"hi\"");
3840        assert!(target.exact_name && target.name_match);
3841    }
3842
3843    #[test]
3844    fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
3845        let conn = search_fixture(
3846            &[
3847                code_row("c1", "src/a.rs", "rust", "sidecar", ""),
3848                code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
3849            ]
3850            .join(","),
3851        );
3852
3853        let candidates =
3854            collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
3855
3856        assert_eq!(candidates.len(), 2);
3857        let target = candidate(&candidates, "sidecar");
3858        assert!(target.exact_name && target.word_match && target.name_match);
3859        assert!(target.bm25.is_some());
3860        let helper = candidate(&candidates, "sidecar_helper");
3861        assert!(!helper.exact_name && helper.word_match && helper.name_match);
3862    }
3863
3864    #[test]
3865    fn an_index_without_the_trigram_table_returns_word_rows_only() {
3866        let conn = sidecar_fixture();
3867        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
3868
3869        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
3870
3871        let names: Vec<&str> = candidates
3872            .iter()
3873            .map(|c| c.result.symbol.name.as_str())
3874            .collect();
3875        assert_eq!(names, vec!["parse_sidecar_file"]);
3876        assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
3877        assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
3878    }
3879
3880    #[test]
3881    fn words_under_three_characters_skip_the_name_branch() {
3882        let conn = search_fixture(
3883            &[
3884                code_row("c1", "src/a.rs", "rust", "ab", ""),
3885                code_row("c2", "src/b.rs", "rust", "cab", ""),
3886            ]
3887            .join(","),
3888        );
3889
3890        let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
3891
3892        assert!(candidates.iter().all(|c| !c.name_match));
3893        assert!(candidate(&candidates, "ab").exact_name);
3894    }
3895
3896    #[test]
3897    fn trigram_terms_include_the_identifier_parts_of_each_word() {
3898        assert_eq!(
3899            trigram_name_terms("collapse_name"),
3900            vec!["collapse_name", "collapse", "name"]
3901        );
3902        assert_eq!(
3903            trigram_name_terms("parse the sha256 sidecar"),
3904            vec!["parse", "sha256", "sha", "256", "sidecar"]
3905        );
3906        assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
3907        assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
3908        assert!(trigram_name_terms("ab").is_empty());
3909    }
3910
3911    #[test]
3912    fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
3913        let conn = search_fixture(
3914            &[
3915                code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
3916                code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
3917            ]
3918            .join(","),
3919        );
3920
3921        let candidates =
3922            collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
3923
3924        let target = candidate(&candidates, "CollapseName");
3925        assert!(target.name_match);
3926        assert_eq!(target.name_terms, vec!["collapse", "name"]);
3927        assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
3928    }
3929
3930    fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
3931        Candidate {
3932            result: SymbolSearchResult {
3933                symbol: Symbol {
3934                    symbol_id: format!("{path}:{name}"),
3935                    file_id: "f".into(),
3936                    path: path.into(),
3937                    language: "rust".into(),
3938                    name: name.into(),
3939                    kind: kind.into(),
3940                    signature: None,
3941                    doc_comment: None,
3942                    visibility: None,
3943                    parent_symbol_id: None,
3944                    start_line: 1,
3945                    start_column: 0,
3946                    end_line: 1,
3947                    end_column: 0,
3948                    start_byte: 0,
3949                    end_byte: 0,
3950                    body_start_line: None,
3951                    body_start_column: None,
3952                    body_end_line: None,
3953                    body_end_column: None,
3954                    body_start_byte: None,
3955                    body_end_byte: None,
3956                    body_hash: None,
3957                    semantic_group: None,
3958                    is_test: false,
3959                    test_container: false,
3960                },
3961                score: 0.0,
3962                snippet: None,
3963                explain: None,
3964            },
3965            bm25: None,
3966            exact_name: false,
3967            word_match: false,
3968            name_match: false,
3969            name_terms: Vec::new(),
3970            documentation: false,
3971        }
3972    }
3973
3974    fn function(name: &str) -> Candidate {
3975        plain_candidate(name, "function", "src/lib.rs")
3976    }
3977
3978    fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
3979        rerank_with(candidates, query, false, None)
3980    }
3981
3982    fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
3983        ranked(candidates, query)
3984            .into_iter()
3985            .map(|(r, _)| r.symbol.name)
3986            .collect()
3987    }
3988
3989    fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
3990        let mut candidate = function(name);
3991        candidate.result.symbol.signature = signature.map(str::to_string);
3992        candidate.result.symbol.doc_comment = Some(doc.into());
3993        candidate
3994    }
3995
3996    fn strip_ansi_case() -> Vec<Candidate> {
3997        vec![
3998            documented("strip_ansi", None, "Remove ANSI escape sequences"),
3999            documented(
4000                "_strip_code_fences",
4001                Some("def _strip_code_fences(text: str)"),
4002                "The first fenced code block's body, or the stripped text",
4003            ),
4004        ]
4005    }
4006
4007    #[test]
4008    fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4009        assert_eq!(
4010            rerank_words("parse the sha256 sidecar file"),
4011            vec!["parse", "sha", "256", "sidecar", "file"]
4012        );
4013        assert_eq!(
4014            rerank_words("parse_sha256_sidecar"),
4015            vec!["parse", "sha", "256", "sidecar"]
4016        );
4017        assert_eq!(
4018            rerank_words("ParseHTTPResponse"),
4019            vec!["parse", "http", "response"]
4020        );
4021        assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4022        assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4023    }
4024
4025    #[test]
4026    fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4027        for word in ["was", "whether", "another"] {
4028            assert!(is_stop_word(word), "{word} must be a stop word");
4029        }
4030        for word in ["down", "into", "run"] {
4031            assert!(!is_stop_word(word), "{word} must stay a content word");
4032        }
4033        assert_eq!(rerank_words("what was the file"), vec!["file"]);
4034    }
4035
4036    #[test]
4037    fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4038        let mut private = function("_create_skill");
4039        private.bm25 = Some(-9.0);
4040        let mut public = function("create_skill");
4041        public.bm25 = Some(-1.0);
4042
4043        let rows = ranked(vec![private, public], "create skill");
4044
4045        assert_eq!(rows[0].0.score, rows[1].0.score);
4046        assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4047        assert_eq!(
4048            rows.iter()
4049                .map(|(r, _)| r.symbol.name.as_str())
4050                .collect::<Vec<_>>(),
4051            vec!["create_skill", "_create_skill"]
4052        );
4053    }
4054
4055    #[test]
4056    fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4057        let rows = ranked(
4058            vec![
4059                plain_candidate("Glob", "constant", "src/glob.rs"),
4060                function("matches_glob_pattern"),
4061            ],
4062            "glob",
4063        );
4064
4065        assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4066        assert_eq!(rows[1].1.name_tier, "whole");
4067        assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4068    }
4069
4070    #[test]
4071    fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4072        let rows = ranked(
4073            vec![
4074                function("validate_everything"),
4075                function("validate_syntax_now"),
4076                function("validate_syntax"),
4077                function("unrelated"),
4078            ],
4079            "validate syntax",
4080        );
4081        let tiers: Vec<(&str, &str, f64)> = rows
4082            .iter()
4083            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4084            .collect();
4085
4086        assert_eq!(
4087            tiers,
4088            vec![
4089                ("validate_syntax", "whole", W_NAME_WHOLE),
4090                ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4091                ("validate_everything", "partial", 0.0),
4092                ("unrelated", "none", 0.0),
4093            ]
4094        );
4095        assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4096        assert_eq!(
4097            rows[1].0.score,
4098            W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4099        );
4100        assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4101        assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4102    }
4103
4104    #[test]
4105    fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4106        assert_eq!(
4107            ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4108            vec!["strip_ansi", "_strip_code_fences"]
4109        );
4110    }
4111
4112    #[test]
4113    fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4114        let candidates = vec![
4115            function("execute_julie_extract"),
4116            documented("slice_bytes", None, "cut a byte range"),
4117        ];
4118
4119        let rows = ranked(candidates, "cut");
4120        let bonuses: Vec<(&str, &str, f64)> = rows
4121            .iter()
4122            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4123            .collect();
4124
4125        assert_eq!(
4126            bonuses,
4127            vec![
4128                ("execute_julie_extract", "partial", 0.0),
4129                ("slice_bytes", "none", 0.0),
4130            ]
4131        );
4132        assert_eq!(rows[0].0.score, rows[1].0.score);
4133    }
4134
4135    #[test]
4136    fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4137        let candidates = vec![
4138            function("validate_everything"),
4139            function("validate_syntax_now"),
4140            function("validate_syntax"),
4141            function("unrelated"),
4142        ];
4143
4144        assert_eq!(
4145            ranked_names(candidates, "validate syntax"),
4146            vec![
4147                "validate_syntax",
4148                "validate_syntax_now",
4149                "validate_everything",
4150                "unrelated"
4151            ]
4152        );
4153    }
4154
4155    #[test]
4156    fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4157        let mut rows: Vec<String> = (0..10)
4158            .map(|i| {
4159                code_row(
4160                    &format!("s{i}"),
4161                    &format!("src/f{i}.rs"),
4162                    "rust",
4163                    &format!("search_{i}"),
4164                    "searches the index",
4165                )
4166            })
4167            .collect();
4168        rows.push(code_row(
4169            "rare",
4170            "src/rare.rs",
4171            "rust",
4172            "sanitize_input",
4173            "sanitize the input",
4174        ));
4175        let conn = search_fixture(&rows.join(", "));
4176        let terms = vec!["sanitize".to_string(), "search".to_string()];
4177
4178        let weights = idf_weights(&conn, &terms);
4179        assert!(weights[0] > weights[1]);
4180    }
4181
4182    #[test]
4183    fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
4184        let conn = search_fixture(&code_row(
4185            "n",
4186            "src/news.rs",
4187            "rust",
4188            "fetch_news",
4189            "fetch the news feed",
4190        ));
4191        let terms = vec!["news".to_string(), "unseen".to_string()];
4192
4193        let weights = idf_weights(&conn, &terms);
4194
4195        assert!(weights[0] < weights[1]);
4196    }
4197
4198    #[test]
4199    fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4200        let crediting_field = |padding: usize| {
4201            let mut row = function("handler");
4202            row.result.symbol.signature = Some(format!(
4203                "fn handler({}sidecar: u8)",
4204                "a: u8, ".repeat(padding)
4205            ));
4206            ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
4207        };
4208
4209        assert_eq!(crediting_field(4), "signature");
4210        assert_eq!(crediting_field(80), "none");
4211    }
4212
4213    #[test]
4214    fn explain_terms_name_the_crediting_field_of_every_query_term() {
4215        let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
4216        let terms: Vec<(&str, &str, f64)> = rows[0]
4217            .1
4218            .terms
4219            .iter()
4220            .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
4221            .collect();
4222
4223        assert_eq!(rows[0].0.symbol.name, "strip_ansi");
4224        assert_eq!(
4225            terms,
4226            vec![
4227                ("strip", "name", 3.0),
4228                ("ansi", "name", 3.0),
4229                ("escape", "doc", TEXT_CREDIT),
4230                ("codes", "none", 0.0),
4231            ]
4232        );
4233    }
4234
4235    #[test]
4236    fn name_coverage_accepts_token_runs_substrings_and_stems() {
4237        let strengths = |name: &str, query: &str| {
4238            let stemmer = Stemmer::create(Algorithm::English);
4239            let words: Vec<QueryWord> = rerank_words(query)
4240                .into_iter()
4241                .map(|word| QueryWord {
4242                    stem: stemmer.stem(&word).into_owned(),
4243                    word,
4244                })
4245                .collect();
4246            name_hits(name, &words, &stemmer)
4247        };
4248
4249        assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
4250        assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
4251        assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
4252        assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
4253        assert_eq!(strengths("is_ok", "ok"), vec![3]);
4254        assert_eq!(strengths("isReady", "is"), vec![3]);
4255        assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
4256        assert_eq!(
4257            strengths("parseSha256Sidecar", "sidecar checksum"),
4258            vec![3, 0]
4259        );
4260        assert_eq!(
4261            strengths("parseSha256Sidecar", "checksum digest"),
4262            vec![0, 0]
4263        );
4264    }
4265
4266    #[test]
4267    fn a_rarer_term_moves_the_score_more_than_a_common_one() {
4268        let weights = [4.0, 1.0];
4269        let rows = rerank_with(
4270            vec![function("rare_helper"), function("common_helper")],
4271            "rare common",
4272            false,
4273            Some(&weights),
4274        );
4275
4276        assert_eq!(
4277            rows[0].1.word_weights,
4278            vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
4279        );
4280        assert_eq!(rows[0].0.symbol.name, "rare_helper");
4281        assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
4282        assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
4283    }
4284
4285    #[test]
4286    fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
4287        let rows = ranked(
4288            vec![
4289                function("render_mode"),
4290                plain_candidate("retry_count", "constant", "src/scan.rs"),
4291            ],
4292            "how many times a failed download is tried again retry limit",
4293        );
4294
4295        assert_eq!(rows[0].0.symbol.name, "retry_count");
4296        assert_eq!(rows[0].1.name_tier, "partial");
4297        assert!(rows[0].0.score > rows[1].0.score);
4298        assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
4299    }
4300
4301    #[test]
4302    fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4303        let mut row = function("load");
4304        row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
4305        row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
4306        let (result, explain) = ranked(vec![row], "config settings").remove(0);
4307
4308        assert_eq!(
4309            explain.terms,
4310            vec![
4311                ("config".into(), "signature".into(), TEXT_CREDIT),
4312                ("settings".into(), "none".into(), 0.0),
4313            ]
4314        );
4315        assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
4316    }
4317
4318    #[test]
4319    fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
4320        let crediting_field =
4321            |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
4322        let doc_field = |doc: &str, query: &str| {
4323            let mut row = function("row");
4324            row.result.symbol.doc_comment = Some(doc.into());
4325            crediting_field(row, query)
4326        };
4327        let sig_field = |signature: &str, query: &str| {
4328            let mut row = function("row");
4329            row.result.symbol.signature = Some(signature.into());
4330            crediting_field(row, query)
4331        };
4332
4333        assert_eq!(doc_field("The system runs.", "stemming"), "none");
4334        assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
4335        assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
4336        assert_eq!(doc_field("An important port.", "porter"), "none");
4337        assert_eq!(
4338            sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
4339            "signature"
4340        );
4341        assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
4342        assert_eq!(sig_field("fn okay()", "ok"), "none");
4343        assert_eq!(
4344            sig_field("fn parseSha256Sidecar(text)", "sidecar"),
4345            "signature"
4346        );
4347    }
4348
4349    #[test]
4350    fn text_tokens_split_like_query_words_then_identifiers() {
4351        fn two_pass(text: &str) -> Vec<&str> {
4352            query_words(text)
4353                .into_iter()
4354                .flat_map(split_identifier)
4355                .collect()
4356        }
4357        fn one_pass(text: &str) -> Vec<&str> {
4358            let mut out = Vec::new();
4359            text_tokens_into(text, &mut out);
4360            out
4361        }
4362        let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
4363        let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
4364
4365        assert_eq!(one_pass(ascii), two_pass(ascii));
4366        assert_eq!(
4367            one_pass(ascii),
4368            vec![
4369                "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
4370                "sha", "256", "sum",
4371            ]
4372        );
4373        assert_eq!(one_pass(unicode), two_pass(unicode));
4374        assert!(one_pass("").is_empty());
4375        assert!(one_pass("_ __ ...").is_empty());
4376    }
4377
4378    #[test]
4379    fn a_doc_credits_a_term_by_its_stem() {
4380        let mut row = function("check");
4381        row.result.symbol.doc_comment = Some("Validates the input.".into());
4382        let explain = ranked(vec![row], "validation").remove(0).1;
4383
4384        assert_eq!(
4385            explain.terms,
4386            vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
4387        );
4388    }
4389
4390    #[test]
4391    fn kind_prior_orders_definitions_over_members_over_imports() {
4392        let rows = ranked(
4393            vec![
4394                plain_candidate("Scan", "import", "src/a.rs"),
4395                plain_candidate("Scan", "enum_member", "src/b.rs"),
4396                plain_candidate("Scan", "function", "src/c.rs"),
4397            ],
4398            "scan",
4399        );
4400        let order: Vec<(&str, f64)> = rows
4401            .iter()
4402            .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
4403            .collect();
4404
4405        assert_eq!(
4406            order,
4407            vec![
4408                ("src/c.rs", W_KIND_DEFINITION),
4409                ("src/b.rs", W_KIND_MEMBER),
4410                ("src/a.rs", W_KIND_IMPORT),
4411            ]
4412        );
4413    }
4414
4415    #[test]
4416    fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
4417        let names = ranked_names(
4418            vec![
4419                function("RenderMode"),
4420                plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
4421            ],
4422            "retry download limit timeout",
4423        );
4424
4425        assert_eq!(names[0], "MaxRetryCount");
4426    }
4427
4428    #[test]
4429    fn path_role_demotes_role_directories_unless_the_query_names_them() {
4430        let rows = |query: &str| {
4431            ranked(
4432                vec![
4433                    plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
4434                    plain_candidate("verify_checksum", "function", "src/archive.rs"),
4435                ],
4436                query,
4437            )
4438        };
4439
4440        let plain = rows("verify checksum");
4441        assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
4442        assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
4443
4444        let named = rows("launcher script verify checksum");
4445        assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
4446
4447        let only_launcher = rows("launcher verify checksum");
4448        assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
4449        assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
4450
4451        let windows = ranked(
4452            vec![plain_candidate(
4453                "verifyChecksum",
4454                "function",
4455                "scripts\\launcher.ts",
4456            )],
4457            "verify checksum",
4458        );
4459        assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
4460    }
4461
4462    #[test]
4463    fn documentation_rows_sort_after_every_code_row() {
4464        let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
4465        heading.documentation = true;
4466        heading.result.symbol.language = "markdown".into();
4467        heading.result.symbol.signature = Some("Verify checksum".into());
4468        heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
4469        let rows = ranked(
4470            vec![
4471                heading,
4472                plain_candidate("unrelated", "variable", "src/a.rs"),
4473            ],
4474            "verify checksum",
4475        );
4476
4477        assert_eq!(rows[0].0.symbol.name, "unrelated");
4478        assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
4479        assert_eq!(rows[1].1.name_tier, "whole");
4480        assert!(rows[1].0.score < 0.0);
4481    }
4482
4483    #[test]
4484    fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
4485        let rows = |query: &str, include_tests: bool| {
4486            let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
4487            test_row.result.symbol.is_test = true;
4488            let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
4489            rerank_with(vec![plain_row, test_row], query, include_tests, None)
4490        };
4491
4492        let boosted = rows("payment flow tests", true);
4493        assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
4494        assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
4495        assert_eq!(boosted[1].1.test_intent, 0.0);
4496
4497        assert!(
4498            rows("payment flow tests", false)
4499                .iter()
4500                .all(|(_, e)| e.test_intent == 0.0)
4501        );
4502        assert!(
4503            rows("payment flow", true)
4504                .iter()
4505                .all(|(_, e)| e.test_intent == 0.0)
4506        );
4507    }
4508
4509    #[test]
4510    fn ties_break_by_bm25_then_name_length_then_path() {
4511        let mut word_row = plain_candidate("payment", "function", "src/z.rs");
4512        word_row.word_match = true;
4513        word_row.bm25 = Some(-4.0);
4514        let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
4515        weaker_word_row.word_match = true;
4516        weaker_word_row.bm25 = Some(-2.0);
4517        let mut name_only = plain_candidate("payment", "function", "src/b.rs");
4518        name_only.name_match = true;
4519        let rows = ranked(
4520            vec![
4521                plain_candidate("payment", "function", "src/y.rs"),
4522                name_only,
4523                weaker_word_row,
4524                word_row,
4525            ],
4526            "payment",
4527        );
4528        let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
4529
4530        assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
4531
4532        let by_length = ranked_names(
4533            vec![
4534                function("payment_gateway_client"),
4535                function("payment_gateway"),
4536            ],
4537            "gateway",
4538        );
4539        assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
4540    }
4541
4542    #[test]
4543    fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
4544        let mut token = function("csr");
4545        token.bm25 = Some(-1.0);
4546        let mut substring = function("action_csrf_token");
4547        substring.bm25 = Some(-5.0);
4548
4549        let rows = ranked(vec![substring, token], "csr adjacency");
4550
4551        assert!(rows[0].0.score > rows[1].0.score);
4552        assert_eq!(rows[0].0.symbol.name, "csr");
4553    }
4554
4555    #[test]
4556    fn an_acronym_token_outranks_a_name_that_only_contains_it() {
4557        let mut token = function("http_client");
4558        token.bm25 = Some(-1.0);
4559        let mut substring = function("shttpd_config");
4560        substring.bm25 = Some(-5.0);
4561
4562        let rows = ranked(vec![substring, token], "http");
4563
4564        assert!(rows[0].0.score > rows[1].0.score);
4565        assert_eq!(rows[0].0.symbol.name, "http_client");
4566    }
4567
4568    #[test]
4569    fn explain_reports_the_sum_of_the_name_strengths() {
4570        let rows = ranked(vec![function("action_csrf_token")], "csr token");
4571
4572        assert_eq!(rows[0].1.name_strength, 4);
4573    }
4574
4575    #[test]
4576    fn snippets_follow_the_admitting_branch() {
4577        let mut word_row = function("parse_sidecar_file");
4578        word_row.word_match = true;
4579        word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
4580        let mut name_row = function("parseSha256Sidecar");
4581        name_row.name_match = true;
4582        name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
4583        let mut exact_row = function("sha256");
4584        exact_row.exact_name = true;
4585        let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
4586        let snippets: Vec<(&str, &str)> = rows
4587            .iter()
4588            .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
4589            .collect();
4590
4591        assert_eq!(
4592            snippets,
4593            vec![
4594                ("sha256", "sha256"),
4595                ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
4596                ("parse_sidecar_file", "parse the [sha256] sidecar file"),
4597            ]
4598        );
4599        assert_eq!(rows[1].1.branches, vec!["name"]);
4600        assert_eq!(rows[0].1.branches, vec!["exact"]);
4601    }
4602
4603    #[test]
4604    fn explain_is_attached_only_when_requested() {
4605        let conn = sidecar_fixture();
4606        let query = "sha256";
4607
4608        let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
4609        assert!(silent.iter().all(|r| r.explain.is_none()));
4610        assert!(silent[0].score > 0.0);
4611        assert_eq!(
4612            serde_json::to_value(&silent[0]).unwrap().get("explain"),
4613            None
4614        );
4615
4616        let explained =
4617            fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
4618        let by_name = |name: &str| {
4619            explained
4620                .iter()
4621                .find(|r| r.symbol.name == name)
4622                .and_then(|r| r.explain.as_ref())
4623                .unwrap()
4624        };
4625        let name_only = by_name("parseSha256Sidecar");
4626        assert_eq!(name_only.candidates, 2);
4627        assert_eq!(name_only.branches, vec!["name"]);
4628        assert_eq!(name_only.bm25, None);
4629        let word_row = by_name("parse_sidecar_file");
4630        assert!(word_row.bm25.unwrap() < 0.0);
4631        assert_eq!(word_row.candidates, 2);
4632        assert!(
4633            serde_json::to_value(&explained[0])
4634                .unwrap()
4635                .get("explain")
4636                .is_some()
4637        );
4638    }
4639
4640    #[test]
4641    fn search_symbols_treats_like_wildcards_as_literals() {
4642        let dir = crate::safe_tempdir();
4643        let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
4644        let conn = open_read_write(&db_path).unwrap();
4645        conn.execute_batch(
4646            "CREATE TABLE symbols (
4647                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
4648                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4649                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4650                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4651                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4652                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4653                semantic_group TEXT, is_test INTEGER, test_container INTEGER
4654            );
4655            INSERT INTO symbols VALUES (
4656                's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
4657                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
4658            );
4659            INSERT INTO symbols VALUES (
4660                'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
4661                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
4662            );
4663            INSERT INTO symbols VALUES (
4664                'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
4665                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
4666            );
4667            CREATE TABLE files (
4668                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
4669                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
4670            );
4671            INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
4672            INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
4673            );",
4674        )
4675        .unwrap();
4676
4677        assert_eq!(
4678            search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
4679            "literal%name"
4680        );
4681        assert_eq!(
4682            search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
4683            "literal_name"
4684        );
4685        assert_eq!(
4686            load_scoped_files(&conn, Some("src/literal_path"))
4687                .unwrap()
4688                .len(),
4689            1
4690        );
4691    }
4692
4693    #[test]
4694    fn find_references_for_symbol_limits_callees_by_symbol_id() {
4695        let dir = crate::safe_tempdir();
4696        let db_path = dir.path().join("find_references_for_symbol.db");
4697        let conn = open_read_write(&db_path).unwrap();
4698        conn.execute_batch(
4699            "CREATE TABLE symbols (
4700                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
4701                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4702                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4703                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4704                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4705                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4706                semantic_group TEXT, is_test INTEGER, test_container INTEGER
4707            );
4708            CREATE TABLE relationships (
4709                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
4710                start_line INTEGER, start_column INTEGER
4711            );
4712            CREATE TABLE pending_relationships (
4713                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
4714                start_line INTEGER, start_column INTEGER
4715            );
4716            INSERT INTO symbols VALUES
4717                ('wanted', 'f', 'a.rs', 'rust', 'new', 'method', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
4718                ('other', 'f', 'b.rs', 'rust', 'new', 'method', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
4719                ('wanted-callee', 'f', 'a.rs', 'rust', 'wanted_dep', 'function', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
4720                ('other-callee', 'f', 'b.rs', 'rust', 'other_dep', 'function', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0);
4721            INSERT INTO relationships VALUES
4722                ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
4723                ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
4724        )
4725        .unwrap();
4726
4727        let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
4728        assert_eq!(references.len(), 1);
4729        assert_eq!(references[0].to_symbol_name, "wanted_dep");
4730    }
4731
4732    #[test]
4733    fn test_fts_search_symbols_and_porter_stemming() {
4734        let dir = crate::safe_tempdir();
4735        let db_path = dir.path().join("fts_search_symbols.db");
4736        let conn = open_read_write(&db_path).unwrap();
4737
4738        conn.execute_batch(
4739            "CREATE TABLE symbols (
4740                symbol_id TEXT PRIMARY KEY,
4741                file_id TEXT,
4742                path TEXT,
4743                language TEXT,
4744                name TEXT,
4745                kind TEXT,
4746                signature TEXT,
4747                doc_comment TEXT,
4748                visibility TEXT,
4749                parent_symbol_id TEXT,
4750                start_line INTEGER,
4751                start_column INTEGER,
4752                end_line INTEGER,
4753                end_column INTEGER,
4754                start_byte INTEGER,
4755                end_byte INTEGER,
4756                body_start_line INTEGER,
4757                body_start_column INTEGER,
4758                body_end_line INTEGER,
4759                body_end_column INTEGER,
4760                body_start_byte INTEGER,
4761                body_end_byte INTEGER,
4762                body_hash TEXT,
4763                semantic_group TEXT,
4764                is_test INTEGER,
4765                test_container INTEGER
4766            );
4767            INSERT INTO symbols VALUES (
4768                's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
4769                'pub trait PaymentGateway', 'Core payment provider interface for transactions',
4770                'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
4771            );
4772            INSERT INTO symbols VALUES (
4773                's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
4774                'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
4775                'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
4776            );
4777            INSERT INTO symbols VALUES (
4778                's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
4779                'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
4780                'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
4781            );
4782            INSERT INTO symbols VALUES (
4783                's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
4784                'fn test_payment_flow()', 'Tests payment charge workflow',
4785                NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
4786            );",
4787        )
4788        .unwrap();
4789
4790        ensure_fts_index(&conn).unwrap();
4791
4792        // 1. Porter stemming match: 'parsing' matches 'parse_tokens' and 'Parses' docstring
4793        let results =
4794            fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
4795        assert_eq!(results.len(), 1);
4796        assert_eq!(results[0].symbol.name, "parse_tokens");
4797        assert!(results[0].snippet.is_some());
4798
4799        // 2. Docstring conceptual search: 'transactions' matches 'PaymentGateway'
4800        let results =
4801            fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
4802        assert_eq!(results.len(), 1);
4803        assert_eq!(results[0].symbol.name, "PaymentGateway");
4804
4805        // 3. Test filter: searching 'payment' with include_tests=false ignores 'test_payment_flow'
4806        let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
4807        assert_eq!(results.len(), 2);
4808        assert!(results.iter().all(|r| !r.symbol.is_test));
4809
4810        // 4. Test filter: searching 'payment' with include_tests=true includes 'test_payment_flow'
4811        let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
4812        assert_eq!(results.len(), 3);
4813
4814        // 5. Fallback OR matching: multi-term where only some match
4815        let results =
4816            fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
4817        assert_eq!(results.len(), 1);
4818        assert_eq!(results[0].symbol.name, "StripeClient");
4819    }
4820
4821    #[test]
4822    fn find_related_tests_returns_each_test_once_under_the_limit() {
4823        let dir = crate::safe_tempdir();
4824        let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
4825        conn.execute_batch(
4826            "CREATE TABLE symbols (
4827                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
4828                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
4829                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
4830                start_column INTEGER, end_line INTEGER, end_column INTEGER,
4831                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4832                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4833                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4834                semantic_group TEXT, is_test INTEGER, test_container INTEGER
4835            );
4836            CREATE TABLE relationships (
4837                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
4838                start_line INTEGER, start_column INTEGER
4839            );
4840            CREATE TABLE pending_relationships (
4841                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
4842                start_line INTEGER, start_column INTEGER,
4843                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
4844            );
4845            CREATE TABLE type_facts (
4846                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
4847            );
4848            INSERT INTO symbols VALUES
4849                ('s_target', 'f1', 'src/lib.rs', 'rust', 'compute', 'function', 'pub fn compute()', NULL, 'pub', NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
4850                ('t_a', 'f2', 'tests/a.rs', 'rust', 'first_case', 'function', 'fn first_case()', NULL, NULL, NULL, 1, 0, 20, 1, 0, 300, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 0),
4851                ('t_b', 'f3', 'tests/b.rs', 'rust', 'second_case', 'function', 'fn second_case()', NULL, NULL, NULL, 1, 0, 10, 1, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 0);
4852            INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES
4853                ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
4854                ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
4855                ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
4856                ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
4857                ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
4858                ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
4859        )
4860        .unwrap();
4861        let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
4862
4863        let tests = find_related_tests(&conn, &target, 5).unwrap();
4864
4865        let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
4866        names.sort();
4867        assert_eq!(names, vec!["first_case", "second_case"]);
4868    }
4869
4870    #[test]
4871    fn documentation_rows_rank_after_code_in_search() {
4872        let dir = crate::safe_tempdir();
4873        let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
4874        conn.execute_batch(
4875            "CREATE TABLE symbols (
4876                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
4877                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4878                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4879                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4880                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4881                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4882                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
4883            );
4884            INSERT INTO symbols VALUES
4885                ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
4886                 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
4887                 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
4888                ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
4889                 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
4890                 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
4891        )
4892        .unwrap();
4893        ensure_fts_index(&conn).unwrap();
4894
4895        let results =
4896            fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
4897                .unwrap();
4898
4899        assert_eq!(results.len(), 2);
4900        assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
4901        assert_eq!(results[1].symbol.name, "Reconcile offline edits");
4902    }
4903
4904    #[test]
4905    fn test_queries_nocase_and_path_normalization() {
4906        let conn = Connection::open_in_memory().unwrap();
4907        conn.execute_batch(
4908            "CREATE TABLE files (
4909                file_id TEXT PRIMARY KEY,
4910                path TEXT NOT NULL,
4911                language TEXT,
4912                content_hash TEXT,
4913                content_bytes INTEGER,
4914                line_count INTEGER,
4915                indexed_at INTEGER
4916            );
4917            CREATE TABLE symbols (
4918                symbol_id TEXT PRIMARY KEY,
4919                file_id TEXT,
4920                path TEXT NOT NULL,
4921                language TEXT,
4922                name TEXT,
4923                kind TEXT,
4924                signature TEXT,
4925                doc_comment TEXT,
4926                visibility TEXT,
4927                parent_symbol_id TEXT,
4928                start_line INTEGER,
4929                start_column INTEGER,
4930                end_line INTEGER,
4931                end_column INTEGER,
4932                start_byte INTEGER,
4933                end_byte INTEGER,
4934                body_start_line INTEGER,
4935                body_start_column INTEGER,
4936                body_end_line INTEGER,
4937                body_end_column INTEGER,
4938                body_start_byte INTEGER,
4939                body_end_byte INTEGER,
4940                body_hash TEXT,
4941                semantic_group TEXT,
4942                is_test INTEGER,
4943                test_container INTEGER
4944            );
4945            -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
4946            INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
4947            INSERT INTO symbols VALUES (
4948                's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
4949                'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
4950                2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
4951            );",
4952        )
4953        .unwrap();
4954
4955        // 1. get_file: query with uppercase, lowercase, and forward slashes
4956        let file = get_file(&conn, "SRC/PAYMENT.RS")
4957            .unwrap()
4958            .expect("File should be found");
4959        assert_eq!(
4960            file.path, "src/Payment.rs",
4961            "Path should be normalized to forward slashes"
4962        );
4963
4964        let file2 = get_file(&conn, "src/payment.rs")
4965            .unwrap()
4966            .expect("File should be found");
4967        assert_eq!(file2.path, "src/Payment.rs");
4968
4969        // 2. load_file_symbols: query with uppercase and forward slashes
4970        let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
4971        assert_eq!(syms.len(), 1);
4972        assert_eq!(
4973            syms[0].path, "src/Payment.rs",
4974            "Symbol path should be normalized to forward slashes"
4975        );
4976
4977        // 3. get_symbol_by_name with path filter
4978        let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
4979            .unwrap()
4980            .expect("Symbol should be found with case-insensitive path filter");
4981        assert_eq!(sym.path, "src/Payment.rs");
4982    }
4983
4984    #[test]
4985    fn test_exact_case_prioritized_over_nocase() {
4986        let conn = Connection::open_in_memory().unwrap();
4987        conn.execute_batch(
4988            "CREATE TABLE files (
4989                file_id TEXT PRIMARY KEY,
4990                path TEXT NOT NULL,
4991                language TEXT,
4992                content_hash TEXT,
4993                content_bytes INTEGER,
4994                line_count INTEGER,
4995                indexed_at TEXT
4996            );
4997            CREATE TABLE symbols (
4998                symbol_id TEXT PRIMARY KEY,
4999                file_id TEXT,
5000                path TEXT NOT NULL,
5001                language TEXT,
5002                name TEXT NOT NULL,
5003                kind TEXT NOT NULL,
5004                signature TEXT,
5005                doc_comment TEXT,
5006                visibility TEXT,
5007                parent_symbol_id TEXT,
5008                start_line INTEGER,
5009                start_column INTEGER,
5010                end_line INTEGER,
5011                end_column INTEGER,
5012                start_byte INTEGER,
5013                end_byte INTEGER,
5014                body_start_line INTEGER,
5015                body_start_column INTEGER,
5016                body_end_line INTEGER,
5017                body_end_column INTEGER,
5018                body_start_byte INTEGER,
5019                body_end_byte INTEGER,
5020                body_hash TEXT,
5021                semantic_group TEXT,
5022                is_test INTEGER,
5023                test_container INTEGER
5024            );
5025            INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5026            INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5027            INSERT INTO symbols VALUES (
5028                's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5029                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5030                2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5031            );
5032            INSERT INTO symbols VALUES (
5033                's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5034                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5035                2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5036            );",
5037        )
5038        .unwrap();
5039
5040        // Exact match should return exact file, not conflate with sibling differing only by case
5041        let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5042        assert_eq!(f_lower.path, "src/payment.rs");
5043        assert_eq!(f_lower.file_id, "f2");
5044
5045        let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5046        assert_eq!(f_upper.path, "src/Payment.rs");
5047        assert_eq!(f_upper.file_id, "f1");
5048
5049        let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5050        assert_eq!(syms_lower.len(), 1);
5051        assert_eq!(syms_lower[0].file_id, "f2");
5052
5053        let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5054        assert_eq!(syms_upper.len(), 1);
5055        assert_eq!(syms_upper[0].file_id, "f1");
5056    }
5057
5058    #[test]
5059    fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5060        let dir = crate::safe_tempdir();
5061        let db_path = dir.path().join("conservative_resolution.db");
5062        let conn = open_read_write(&db_path).unwrap();
5063
5064        conn.execute_batch(
5065            "CREATE TABLE symbols (
5066                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5067                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5068                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5069                start_column INTEGER, end_line INTEGER, end_column INTEGER,
5070                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5071                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5072                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5073                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5074            );
5075            CREATE TABLE relationships (
5076                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5077                start_line INTEGER, start_column INTEGER
5078            );
5079            CREATE TABLE pending_relationships (
5080                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5081                start_line INTEGER, start_column INTEGER,
5082                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5083            );
5084            CREATE TABLE type_facts (
5085                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5086            );
5087            -- Workspace struct Workspace and method Workspace::new
5088            INSERT INTO symbols VALUES
5089                ('s_ws', 'f1', 'src/workspace.rs', 'rust', 'Workspace', 'struct', 'pub struct Workspace', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'struct', 0, 0),
5090                ('s_ws_new', 'f1', 'src/workspace.rs', 'rust', 'new', 'method', 'pub fn new() -> Workspace', NULL, 'pub', 's_ws', 2, 4, 4, 5, 20, 50, 2, 4, 4, 5, 20, 50, 'h1', 'method', 0, 0),
5091                ('s_caller', 'f2', 'src/caller.rs', 'rust', 'my_func', 'function', 'pub fn my_func()', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'function', 0, 0);
5092
5093            -- my_func calls Vec::new() (external namespace 'Vec')
5094            INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES
5095                ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5096        )
5097        .unwrap();
5098
5099        // When include_external is false, calling Vec::new() should NOT resolve to Workspace::new()
5100        let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5101        assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5102
5103        let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5104        assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5105
5106        // Caller references for Workspace::new should NOT list my_func
5107        let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5108        assert!(
5109            callers.is_empty(),
5110            "Expected 0 callers for Workspace::new, got: {:?}",
5111            callers
5112        );
5113
5114        // Blast radius for Workspace::new should NOT impact my_func (which only called Vec::new)
5115        let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5116        assert!(
5117            !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5118            "my_func should not be impacted before calling Workspace::new: {:?}",
5119            blast.impacted_symbols
5120        );
5121
5122        // Now add a call to Workspace::new()
5123        conn.execute(
5124            "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_caller', 'new', 'calls', 'src/caller.rs', 5, 8, NULL, '[\"Workspace\"]', 'Workspace::new')",
5125            [],
5126        )
5127        .unwrap();
5128
5129        let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5130        assert_eq!(
5131            sigs2.len(),
5132            1,
5133            "Expected 1 signature for Workspace::new, got: {:?}",
5134            sigs2
5135        );
5136        assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5137
5138        // Blast radius for Workspace::new should now include my_func
5139        let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5140        assert!(
5141            blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5142            "my_func should be impacted after calling Workspace::new: {:?}",
5143            blast2.impacted_symbols
5144        );
5145
5146        // Add a bare call to new() from an unrelated caller s_other
5147        conn.execute(
5148            "INSERT INTO symbols VALUES
5149                ('s_other', 'f3', 'src/other.rs', 'rust', 'other_func', 'function', 'pub fn other_func()', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'function', 0, 0);",
5150            [],
5151        )
5152        .unwrap();
5153        conn.execute(
5154            "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_other', 'new', 'calls', 'src/other.rs', 2, 8, NULL, NULL, 'new')",
5155            [],
5156        )
5157        .unwrap();
5158
5159        // Bare call from unrelated function should NOT resolve to Workspace::new
5160        let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
5161        assert!(
5162            sigs_other.is_empty(),
5163            "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
5164            sigs_other
5165        );
5166
5167        // A sibling method inside Workspace calling bare new() SHOULD resolve to Workspace::new
5168        conn.execute(
5169            "INSERT INTO symbols VALUES
5170                ('s_ws_helper', 'f1', 'src/workspace.rs', 'rust', 'helper', 'method', 'pub fn helper()', NULL, 'pub', 's_ws', 5, 4, 7, 5, 60, 90, 5, 4, 7, 5, 60, 90, 'h2', 'method', 0, 0);",
5171            [],
5172        )
5173        .unwrap();
5174        conn.execute(
5175            "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_ws_helper', 'new', 'calls', 'src/workspace.rs', 6, 8, NULL, NULL, 'new')",
5176            [],
5177        )
5178        .unwrap();
5179
5180        let sigs_sibling =
5181            find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
5182        assert_eq!(
5183            sigs_sibling.len(),
5184            1,
5185            "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
5186            sigs_sibling
5187        );
5188
5189        // With include_external: true, external calls should be returned
5190        let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
5191        assert!(
5192            ext_sigs.iter().any(|s| s.contains("Vec")),
5193            "include_external: true should include external Vec::new: {:?}",
5194            ext_sigs
5195        );
5196    }
5197
5198    #[test]
5199    fn test_find_structural_facts_and_literals_scoped() {
5200        let dir = crate::safe_tempdir();
5201        let db_path = dir.path().join("facts_test.db");
5202        let conn = open_read_write(&db_path).unwrap();
5203        conn.execute_batch(
5204            "CREATE TABLE symbols (
5205                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5206                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5207                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5208                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5209                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5210                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5211                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5212            );
5213            CREATE TABLE structural_facts (
5214                structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5215                pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
5216                start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
5217            );
5218            CREATE TABLE literals (
5219                literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5220                kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
5221                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5222                start_byte INTEGER, end_byte INTEGER
5223            );
5224            INSERT INTO structural_facts VALUES
5225                ('sf_toml', 'f1', 'Cargo.toml', 'toml', 'toml.key_value.v1', 'key_value', 'table', NULL, 1, 2, 1.0, '{\"key\":\"command\",\"key_path\":\"mcp_servers.code-kb.command\"}'),
5226                ('sf_yaml', 'f6', '.github/workflows/ci.yml', 'yaml', 'yaml.key_value.v1', 'key_value', 'block_mapping_pair', NULL, 3, 3, 1.0, '{\"key\":\"name\",\"key_path\":\"$.on.name\"}'),
5227                ('sf_route', 'f2', 'src/routes/api.rs', 'rust', 'http.route.v1', 'get_users', 'function', NULL, 10, 20, 1.0, '{\"verb\":\"GET\",\"normalized_route_template\":\"/api/v1/users/:id\"}'),
5228                ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'db.sql.select', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
5229                ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'orm.model.entity', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
5230                ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
5231            INSERT INTO literals VALUES
5232                ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
5233                ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
5234                ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
5235                ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
5236        )
5237        .unwrap();
5238
5239        // 1. "config" alias
5240        let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
5241        assert_eq!(facts_config.len(), 2);
5242        assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
5243        assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
5244        assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
5245        assert_eq!(
5246            facts_config[1].key.as_deref(),
5247            Some("mcp_servers.code-kb.command")
5248        );
5249        let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
5250        assert_eq!(lits_config.len(), 1);
5251        assert_eq!(lits_config[0].kind, "toml_key");
5252
5253        // 2. "route" and "routes" aliases
5254        let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
5255        assert_eq!(facts_route.len(), 1);
5256        assert_eq!(facts_route[0].pattern_id, "http.route.v1");
5257        assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
5258        let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
5259        assert_eq!(facts_routes.len(), 1);
5260        let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
5261        assert_eq!(lits_route.len(), 1);
5262        assert_eq!(lits_route[0].kind, "http_route");
5263
5264        // 3. "query", "queries", "sql" aliases
5265        for q in &["query", "queries", "sql"] {
5266            let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
5267            assert_eq!(facts.len(), 1, "Failed for {}", q);
5268            assert_eq!(facts[0].pattern_id, "db.sql.select");
5269            let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
5270            assert_eq!(lits.len(), 1, "Failed for {}", q);
5271            assert_eq!(lits[0].kind, "sql_query");
5272        }
5273
5274        // 4. "model" and "models" aliases
5275        for m in &["model", "models"] {
5276            let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
5277            assert_eq!(facts.len(), 1, "Failed for {}", m);
5278            assert_eq!(facts[0].pattern_id, "orm.model.entity");
5279            let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
5280            assert_eq!(lits.len(), 1, "Failed for {}", m);
5281            assert_eq!(lits[0].kind, "model_table");
5282        }
5283
5284        // 5. Custom / unknown category
5285        let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
5286        assert_eq!(facts_custom.len(), 1);
5287        assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
5288        assert_eq!(facts_custom[0].key, None);
5289
5290        // 6. Path filter: exact file match
5291        let facts_exact =
5292            find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
5293        assert_eq!(facts_exact.len(), 1);
5294        let facts_miss =
5295            find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
5296        assert_eq!(facts_miss.len(), 0);
5297
5298        // 7. Path filter: directory prefix
5299        let facts_dir =
5300            find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
5301        assert_eq!(facts_dir.len(), 1);
5302        let facts_dir_miss =
5303            find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
5304        assert_eq!(facts_dir_miss.len(), 0);
5305
5306        // 8. Delegating find_structural_facts and find_literals
5307        let f_del = find_structural_facts(&conn, "config", 10).unwrap();
5308        assert_eq!(f_del.len(), 2);
5309        let l_del = find_literals(&conn, "config", 10).unwrap();
5310        assert_eq!(l_del.len(), 1);
5311    }
5312
5313    fn local_variable_fixture() -> Connection {
5314        let conn = Connection::open_in_memory().unwrap();
5315        conn.execute_batch(
5316            "CREATE TABLE symbols (
5317                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5318                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5319                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5320                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5321                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5322                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5323                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5324            );
5325            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5326                                 parent_symbol_id, start_line, start_column, end_line, end_column,
5327                                 start_byte, end_byte, is_test, test_container)
5328            VALUES
5329                ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
5330                 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5331                ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5332                 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5333                ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
5334                 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
5335                ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5336                 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
5337                ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5338                 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
5339                ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
5340                 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
5341                ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5342                 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
5343        )
5344        .unwrap();
5345        conn
5346    }
5347
5348    fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
5349        let mut stmt = conn
5350            .prepare(
5351                "SELECT s.symbol_id FROM symbols_fts f
5352                 JOIN symbols s ON s.rowid = f.rowid
5353                 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
5354            )
5355            .unwrap();
5356        let mut ids = stmt
5357            .query_map(params![query], |row| row.get::<_, String>(0))
5358            .unwrap()
5359            .collect::<Result<Vec<_>, _>>()
5360            .unwrap();
5361        ids.sort();
5362        ids
5363    }
5364
5365    #[test]
5366    fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
5367        let conn = local_variable_fixture();
5368        conn.execute_batch(
5369            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
5370                name, signature, doc_comment,
5371                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
5372            );
5373            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
5374            SELECT rowid, name, signature, doc_comment FROM symbols;",
5375        )
5376        .unwrap();
5377
5378        ensure_fts_index(&conn).unwrap();
5379
5380        assert_eq!(
5381            matched_symbol_ids(&conn, "sqlite"),
5382            vec!["field", "func", "global", "pool"]
5383        );
5384    }
5385
5386    #[test]
5387    fn lookup_excludes_locals_and_parameters() {
5388        let conn = local_variable_fixture();
5389
5390        let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
5391            .unwrap()
5392            .into_iter()
5393            .map(|s| s.symbol_id)
5394            .collect();
5395
5396        assert!(!ids.contains(&"local".to_string()));
5397        assert!(!ids.contains(&"nested".to_string()));
5398        assert!(ids.contains(&"field".to_string()));
5399        assert!(ids.contains(&"global".to_string()));
5400    }
5401
5402    #[test]
5403    fn search_excludes_locals_and_parameters() {
5404        let conn = local_variable_fixture();
5405        ensure_fts_index(&conn).unwrap();
5406
5407        let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
5408            .unwrap()
5409            .into_iter()
5410            .map(|r| r.symbol.symbol_id)
5411            .collect();
5412
5413        assert!(!ids.contains(&"local".to_string()));
5414        assert!(ids.contains(&"func".to_string()));
5415    }
5416
5417    #[test]
5418    fn variable_kind_search_keeps_full_text_matching() {
5419        let conn = local_variable_fixture();
5420        ensure_fts_index(&conn).unwrap();
5421
5422        let ids: Vec<String> = fts_search_symbols_scoped(
5423            &conn,
5424            "sqlite connection",
5425            Some("variable"),
5426            None,
5427            false,
5428            10,
5429        )
5430        .unwrap()
5431        .into_iter()
5432        .map(|r| r.symbol.symbol_id)
5433        .collect();
5434
5435        assert!(ids.contains(&"global".to_string()));
5436        assert!(ids.contains(&"field".to_string()));
5437    }
5438
5439    #[test]
5440    fn qualified_lookup_returns_the_named_local_variable() {
5441        let conn = local_variable_fixture();
5442
5443        let ids: Vec<String> =
5444            search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
5445                .unwrap()
5446                .into_iter()
5447                .map(|s| s.symbol_id)
5448                .collect();
5449
5450        assert_eq!(ids, vec!["local".to_string()]);
5451    }
5452
5453    #[test]
5454    fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
5455        let conn = Connection::open_in_memory().unwrap();
5456        conn.execute_batch(
5457            "CREATE TABLE symbols (
5458                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5459                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5460                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5461                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5462                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5463                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5464                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5465            );
5466            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5467                                 parent_symbol_id, start_line, start_column, end_line, end_column,
5468                                 start_byte, end_byte, is_test, test_container)
5469            VALUES
5470                ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
5471                 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5472                ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
5473                 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5474                ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
5475                 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
5476        )
5477        .unwrap();
5478        ensure_fts_index(&conn).unwrap();
5479
5480        let rows =
5481            fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
5482                .unwrap();
5483
5484        assert_eq!(rows.len(), 1);
5485        assert_eq!(rows[0].symbol.symbol_id, "local");
5486        let explain = rows[0].explain.as_ref().unwrap();
5487        assert_eq!(explain.name_tier, "whole");
5488        assert_eq!(explain.branches, vec!["exact", "name"]);
5489        assert_eq!(explain.candidates, 2);
5490    }
5491
5492    #[test]
5493    fn variable_kind_filter_returns_locals_and_parameters() {
5494        let conn = local_variable_fixture();
5495        ensure_fts_index(&conn).unwrap();
5496
5497        let lookup_ids: Vec<String> =
5498            search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
5499                .unwrap()
5500                .into_iter()
5501                .map(|s| s.symbol_id)
5502                .collect();
5503        assert!(lookup_ids.contains(&"local".to_string()));
5504        assert!(lookup_ids.contains(&"nested".to_string()));
5505
5506        let search_ids: Vec<String> =
5507            fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
5508                .unwrap()
5509                .into_iter()
5510                .map(|r| r.symbol.symbol_id)
5511                .collect();
5512        assert!(search_ids.contains(&"local".to_string()));
5513    }
5514}