Skip to main content

code_kb_core/
queries.rs

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