Skip to main content

code_kb_core/
queries.rs

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