Skip to main content

code_kb_core/
queries.rs

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