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                          AND NOT EXISTS (
2341                              SELECT 1 FROM symbols shadow
2342                              WHERE shadow.name = receiver.name
2343                                AND shadow.path = receiver.path
2344                                AND shadow.parent_symbol_id = p.from_symbol_id
2345                                AND shadow.kind IN ('variable', 'parameter')
2346                                AND shadow.symbol_id != receiver.symbol_id
2347                          )
2348                    )
2349                )
2350                AND NOT EXISTS (
2351                    SELECT 1 FROM {ns}
2352                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
2353                      AND NOT EXISTS (
2354                          WITH RECURSIVE ancestor(symbol_id, depth) AS (
2355                              SELECT {target}.parent_symbol_id, 0
2356                              UNION ALL
2357                              SELECT s.parent_symbol_id, ancestor.depth + 1
2358                              FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
2359                              WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
2360                          )
2361                          SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
2362                          WHERE a.name = value
2363                      )
2364                      AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2365                      AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
2366                )
2367            )
2368            OR (
2369                (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2370                AND (
2371                    p.target_receiver IS NULL
2372                    OR p.target_receiver = ''
2373                    {import_alias_receiver}
2374                )
2375                AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
2376                AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
2377                    SELECT 1 FROM symbols closer
2378                    WHERE closer.name = {target}.name
2379                      AND closer.symbol_id != {target}.symbol_id
2380                      AND closer.parent_symbol_id IS NULL
2381                      AND closer.kind = {target}.kind
2382                      AND NOT (p.kind IS 'extends' AND closer.symbol_id = p.from_symbol_id)
2383                      AND {closer_rank} > {target_rank}
2384                ))
2385            )
2386            OR (
2387                {target}.parent_symbol_id IS NULL
2388                AND EXISTS (
2389                    SELECT 1 FROM {ns}
2390                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
2391                      AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2392                )
2393            )
2394            )
2395        )"
2396    )
2397}
2398
2399/// SQL predicate excluding rows julie marked as documentation, or the always-true `1 = 1` when
2400/// the column is absent, because a bare `1` in ORDER BY means the first result column in SQLite.
2401const DOCUMENTATION_LANGUAGES: &[&str] = &[
2402    "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
2403];
2404
2405fn documentation_language_list() -> String {
2406    DOCUMENTATION_LANGUAGES
2407        .iter()
2408        .map(|l| format!("'{l}'"))
2409        .collect::<Vec<_>>()
2410        .join(", ")
2411}
2412
2413fn not_documentation(conn: &Connection, alias: &str) -> String {
2414    let has_content_type: bool = conn
2415        .query_row(
2416            "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
2417            [],
2418            |_| Ok(true),
2419        )
2420        .unwrap_or(false);
2421    if has_content_type {
2422        format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
2423    } else {
2424        "1 = 1".to_string()
2425    }
2426}
2427
2428fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
2429    conn.query_row(
2430        "SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2",
2431        [table, column],
2432        |_| Ok(true),
2433    )
2434    .unwrap_or(false)
2435}
2436
2437pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
2438    conn.query_row(
2439        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
2440        [name],
2441        |_| Ok(true),
2442    )
2443    .unwrap_or(false)
2444}
2445
2446fn has_pending_namespace_column(conn: &Connection) -> bool {
2447    let has_ns: bool = conn
2448        .query_row(
2449            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
2450            [],
2451            |_| Ok(true),
2452        )
2453        .unwrap_or(false);
2454    let has_display: bool = conn
2455        .query_row(
2456            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
2457            [],
2458            |_| Ok(true),
2459        )
2460        .unwrap_or(false);
2461    let has_receiver: bool = conn
2462        .query_row(
2463            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
2464            [],
2465            |_| Ok(true),
2466        )
2467        .unwrap_or(false);
2468    has_ns && has_display && has_receiver
2469}
2470
2471fn find_references_internal(
2472    conn: &Connection,
2473    symbol_name: &str,
2474    direction: &str,
2475    limit: usize,
2476    symbol_id: Option<&str>,
2477    include_external: bool,
2478) -> Result<Vec<ReferenceSite>, QueryError> {
2479    let mut results = Vec::new();
2480
2481    if direction == "callers" {
2482        // Find callers: references pointing to target symbol
2483        let mut stmt = conn.prepare(
2484            "SELECT s_from.name AS from_name,
2485                    r.from_symbol_id,
2486                    s_to.name AS to_name,
2487                    r.kind,
2488                    r.path,
2489                    r.start_line,
2490                    r.start_column
2491             FROM relationships r
2492             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2493             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2494             WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
2495             LIMIT ?2",
2496        )?;
2497
2498        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2499            Ok(ReferenceSite {
2500                from_symbol_name: row.get(0)?,
2501                from_symbol_id: row.get(1)?,
2502                to_symbol_name: row.get(2)?,
2503                kind: row.get(3)?,
2504                path: row.get::<_, String>(4)?.replace('\\', "/"),
2505                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2506                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2507                occurrences: None,
2508            })
2509        })?;
2510
2511        for r in rows {
2512            results.push(r?);
2513        }
2514
2515        // Also query pending_relationships for callers if results < limit
2516        if results.len() < limit {
2517            let remaining = limit - results.len();
2518            if has_pending_namespace_column(conn) {
2519                if let Some(sid) = symbol_id {
2520                    let mut pending_stmt = conn.prepare(
2521                        &format!("SELECT s_from.name AS from_name,
2522                                p.from_symbol_id,
2523                                p.target_terminal_name AS to_name,
2524                                p.kind,
2525                                p.path,
2526                                p.start_line,
2527                                p.start_column
2528                         FROM pending_relationships p
2529                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2530                         JOIN symbols s_target ON s_target.symbol_id = ?3
2531                         LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2532                          WHERE p.target_terminal_name = ?1
2533                            AND {pred}
2534                          LIMIT ?2", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
2535                    )?;
2536
2537                    let p_rows = pending_stmt.query_map(
2538                        params![symbol_name, remaining as i64, sid],
2539                        |row| {
2540                            Ok(ReferenceSite {
2541                                from_symbol_name: row.get(0)?,
2542                                from_symbol_id: row.get(1)?,
2543                                to_symbol_name: row.get(2)?,
2544                                kind: row.get(3)?,
2545                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2546                                start_line: Some(row.get::<_, i64>(5)? as usize),
2547                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2548                                occurrences: None,
2549                            })
2550                        },
2551                    )?;
2552                    for r in p_rows {
2553                        results.push(r?);
2554                    }
2555                } else {
2556                    let mut pending_stmt = conn.prepare(
2557                        "SELECT s_from.name AS from_name,
2558                                p.from_symbol_id,
2559                                p.target_terminal_name AS to_name,
2560                                p.kind,
2561                                p.path,
2562                                p.start_line,
2563                                p.start_column
2564                         FROM pending_relationships p
2565                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2566                         WHERE p.target_terminal_name = ?1
2567                           AND (
2568                               (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2569                               OR EXISTS (
2570                                   SELECT 1 FROM symbols s_any
2571                                   JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
2572                                   WHERE s_any.name = p.target_terminal_name
2573                                     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)
2574                               )
2575                           )
2576                         LIMIT ?2",
2577                    )?;
2578
2579                    let p_rows =
2580                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2581                            Ok(ReferenceSite {
2582                                from_symbol_name: row.get(0)?,
2583                                from_symbol_id: row.get(1)?,
2584                                to_symbol_name: row.get(2)?,
2585                                kind: row.get(3)?,
2586                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2587                                start_line: Some(row.get::<_, i64>(5)? as usize),
2588                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2589                                occurrences: None,
2590                            })
2591                        })?;
2592                    for r in p_rows {
2593                        results.push(r?);
2594                    }
2595                }
2596            } else {
2597                let is_nested = if let Some(sid) = symbol_id {
2598                    conn.query_row(
2599                        "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
2600                        params![sid],
2601                        |_| Ok(true),
2602                    )
2603                    .unwrap_or(false)
2604                } else {
2605                    false
2606                };
2607
2608                if !is_nested {
2609                    let mut pending_stmt = conn.prepare(
2610                        "SELECT s_from.name AS from_name,
2611                                p.from_symbol_id,
2612                                p.target_terminal_name AS to_name,
2613                                p.kind,
2614                                p.path,
2615                                p.start_line,
2616                                p.start_column
2617                         FROM pending_relationships p
2618                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2619                         WHERE p.target_terminal_name = ?1
2620                         LIMIT ?2",
2621                    )?;
2622
2623                    let p_rows =
2624                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2625                            Ok(ReferenceSite {
2626                                from_symbol_name: row.get(0)?,
2627                                from_symbol_id: row.get(1)?,
2628                                to_symbol_name: row.get(2)?,
2629                                kind: row.get(3)?,
2630                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2631                                start_line: Some(row.get::<_, i64>(5)? as usize),
2632                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2633                                occurrences: None,
2634                            })
2635                        })?;
2636
2637                    for r in p_rows {
2638                        results.push(r?);
2639                    }
2640                }
2641            }
2642        }
2643
2644        if results.len() < limit && has_table(conn, "identifiers") {
2645            let remaining = limit - results.len();
2646            let mut ident_stmt = conn.prepare(
2647                "SELECT COALESCE(s.name, ''),
2648                        COALESCE(i.containing_symbol_id, ''),
2649                        i.name,
2650                        CASE WHEN json_valid(i.metadata_json)
2651                                  AND json_extract(i.metadata_json, '$.role') = 'signal_handler'
2652                             THEN CASE WHEN json_extract(i.metadata_json, '$.receiver') = (
2653                                      SELECT CASE WHEN t.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
2654                                                  THEN t.name ELSE tp.name END
2655                                      FROM symbols t
2656                                      LEFT JOIN symbols tp ON tp.symbol_id = t.parent_symbol_id
2657                                      WHERE t.symbol_id = ?3)
2658                                  THEN 'handler' ELSE 'handler (candidate)' END
2659                             ELSE i.kind END,
2660                        i.path,
2661                        i.start_line,
2662                        i.start_column
2663                 FROM identifiers i
2664                 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2665                 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
2666                   AND COALESCE(s.kind, '') != 'import'
2667                   AND NOT EXISTS (
2668                       SELECT 1 FROM relationships covered
2669                       JOIN symbols covered_to ON covered.to_symbol_id = covered_to.symbol_id
2670                       WHERE covered_to.name = i.name
2671                         AND covered.path = i.path
2672                         AND covered.start_line = i.start_line
2673                         AND (?3 IS NULL OR covered.to_symbol_id = ?3)
2674                   )
2675                   AND NOT EXISTS (
2676                       SELECT 1 FROM pending_relationships covered
2677                       WHERE covered.target_terminal_name = i.name
2678                         AND covered.path = i.path
2679                         AND covered.start_line = i.start_line
2680                   )
2681                   AND (?3 IS NULL OR NOT EXISTS (
2682                       SELECT 1 FROM symbols owner
2683                       JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
2684                       WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
2685                         AND member.name = i.name
2686                         AND owner.name IS NOT (SELECT parent.name FROM symbols target
2687                                                JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
2688                                                WHERE target.symbol_id = ?3)
2689                   ))
2690                 ORDER BY i.path, i.start_line
2691                 LIMIT ?2",
2692            )?;
2693            let rows =
2694                ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
2695                    Ok(ReferenceSite {
2696                        from_symbol_name: row.get(0)?,
2697                        from_symbol_id: row.get(1)?,
2698                        to_symbol_name: row.get(2)?,
2699                        kind: row.get(3)?,
2700                        path: row.get::<_, String>(4)?.replace('\\', "/"),
2701                        start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2702                        start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2703                        occurrences: None,
2704                    })
2705                })?;
2706            for r in rows {
2707                results.push(r?);
2708            }
2709
2710            if let Some(sid) = symbol_id.filter(|_| results.len() < limit) {
2711                let remaining = limit - results.len();
2712                let mut receiver_stmt = conn.prepare(
2713                    "SELECT COALESCE(s.name, ''),
2714                            COALESCE(i.containing_symbol_id, ''),
2715                            i.name,
2716                            i.kind,
2717                            i.path,
2718                            MIN(i.start_line),
2719                            i.start_column,
2720                            COUNT(*)
2721                     FROM identifiers i
2722                     LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2723                     JOIN symbols target ON target.symbol_id = ?2
2724                     LEFT JOIN symbols target_parent ON target_parent.symbol_id = target.parent_symbol_id
2725                     WHERE i.kind = 'member_access'
2726                       AND i.name != target.name
2727                       AND COALESCE(s.kind, '') != 'import'
2728                       AND target.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
2729                       AND json_valid(i.metadata_json)
2730                       AND json_extract(i.metadata_json, '$.receiver') = target.name
2731                       AND (json_extract(i.metadata_json, '$.receiver_qualifier') IS NULL
2732                            OR json_extract(i.metadata_json, '$.receiver_qualifier') = target_parent.name)
2733                     GROUP BY i.path
2734                     ORDER BY i.path, i.start_line
2735                     LIMIT ?1",
2736                )?;
2737                let rows = receiver_stmt.query_map(params![remaining as i64, sid], |row| {
2738                    Ok(ReferenceSite {
2739                        from_symbol_name: row.get(0)?,
2740                        from_symbol_id: row.get(1)?,
2741                        to_symbol_name: row.get(2)?,
2742                        kind: row.get(3)?,
2743                        path: row.get::<_, String>(4)?.replace('\\', "/"),
2744                        start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2745                        start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2746                        occurrences: Some(row.get::<_, i64>(7)? as usize),
2747                    })
2748                })?;
2749                for r in rows {
2750                    results.push(r?);
2751                }
2752            }
2753        }
2754    } else {
2755        // Find callees: symbols called by target symbol
2756        let mut stmt = conn.prepare(
2757            "SELECT s_from.name AS from_name,
2758                    r.from_symbol_id,
2759                    s_to.name AS to_name,
2760                    r.kind,
2761                    r.path,
2762                    r.start_line,
2763                    r.start_column
2764             FROM relationships r
2765             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2766             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2767             WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
2768             LIMIT ?2",
2769        )?;
2770
2771        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2772            Ok(ReferenceSite {
2773                from_symbol_name: row.get(0)?,
2774                from_symbol_id: row.get(1)?,
2775                to_symbol_name: row.get(2)?,
2776                kind: row.get(3)?,
2777                path: row.get::<_, String>(4)?.replace('\\', "/"),
2778                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2779                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2780                occurrences: None,
2781            })
2782        })?;
2783
2784        for r in rows {
2785            results.push(r?);
2786        }
2787
2788        // Also query pending_relationships for callees
2789        if results.len() < limit {
2790            let remaining = limit - results.len();
2791            let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
2792                let sql = if include_external {
2793                    String::from("SELECT DISTINCT s_from.name AS from_name,
2794                            p.from_symbol_id,
2795                            COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
2796                            p.kind,
2797                            p.path,
2798                            p.start_line,
2799                            p.start_column
2800                     FROM pending_relationships p
2801                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2802                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2803                     LIMIT ?2")
2804                } else {
2805                    format!("SELECT DISTINCT s_from.name AS from_name,
2806                            p.from_symbol_id,
2807                            p.target_terminal_name AS to_name,
2808                            p.kind,
2809                            p.path,
2810                            p.start_line,
2811                            p.start_column
2812                     FROM pending_relationships p
2813                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2814                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2815                       AND EXISTS (
2816                           SELECT 1 FROM symbols s_to
2817                           LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
2818                           WHERE s_to.name = p.target_terminal_name
2819                             AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2820                             AND {pred}
2821                       )
2822                     LIMIT ?2", pred = pending_target_predicate(conn, "s_to", "s_to_parent"))
2823                };
2824                let mut pending_stmt = conn.prepare(&sql)?;
2825                let rows = pending_stmt.query_map(
2826                    params![symbol_name, remaining as i64, symbol_id],
2827                    |row| {
2828                        Ok(ReferenceSite {
2829                            from_symbol_name: row.get(0)?,
2830                            from_symbol_id: row.get(1)?,
2831                            to_symbol_name: row.get(2)?,
2832                            kind: row.get(3)?,
2833                            path: row.get::<_, String>(4)?.replace('\\', "/"),
2834                            start_line: Some(row.get::<_, i64>(5)? as usize),
2835                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2836                            occurrences: None,
2837                        })
2838                    },
2839                )?;
2840                let mut out = Vec::new();
2841                for r in rows {
2842                    out.push(r?);
2843                }
2844                out
2845            } else {
2846                let sql = if include_external {
2847                    "SELECT DISTINCT s_from.name AS from_name,
2848                            p.from_symbol_id,
2849                            p.target_terminal_name AS to_name,
2850                            p.kind,
2851                            p.path,
2852                            p.start_line,
2853                            p.start_column
2854                     FROM pending_relationships p
2855                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2856                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2857                     LIMIT ?2"
2858                } else {
2859                    "SELECT DISTINCT s_from.name AS from_name,
2860                            p.from_symbol_id,
2861                            p.target_terminal_name AS to_name,
2862                            p.kind,
2863                            p.path,
2864                            p.start_line,
2865                            p.start_column
2866                     FROM pending_relationships p
2867                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2868                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2869                       AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
2870                     LIMIT ?2"
2871                };
2872                let mut pending_stmt = conn.prepare(sql)?;
2873
2874                let rows = pending_stmt.query_map(
2875                    params![symbol_name, remaining as i64, symbol_id],
2876                    |row| {
2877                        Ok(ReferenceSite {
2878                            from_symbol_name: row.get(0)?,
2879                            from_symbol_id: row.get(1)?,
2880                            to_symbol_name: row.get(2)?,
2881                            kind: row.get(3)?,
2882                            path: row.get::<_, String>(4)?.replace('\\', "/"),
2883                            start_line: Some(row.get::<_, i64>(5)? as usize),
2884                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2885                            occurrences: None,
2886                        })
2887                    },
2888                )?;
2889                let mut out = Vec::new();
2890                for r in rows {
2891                    out.push(r?);
2892                }
2893                out
2894            };
2895
2896            for r in p_rows {
2897                results.push(r);
2898            }
2899        }
2900    }
2901
2902    Ok(results)
2903}
2904
2905/// Resolve callee signatures directly in a single joined query, avoiding N+1 queries
2906/// and preserving ambiguous methods across types. Prioritizes functions/methods over enum variants.
2907pub fn find_callee_signatures(
2908    conn: &Connection,
2909    symbol_name: &str,
2910    symbol_id: &str,
2911    limit: usize,
2912    include_external: bool,
2913) -> Result<Vec<String>, QueryError> {
2914    let mut stmt = conn.prepare(
2915        "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2916         FROM relationships r
2917         JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2918         JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2919         WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
2920         LIMIT ?3",
2921    )?;
2922
2923    let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
2924        Ok((
2925            row.get::<_, String>(0)?,
2926            row.get::<_, Option<String>>(1)?,
2927            row.get::<_, String>(2)?.replace('\\', "/"),
2928            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2929            row.get::<_, String>(4)?,
2930        ))
2931    })?;
2932
2933    let mut signatures = Vec::new();
2934    let mut variants = Vec::new();
2935
2936    for r in rows.flatten() {
2937        let (name, sig_opt, path, line, kind) = r;
2938        let sig = sig_opt.unwrap_or(name);
2939        let entry = format!("{sig} ({path}:{line})");
2940        if kind == "variant" {
2941            if !variants.contains(&entry) {
2942                variants.push(entry);
2943            }
2944        } else if !signatures.contains(&entry) {
2945            signatures.push(entry);
2946        }
2947    }
2948
2949    if signatures.len() < limit {
2950        let remaining = (limit - signatures.len()) * 2;
2951        let p_rows: Vec<(String, Option<String>, String, usize, String)> =
2952            if has_pending_namespace_column(conn) {
2953                let mut p_stmt = conn.prepare(
2954                &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2955                 FROM pending_relationships p
2956                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2957                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2958                 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2959                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2960                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2961                    AND {pred}
2962                 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
2963            )?;
2964
2965                let rows =
2966                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2967                        Ok((
2968                            row.get::<_, String>(0)?,
2969                            row.get::<_, Option<String>>(1)?,
2970                            row.get::<_, String>(2)?.replace('\\', "/"),
2971                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2972                            row.get::<_, String>(4)?,
2973                        ))
2974                    })?;
2975                rows.flatten().collect()
2976            } else {
2977                let mut p_stmt = conn.prepare(
2978                "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2979                 FROM pending_relationships p
2980                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2981                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2982                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2983                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2984                 LIMIT ?3",
2985            )?;
2986
2987                let rows =
2988                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2989                        Ok((
2990                            row.get::<_, String>(0)?,
2991                            row.get::<_, Option<String>>(1)?,
2992                            row.get::<_, String>(2)?.replace('\\', "/"),
2993                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2994                            row.get::<_, String>(4)?,
2995                        ))
2996                    })?;
2997                rows.flatten().collect()
2998            };
2999
3000        for r in p_rows {
3001            let (name, sig_opt, path, line, kind) = r;
3002            let sig = sig_opt.unwrap_or(name);
3003            let entry = format!("{sig} ({path}:{line})");
3004            if kind == "variant" {
3005                if !variants.contains(&entry) {
3006                    variants.push(entry);
3007                }
3008            } else if !signatures.contains(&entry) {
3009                signatures.push(entry);
3010            }
3011        }
3012    }
3013
3014    if include_external && signatures.len() < limit {
3015        let remaining = (limit - signatures.len()) * 2;
3016        let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
3017            let mut ext_stmt = conn.prepare(
3018                &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
3019                 FROM pending_relationships p
3020                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3021                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3022                   AND NOT EXISTS (
3023                       SELECT 1 FROM symbols s_to
3024                       LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
3025                       WHERE s_to.name = p.target_terminal_name
3026                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3027                         AND {pred}
3028                   )
3029                 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
3030            )?;
3031
3032            let rows =
3033                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3034                    Ok((
3035                        row.get::<_, String>(0)?,
3036                        row.get::<_, String>(1)?.replace('\\', "/"),
3037                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
3038                    ))
3039                })?;
3040            rows.flatten().collect()
3041        } else {
3042            let mut ext_stmt = conn.prepare(
3043                "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
3044                 FROM pending_relationships p
3045                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3046                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3047                   AND NOT EXISTS (
3048                       SELECT 1 FROM symbols s_to
3049                       WHERE s_to.name = p.target_terminal_name
3050                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3051                   )
3052                 LIMIT ?3",
3053            )?;
3054
3055            let rows =
3056                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3057                    Ok((
3058                        row.get::<_, String>(0)?,
3059                        row.get::<_, String>(1)?.replace('\\', "/"),
3060                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
3061                    ))
3062                })?;
3063            rows.flatten().collect()
3064        };
3065
3066        for r in ext_rows {
3067            let (name, path, line) = r;
3068            let entry = format!("{name} ({path}:{line})");
3069            if !signatures.contains(&entry) {
3070                signatures.push(entry);
3071            }
3072        }
3073    }
3074
3075    for v in variants {
3076        if signatures.len() >= limit {
3077            break;
3078        }
3079        if !signatures.contains(&v) {
3080            signatures.push(v);
3081        }
3082    }
3083
3084    signatures.truncate(limit);
3085    Ok(signatures)
3086}
3087
3088const SQL_FAMILIES: &[&str] = &["sql."];
3089
3090const ROUTE_FAMILIES: &[&str] = &[
3091    ".route.",
3092    ".attribute_route.",
3093    ".scope_route.",
3094    ".file_route.",
3095    ".route_handler.",
3096    ".route_reference.",
3097    ".route_group.",
3098    ".route_prefix.",
3099    ".resource_route.",
3100    ".router_mount.",
3101    ".include_router.",
3102    ".server_route.",
3103    ".route_definition.",
3104];
3105
3106const CONFIG_FAMILIES: &[&str] = &["toml.", "yaml.", "json.", "env."];
3107
3108const MODEL_FAMILIES: &[&str] = &[
3109    "sql.table_definition.v1",
3110    "sql.column_definition.v1",
3111    "sql.constraint.v1",
3112    "sql.foreign_key.v1",
3113    "sql.index_definition.v1",
3114    "json.schema.v1",
3115];
3116
3117const SIGNAL_FAMILIES: &[&str] = &[".signal_declaration."];
3118
3119const IMPORT_FAMILIES: &[&str] = &[".import_statement.", ".import."];
3120
3121const BINDING_FAMILIES: &[&str] = &[".binding."];
3122
3123const COMPONENT_FAMILIES: &[&str] = &[".object_instantiation.", ".object_type."];
3124
3125const MODULE_FAMILIES: &[&str] = &[".module."];
3126
3127const PRAGMA_FAMILIES: &[&str] = &[".pragma.", "javascript.qml_directive.v1"];
3128
3129const PROPERTY_FAMILIES: &[&str] = &[".property_declaration.", ".qt_property."];
3130
3131/// Category aliases mapped to the pattern-id families they name.
3132///
3133/// A rule that starts with a dot matches any pattern id that contains it, a rule that ends with a
3134/// dot matches any pattern id that starts with it, and any other rule matches one exact id.
3135pub const CATEGORY_ALIASES: &[(&str, &[&str])] = &[
3136    ("sql", SQL_FAMILIES),
3137    ("query", SQL_FAMILIES),
3138    ("queries", SQL_FAMILIES),
3139    ("route", ROUTE_FAMILIES),
3140    ("routes", ROUTE_FAMILIES),
3141    ("config", CONFIG_FAMILIES),
3142    ("model", MODEL_FAMILIES),
3143    ("models", MODEL_FAMILIES),
3144    ("signal", SIGNAL_FAMILIES),
3145    ("signals", SIGNAL_FAMILIES),
3146    ("import", IMPORT_FAMILIES),
3147    ("imports", IMPORT_FAMILIES),
3148    ("binding", BINDING_FAMILIES),
3149    ("bindings", BINDING_FAMILIES),
3150    ("component", COMPONENT_FAMILIES),
3151    ("components", COMPONENT_FAMILIES),
3152    ("module", MODULE_FAMILIES),
3153    ("modules", MODULE_FAMILIES),
3154    ("pragma", PRAGMA_FAMILIES),
3155    ("property", PROPERTY_FAMILIES),
3156    ("properties", PROPERTY_FAMILIES),
3157];
3158
3159fn category_families(category: &str) -> Option<&'static [&'static str]> {
3160    let wanted = category.trim().to_ascii_lowercase();
3161    CATEGORY_ALIASES
3162        .iter()
3163        .find(|(alias, _)| *alias == wanted)
3164        .map(|(_, families)| *families)
3165}
3166
3167fn matches_family(pattern_id: &str, rule: &str) -> bool {
3168    if rule.starts_with('.') {
3169        pattern_id.contains(rule)
3170    } else if rule.ends_with('.') {
3171        pattern_id.starts_with(rule)
3172    } else {
3173        pattern_id == rule
3174    }
3175}
3176
3177fn family_clause(column: &str, families: &[&str]) -> String {
3178    let alternatives: Vec<String> = families
3179        .iter()
3180        .map(|rule| {
3181            let escaped = escape_like(rule);
3182            if rule.starts_with('.') {
3183                format!("{column} LIKE '%{escaped}%' ESCAPE '\\'")
3184            } else if rule.ends_with('.') {
3185                format!("{column} LIKE '{escaped}%' ESCAPE '\\'")
3186            } else {
3187                format!("{column} = '{rule}'")
3188            }
3189        })
3190        .collect();
3191    format!("({})", alternatives.join(" OR "))
3192}
3193
3194/// True when the category text names one of the aliases in `CATEGORY_ALIASES`.
3195pub fn is_category_alias(category: &str) -> bool {
3196    category_families(category).is_some()
3197}
3198
3199/// Counts the patterns and facts each category alias reaches, given a category listing.
3200///
3201/// Only one alias per family list is reported, and an alias with no facts is left out.
3202pub fn alias_fact_counts(categories: &[(String, usize)]) -> Vec<(&'static str, usize, usize)> {
3203    let mut reported: Vec<&[&str]> = Vec::new();
3204    let mut counts = Vec::new();
3205    for (alias, families) in CATEGORY_ALIASES {
3206        if reported.contains(families) {
3207            continue;
3208        }
3209        reported.push(families);
3210        let mut patterns = 0;
3211        let mut facts = 0;
3212        for (pattern_id, count) in categories {
3213            if families.iter().any(|rule| matches_family(pattern_id, rule)) {
3214                patterns += 1;
3215                facts += count;
3216            }
3217        }
3218        if facts > 0 {
3219            counts.push((*alias, patterns, facts));
3220        }
3221    }
3222    counts
3223}
3224
3225/// Find structural facts by category (e.g. route, query, model, config), optionally scoped by path.
3226pub fn find_structural_facts_scoped(
3227    conn: &Connection,
3228    category: &str,
3229    path_filter: Option<&str>,
3230    limit: usize,
3231) -> Result<Vec<StructuralFact>, QueryError> {
3232    validate_result_limit(limit)?;
3233    let norm_path = path_filter
3234        .map(|p| {
3235            p.replace('\\', "/")
3236                .trim_start_matches("./")
3237                .trim_matches('/')
3238                .to_string()
3239        })
3240        .filter(|p| !p.is_empty());
3241    let dir_prefix = norm_path
3242        .as_deref()
3243        .map(|p| format!("{}/%", escape_like(p)));
3244    let cat_pattern = format!("%{}%", escape_like(category));
3245
3246    let cat_clause = match category_families(category) {
3247        Some(families) => family_clause("sf.pattern_id", families),
3248        None => {
3249            "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
3250                .to_string()
3251        }
3252    };
3253
3254    let sql = format!(
3255        "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
3256                sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
3257                sf.start_line, sf.end_line, sf.confidence,
3258                COALESCE(
3259                    CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
3260                         THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
3261                         ELSE json_extract(sf.metadata_json, '$.key_path') END,
3262                    json_extract(sf.metadata_json, '$.key'),
3263                    json_extract(sf.metadata_json, '$.normalized_route_template'),
3264                    CASE WHEN sf.pattern_id = 'cpp.qt_property.v1'
3265                         THEN json_extract(sf.metadata_json, '$.name') END
3266                ) AS display_key,
3267                sf.metadata_json
3268         FROM structural_facts sf
3269         LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
3270         WHERE (:cat IS NOT NULL AND {cat_clause})
3271           AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3272         ORDER BY sf.path ASC, sf.start_line ASC
3273         LIMIT :limit"
3274    );
3275
3276    let mut stmt = conn.prepare(&sql)?;
3277    let rows = stmt.query_map(
3278        rusqlite::named_params! {
3279            ":cat": cat_pattern,
3280            ":path": norm_path.as_deref(),
3281            ":dir_prefix": dir_prefix.as_deref(),
3282            ":limit": limit as i64,
3283        },
3284        |row| {
3285            Ok(StructuralFact {
3286                structural_fact_id: row.get(0)?,
3287                path: row.get::<_, String>(1)?.replace('\\', "/"),
3288                language: row.get(2)?,
3289                pattern_id: row.get(3)?,
3290                capture_name: row.get(4)?,
3291                node_kind: row.get(5)?,
3292                key: row.get(10)?,
3293                metadata: row
3294                    .get::<_, Option<String>>(11)?
3295                    .map(|json| serde_json::from_str(&json))
3296                    .transpose()
3297                    .map_err(|err| {
3298                        rusqlite::Error::FromSqlConversionFailure(
3299                            11,
3300                            rusqlite::types::Type::Text,
3301                            Box::new(err),
3302                        )
3303                    })?,
3304                containing_symbol_name: row.get(6)?,
3305                start_line: row.get::<_, i64>(7)? as usize,
3306                end_line: row.get::<_, i64>(8)? as usize,
3307                confidence: row.get(9)?,
3308            })
3309        },
3310    )?;
3311
3312    let mut results = Vec::new();
3313    for r in rows {
3314        results.push(r?);
3315    }
3316    Ok(results)
3317}
3318
3319/// Find structural facts by category (e.g. route, query, model, config).
3320pub fn find_structural_facts(
3321    conn: &Connection,
3322    category: &str,
3323    limit: usize,
3324) -> Result<Vec<StructuralFact>, QueryError> {
3325    find_structural_facts_scoped(conn, category, None, limit)
3326}
3327
3328/// Find literals (endpoints, SQL queries, configs) matching category, optionally scoped by path.
3329pub fn find_literals_scoped(
3330    conn: &Connection,
3331    category: &str,
3332    path_filter: Option<&str>,
3333    limit: usize,
3334) -> Result<Vec<LiteralFact>, QueryError> {
3335    validate_result_limit(limit)?;
3336    let norm_path = path_filter
3337        .map(|p| {
3338            p.replace('\\', "/")
3339                .trim_start_matches("./")
3340                .trim_matches('/')
3341                .to_string()
3342        })
3343        .filter(|p| !p.is_empty());
3344    let dir_prefix = norm_path
3345        .as_deref()
3346        .map(|p| format!("{}/%", escape_like(p)));
3347    let cat_pattern = format!("%{}%", escape_like(category));
3348
3349    let cat_lower = category.trim().to_ascii_lowercase();
3350    let cat_clause = match cat_lower.as_str() {
3351        "config" => {
3352            "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
3353        }
3354        "route" | "routes" => "l.kind LIKE '%route%'",
3355        "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
3356        "model" | "models" => "l.kind LIKE '%model%'",
3357        _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
3358    };
3359
3360    let sql = format!(
3361        "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
3362                l.start_line, s.name AS containing_symbol_name
3363         FROM literals l
3364         LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
3365         WHERE (:cat IS NOT NULL AND {cat_clause})
3366           AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3367         ORDER BY l.path ASC, l.start_line ASC
3368         LIMIT :limit"
3369    );
3370
3371    let mut stmt = conn.prepare(&sql)?;
3372    let rows = stmt.query_map(
3373        rusqlite::named_params! {
3374            ":cat": cat_pattern,
3375            ":path": norm_path.as_deref(),
3376            ":dir_prefix": dir_prefix.as_deref(),
3377            ":limit": limit as i64,
3378        },
3379        |row| {
3380            Ok(LiteralFact {
3381                literal_id: row.get(0)?,
3382                path: row.get::<_, String>(1)?.replace('\\', "/"),
3383                literal_text: row.get(2)?,
3384                kind: row.get(3)?,
3385                carrier: row.get(4)?,
3386                start_line: row.get::<_, i64>(5)? as usize,
3387                containing_symbol_name: row.get(6)?,
3388            })
3389        },
3390    )?;
3391
3392    let mut results = Vec::new();
3393    for r in rows {
3394        results.push(r?);
3395    }
3396    Ok(results)
3397}
3398
3399/// Find literals (endpoints, SQL queries, configs) matching category.
3400pub fn find_literals(
3401    conn: &Connection,
3402    category: &str,
3403    limit: usize,
3404) -> Result<Vec<LiteralFact>, QueryError> {
3405    find_literals_scoped(conn, category, None, limit)
3406}
3407
3408/// List available structural fact and literal categories with counts, optionally scoped by path.
3409pub fn list_structural_fact_categories_scoped(
3410    conn: &Connection,
3411    path_filter: Option<&str>,
3412) -> Result<Vec<(String, usize)>, QueryError> {
3413    let norm_path = path_filter
3414        .map(|p| {
3415            p.replace('\\', "/")
3416                .trim_start_matches("./")
3417                .trim_matches('/')
3418                .to_string()
3419        })
3420        .filter(|p| !p.is_empty());
3421    let dir_prefix = norm_path
3422        .as_deref()
3423        .map(|p| format!("{}/%", escape_like(p)));
3424
3425    let mut categories = Vec::new();
3426
3427    let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
3428               WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3429               GROUP BY pattern_id ORDER BY cnt DESC";
3430    let mut stmt = conn.prepare(sql)?;
3431    let rows = stmt.query_map(
3432        rusqlite::named_params! {
3433            ":path": norm_path.as_deref(),
3434            ":dir_prefix": dir_prefix.as_deref(),
3435        },
3436        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3437    )?;
3438    for r in rows {
3439        categories.push(r?);
3440    }
3441
3442    let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
3443                   WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3444                   GROUP BY kind ORDER BY cnt DESC";
3445    let mut lit_stmt = conn.prepare(lit_sql)?;
3446    let lit_rows = lit_stmt.query_map(
3447        rusqlite::named_params! {
3448            ":path": norm_path.as_deref(),
3449            ":dir_prefix": dir_prefix.as_deref(),
3450        },
3451        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3452    )?;
3453    for r in lit_rows {
3454        categories.push(r?);
3455    }
3456
3457    Ok(categories)
3458}
3459
3460/// List all available structural fact and literal categories with counts.
3461pub fn list_structural_fact_categories(
3462    conn: &Connection,
3463) -> Result<Vec<(String, usize)>, QueryError> {
3464    list_structural_fact_categories_scoped(conn, None)
3465}
3466
3467/// Find type facts for a symbol.
3468pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
3469    let has_table: bool = conn
3470        .query_row(
3471            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
3472            [],
3473            |_| Ok(true),
3474        )
3475        .unwrap_or(false);
3476    if !has_table {
3477        return Ok(Vec::new());
3478    }
3479
3480    let mut stmt = conn.prepare(
3481        "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
3482         FROM type_facts
3483         WHERE symbol_id = ?1",
3484    )?;
3485
3486    let rows = stmt.query_map(params![symbol_id], |row| {
3487        Ok(TypeFact {
3488            type_fact_id: row.get(0)?,
3489            symbol_id: row.get(1)?,
3490            language: row.get(2)?,
3491            resolved_type: row.get(3)?,
3492            generic_params: row.get(4)?,
3493        })
3494    })?;
3495
3496    let mut results = Vec::new();
3497    for r in rows {
3498        results.push(r?);
3499    }
3500    Ok(results)
3501}
3502
3503/// True when a repository-relative path looks like a test file. Directory rules and file-name
3504/// rules are kept apart: a `test`, `tests`, `autotests`, or `__tests__` directory anywhere
3505/// including the repository root, or a file name that starts with Qt's `tst_`, starts with
3506/// `test_` in Python or Ruby, contains `_test.`,
3507/// `.test.`, or `.spec.`, is exactly `test.rs` or `tests.rs`, or ends with the C# `Tests.cs`
3508/// (case-sensitive, so `Contests.cs` is a production file).
3509pub fn is_test_path(path: &str) -> bool {
3510    let p = path.replace('\\', "/");
3511    let cut = p.rfind('/').map_or(0, |i| i + 1);
3512    let directories = format!("/{}/", p[..cut].to_lowercase());
3513    let file_name = &p[cut..];
3514    let lower_name = file_name.to_lowercase();
3515    directories.contains("/test/")
3516        || directories.contains("/tests/")
3517        || directories.contains("/autotests/")
3518        || directories.contains("/__tests__/")
3519        || lower_name.starts_with("tst_")
3520        || (lower_name.starts_with("test_")
3521            && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
3522        || lower_name.contains("_test.")
3523        || lower_name.contains(".test.")
3524        || lower_name.contains(".spec.")
3525        || lower_name == "test.rs"
3526        || lower_name == "tests.rs"
3527        || file_name.ends_with("Tests.cs")
3528}
3529
3530/// SQL boolean over `alias.path` that mirrors [`is_test_path`] rule for rule.
3531///
3532/// `test_path_rule_and_its_sql_mirror_agree_on_every_path` runs both forms over one path list so
3533/// the two cannot drift apart.
3534///
3535/// Every rule needs `test`, `spec`, or `tst_` in the path, so a cheap substring test guards the
3536/// rules and lets most rows skip the path split. Without the guard the split costs about seven
3537/// times more over a half-million rows.
3538pub(crate) fn test_path_predicate(alias: &str) -> String {
3539    let guard = format!(
3540        "(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%' OR lower({alias}.path) LIKE '%tst\\_%' ESCAPE '\\')"
3541    );
3542    let p = format!("replace({alias}.path, '\\', '/')");
3543    let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
3544    let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
3545    let lower_name = format!("lower({file_name})");
3546    let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
3547    let clauses = [
3548        like(&directories, "%/test/%"),
3549        like(&directories, "%/tests/%"),
3550        like(&directories, "%/autotests/%"),
3551        like(&directories, "%/\\_\\_tests\\_\\_/%"),
3552        like(&lower_name, "tst\\_%"),
3553        like(&lower_name, "test\\_%.py"),
3554        like(&lower_name, "test\\_%.rb"),
3555        like(&lower_name, "%\\_test.%"),
3556        like(&lower_name, "%.test.%"),
3557        like(&lower_name, "%.spec.%"),
3558        format!("{lower_name} = 'test.rs'"),
3559        format!("{lower_name} = 'tests.rs'"),
3560        format!("{file_name} GLOB '*Tests.cs'"),
3561    ]
3562    .join(" OR ");
3563    format!("({guard} AND ({clauses}))")
3564}
3565
3566/// Compute blast radius and likely tests for given seed symbols or seed file paths.
3567/// Recursively walks reverse reachability (transitive callers) up to `max_depth` in SQLite.
3568pub fn compute_blast_radius_scoped(
3569    conn: &Connection,
3570    seed_symbols: &[&str],
3571    symbol_path_filter: Option<&str>,
3572    seed_paths: &[&str],
3573    max_depth: usize,
3574    limit: usize,
3575) -> Result<BlastRadiusResult, QueryError> {
3576    compute_blast_radius_scoped_with_ids(
3577        conn,
3578        seed_symbols,
3579        &[],
3580        symbol_path_filter,
3581        seed_paths,
3582        max_depth,
3583        limit,
3584    )
3585}
3586
3587/// Compute blast radius with exact current-index IDs kept as traversal seeds.
3588pub fn compute_blast_radius_scoped_with_ids(
3589    conn: &Connection,
3590    seed_symbols: &[&str],
3591    seed_ids: &[&str],
3592    symbol_path_filter: Option<&str>,
3593    seed_paths: &[&str],
3594    max_depth: usize,
3595    limit: usize,
3596) -> Result<BlastRadiusResult, QueryError> {
3597    validate_result_limit(limit)?;
3598    let max_depth = max_depth.min(5);
3599    let mut resolved_seed_symbols = seed_symbols
3600        .iter()
3601        .map(|name| {
3602            get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
3603                let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
3604                QueryError::SymbolNotFound {
3605                    name: (*name).to_string(),
3606                    workspace,
3607                    hint,
3608                }
3609            })
3610        })
3611        .collect::<Result<Vec<_>, _>>()?;
3612    for id in seed_ids {
3613        let symbol = get_symbol_by_id(conn, id)?.ok_or_else(|| {
3614            let workspace = workspace_name(conn);
3615            QueryError::SymbolNotFound {
3616                name: format!("symbol id {id}"),
3617                workspace,
3618                hint: "Run lookup_symbol or search_symbols again and select a current id."
3619                    .to_string(),
3620            }
3621        })?;
3622        resolved_seed_symbols.push(symbol);
3623    }
3624    let mut seeds = Vec::new();
3625    let seed_type = if (!seed_symbols.is_empty() || !seed_ids.is_empty()) && !seed_paths.is_empty()
3626    {
3627        for s in seed_symbols {
3628            seeds.push(s.to_string());
3629        }
3630        for id in seed_ids {
3631            seeds.push(id.to_string());
3632        }
3633        for p in seed_paths {
3634            seeds.push(p.to_string());
3635        }
3636        "mixed".to_string()
3637    } else if !seed_symbols.is_empty() || !seed_ids.is_empty() {
3638        for s in seed_symbols {
3639            seeds.push(s.to_string());
3640        }
3641        for id in seed_ids {
3642            seeds.push(id.to_string());
3643        }
3644        "symbol".to_string()
3645    } else if !seed_paths.is_empty() {
3646        for p in seed_paths {
3647            seeds.push(p.to_string());
3648        }
3649        "file".to_string()
3650    } else {
3651        return Ok(BlastRadiusResult {
3652            seed_type: "none".to_string(),
3653            seeds: Vec::new(),
3654            likely_tests: Vec::new(),
3655            impacted_symbols: Vec::new(),
3656            likely_tests_truncated: false,
3657            impacted_symbols_truncated: false,
3658            traversal_ceiling_reached: false,
3659            test_file_ceiling_reached: false,
3660        });
3661    };
3662
3663    let mut where_clauses = Vec::new();
3664    let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
3665
3666    if !resolved_seed_symbols.is_empty() {
3667        let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3668            .map(|i| format!("?{}", i))
3669            .collect();
3670        where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3671        for symbol in &resolved_seed_symbols {
3672            params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3673        }
3674    }
3675
3676    if !seed_paths.is_empty() {
3677        let mut path_conds = Vec::new();
3678        for p in seed_paths.iter() {
3679            let raw = p
3680                .replace('\\', "/")
3681                .trim_start_matches("./")
3682                .trim_matches('/')
3683                .to_string();
3684            let exact_idx = params_vec.len() + 1;
3685            params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3686            let dir_pattern = format!("{}/%", escape_like(&raw));
3687            let like_idx = params_vec.len() + 1;
3688            params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3689            path_conds.push(format!(
3690                "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3691            ));
3692        }
3693        where_clauses.push(format!("({})", path_conds.join(" OR ")));
3694    }
3695
3696    let seed_condition = where_clauses.join(" OR ");
3697    let max_depth_idx = params_vec.len() + 1;
3698    params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3699
3700    let mut traversal_ceiling_reached = false;
3701    let mut test_file_ceiling_reached = false;
3702
3703    let has_relationships: bool = conn
3704        .query_row(
3705            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3706            [],
3707            |_| Ok(true),
3708        )
3709        .unwrap_or(false);
3710
3711    let has_pending: bool = conn
3712        .query_row(
3713            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3714            [],
3715            |_| Ok(true),
3716        )
3717        .unwrap_or(false);
3718
3719    let mut likely_tests = Vec::new();
3720    let mut impacted_symbols = Vec::new();
3721    let mut seen_test_keys = HashSet::new();
3722
3723    let mut recursive_branches = Vec::new();
3724
3725    if has_relationships {
3726        recursive_branches.push(format!(
3727            "SELECT r.from_symbol_id, iw.depth + 1
3728             FROM relationships r
3729             JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3730             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3731             WHERE iw.depth < ?{max_depth_idx}
3732               AND s_from.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})"
3733        ));
3734    }
3735
3736    if has_pending {
3737        let (parent_join, ns_condition) = if conn
3738            .query_row(
3739                "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3740                [],
3741                |_| Ok(true),
3742            )
3743            .unwrap_or(false)
3744        {
3745            (
3746                "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3747            LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3748                format!("AND {pred}", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
3749            )
3750        } else {
3751            ("", String::new())
3752        };
3753
3754        recursive_branches.push(format!(
3755            "SELECT p.from_symbol_id, iw.depth + 1
3756             FROM pending_relationships p
3757             JOIN symbols s_target ON p.target_terminal_name = s_target.name
3758             JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3759             {parent_join}
3760             WHERE iw.depth < ?{max_depth_idx}
3761               AND s_target.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3762               {ns_condition}"
3763        ));
3764    }
3765
3766    if !recursive_branches.is_empty() {
3767        let recursive_sql = recursive_branches.join("\n UNION \n");
3768        let not_documentation = not_documentation(conn, "s");
3769        let sql = format!(
3770            "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3771                SELECT symbol_id, 0
3772                FROM symbols
3773                WHERE ({seed_condition})
3774                  AND kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3775
3776                UNION
3777
3778                {recursive_sql}
3779            )
3780            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
3781            FROM impact_walk iw
3782            CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3783            WHERE s.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3784              AND {not_documentation}
3785            GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3786            HAVING MIN(iw.depth) > 0
3787            ORDER BY min_depth ASC, s.path ASC, s.name ASC
3788            LIMIT 201"
3789        );
3790
3791        let mut stmt = conn.prepare(&sql)?;
3792        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3793            .iter()
3794            .map(|v| v as &dyn rusqlite::ToSql)
3795            .collect();
3796
3797        let rows = stmt.query_map(param_refs.as_slice(), |row| {
3798            Ok((
3799                row.get::<_, String>(0)?,
3800                row.get::<_, String>(1)?,
3801                row.get::<_, String>(2)?,
3802                row.get::<_, String>(3)?,
3803                row.get::<_, i64>(4)? as usize,
3804                row.get::<_, bool>(5)?,
3805                row.get::<_, bool>(6)?,
3806                row.get::<_, i64>(7)? as usize,
3807            ))
3808        })?;
3809
3810        for (index, r) in rows.enumerate() {
3811            if index == 200 {
3812                traversal_ceiling_reached = true;
3813                break;
3814            }
3815            let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3816            let path = raw_path.replace('\\', "/");
3817            let is_test_target = is_test || test_container || is_test_path(&path);
3818
3819            if is_test_target {
3820                let key = format!("{}:{}", path, line);
3821                if seen_test_keys.insert(key) {
3822                    likely_tests.push(TestTarget {
3823                        name,
3824                        path,
3825                        line,
3826                        reason: format!("transitive caller [depth {depth}]"),
3827                    });
3828                }
3829            } else {
3830                impacted_symbols.push(ImpactedSymbol {
3831                    name,
3832                    kind,
3833                    path,
3834                    line,
3835                    depth,
3836                });
3837            }
3838        }
3839    }
3840
3841    let mut test_name_terms = Vec::new();
3842    let mut module_terms = Vec::new();
3843    for path in seed_paths.iter().copied().chain(
3844        resolved_seed_symbols
3845            .iter()
3846            .map(|symbol| symbol.path.as_str()),
3847    ) {
3848        let mut parts = path.rsplit(['/', '\\']);
3849        let file = parts.next().unwrap_or(path);
3850        if let Some(stem) = std::path::Path::new(file)
3851            .file_stem()
3852            .and_then(|s| s.to_str())
3853            && stem.len() >= 3
3854            && !test_name_terms.iter().any(|(name, _)| name == stem)
3855        {
3856            test_name_terms.push((stem.to_string(), "stem-matched test file"));
3857        }
3858        let module = parts.next();
3859        if parts.next() == Some("src")
3860            && let Some(module) = module
3861            && module.len() >= 3
3862            && !["test", "tests", "spec", "specs", "bin", "lib"]
3863                .iter()
3864                .any(|generic| module.eq_ignore_ascii_case(generic))
3865            && !test_name_terms.iter().any(|(name, _)| name == module)
3866            && !module_terms.iter().any(|name| name == module)
3867        {
3868            module_terms.push(module.to_string());
3869        }
3870    }
3871    test_name_terms.extend(
3872        module_terms
3873            .into_iter()
3874            .map(|module| (module, "module-matched test file")),
3875    );
3876
3877    let has_files: bool = conn
3878        .query_row(
3879            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3880            [],
3881            |_| Ok(true),
3882        )
3883        .unwrap_or(false);
3884
3885    if has_files {
3886        let doc_file = format!(
3887            "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3888            not_documentation(conn, "d")
3889        );
3890        let mut test_files_stmt = conn.prepare(&format!(
3891            "SELECT DISTINCT path FROM files
3892             WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3893               AND NOT {doc_file}
3894             ORDER BY path ASC
3895             LIMIT 11"
3896        ))?;
3897        let mut module_test_files_stmt = conn.prepare(&format!(
3898            "SELECT DISTINCT path FROM files
3899             WHERE (path LIKE '%test%' OR path LIKE '%spec%')
3900               AND (path LIKE ?1 ESCAPE '\\' OR path LIKE ?2 ESCAPE '\\')
3901               AND NOT {doc_file}
3902             ORDER BY path ASC
3903             LIMIT 201"
3904        ))?;
3905        for (term, reason) in test_name_terms {
3906            let module_match = reason == "module-matched test file";
3907            let escaped_term = escape_like(&term);
3908            let mut rows = if module_match {
3909                module_test_files_stmt.query(rusqlite::params![
3910                    format!("%/{escaped_term}%"),
3911                    format!("%/test\\_{escaped_term}%")
3912                ])?
3913            } else {
3914                test_files_stmt.query([format!("%{escaped_term}%")])?
3915            };
3916            let mut candidates = 0;
3917            let mut matches = 0;
3918            while let Some(row) = rows.next()? {
3919                candidates += 1;
3920                if candidates == 201 {
3921                    test_file_ceiling_reached = true;
3922                    break;
3923                }
3924                let p: String = row.get(0)?;
3925                if module_match {
3926                    let basename = p
3927                        .rsplit(['/', '\\'])
3928                        .next()
3929                        .unwrap_or_default()
3930                        .to_lowercase();
3931                    let term = term.to_lowercase();
3932                    if !basename.starts_with(&term)
3933                        && !basename.starts_with(&format!("test_{term}"))
3934                    {
3935                        continue;
3936                    }
3937                }
3938                if matches == 10 {
3939                    test_file_ceiling_reached = true;
3940                    break;
3941                }
3942                matches += 1;
3943                let p = p.replace('\\', "/");
3944                let key = format!("{}:1", p);
3945                if seen_test_keys.insert(key) {
3946                    likely_tests.push(TestTarget {
3947                        name: p.clone(),
3948                        path: p,
3949                        line: 1,
3950                        reason: reason.to_string(),
3951                    });
3952                }
3953            }
3954        }
3955    }
3956
3957    let likely_tests_truncated = likely_tests.len() > limit;
3958    let impacted_symbols_truncated = impacted_symbols.len() > limit;
3959    if likely_tests.len() > limit {
3960        likely_tests.truncate(limit);
3961    }
3962    if impacted_symbols.len() > limit {
3963        impacted_symbols.truncate(limit);
3964    }
3965
3966    Ok(BlastRadiusResult {
3967        seed_type,
3968        seeds,
3969        likely_tests,
3970        impacted_symbols,
3971        likely_tests_truncated,
3972        impacted_symbols_truncated,
3973        traversal_ceiling_reached,
3974        test_file_ceiling_reached,
3975    })
3976}
3977
3978/// Compute blast radius and likely tests for given seed symbols or seed file paths.
3979pub fn compute_blast_radius(
3980    conn: &Connection,
3981    seed_symbols: &[&str],
3982    seed_paths: &[&str],
3983    max_depth: usize,
3984    limit: usize,
3985) -> Result<BlastRadiusResult, QueryError> {
3986    compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3987}
3988
3989#[cfg(test)]
3990mod tests {
3991    #[test]
3992    fn result_limit_rejects_values_above_the_shared_ceiling() {
3993        assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3994        assert!(matches!(
3995            validate_result_limit(usize::MAX),
3996            Err(QueryError::InvalidResultLimit(usize::MAX))
3997        ));
3998    }
3999
4000    #[test]
4001    fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
4002        let conn = Connection::open_in_memory().unwrap();
4003
4004        assert!(matches!(
4005            find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
4006            Err(QueryError::InvalidResultLimit(usize::MAX))
4007        ));
4008    }
4009
4010    use super::*;
4011    use crate::db::{ensure_fts_index, open_read_write};
4012
4013    #[test]
4014    fn count_parse_diagnostics_counts_rows_for_one_file() {
4015        let dir = crate::safe_tempdir();
4016        let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
4017
4018        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
4019
4020        conn.execute_batch(
4021            "CREATE TABLE parse_diagnostics (
4022                diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
4023            );
4024            INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
4025            INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
4026            INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
4027        )
4028        .unwrap();
4029
4030        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
4031        assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
4032        assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
4033    }
4034
4035    #[test]
4036    fn test_sanitize_fts5_query() {
4037        let (and_q, or_q) = sanitize_fts5_query("parse tokens");
4038        assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
4039        assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
4040
4041        let (and_q, or_q) = sanitize_fts5_query("  Option<T>  ");
4042        assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
4043        assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
4044
4045        let (and_q, or_q) = sanitize_fts5_query("   ");
4046        assert!(and_q.is_empty());
4047        assert!(or_q.is_empty());
4048    }
4049
4050    #[test]
4051    fn sanitize_splits_case_boundaries_and_drops_stop_words() {
4052        let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
4053        assert_eq!(
4054            and_q,
4055            "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
4056        );
4057        assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
4058
4059        let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
4060        assert_eq!(
4061            and_q,
4062            "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
4063        );
4064
4065        let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
4066        assert_eq!(
4067            and_q,
4068            "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
4069        );
4070        assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
4071
4072        let (and_q, _) = sanitize_fts5_query("validate_syntax");
4073        assert_eq!(
4074            and_q,
4075            "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
4076        );
4077
4078        let (and_q, _) = sanitize_fts5_query("isReady");
4079        assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
4080
4081        let (and_q, _) = sanitize_fts5_query("before");
4082        assert_eq!(and_q, "\"before\"*");
4083
4084        let (and_q, _) = sanitize_fts5_query("fooBar quux");
4085        assert_eq!(
4086            and_q,
4087            "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
4088        );
4089
4090        let (and_q, _) = sanitize_fts5_query("the for a");
4091        assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
4092    }
4093
4094    fn search_fixture(rows: &str) -> Connection {
4095        let conn = Connection::open_in_memory().unwrap();
4096        conn.execute_batch(&format!(
4097            "CREATE TABLE symbols (
4098                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
4099                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4100                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4101                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4102                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4103                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4104                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
4105            );
4106            INSERT INTO symbols VALUES {rows};"
4107        ))
4108        .unwrap();
4109        ensure_fts_index(&conn).unwrap();
4110        conn
4111    }
4112
4113    fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
4114        format!(
4115            "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
4116              10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
4117        )
4118    }
4119
4120    fn doc_row(id: &str, name: &str, doc: &str) -> String {
4121        format!(
4122            "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
4123              3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
4124        )
4125    }
4126
4127    fn search_names(conn: &Connection, query: &str) -> Vec<String> {
4128        fts_search_symbols_scoped(conn, query, None, None, false, 10)
4129            .unwrap()
4130            .into_iter()
4131            .map(|r| r.symbol.name)
4132            .collect()
4133    }
4134
4135    const TEST_PATH_CASES: &[(&str, bool)] = &[
4136        ("tests/foo.py", true),
4137        ("tests/x.py", true),
4138        ("tests/tools/test_web.py", true),
4139        ("src/tests/x.rs", true),
4140        ("__tests__/a.ts", true),
4141        ("a/__tests__/b.ts", true),
4142        ("test/x.java", true),
4143        ("src/test/Helper.java", true),
4144        ("src/test_utils.py", true),
4145        ("lib/test_helper.rb", true),
4146        ("test_config.py", true),
4147        ("pkg/test_data/x.json", false),
4148        ("src/test_detection.rs", false),
4149        ("autotests/tst_pagerow.qml", true),
4150        ("autotests/helper.qml", true),
4151        ("src/autotests/columnview.cpp", true),
4152        ("tst_foo.qml", true),
4153        ("src/tst_columnview.qml", true),
4154        ("autotests\\tst_bar.qml", true),
4155        ("autotests_helper/x.rs", false),
4156        ("src/autotest.rs", false),
4157        ("src/tstamp.rs", false),
4158        ("src/tst.rs", false),
4159        ("crates/julie-index/src/analysis/test_quality.rs", false),
4160        ("x/foo_test.go", true),
4161        ("x/foo.test.ts", true),
4162        ("x/foo.spec.js", true),
4163        ("src/lib_test.rs", true),
4164        ("src/test.rs", true),
4165        ("tests.rs", true),
4166        ("src/tests.rs", true),
4167        ("Foo.Tests.cs", true),
4168        ("x/FooTests.cs", true),
4169        ("src/FooTests.cs", true),
4170        ("src/Foo.Tests.cs", true),
4171        ("tests/Foo.cs", true),
4172        ("x/parser.spec.ts", true),
4173        ("test_x.py", true),
4174        ("test.rs", true),
4175        ("tests\\x.py", true),
4176        ("src/protocol.spec.v1/parser.rs", false),
4177        ("pkg/test_support/runtime.py", false),
4178        ("src/Contests.cs", false),
4179        ("spec/x.rb", false),
4180        ("crates/x/src/impact/likely_tests.rs", false),
4181        ("x/foo_tests.rs", false),
4182        ("src/latest.rs", false),
4183        ("x/latest.go", false),
4184        ("x/manifest.rs", false),
4185        ("src/attest.rs", false),
4186        ("contest/x.py", false),
4187        ("src/testing.rs", false),
4188        ("src/main.rs", false),
4189        ("pkg/service.go", false),
4190    ];
4191
4192    #[test]
4193    fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
4194        let conn = Connection::open_in_memory().unwrap();
4195        let sql = format!(
4196            "SELECT {} FROM (SELECT :path AS path) s",
4197            test_path_predicate("s")
4198        );
4199        let mut stmt = conn.prepare(&sql).unwrap();
4200        for (path, expected) in TEST_PATH_CASES {
4201            assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
4202            let from_sql: bool = stmt
4203                .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
4204                .unwrap();
4205            assert_eq!(from_sql, *expected, "sql mirror: {path}");
4206        }
4207    }
4208
4209    #[test]
4210    fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
4211        let conn = search_fixture(
4212            &[
4213                code_row(
4214                    "a",
4215                    "src/parser.rs",
4216                    "rust",
4217                    "parse_sidecar",
4218                    "Parse a sidecar.",
4219                ),
4220                code_row(
4221                    "b",
4222                    "src/tests/helpers.py",
4223                    "python",
4224                    "parse_sidecar_fixture",
4225                    "Parse a sidecar.",
4226                ),
4227            ]
4228            .join(", "),
4229        );
4230
4231        let default_search: Vec<String> =
4232            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
4233                .unwrap()
4234                .into_iter()
4235                .map(|r| r.symbol.name)
4236                .collect();
4237        assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
4238
4239        let with_tests: Vec<String> =
4240            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
4241                .unwrap()
4242                .into_iter()
4243                .map(|r| r.symbol.name)
4244                .collect();
4245        assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
4246
4247        let default_lookup: Vec<String> =
4248            search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
4249                .unwrap()
4250                .into_iter()
4251                .map(|s| s.name)
4252                .collect();
4253        assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
4254
4255        let lookup_with_tests: Vec<String> =
4256            search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
4257                .unwrap()
4258                .into_iter()
4259                .map(|s| s.name)
4260                .collect();
4261        assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
4262    }
4263
4264    #[test]
4265    fn count_file_symbols_prefers_the_exact_case_path_like_the_loader() {
4266        let conn = search_fixture(
4267            &[
4268                code_row("a", "src/Foo.rs", "rust", "one", ""),
4269                code_row("b", "src/foo.rs", "rust", "two", ""),
4270                code_row("c", "src/foo.rs", "rust", "three", ""),
4271            ]
4272            .join(", "),
4273        );
4274        for path in ["src/Foo.rs", "src/foo.rs", "src/FOO.rs", "src\\foo.rs"] {
4275            assert_eq!(
4276                count_file_symbols(&conn, path),
4277                load_file_symbols(&conn, path).unwrap().len(),
4278                "{path}"
4279            );
4280        }
4281        assert_eq!(count_file_symbols(&conn, "src/Foo.rs"), 1);
4282        assert_eq!(count_file_symbols(&conn, "src/FOO.rs"), 3);
4283    }
4284
4285    #[test]
4286    fn lookup_statement_is_not_planned_as_a_multi_index_or() {
4287        let conn = search_fixture(&code_row("a", "src/lib.rs", "rust", "needle", ""));
4288        conn.execute_batch(
4289            "CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
4290             CREATE INDEX idx_symbols_test_container ON symbols(test_container);
4291             CREATE INDEX idx_symbols_is_test ON symbols(is_test);",
4292        )
4293        .unwrap();
4294        let sql = format!(
4295            "EXPLAIN QUERY PLAN {}",
4296            search_symbols_sql(false, false, 20)
4297        );
4298        let plan: Vec<String> = conn
4299            .prepare(&sql)
4300            .unwrap()
4301            .query_map(
4302                rusqlite::named_params! {
4303                    ":query": "needle",
4304                    ":pattern": "%needle%",
4305                    ":kind": None::<&str>,
4306                    ":path": None::<&str>,
4307                    ":path_like": None::<&str>,
4308                },
4309                |row| row.get::<_, String>(3),
4310            )
4311            .unwrap()
4312            .collect::<Result<_, _>>()
4313            .unwrap();
4314        assert!(
4315            !plan.iter().any(|step| step.contains("MULTI-INDEX OR")),
4316            "{plan:?}"
4317        );
4318    }
4319
4320    #[test]
4321    fn qualified_lookup_in_a_test_file_returns_the_named_row() {
4322        let conn = search_fixture(
4323            &[
4324                "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
4325                  1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
4326                "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
4327                  10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
4328            ]
4329            .join(", "),
4330        );
4331
4332        assert_eq!(
4333            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
4334                .unwrap()
4335                .len(),
4336            1
4337        );
4338        assert_eq!(
4339            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
4340                .unwrap()
4341                .len(),
4342            1
4343        );
4344        assert!(
4345            search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
4346                .unwrap()
4347                .is_empty()
4348        );
4349    }
4350
4351    #[test]
4352    fn concept_query_prefers_partial_code_match_over_full_doc_match() {
4353        let conn = search_fixture(
4354            &[
4355                doc_row(
4356                    "d1",
4357                    "Safety guarantees",
4358                    "Pre-flight syntax validation runs before the edit touches disk",
4359                ),
4360                doc_row(
4361                    "d2",
4362                    "Audit",
4363                    "The syntax validation before an edit is the invariant",
4364                ),
4365                code_row(
4366                    "c1",
4367                    "src/syntax.rs",
4368                    "rust",
4369                    "validate_syntax",
4370                    "Validate the syntax of a file",
4371                ),
4372                code_row(
4373                    "c2",
4374                    "src/edit.rs",
4375                    "rust",
4376                    "replace_symbol_body",
4377                    "Atomic edit with validation",
4378                ),
4379            ]
4380            .join(","),
4381        );
4382
4383        let names = search_names(&conn, "syntax validation before edit");
4384
4385        assert_eq!(names[0], "validate_syntax");
4386        assert!(names.contains(&"replace_symbol_body".to_string()));
4387        assert!(names.contains(&"Safety guarantees".to_string()));
4388    }
4389
4390    #[test]
4391    fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
4392        let conn = search_fixture(
4393            &[
4394                code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
4395                code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
4396            ]
4397            .join(","),
4398        );
4399
4400        let mut camel = search_names(&conn, "ValidateSyntax");
4401        camel.sort();
4402        assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
4403        let mut words = search_names(&conn, "validate syntax");
4404        words.sort();
4405        assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
4406    }
4407
4408    #[test]
4409    fn stop_word_prefixed_camel_case_symbol_is_still_found() {
4410        let conn = search_fixture(
4411            &[
4412                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4413                code_row("c2", "src/hooks.rs", "rust", "before", ""),
4414                code_row(
4415                    "c3",
4416                    "src/x.rs",
4417                    "rust",
4418                    "fooBar",
4419                    "has fooBar but not the other word",
4420                ),
4421            ]
4422            .join(","),
4423        );
4424
4425        assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
4426        assert_eq!(search_names(&conn, "before"), vec!["before"]);
4427    }
4428
4429    #[test]
4430    fn related_tests_use_the_name_as_typed_without_splitting() {
4431        let conn = search_fixture(
4432            &[
4433                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4434                "('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(),
4435                "('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(),
4436            ]
4437            .join(","),
4438        );
4439        let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
4440
4441        let names: Vec<String> = find_related_tests(&conn, &target, 5)
4442            .unwrap()
4443            .into_iter()
4444            .map(|t| t.name)
4445            .collect();
4446
4447        assert_eq!(names, vec!["isReady_reports_true"]);
4448    }
4449
4450    #[test]
4451    fn related_tests_admit_recognized_unflagged_paths_without_low_signal_rows() {
4452        let conn = search_fixture(
4453            "('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'),
4454              ('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'),
4455              ('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'),
4456              ('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'),
4457              ('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'),
4458              ('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'),
4459              ('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'),
4460              ('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')",
4461        );
4462        conn.execute_batch(
4463            "CREATE TABLE relationships (from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT, start_line INTEGER, start_column INTEGER);
4464             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);
4465             CREATE TABLE type_facts (type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT);
4466             INSERT INTO relationships VALUES ('direct', 'target', 'calls', 'tests/direct.rs', 1, 0);
4467             INSERT INTO pending_relationships VALUES ('pending', 'calculate', 'calls', 'autotests/tst_pending.qml', 1, 0, NULL, '[]', 'calculate');",
4468        )
4469        .unwrap();
4470        let target = get_symbol_by_name(&conn, "calculate", None)
4471            .unwrap()
4472            .unwrap();
4473
4474        let names: Vec<String> = find_related_tests(&conn, &target, 5)
4475            .unwrap()
4476            .into_iter()
4477            .map(|test| test.name)
4478            .collect();
4479
4480        assert_eq!(
4481            names,
4482            vec![
4483                "direct_case",
4484                "pending_case",
4485                "calculate_named_case",
4486                "fts_case"
4487            ]
4488        );
4489    }
4490
4491    #[test]
4492    fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
4493        let conn = search_fixture(
4494            &[
4495                code_row(
4496                    "c1",
4497                    "src/queries.rs",
4498                    "rust",
4499                    "fts_search_symbols_scoped",
4500                    "search symbols scoped with fts",
4501                ),
4502                code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
4503            ]
4504            .join(","),
4505        );
4506
4507        assert_eq!(
4508            search_names(&conn, "search_symbols_scoped")[0],
4509            "search_symbols_scoped"
4510        );
4511    }
4512
4513    fn sidecar_fixture() -> Connection {
4514        search_fixture(
4515            &[
4516                code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
4517                code_row(
4518                    "c2",
4519                    "src/sidecar.rs",
4520                    "rust",
4521                    "parse_sidecar_file",
4522                    "parse the sha256 sidecar file",
4523                ),
4524            ]
4525            .join(","),
4526        )
4527    }
4528
4529    fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
4530        candidates
4531            .iter()
4532            .find(|c| c.result.symbol.name == name)
4533            .unwrap_or_else(|| panic!("{name} is not a candidate"))
4534    }
4535
4536    #[test]
4537    fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
4538        let conn = sidecar_fixture();
4539
4540        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4541
4542        let target = candidate(&candidates, "parseSha256Sidecar");
4543        assert!(target.name_match);
4544        assert!(!target.word_match);
4545        assert!(!target.exact_name);
4546        assert!(target.name_terms.contains(&"sha256".to_string()));
4547    }
4548
4549    #[test]
4550    fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
4551        let conn = search_fixture(
4552            &[
4553                code_row(
4554                    "c1",
4555                    "examples/demo.rs",
4556                    "rust",
4557                    "demo",
4558                    "restore offline state",
4559                ),
4560                code_row(
4561                    "c2",
4562                    "src/replay.rs",
4563                    "rust",
4564                    "replay",
4565                    "restore offline records",
4566                ),
4567            ]
4568            .join(","),
4569        );
4570
4571        let candidates =
4572            collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
4573                .unwrap();
4574
4575        assert!(candidate(&candidates, "demo").word_match);
4576        assert!(candidate(&candidates, "replay").word_match);
4577        assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
4578    }
4579
4580    #[test]
4581    fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
4582        let mut rows: Vec<String> = (1..=170)
4583            .map(|i| {
4584                code_row(
4585                    &format!("h{i:03}"),
4586                    "src/sidecar.rs",
4587                    "rust",
4588                    &format!("sidecar_helper_{i:03}"),
4589                    "parse sidecar file",
4590                )
4591            })
4592            .collect();
4593        rows.push(code_row(
4594            "c1",
4595            "src/sidecar.rs",
4596            "rust",
4597            "parseSha256Sidecar",
4598            "",
4599        ));
4600        let conn = search_fixture(&rows.join(","));
4601
4602        let candidates = collect_search_candidates(
4603            &conn,
4604            "parse the sha256 sidecar file",
4605            None,
4606            None,
4607            false,
4608            40,
4609        )
4610        .unwrap();
4611
4612        assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
4613        assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
4614    }
4615
4616    #[test]
4617    fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
4618        let mut rows: Vec<String> = (1..=20)
4619            .map(|i| {
4620                code_row(
4621                    &format!("a{i:02}"),
4622                    "src/a.rs",
4623                    "rust",
4624                    &format!("both_{i:02}"),
4625                    "restore offline",
4626                )
4627            })
4628            .collect();
4629        rows.extend((1..=50).map(|i| {
4630            code_row(
4631                &format!("p{i:02}"),
4632                "src/p.rs",
4633                "rust",
4634                &format!("partial_{i:02}"),
4635                "restore records",
4636            )
4637        }));
4638        let conn = search_fixture(&rows.join(","));
4639
4640        let candidates =
4641            collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
4642
4643        let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
4644        assert_eq!(word_rows.len(), 40);
4645        assert_eq!(
4646            word_rows
4647                .iter()
4648                .filter(|c| c.result.symbol.name.starts_with("both_"))
4649                .count(),
4650            20
4651        );
4652    }
4653
4654    #[test]
4655    fn exact_name_is_admitted_regardless_of_case() {
4656        let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
4657
4658        let candidates =
4659            collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
4660        assert!(candidate(&candidates, "xyzzy_q").exact_name);
4661
4662        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4663        let candidates =
4664            collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
4665        assert!(candidate(&candidates, "xyzzy_q").exact_name);
4666    }
4667
4668    #[test]
4669    fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
4670        let conn = search_fixture(&code_row(
4671            "c1",
4672            "src/say.js",
4673            "javascript",
4674            "say \"hi\"",
4675            "",
4676        ));
4677
4678        let candidates =
4679            collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
4680
4681        let target = candidate(&candidates, "say \"hi\"");
4682        assert!(target.exact_name && target.name_match);
4683    }
4684
4685    #[test]
4686    fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
4687        let conn = search_fixture(
4688            &[
4689                code_row("c1", "src/a.rs", "rust", "sidecar", ""),
4690                code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
4691            ]
4692            .join(","),
4693        );
4694
4695        let candidates =
4696            collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
4697
4698        assert_eq!(candidates.len(), 2);
4699        let target = candidate(&candidates, "sidecar");
4700        assert!(target.exact_name && target.word_match && target.name_match);
4701        assert!(target.bm25.is_some());
4702        let helper = candidate(&candidates, "sidecar_helper");
4703        assert!(!helper.exact_name && helper.word_match && helper.name_match);
4704    }
4705
4706    #[test]
4707    fn an_index_without_the_trigram_table_returns_word_rows_only() {
4708        let conn = sidecar_fixture();
4709        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4710
4711        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4712
4713        let names: Vec<&str> = candidates
4714            .iter()
4715            .map(|c| c.result.symbol.name.as_str())
4716            .collect();
4717        assert_eq!(names, vec!["parse_sidecar_file"]);
4718        assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
4719        assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
4720    }
4721
4722    #[test]
4723    fn words_under_three_characters_skip_the_name_branch() {
4724        let conn = search_fixture(
4725            &[
4726                code_row("c1", "src/a.rs", "rust", "ab", ""),
4727                code_row("c2", "src/b.rs", "rust", "cab", ""),
4728            ]
4729            .join(","),
4730        );
4731
4732        let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
4733
4734        assert!(candidates.iter().all(|c| !c.name_match));
4735        assert!(candidate(&candidates, "ab").exact_name);
4736    }
4737
4738    #[test]
4739    fn trigram_terms_include_the_identifier_parts_of_each_word() {
4740        assert_eq!(
4741            trigram_name_terms("collapse_name"),
4742            vec!["collapse_name", "collapse", "name"]
4743        );
4744        assert_eq!(
4745            trigram_name_terms("parse the sha256 sidecar"),
4746            vec!["parse", "sha256", "sha", "256", "sidecar"]
4747        );
4748        assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
4749        assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
4750        assert!(trigram_name_terms("ab").is_empty());
4751    }
4752
4753    #[test]
4754    fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
4755        let conn = search_fixture(
4756            &[
4757                code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
4758                code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
4759            ]
4760            .join(","),
4761        );
4762
4763        let candidates =
4764            collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
4765
4766        let target = candidate(&candidates, "CollapseName");
4767        assert!(target.name_match);
4768        assert_eq!(target.name_terms, vec!["collapse", "name"]);
4769        assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
4770    }
4771
4772    fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
4773        Candidate {
4774            result: SymbolSearchResult {
4775                symbol: Symbol {
4776                    symbol_id: format!("{path}:{name}"),
4777                    file_id: "f".into(),
4778                    path: path.into(),
4779                    language: "rust".into(),
4780                    name: name.into(),
4781                    kind: kind.into(),
4782                    signature: None,
4783                    doc_comment: None,
4784                    visibility: None,
4785                    parent_symbol_id: None,
4786                    start_line: 1,
4787                    start_column: 0,
4788                    end_line: 1,
4789                    end_column: 0,
4790                    start_byte: 0,
4791                    end_byte: 0,
4792                    body_start_line: None,
4793                    body_start_column: None,
4794                    body_end_line: None,
4795                    body_end_column: None,
4796                    body_start_byte: None,
4797                    body_end_byte: None,
4798                    body_hash: None,
4799                    semantic_group: None,
4800                    is_test: false,
4801                    test_container: false,
4802                },
4803                score: 0.0,
4804                snippet: None,
4805                explain: None,
4806            },
4807            bm25: None,
4808            exact_name: false,
4809            word_match: false,
4810            name_match: false,
4811            name_terms: Vec::new(),
4812            documentation: false,
4813        }
4814    }
4815
4816    fn function(name: &str) -> Candidate {
4817        plain_candidate(name, "function", "src/lib.rs")
4818    }
4819
4820    fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
4821        rerank_with(candidates, query, false, None)
4822    }
4823
4824    fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
4825        ranked(candidates, query)
4826            .into_iter()
4827            .map(|(r, _)| r.symbol.name)
4828            .collect()
4829    }
4830
4831    fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
4832        let mut candidate = function(name);
4833        candidate.result.symbol.signature = signature.map(str::to_string);
4834        candidate.result.symbol.doc_comment = Some(doc.into());
4835        candidate
4836    }
4837
4838    fn strip_ansi_case() -> Vec<Candidate> {
4839        vec![
4840            documented("strip_ansi", None, "Remove ANSI escape sequences"),
4841            documented(
4842                "_strip_code_fences",
4843                Some("def _strip_code_fences(text: str)"),
4844                "The first fenced code block's body, or the stripped text",
4845            ),
4846        ]
4847    }
4848
4849    #[test]
4850    fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4851        assert_eq!(
4852            rerank_words("parse the sha256 sidecar file"),
4853            vec!["parse", "sha", "256", "sidecar", "file"]
4854        );
4855        assert_eq!(
4856            rerank_words("parse_sha256_sidecar"),
4857            vec!["parse", "sha", "256", "sidecar"]
4858        );
4859        assert_eq!(
4860            rerank_words("ParseHTTPResponse"),
4861            vec!["parse", "http", "response"]
4862        );
4863        assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4864        assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4865    }
4866
4867    #[test]
4868    fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4869        for word in ["was", "whether", "another"] {
4870            assert!(is_stop_word(word), "{word} must be a stop word");
4871        }
4872        for word in ["down", "into", "run"] {
4873            assert!(!is_stop_word(word), "{word} must stay a content word");
4874        }
4875        assert_eq!(rerank_words("what was the file"), vec!["file"]);
4876    }
4877
4878    #[test]
4879    fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4880        let mut private = function("_create_skill");
4881        private.bm25 = Some(-9.0);
4882        let mut public = function("create_skill");
4883        public.bm25 = Some(-1.0);
4884
4885        let rows = ranked(vec![private, public], "create skill");
4886
4887        assert_eq!(rows[0].0.score, rows[1].0.score);
4888        assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4889        assert_eq!(
4890            rows.iter()
4891                .map(|(r, _)| r.symbol.name.as_str())
4892                .collect::<Vec<_>>(),
4893            vec!["create_skill", "_create_skill"]
4894        );
4895    }
4896
4897    #[test]
4898    fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4899        let rows = ranked(
4900            vec![
4901                plain_candidate("Glob", "constant", "src/glob.rs"),
4902                function("matches_glob_pattern"),
4903            ],
4904            "glob",
4905        );
4906
4907        assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4908        assert_eq!(rows[1].1.name_tier, "whole");
4909        assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4910    }
4911
4912    #[test]
4913    fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4914        let rows = ranked(
4915            vec![
4916                function("validate_everything"),
4917                function("validate_syntax_now"),
4918                function("validate_syntax"),
4919                function("unrelated"),
4920            ],
4921            "validate syntax",
4922        );
4923        let tiers: Vec<(&str, &str, f64)> = rows
4924            .iter()
4925            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4926            .collect();
4927
4928        assert_eq!(
4929            tiers,
4930            vec![
4931                ("validate_syntax", "whole", W_NAME_WHOLE),
4932                ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4933                ("validate_everything", "partial", 0.0),
4934                ("unrelated", "none", 0.0),
4935            ]
4936        );
4937        assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4938        assert_eq!(
4939            rows[1].0.score,
4940            W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4941        );
4942        assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4943        assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4944    }
4945
4946    #[test]
4947    fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4948        assert_eq!(
4949            ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4950            vec!["strip_ansi", "_strip_code_fences"]
4951        );
4952    }
4953
4954    #[test]
4955    fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4956        let candidates = vec![
4957            function("execute_julie_extract"),
4958            documented("slice_bytes", None, "cut a byte range"),
4959        ];
4960
4961        let rows = ranked(candidates, "cut");
4962        let bonuses: Vec<(&str, &str, f64)> = rows
4963            .iter()
4964            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4965            .collect();
4966
4967        assert_eq!(
4968            bonuses,
4969            vec![
4970                ("execute_julie_extract", "partial", 0.0),
4971                ("slice_bytes", "none", 0.0),
4972            ]
4973        );
4974        assert_eq!(rows[0].0.score, rows[1].0.score);
4975    }
4976
4977    #[test]
4978    fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4979        let candidates = vec![
4980            function("validate_everything"),
4981            function("validate_syntax_now"),
4982            function("validate_syntax"),
4983            function("unrelated"),
4984        ];
4985
4986        assert_eq!(
4987            ranked_names(candidates, "validate syntax"),
4988            vec![
4989                "validate_syntax",
4990                "validate_syntax_now",
4991                "validate_everything",
4992                "unrelated"
4993            ]
4994        );
4995    }
4996
4997    #[test]
4998    fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4999        let mut rows: Vec<String> = (0..10)
5000            .map(|i| {
5001                code_row(
5002                    &format!("s{i}"),
5003                    &format!("src/f{i}.rs"),
5004                    "rust",
5005                    &format!("search_{i}"),
5006                    "searches the index",
5007                )
5008            })
5009            .collect();
5010        rows.push(code_row(
5011            "rare",
5012            "src/rare.rs",
5013            "rust",
5014            "sanitize_input",
5015            "sanitize the input",
5016        ));
5017        let conn = search_fixture(&rows.join(", "));
5018        let terms = vec!["sanitize".to_string(), "search".to_string()];
5019
5020        let weights = idf_weights(&conn, &terms);
5021        assert!(weights[0] > weights[1]);
5022    }
5023
5024    #[test]
5025    fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
5026        let conn = search_fixture(&code_row(
5027            "n",
5028            "src/news.rs",
5029            "rust",
5030            "fetch_news",
5031            "fetch the news feed",
5032        ));
5033        let terms = vec!["news".to_string(), "unseen".to_string()];
5034
5035        let weights = idf_weights(&conn, &terms);
5036
5037        assert!(weights[0] < weights[1]);
5038    }
5039
5040    #[test]
5041    fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
5042        let crediting_field = |padding: usize| {
5043            let mut row = function("handler");
5044            row.result.symbol.signature = Some(format!(
5045                "fn handler({}sidecar: u8)",
5046                "a: u8, ".repeat(padding)
5047            ));
5048            ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
5049        };
5050
5051        assert_eq!(crediting_field(4), "signature");
5052        assert_eq!(crediting_field(80), "none");
5053    }
5054
5055    #[test]
5056    fn explain_terms_name_the_crediting_field_of_every_query_term() {
5057        let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
5058        let terms: Vec<(&str, &str, f64)> = rows[0]
5059            .1
5060            .terms
5061            .iter()
5062            .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
5063            .collect();
5064
5065        assert_eq!(rows[0].0.symbol.name, "strip_ansi");
5066        assert_eq!(
5067            terms,
5068            vec![
5069                ("strip", "name", 3.0),
5070                ("ansi", "name", 3.0),
5071                ("escape", "doc", TEXT_CREDIT),
5072                ("codes", "none", 0.0),
5073            ]
5074        );
5075    }
5076
5077    #[test]
5078    fn name_coverage_accepts_token_runs_substrings_and_stems() {
5079        let strengths = |name: &str, query: &str| {
5080            let stemmer = Stemmer::create(Algorithm::English);
5081            let words: Vec<QueryWord> = rerank_words(query)
5082                .into_iter()
5083                .map(|word| QueryWord {
5084                    stem: stemmer.stem(&word).into_owned(),
5085                    word,
5086                })
5087                .collect();
5088            name_hits(name, &words, &stemmer)
5089        };
5090
5091        assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
5092        assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
5093        assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
5094        assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
5095        assert_eq!(strengths("is_ok", "ok"), vec![3]);
5096        assert_eq!(strengths("isReady", "is"), vec![3]);
5097        assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
5098        assert_eq!(
5099            strengths("parseSha256Sidecar", "sidecar checksum"),
5100            vec![3, 0]
5101        );
5102        assert_eq!(
5103            strengths("parseSha256Sidecar", "checksum digest"),
5104            vec![0, 0]
5105        );
5106    }
5107
5108    #[test]
5109    fn a_rarer_term_moves_the_score_more_than_a_common_one() {
5110        let weights = [4.0, 1.0];
5111        let rows = rerank_with(
5112            vec![function("rare_helper"), function("common_helper")],
5113            "rare common",
5114            false,
5115            Some(&weights),
5116        );
5117
5118        assert_eq!(
5119            rows[0].1.word_weights,
5120            vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
5121        );
5122        assert_eq!(rows[0].0.symbol.name, "rare_helper");
5123        assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
5124        assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
5125    }
5126
5127    #[test]
5128    fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
5129        let rows = ranked(
5130            vec![
5131                function("render_mode"),
5132                plain_candidate("retry_count", "constant", "src/scan.rs"),
5133            ],
5134            "how many times a failed download is tried again retry limit",
5135        );
5136
5137        assert_eq!(rows[0].0.symbol.name, "retry_count");
5138        assert_eq!(rows[0].1.name_tier, "partial");
5139        assert!(rows[0].0.score > rows[1].0.score);
5140        assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
5141    }
5142
5143    #[test]
5144    fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
5145        let mut row = function("load");
5146        row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
5147        row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
5148        let (result, explain) = ranked(vec![row], "config settings").remove(0);
5149
5150        assert_eq!(
5151            explain.terms,
5152            vec![
5153                ("config".into(), "signature".into(), TEXT_CREDIT),
5154                ("settings".into(), "none".into(), 0.0),
5155            ]
5156        );
5157        assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
5158    }
5159
5160    #[test]
5161    fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
5162        let crediting_field =
5163            |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
5164        let doc_field = |doc: &str, query: &str| {
5165            let mut row = function("row");
5166            row.result.symbol.doc_comment = Some(doc.into());
5167            crediting_field(row, query)
5168        };
5169        let sig_field = |signature: &str, query: &str| {
5170            let mut row = function("row");
5171            row.result.symbol.signature = Some(signature.into());
5172            crediting_field(row, query)
5173        };
5174
5175        assert_eq!(doc_field("The system runs.", "stemming"), "none");
5176        assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
5177        assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
5178        assert_eq!(doc_field("An important port.", "porter"), "none");
5179        assert_eq!(
5180            sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
5181            "signature"
5182        );
5183        assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
5184        assert_eq!(sig_field("fn okay()", "ok"), "none");
5185        assert_eq!(
5186            sig_field("fn parseSha256Sidecar(text)", "sidecar"),
5187            "signature"
5188        );
5189    }
5190
5191    #[test]
5192    fn text_tokens_split_like_query_words_then_identifiers() {
5193        fn two_pass(text: &str) -> Vec<&str> {
5194            query_words(text)
5195                .into_iter()
5196                .flat_map(split_identifier)
5197                .collect()
5198        }
5199        fn one_pass(text: &str) -> Vec<&str> {
5200            let mut out = Vec::new();
5201            text_tokens_into(text, &mut out);
5202            out
5203        }
5204        let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
5205        let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
5206
5207        assert_eq!(one_pass(ascii), two_pass(ascii));
5208        assert_eq!(
5209            one_pass(ascii),
5210            vec![
5211                "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
5212                "sha", "256", "sum",
5213            ]
5214        );
5215        assert_eq!(one_pass(unicode), two_pass(unicode));
5216        assert!(one_pass("").is_empty());
5217        assert!(one_pass("_ __ ...").is_empty());
5218    }
5219
5220    #[test]
5221    fn a_doc_credits_a_term_by_its_stem() {
5222        let mut row = function("check");
5223        row.result.symbol.doc_comment = Some("Validates the input.".into());
5224        let explain = ranked(vec![row], "validation").remove(0).1;
5225
5226        assert_eq!(
5227            explain.terms,
5228            vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
5229        );
5230    }
5231
5232    #[test]
5233    fn kind_prior_orders_definitions_over_members_over_imports() {
5234        let rows = ranked(
5235            vec![
5236                plain_candidate("Scan", "import", "src/a.rs"),
5237                plain_candidate("Scan", "enum_member", "src/b.rs"),
5238                plain_candidate("Scan", "function", "src/c.rs"),
5239            ],
5240            "scan",
5241        );
5242        let order: Vec<(&str, f64)> = rows
5243            .iter()
5244            .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
5245            .collect();
5246
5247        assert_eq!(
5248            order,
5249            vec![
5250                ("src/c.rs", W_KIND_DEFINITION),
5251                ("src/b.rs", W_KIND_MEMBER),
5252                ("src/a.rs", W_KIND_IMPORT),
5253            ]
5254        );
5255    }
5256
5257    #[test]
5258    fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
5259        let names = ranked_names(
5260            vec![
5261                function("RenderMode"),
5262                plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
5263            ],
5264            "retry download limit timeout",
5265        );
5266
5267        assert_eq!(names[0], "MaxRetryCount");
5268    }
5269
5270    #[test]
5271    fn path_role_demotes_role_directories_unless_the_query_names_them() {
5272        let rows = |query: &str| {
5273            ranked(
5274                vec![
5275                    plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
5276                    plain_candidate("verify_checksum", "function", "src/archive.rs"),
5277                ],
5278                query,
5279            )
5280        };
5281
5282        let plain = rows("verify checksum");
5283        assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
5284        assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
5285
5286        let named = rows("launcher script verify checksum");
5287        assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
5288
5289        let only_launcher = rows("launcher verify checksum");
5290        assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
5291        assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
5292
5293        let windows = ranked(
5294            vec![plain_candidate(
5295                "verifyChecksum",
5296                "function",
5297                "scripts\\launcher.ts",
5298            )],
5299            "verify checksum",
5300        );
5301        assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
5302    }
5303
5304    #[test]
5305    fn documentation_rows_sort_after_every_code_row() {
5306        let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
5307        heading.documentation = true;
5308        heading.result.symbol.language = "markdown".into();
5309        heading.result.symbol.signature = Some("Verify checksum".into());
5310        heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
5311        let rows = ranked(
5312            vec![
5313                heading,
5314                plain_candidate("unrelated", "variable", "src/a.rs"),
5315            ],
5316            "verify checksum",
5317        );
5318
5319        assert_eq!(rows[0].0.symbol.name, "unrelated");
5320        assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
5321        assert_eq!(rows[1].1.name_tier, "whole");
5322        assert!(rows[1].0.score < 0.0);
5323    }
5324
5325    #[test]
5326    fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
5327        let rows = |query: &str, include_tests: bool| {
5328            let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
5329            test_row.result.symbol.is_test = true;
5330            let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
5331            rerank_with(vec![plain_row, test_row], query, include_tests, None)
5332        };
5333
5334        let boosted = rows("payment flow tests", true);
5335        assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
5336        assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
5337        assert_eq!(boosted[1].1.test_intent, 0.0);
5338
5339        assert!(
5340            rows("payment flow tests", false)
5341                .iter()
5342                .all(|(_, e)| e.test_intent == 0.0)
5343        );
5344        assert!(
5345            rows("payment flow", true)
5346                .iter()
5347                .all(|(_, e)| e.test_intent == 0.0)
5348        );
5349    }
5350
5351    #[test]
5352    fn ties_break_by_bm25_then_name_length_then_path() {
5353        let mut word_row = plain_candidate("payment", "function", "src/z.rs");
5354        word_row.word_match = true;
5355        word_row.bm25 = Some(-4.0);
5356        let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
5357        weaker_word_row.word_match = true;
5358        weaker_word_row.bm25 = Some(-2.0);
5359        let mut name_only = plain_candidate("payment", "function", "src/b.rs");
5360        name_only.name_match = true;
5361        let rows = ranked(
5362            vec![
5363                plain_candidate("payment", "function", "src/y.rs"),
5364                name_only,
5365                weaker_word_row,
5366                word_row,
5367            ],
5368            "payment",
5369        );
5370        let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
5371
5372        assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
5373
5374        let by_length = ranked_names(
5375            vec![
5376                function("payment_gateway_client"),
5377                function("payment_gateway"),
5378            ],
5379            "gateway",
5380        );
5381        assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
5382    }
5383
5384    #[test]
5385    fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
5386        let mut token = function("csr");
5387        token.bm25 = Some(-1.0);
5388        let mut substring = function("action_csrf_token");
5389        substring.bm25 = Some(-5.0);
5390
5391        let rows = ranked(vec![substring, token], "csr adjacency");
5392
5393        assert!(rows[0].0.score > rows[1].0.score);
5394        assert_eq!(rows[0].0.symbol.name, "csr");
5395    }
5396
5397    #[test]
5398    fn an_acronym_token_outranks_a_name_that_only_contains_it() {
5399        let mut token = function("http_client");
5400        token.bm25 = Some(-1.0);
5401        let mut substring = function("shttpd_config");
5402        substring.bm25 = Some(-5.0);
5403
5404        let rows = ranked(vec![substring, token], "http");
5405
5406        assert!(rows[0].0.score > rows[1].0.score);
5407        assert_eq!(rows[0].0.symbol.name, "http_client");
5408    }
5409
5410    #[test]
5411    fn explain_reports_the_sum_of_the_name_strengths() {
5412        let rows = ranked(vec![function("action_csrf_token")], "csr token");
5413
5414        assert_eq!(rows[0].1.name_strength, 4);
5415    }
5416
5417    #[test]
5418    fn snippets_follow_the_admitting_branch() {
5419        let mut word_row = function("parse_sidecar_file");
5420        word_row.word_match = true;
5421        word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
5422        let mut name_row = function("parseSha256Sidecar");
5423        name_row.name_match = true;
5424        name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
5425        let mut exact_row = function("sha256");
5426        exact_row.exact_name = true;
5427        let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
5428        let snippets: Vec<(&str, &str)> = rows
5429            .iter()
5430            .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
5431            .collect();
5432
5433        assert_eq!(
5434            snippets,
5435            vec![
5436                ("sha256", "sha256"),
5437                ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
5438                ("parse_sidecar_file", "parse the [sha256] sidecar file"),
5439            ]
5440        );
5441        assert_eq!(rows[1].1.branches, vec!["name"]);
5442        assert_eq!(rows[0].1.branches, vec!["exact"]);
5443    }
5444
5445    #[test]
5446    fn explain_is_attached_only_when_requested() {
5447        let conn = sidecar_fixture();
5448        let query = "sha256";
5449
5450        let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
5451        assert!(silent.iter().all(|r| r.explain.is_none()));
5452        assert!(silent[0].score > 0.0);
5453        assert_eq!(
5454            serde_json::to_value(&silent[0]).unwrap().get("explain"),
5455            None
5456        );
5457
5458        let explained =
5459            fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
5460        let by_name = |name: &str| {
5461            explained
5462                .iter()
5463                .find(|r| r.symbol.name == name)
5464                .and_then(|r| r.explain.as_ref())
5465                .unwrap()
5466        };
5467        let name_only = by_name("parseSha256Sidecar");
5468        assert_eq!(name_only.candidates, 2);
5469        assert_eq!(name_only.branches, vec!["name"]);
5470        assert_eq!(name_only.bm25, None);
5471        let word_row = by_name("parse_sidecar_file");
5472        assert!(word_row.bm25.unwrap() < 0.0);
5473        assert_eq!(word_row.candidates, 2);
5474        assert!(
5475            serde_json::to_value(&explained[0])
5476                .unwrap()
5477                .get("explain")
5478                .is_some()
5479        );
5480    }
5481
5482    #[test]
5483    fn search_symbols_treats_like_wildcards_as_literals() {
5484        let dir = crate::safe_tempdir();
5485        let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
5486        let conn = open_read_write(&db_path).unwrap();
5487        conn.execute_batch(
5488            "CREATE TABLE symbols (
5489                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5490                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5491                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5492                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5493                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5494                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5495                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5496            );
5497            INSERT INTO symbols VALUES (
5498                's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
5499                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5500            );
5501            INSERT INTO symbols VALUES (
5502                'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
5503                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5504            );
5505            INSERT INTO symbols VALUES (
5506                'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
5507                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5508            );
5509            CREATE TABLE files (
5510                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
5511                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
5512            );
5513            INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
5514            INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
5515            );",
5516        )
5517        .unwrap();
5518
5519        assert_eq!(
5520            search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
5521            "literal%name"
5522        );
5523        assert_eq!(
5524            search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
5525            "literal_name"
5526        );
5527        assert_eq!(
5528            load_scoped_files(&conn, Some("src/literal_path"))
5529                .unwrap()
5530                .len(),
5531            1
5532        );
5533    }
5534
5535    #[test]
5536    fn find_references_for_symbol_limits_callees_by_symbol_id() {
5537        let dir = crate::safe_tempdir();
5538        let db_path = dir.path().join("find_references_for_symbol.db");
5539        let conn = open_read_write(&db_path).unwrap();
5540        conn.execute_batch(
5541            "CREATE TABLE symbols (
5542                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5543                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5544                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5545                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5546                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5547                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5548                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5549            );
5550            CREATE TABLE relationships (
5551                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5552                start_line INTEGER, start_column INTEGER
5553            );
5554            CREATE TABLE pending_relationships (
5555                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5556                start_line INTEGER, start_column INTEGER
5557            );
5558            INSERT INTO symbols VALUES
5559                ('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),
5560                ('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),
5561                ('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),
5562                ('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);
5563            INSERT INTO relationships VALUES
5564                ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
5565                ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
5566        )
5567        .unwrap();
5568
5569        let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
5570        assert_eq!(references.len(), 1);
5571        assert_eq!(references[0].to_symbol_name, "wanted_dep");
5572    }
5573
5574    #[test]
5575    fn test_fts_search_symbols_and_porter_stemming() {
5576        let dir = crate::safe_tempdir();
5577        let db_path = dir.path().join("fts_search_symbols.db");
5578        let conn = open_read_write(&db_path).unwrap();
5579
5580        conn.execute_batch(
5581            "CREATE TABLE symbols (
5582                symbol_id TEXT PRIMARY KEY,
5583                file_id TEXT,
5584                path TEXT,
5585                language TEXT,
5586                name TEXT,
5587                kind TEXT,
5588                signature TEXT,
5589                doc_comment TEXT,
5590                visibility TEXT,
5591                parent_symbol_id TEXT,
5592                start_line INTEGER,
5593                start_column INTEGER,
5594                end_line INTEGER,
5595                end_column INTEGER,
5596                start_byte INTEGER,
5597                end_byte INTEGER,
5598                body_start_line INTEGER,
5599                body_start_column INTEGER,
5600                body_end_line INTEGER,
5601                body_end_column INTEGER,
5602                body_start_byte INTEGER,
5603                body_end_byte INTEGER,
5604                body_hash TEXT,
5605                semantic_group TEXT,
5606                is_test INTEGER,
5607                test_container INTEGER
5608            );
5609            INSERT INTO symbols VALUES (
5610                's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
5611                'pub trait PaymentGateway', 'Core payment provider interface for transactions',
5612                'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
5613            );
5614            INSERT INTO symbols VALUES (
5615                's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
5616                'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
5617                'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
5618            );
5619            INSERT INTO symbols VALUES (
5620                's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
5621                'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
5622                'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
5623            );
5624            INSERT INTO symbols VALUES (
5625                's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
5626                'fn test_payment_flow()', 'Tests payment charge workflow',
5627                NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
5628            );",
5629        )
5630        .unwrap();
5631
5632        ensure_fts_index(&conn).unwrap();
5633
5634        // 1. Porter stemming match: 'parsing' matches 'parse_tokens' and 'Parses' docstring
5635        let results =
5636            fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
5637        assert_eq!(results.len(), 1);
5638        assert_eq!(results[0].symbol.name, "parse_tokens");
5639        assert!(results[0].snippet.is_some());
5640
5641        // 2. Docstring conceptual search: 'transactions' matches 'PaymentGateway'
5642        let results =
5643            fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
5644        assert_eq!(results.len(), 1);
5645        assert_eq!(results[0].symbol.name, "PaymentGateway");
5646
5647        // 3. Test filter: searching 'payment' with include_tests=false ignores 'test_payment_flow'
5648        let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
5649        assert_eq!(results.len(), 2);
5650        assert!(results.iter().all(|r| !r.symbol.is_test));
5651
5652        // 4. Test filter: searching 'payment' with include_tests=true includes 'test_payment_flow'
5653        let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
5654        assert_eq!(results.len(), 3);
5655
5656        // 5. Fallback OR matching: multi-term where only some match
5657        let results =
5658            fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
5659        assert_eq!(results.len(), 1);
5660        assert_eq!(results[0].symbol.name, "StripeClient");
5661    }
5662
5663    #[test]
5664    fn find_related_tests_returns_each_test_once_under_the_limit() {
5665        let dir = crate::safe_tempdir();
5666        let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
5667        conn.execute_batch(
5668            "CREATE TABLE symbols (
5669                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5670                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5671                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5672                start_column INTEGER, end_line INTEGER, end_column INTEGER,
5673                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5674                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5675                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5676                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5677            );
5678            CREATE TABLE relationships (
5679                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5680                start_line INTEGER, start_column INTEGER
5681            );
5682            CREATE TABLE pending_relationships (
5683                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5684                start_line INTEGER, start_column INTEGER,
5685                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5686            );
5687            CREATE TABLE type_facts (
5688                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5689            );
5690            INSERT INTO symbols VALUES
5691                ('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),
5692                ('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),
5693                ('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);
5694            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
5695                ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
5696                ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
5697                ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
5698                ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
5699                ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
5700                ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
5701        )
5702        .unwrap();
5703        let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
5704
5705        let tests = find_related_tests(&conn, &target, 5).unwrap();
5706
5707        let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
5708        names.sort();
5709        assert_eq!(names, vec!["first_case", "second_case"]);
5710    }
5711
5712    #[test]
5713    fn documentation_rows_rank_after_code_in_search() {
5714        let dir = crate::safe_tempdir();
5715        let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
5716        conn.execute_batch(
5717            "CREATE TABLE symbols (
5718                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5719                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5720                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5721                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5722                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5723                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5724                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5725            );
5726            INSERT INTO symbols VALUES
5727                ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
5728                 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
5729                 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
5730                ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
5731                 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
5732                 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
5733        )
5734        .unwrap();
5735        ensure_fts_index(&conn).unwrap();
5736
5737        let results =
5738            fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
5739                .unwrap();
5740
5741        assert_eq!(results.len(), 2);
5742        assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
5743        assert_eq!(results[1].symbol.name, "Reconcile offline edits");
5744    }
5745
5746    #[test]
5747    fn test_queries_nocase_and_path_normalization() {
5748        let conn = Connection::open_in_memory().unwrap();
5749        conn.execute_batch(
5750            "CREATE TABLE files (
5751                file_id TEXT PRIMARY KEY,
5752                path TEXT NOT NULL,
5753                language TEXT,
5754                content_hash TEXT,
5755                content_bytes INTEGER,
5756                line_count INTEGER,
5757                indexed_at INTEGER
5758            );
5759            CREATE TABLE symbols (
5760                symbol_id TEXT PRIMARY KEY,
5761                file_id TEXT,
5762                path TEXT NOT NULL,
5763                language TEXT,
5764                name TEXT,
5765                kind TEXT,
5766                signature TEXT,
5767                doc_comment TEXT,
5768                visibility TEXT,
5769                parent_symbol_id TEXT,
5770                start_line INTEGER,
5771                start_column INTEGER,
5772                end_line INTEGER,
5773                end_column INTEGER,
5774                start_byte INTEGER,
5775                end_byte INTEGER,
5776                body_start_line INTEGER,
5777                body_start_column INTEGER,
5778                body_end_line INTEGER,
5779                body_end_column INTEGER,
5780                body_start_byte INTEGER,
5781                body_end_byte INTEGER,
5782                body_hash TEXT,
5783                semantic_group TEXT,
5784                is_test INTEGER,
5785                test_container INTEGER
5786            );
5787            -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
5788            INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
5789            INSERT INTO symbols VALUES (
5790                's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
5791                'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5792                2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
5793            );",
5794        )
5795        .unwrap();
5796
5797        // 1. get_file: query with uppercase, lowercase, and forward slashes
5798        let file = get_file(&conn, "SRC/PAYMENT.RS")
5799            .unwrap()
5800            .expect("File should be found");
5801        assert_eq!(
5802            file.path, "src/Payment.rs",
5803            "Path should be normalized to forward slashes"
5804        );
5805
5806        let file2 = get_file(&conn, "src/payment.rs")
5807            .unwrap()
5808            .expect("File should be found");
5809        assert_eq!(file2.path, "src/Payment.rs");
5810
5811        // 2. load_file_symbols: query with uppercase and forward slashes
5812        let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
5813        assert_eq!(syms.len(), 1);
5814        assert_eq!(
5815            syms[0].path, "src/Payment.rs",
5816            "Symbol path should be normalized to forward slashes"
5817        );
5818
5819        // 3. get_symbol_by_name with path filter
5820        let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
5821            .unwrap()
5822            .expect("Symbol should be found with case-insensitive path filter");
5823        assert_eq!(sym.path, "src/Payment.rs");
5824    }
5825
5826    #[test]
5827    fn test_exact_case_prioritized_over_nocase() {
5828        let conn = Connection::open_in_memory().unwrap();
5829        conn.execute_batch(
5830            "CREATE TABLE files (
5831                file_id TEXT PRIMARY KEY,
5832                path TEXT NOT NULL,
5833                language TEXT,
5834                content_hash TEXT,
5835                content_bytes INTEGER,
5836                line_count INTEGER,
5837                indexed_at TEXT
5838            );
5839            CREATE TABLE symbols (
5840                symbol_id TEXT PRIMARY KEY,
5841                file_id TEXT,
5842                path TEXT NOT NULL,
5843                language TEXT,
5844                name TEXT NOT NULL,
5845                kind TEXT NOT NULL,
5846                signature TEXT,
5847                doc_comment TEXT,
5848                visibility TEXT,
5849                parent_symbol_id TEXT,
5850                start_line INTEGER,
5851                start_column INTEGER,
5852                end_line INTEGER,
5853                end_column INTEGER,
5854                start_byte INTEGER,
5855                end_byte INTEGER,
5856                body_start_line INTEGER,
5857                body_start_column INTEGER,
5858                body_end_line INTEGER,
5859                body_end_column INTEGER,
5860                body_start_byte INTEGER,
5861                body_end_byte INTEGER,
5862                body_hash TEXT,
5863                semantic_group TEXT,
5864                is_test INTEGER,
5865                test_container INTEGER
5866            );
5867            INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5868            INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5869            INSERT INTO symbols VALUES (
5870                's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5871                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5872                2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5873            );
5874            INSERT INTO symbols VALUES (
5875                's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5876                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5877                2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5878            );",
5879        )
5880        .unwrap();
5881
5882        // Exact match should return exact file, not conflate with sibling differing only by case
5883        let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5884        assert_eq!(f_lower.path, "src/payment.rs");
5885        assert_eq!(f_lower.file_id, "f2");
5886
5887        let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5888        assert_eq!(f_upper.path, "src/Payment.rs");
5889        assert_eq!(f_upper.file_id, "f1");
5890
5891        let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5892        assert_eq!(syms_lower.len(), 1);
5893        assert_eq!(syms_lower[0].file_id, "f2");
5894
5895        let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5896        assert_eq!(syms_upper.len(), 1);
5897        assert_eq!(syms_upper[0].file_id, "f1");
5898    }
5899
5900    #[test]
5901    fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5902        let dir = crate::safe_tempdir();
5903        let db_path = dir.path().join("conservative_resolution.db");
5904        let conn = open_read_write(&db_path).unwrap();
5905
5906        conn.execute_batch(
5907            "CREATE TABLE symbols (
5908                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5909                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5910                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5911                start_column INTEGER, end_line INTEGER, end_column INTEGER,
5912                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5913                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5914                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5915                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5916            );
5917            CREATE TABLE relationships (
5918                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5919                start_line INTEGER, start_column INTEGER
5920            );
5921            CREATE TABLE pending_relationships (
5922                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5923                start_line INTEGER, start_column INTEGER,
5924                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5925            );
5926            CREATE TABLE type_facts (
5927                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5928            );
5929            -- Workspace struct Workspace and method Workspace::new
5930            INSERT INTO symbols VALUES
5931                ('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),
5932                ('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),
5933                ('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);
5934
5935            -- my_func calls Vec::new() (external namespace 'Vec')
5936            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
5937                ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5938        )
5939        .unwrap();
5940
5941        // When include_external is false, calling Vec::new() should NOT resolve to Workspace::new()
5942        let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5943        assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5944
5945        let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5946        assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5947
5948        // Caller references for Workspace::new should NOT list my_func
5949        let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5950        assert!(
5951            callers.is_empty(),
5952            "Expected 0 callers for Workspace::new, got: {:?}",
5953            callers
5954        );
5955
5956        // Blast radius for Workspace::new should NOT impact my_func (which only called Vec::new)
5957        let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5958        assert!(
5959            !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5960            "my_func should not be impacted before calling Workspace::new: {:?}",
5961            blast.impacted_symbols
5962        );
5963
5964        // Now add a call to Workspace::new()
5965        conn.execute(
5966            "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')",
5967            [],
5968        )
5969        .unwrap();
5970
5971        let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5972        assert_eq!(
5973            sigs2.len(),
5974            1,
5975            "Expected 1 signature for Workspace::new, got: {:?}",
5976            sigs2
5977        );
5978        assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5979
5980        // Blast radius for Workspace::new should now include my_func
5981        let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5982        assert!(
5983            blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5984            "my_func should be impacted after calling Workspace::new: {:?}",
5985            blast2.impacted_symbols
5986        );
5987
5988        // Add a bare call to new() from an unrelated caller s_other
5989        conn.execute(
5990            "INSERT INTO symbols VALUES
5991                ('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);",
5992            [],
5993        )
5994        .unwrap();
5995        conn.execute(
5996            "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')",
5997            [],
5998        )
5999        .unwrap();
6000
6001        // Bare call from unrelated function should NOT resolve to Workspace::new
6002        let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
6003        assert!(
6004            sigs_other.is_empty(),
6005            "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
6006            sigs_other
6007        );
6008
6009        // A sibling method inside Workspace calling bare new() SHOULD resolve to Workspace::new
6010        conn.execute(
6011            "INSERT INTO symbols VALUES
6012                ('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);",
6013            [],
6014        )
6015        .unwrap();
6016        conn.execute(
6017            "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')",
6018            [],
6019        )
6020        .unwrap();
6021
6022        let sigs_sibling =
6023            find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
6024        assert_eq!(
6025            sigs_sibling.len(),
6026            1,
6027            "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
6028            sigs_sibling
6029        );
6030
6031        // With include_external: true, external calls should be returned
6032        let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
6033        assert!(
6034            ext_sigs.iter().any(|s| s.contains("Vec")),
6035            "include_external: true should include external Vec::new: {:?}",
6036            ext_sigs
6037        );
6038    }
6039
6040    #[test]
6041    fn test_find_structural_facts_and_literals_scoped() {
6042        let dir = crate::safe_tempdir();
6043        let db_path = dir.path().join("facts_test.db");
6044        let conn = open_read_write(&db_path).unwrap();
6045        conn.execute_batch(
6046            "CREATE TABLE symbols (
6047                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
6048                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
6049                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
6050                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
6051                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
6052                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
6053                semantic_group TEXT, is_test INTEGER, test_container INTEGER
6054            );
6055            CREATE TABLE structural_facts (
6056                structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
6057                pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
6058                start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
6059            );
6060            CREATE TABLE literals (
6061                literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
6062                kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
6063                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
6064                start_byte INTEGER, end_byte INTEGER
6065            );
6066            INSERT INTO structural_facts VALUES
6067                ('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\"}'),
6068                ('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\"}'),
6069                ('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\"}'),
6070                ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
6071                ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
6072                ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
6073                ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
6074            INSERT INTO literals VALUES
6075                ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
6076                ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
6077                ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
6078                ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
6079        )
6080        .unwrap();
6081
6082        // 1. "config" alias
6083        let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
6084        assert_eq!(facts_config.len(), 2);
6085        assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
6086        assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
6087        assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
6088        assert_eq!(
6089            facts_config[1].key.as_deref(),
6090            Some("mcp_servers.code-kb.command")
6091        );
6092        let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
6093        assert_eq!(lits_config.len(), 1);
6094        assert_eq!(lits_config[0].kind, "toml_key");
6095
6096        // 2. "route" and "routes" aliases
6097        let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
6098        assert_eq!(facts_route.len(), 1);
6099        assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
6100        assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
6101        let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
6102        assert_eq!(facts_routes.len(), 1);
6103        let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
6104        assert_eq!(lits_route.len(), 1);
6105        assert_eq!(lits_route[0].kind, "http_route");
6106
6107        // 3. "query", "queries", "sql" aliases
6108        for q in &["query", "queries", "sql"] {
6109            let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
6110            assert_eq!(facts.len(), 2, "Failed for {}", q);
6111            assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
6112            let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
6113            assert_eq!(lits.len(), 1, "Failed for {}", q);
6114            assert_eq!(lits[0].kind, "sql_query");
6115        }
6116
6117        // 4. "model" and "models" aliases
6118        for m in &["model", "models"] {
6119            let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
6120            assert_eq!(facts.len(), 1, "Failed for {}", m);
6121            assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
6122            let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
6123            assert_eq!(lits.len(), 1, "Failed for {}", m);
6124            assert_eq!(lits[0].kind, "model_table");
6125        }
6126
6127        // 5. Custom / unknown category
6128        let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
6129        assert_eq!(facts_custom.len(), 1);
6130        assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
6131        assert_eq!(facts_custom[0].key, None);
6132
6133        // 6. Path filter: exact file match
6134        let facts_exact =
6135            find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
6136        assert_eq!(facts_exact.len(), 1);
6137        let facts_miss =
6138            find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
6139        assert_eq!(facts_miss.len(), 0);
6140
6141        // 7. Path filter: directory prefix
6142        let facts_dir =
6143            find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
6144        assert_eq!(facts_dir.len(), 1);
6145        let facts_dir_miss =
6146            find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
6147        assert_eq!(facts_dir_miss.len(), 0);
6148
6149        // 8. Delegating find_structural_facts and find_literals
6150        let f_del = find_structural_facts(&conn, "config", 10).unwrap();
6151        assert_eq!(f_del.len(), 2);
6152        let l_del = find_literals(&conn, "config", 10).unwrap();
6153        assert_eq!(l_del.len(), 1);
6154    }
6155
6156    fn local_variable_fixture() -> Connection {
6157        let conn = Connection::open_in_memory().unwrap();
6158        conn.execute_batch(
6159            "CREATE TABLE symbols (
6160                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
6161                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
6162                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
6163                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
6164                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
6165                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
6166                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
6167            );
6168            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
6169                                 parent_symbol_id, start_line, start_column, end_line, end_column,
6170                                 start_byte, end_byte, is_test, test_container)
6171            VALUES
6172                ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
6173                 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
6174                ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6175                 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
6176                ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
6177                 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
6178                ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6179                 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
6180                ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6181                 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
6182                ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
6183                 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
6184                ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6185                 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
6186        )
6187        .unwrap();
6188        conn
6189    }
6190
6191    fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
6192        let mut stmt = conn
6193            .prepare(
6194                "SELECT s.symbol_id FROM symbols_fts f
6195                 JOIN symbols s ON s.rowid = f.rowid
6196                 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
6197            )
6198            .unwrap();
6199        let mut ids = stmt
6200            .query_map(params![query], |row| row.get::<_, String>(0))
6201            .unwrap()
6202            .collect::<Result<Vec<_>, _>>()
6203            .unwrap();
6204        ids.sort();
6205        ids
6206    }
6207
6208    #[test]
6209    fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
6210        let conn = local_variable_fixture();
6211        conn.execute_batch(
6212            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
6213                name, signature, doc_comment,
6214                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
6215            );
6216            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
6217            SELECT rowid, name, signature, doc_comment FROM symbols;",
6218        )
6219        .unwrap();
6220
6221        ensure_fts_index(&conn).unwrap();
6222
6223        assert_eq!(
6224            matched_symbol_ids(&conn, "sqlite"),
6225            vec!["field", "func", "global", "pool"]
6226        );
6227    }
6228
6229    #[test]
6230    fn lookup_excludes_locals_and_parameters() {
6231        let conn = local_variable_fixture();
6232
6233        let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
6234            .unwrap()
6235            .into_iter()
6236            .map(|s| s.symbol_id)
6237            .collect();
6238
6239        assert!(!ids.contains(&"local".to_string()));
6240        assert!(!ids.contains(&"nested".to_string()));
6241        assert!(ids.contains(&"field".to_string()));
6242        assert!(ids.contains(&"global".to_string()));
6243    }
6244
6245    #[test]
6246    fn search_excludes_locals_and_parameters() {
6247        let conn = local_variable_fixture();
6248        ensure_fts_index(&conn).unwrap();
6249
6250        let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
6251            .unwrap()
6252            .into_iter()
6253            .map(|r| r.symbol.symbol_id)
6254            .collect();
6255
6256        assert!(!ids.contains(&"local".to_string()));
6257        assert!(ids.contains(&"func".to_string()));
6258    }
6259
6260    #[test]
6261    fn variable_kind_search_keeps_full_text_matching() {
6262        let conn = local_variable_fixture();
6263        ensure_fts_index(&conn).unwrap();
6264
6265        let ids: Vec<String> = fts_search_symbols_scoped(
6266            &conn,
6267            "sqlite connection",
6268            Some("variable"),
6269            None,
6270            false,
6271            10,
6272        )
6273        .unwrap()
6274        .into_iter()
6275        .map(|r| r.symbol.symbol_id)
6276        .collect();
6277
6278        assert!(ids.contains(&"global".to_string()));
6279        assert!(ids.contains(&"field".to_string()));
6280    }
6281
6282    #[test]
6283    fn qualified_lookup_returns_the_named_local_variable() {
6284        let conn = local_variable_fixture();
6285
6286        let ids: Vec<String> =
6287            search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
6288                .unwrap()
6289                .into_iter()
6290                .map(|s| s.symbol_id)
6291                .collect();
6292
6293        assert_eq!(ids, vec!["local".to_string()]);
6294    }
6295
6296    #[test]
6297    fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
6298        let conn = Connection::open_in_memory().unwrap();
6299        conn.execute_batch(
6300            "CREATE TABLE symbols (
6301                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
6302                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
6303                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
6304                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
6305                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
6306                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
6307                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
6308            );
6309            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
6310                                 parent_symbol_id, start_line, start_column, end_line, end_column,
6311                                 start_byte, end_byte, is_test, test_container)
6312            VALUES
6313                ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
6314                 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
6315                ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
6316                 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
6317                ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
6318                 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
6319        )
6320        .unwrap();
6321        ensure_fts_index(&conn).unwrap();
6322
6323        let rows =
6324            fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
6325                .unwrap();
6326
6327        assert_eq!(rows.len(), 1);
6328        assert_eq!(rows[0].symbol.symbol_id, "local");
6329        let explain = rows[0].explain.as_ref().unwrap();
6330        assert_eq!(explain.name_tier, "whole");
6331        assert_eq!(explain.branches, vec!["exact", "name"]);
6332        assert_eq!(explain.candidates, 2);
6333    }
6334
6335    #[test]
6336    fn variable_kind_filter_returns_locals_and_parameters() {
6337        let conn = local_variable_fixture();
6338        ensure_fts_index(&conn).unwrap();
6339
6340        let lookup_ids: Vec<String> =
6341            search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6342                .unwrap()
6343                .into_iter()
6344                .map(|s| s.symbol_id)
6345                .collect();
6346        assert!(lookup_ids.contains(&"local".to_string()));
6347        assert!(lookup_ids.contains(&"nested".to_string()));
6348
6349        let search_ids: Vec<String> =
6350            fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6351                .unwrap()
6352                .into_iter()
6353                .map(|r| r.symbol.symbol_id)
6354                .collect();
6355        assert!(search_ids.contains(&"local".to_string()));
6356    }
6357}