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."];
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                ) AS display_key
3217         FROM structural_facts sf
3218         LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
3219         WHERE (:cat IS NOT NULL AND {cat_clause})
3220           AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3221         ORDER BY sf.path ASC, sf.start_line ASC
3222         LIMIT :limit"
3223    );
3224
3225    let mut stmt = conn.prepare(&sql)?;
3226    let rows = stmt.query_map(
3227        rusqlite::named_params! {
3228            ":cat": cat_pattern,
3229            ":path": norm_path.as_deref(),
3230            ":dir_prefix": dir_prefix.as_deref(),
3231            ":limit": limit as i64,
3232        },
3233        |row| {
3234            Ok(StructuralFact {
3235                structural_fact_id: row.get(0)?,
3236                path: row.get::<_, String>(1)?.replace('\\', "/"),
3237                language: row.get(2)?,
3238                pattern_id: row.get(3)?,
3239                capture_name: row.get(4)?,
3240                node_kind: row.get(5)?,
3241                key: row.get(10)?,
3242                containing_symbol_name: row.get(6)?,
3243                start_line: row.get::<_, i64>(7)? as usize,
3244                end_line: row.get::<_, i64>(8)? as usize,
3245                confidence: row.get(9)?,
3246            })
3247        },
3248    )?;
3249
3250    let mut results = Vec::new();
3251    for r in rows {
3252        results.push(r?);
3253    }
3254    Ok(results)
3255}
3256
3257/// Find structural facts by category (e.g. route, query, model, config).
3258pub fn find_structural_facts(
3259    conn: &Connection,
3260    category: &str,
3261    limit: usize,
3262) -> Result<Vec<StructuralFact>, QueryError> {
3263    find_structural_facts_scoped(conn, category, None, limit)
3264}
3265
3266/// Find literals (endpoints, SQL queries, configs) matching category, optionally scoped by path.
3267pub fn find_literals_scoped(
3268    conn: &Connection,
3269    category: &str,
3270    path_filter: Option<&str>,
3271    limit: usize,
3272) -> Result<Vec<LiteralFact>, QueryError> {
3273    validate_result_limit(limit)?;
3274    let norm_path = path_filter
3275        .map(|p| {
3276            p.replace('\\', "/")
3277                .trim_start_matches("./")
3278                .trim_matches('/')
3279                .to_string()
3280        })
3281        .filter(|p| !p.is_empty());
3282    let dir_prefix = norm_path
3283        .as_deref()
3284        .map(|p| format!("{}/%", escape_like(p)));
3285    let cat_pattern = format!("%{}%", escape_like(category));
3286
3287    let cat_lower = category.trim().to_ascii_lowercase();
3288    let cat_clause = match cat_lower.as_str() {
3289        "config" => {
3290            "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
3291        }
3292        "route" | "routes" => "l.kind LIKE '%route%'",
3293        "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
3294        "model" | "models" => "l.kind LIKE '%model%'",
3295        _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
3296    };
3297
3298    let sql = format!(
3299        "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
3300                l.start_line, s.name AS containing_symbol_name
3301         FROM literals l
3302         LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
3303         WHERE (:cat IS NOT NULL AND {cat_clause})
3304           AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3305         ORDER BY l.path ASC, l.start_line ASC
3306         LIMIT :limit"
3307    );
3308
3309    let mut stmt = conn.prepare(&sql)?;
3310    let rows = stmt.query_map(
3311        rusqlite::named_params! {
3312            ":cat": cat_pattern,
3313            ":path": norm_path.as_deref(),
3314            ":dir_prefix": dir_prefix.as_deref(),
3315            ":limit": limit as i64,
3316        },
3317        |row| {
3318            Ok(LiteralFact {
3319                literal_id: row.get(0)?,
3320                path: row.get::<_, String>(1)?.replace('\\', "/"),
3321                literal_text: row.get(2)?,
3322                kind: row.get(3)?,
3323                carrier: row.get(4)?,
3324                start_line: row.get::<_, i64>(5)? as usize,
3325                containing_symbol_name: row.get(6)?,
3326            })
3327        },
3328    )?;
3329
3330    let mut results = Vec::new();
3331    for r in rows {
3332        results.push(r?);
3333    }
3334    Ok(results)
3335}
3336
3337/// Find literals (endpoints, SQL queries, configs) matching category.
3338pub fn find_literals(
3339    conn: &Connection,
3340    category: &str,
3341    limit: usize,
3342) -> Result<Vec<LiteralFact>, QueryError> {
3343    find_literals_scoped(conn, category, None, limit)
3344}
3345
3346/// List available structural fact and literal categories with counts, optionally scoped by path.
3347pub fn list_structural_fact_categories_scoped(
3348    conn: &Connection,
3349    path_filter: Option<&str>,
3350) -> Result<Vec<(String, usize)>, QueryError> {
3351    let norm_path = path_filter
3352        .map(|p| {
3353            p.replace('\\', "/")
3354                .trim_start_matches("./")
3355                .trim_matches('/')
3356                .to_string()
3357        })
3358        .filter(|p| !p.is_empty());
3359    let dir_prefix = norm_path
3360        .as_deref()
3361        .map(|p| format!("{}/%", escape_like(p)));
3362
3363    let mut categories = Vec::new();
3364
3365    let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
3366               WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3367               GROUP BY pattern_id ORDER BY cnt DESC";
3368    let mut stmt = conn.prepare(sql)?;
3369    let rows = stmt.query_map(
3370        rusqlite::named_params! {
3371            ":path": norm_path.as_deref(),
3372            ":dir_prefix": dir_prefix.as_deref(),
3373        },
3374        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3375    )?;
3376    for r in rows {
3377        categories.push(r?);
3378    }
3379
3380    let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
3381                   WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3382                   GROUP BY kind ORDER BY cnt DESC";
3383    let mut lit_stmt = conn.prepare(lit_sql)?;
3384    let lit_rows = lit_stmt.query_map(
3385        rusqlite::named_params! {
3386            ":path": norm_path.as_deref(),
3387            ":dir_prefix": dir_prefix.as_deref(),
3388        },
3389        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3390    )?;
3391    for r in lit_rows {
3392        categories.push(r?);
3393    }
3394
3395    Ok(categories)
3396}
3397
3398/// List all available structural fact and literal categories with counts.
3399pub fn list_structural_fact_categories(
3400    conn: &Connection,
3401) -> Result<Vec<(String, usize)>, QueryError> {
3402    list_structural_fact_categories_scoped(conn, None)
3403}
3404
3405/// Find type facts for a symbol.
3406pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
3407    let has_table: bool = conn
3408        .query_row(
3409            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
3410            [],
3411            |_| Ok(true),
3412        )
3413        .unwrap_or(false);
3414    if !has_table {
3415        return Ok(Vec::new());
3416    }
3417
3418    let mut stmt = conn.prepare(
3419        "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
3420         FROM type_facts
3421         WHERE symbol_id = ?1",
3422    )?;
3423
3424    let rows = stmt.query_map(params![symbol_id], |row| {
3425        Ok(TypeFact {
3426            type_fact_id: row.get(0)?,
3427            symbol_id: row.get(1)?,
3428            language: row.get(2)?,
3429            resolved_type: row.get(3)?,
3430            generic_params: row.get(4)?,
3431        })
3432    })?;
3433
3434    let mut results = Vec::new();
3435    for r in rows {
3436        results.push(r?);
3437    }
3438    Ok(results)
3439}
3440
3441/// True when a repository-relative path looks like a test file. Directory rules and file-name
3442/// rules are kept apart: a `test`, `tests`, `autotests`, or `__tests__` directory anywhere
3443/// including the repository root, or a file name that starts with Qt's `tst_`, starts with
3444/// `test_` in Python or Ruby, contains `_test.`,
3445/// `.test.`, or `.spec.`, is exactly `test.rs` or `tests.rs`, or ends with the C# `Tests.cs`
3446/// (case-sensitive, so `Contests.cs` is a production file).
3447pub fn is_test_path(path: &str) -> bool {
3448    let p = path.replace('\\', "/");
3449    let cut = p.rfind('/').map_or(0, |i| i + 1);
3450    let directories = format!("/{}/", p[..cut].to_lowercase());
3451    let file_name = &p[cut..];
3452    let lower_name = file_name.to_lowercase();
3453    directories.contains("/test/")
3454        || directories.contains("/tests/")
3455        || directories.contains("/autotests/")
3456        || directories.contains("/__tests__/")
3457        || lower_name.starts_with("tst_")
3458        || (lower_name.starts_with("test_")
3459            && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
3460        || lower_name.contains("_test.")
3461        || lower_name.contains(".test.")
3462        || lower_name.contains(".spec.")
3463        || lower_name == "test.rs"
3464        || lower_name == "tests.rs"
3465        || file_name.ends_with("Tests.cs")
3466}
3467
3468/// SQL boolean over `alias.path` that mirrors [`is_test_path`] rule for rule.
3469///
3470/// `test_path_rule_and_its_sql_mirror_agree_on_every_path` runs both forms over one path list so
3471/// the two cannot drift apart.
3472///
3473/// Every rule needs `test`, `spec`, or `tst_` in the path, so a cheap substring test guards the
3474/// rules and lets most rows skip the path split. Without the guard the split costs about seven
3475/// times more over a half-million rows.
3476pub(crate) fn test_path_predicate(alias: &str) -> String {
3477    let guard = format!(
3478        "(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%' OR lower({alias}.path) LIKE '%tst\\_%' ESCAPE '\\')"
3479    );
3480    let p = format!("replace({alias}.path, '\\', '/')");
3481    let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
3482    let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
3483    let lower_name = format!("lower({file_name})");
3484    let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
3485    let clauses = [
3486        like(&directories, "%/test/%"),
3487        like(&directories, "%/tests/%"),
3488        like(&directories, "%/autotests/%"),
3489        like(&directories, "%/\\_\\_tests\\_\\_/%"),
3490        like(&lower_name, "tst\\_%"),
3491        like(&lower_name, "test\\_%.py"),
3492        like(&lower_name, "test\\_%.rb"),
3493        like(&lower_name, "%\\_test.%"),
3494        like(&lower_name, "%.test.%"),
3495        like(&lower_name, "%.spec.%"),
3496        format!("{lower_name} = 'test.rs'"),
3497        format!("{lower_name} = 'tests.rs'"),
3498        format!("{file_name} GLOB '*Tests.cs'"),
3499    ]
3500    .join(" OR ");
3501    format!("({guard} AND ({clauses}))")
3502}
3503
3504/// Compute blast radius and likely tests for given seed symbols or seed file paths.
3505/// Recursively walks reverse reachability (transitive callers) up to `max_depth` in SQLite.
3506pub fn compute_blast_radius_scoped(
3507    conn: &Connection,
3508    seed_symbols: &[&str],
3509    symbol_path_filter: Option<&str>,
3510    seed_paths: &[&str],
3511    max_depth: usize,
3512    limit: usize,
3513) -> Result<BlastRadiusResult, QueryError> {
3514    validate_result_limit(limit)?;
3515    let max_depth = max_depth.min(5);
3516    let resolved_seed_symbols = seed_symbols
3517        .iter()
3518        .map(|name| {
3519            get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
3520                let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
3521                QueryError::SymbolNotFound {
3522                    name: (*name).to_string(),
3523                    workspace,
3524                    hint,
3525                }
3526            })
3527        })
3528        .collect::<Result<Vec<_>, _>>()?;
3529    let mut seeds = Vec::new();
3530    let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
3531        for s in seed_symbols {
3532            seeds.push(s.to_string());
3533        }
3534        for p in seed_paths {
3535            seeds.push(p.to_string());
3536        }
3537        "mixed".to_string()
3538    } else if !seed_symbols.is_empty() {
3539        for s in seed_symbols {
3540            seeds.push(s.to_string());
3541        }
3542        "symbol".to_string()
3543    } else if !seed_paths.is_empty() {
3544        for p in seed_paths {
3545            seeds.push(p.to_string());
3546        }
3547        "file".to_string()
3548    } else {
3549        return Ok(BlastRadiusResult {
3550            seed_type: "none".to_string(),
3551            seeds: Vec::new(),
3552            likely_tests: Vec::new(),
3553            impacted_symbols: Vec::new(),
3554            traversal_ceiling_reached: false,
3555        });
3556    };
3557
3558    let mut where_clauses = Vec::new();
3559    let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
3560
3561    if !resolved_seed_symbols.is_empty() {
3562        let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3563            .map(|i| format!("?{}", i))
3564            .collect();
3565        where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3566        for symbol in &resolved_seed_symbols {
3567            params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3568        }
3569    }
3570
3571    if !seed_paths.is_empty() {
3572        let mut path_conds = Vec::new();
3573        for p in seed_paths.iter() {
3574            let raw = p
3575                .replace('\\', "/")
3576                .trim_start_matches("./")
3577                .trim_matches('/')
3578                .to_string();
3579            let exact_idx = params_vec.len() + 1;
3580            params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3581            let dir_pattern = format!("{}/%", escape_like(&raw));
3582            let like_idx = params_vec.len() + 1;
3583            params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3584            path_conds.push(format!(
3585                "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3586            ));
3587        }
3588        where_clauses.push(format!("({})", path_conds.join(" OR ")));
3589    }
3590
3591    let seed_condition = where_clauses.join(" OR ");
3592    let max_depth_idx = params_vec.len() + 1;
3593    params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3594
3595    let mut traversal_ceiling_reached = false;
3596
3597    let has_relationships: bool = conn
3598        .query_row(
3599            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3600            [],
3601            |_| Ok(true),
3602        )
3603        .unwrap_or(false);
3604
3605    let has_pending: bool = conn
3606        .query_row(
3607            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3608            [],
3609            |_| Ok(true),
3610        )
3611        .unwrap_or(false);
3612
3613    let mut likely_tests = Vec::new();
3614    let mut impacted_symbols = Vec::new();
3615    let mut seen_test_keys = HashSet::new();
3616
3617    let mut recursive_branches = Vec::new();
3618
3619    if has_relationships {
3620        recursive_branches.push(format!(
3621            "SELECT r.from_symbol_id, iw.depth + 1
3622             FROM relationships r
3623             JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3624             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3625             WHERE iw.depth < ?{max_depth_idx}
3626               AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
3627        ));
3628    }
3629
3630    if has_pending {
3631        let (parent_join, ns_condition) = if conn
3632            .query_row(
3633                "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3634                [],
3635                |_| Ok(true),
3636            )
3637            .unwrap_or(false)
3638        {
3639            (
3640                "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3641            LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3642                format!("AND {pred}", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
3643            )
3644        } else {
3645            ("", String::new())
3646        };
3647
3648        recursive_branches.push(format!(
3649            "SELECT p.from_symbol_id, iw.depth + 1
3650             FROM pending_relationships p
3651             JOIN symbols s_target ON p.target_terminal_name = s_target.name
3652             JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3653             {parent_join}
3654             WHERE iw.depth < ?{max_depth_idx}
3655               AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3656               {ns_condition}"
3657        ));
3658    }
3659
3660    if !recursive_branches.is_empty() {
3661        let recursive_sql = recursive_branches.join("\n UNION \n");
3662        let not_documentation = not_documentation(conn, "s");
3663        let sql = format!(
3664            "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3665                SELECT symbol_id, 0
3666                FROM symbols
3667                WHERE ({seed_condition})
3668                  AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3669
3670                UNION
3671
3672                {recursive_sql}
3673            )
3674            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
3675            FROM impact_walk iw
3676            CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3677            WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3678              AND {not_documentation}
3679            GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3680            HAVING MIN(iw.depth) > 0
3681            ORDER BY min_depth ASC, s.path ASC, s.name ASC
3682            LIMIT 200"
3683        );
3684
3685        let mut stmt = conn.prepare(&sql)?;
3686        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3687            .iter()
3688            .map(|v| v as &dyn rusqlite::ToSql)
3689            .collect();
3690
3691        let rows = stmt.query_map(param_refs.as_slice(), |row| {
3692            Ok((
3693                row.get::<_, String>(0)?,
3694                row.get::<_, String>(1)?,
3695                row.get::<_, String>(2)?,
3696                row.get::<_, String>(3)?,
3697                row.get::<_, i64>(4)? as usize,
3698                row.get::<_, bool>(5)?,
3699                row.get::<_, bool>(6)?,
3700                row.get::<_, i64>(7)? as usize,
3701            ))
3702        })?;
3703
3704        let mut row_count = 0;
3705        for r in rows {
3706            row_count += 1;
3707            let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3708            let path = raw_path.replace('\\', "/");
3709            let is_test_target = is_test || test_container || is_test_path(&path);
3710
3711            if is_test_target {
3712                let key = format!("{}:{}", path, line);
3713                if seen_test_keys.insert(key) {
3714                    likely_tests.push(TestTarget {
3715                        name,
3716                        path,
3717                        line,
3718                        reason: format!("transitive caller [depth {depth}]"),
3719                    });
3720                }
3721            } else {
3722                impacted_symbols.push(ImpactedSymbol {
3723                    name,
3724                    kind,
3725                    path,
3726                    line,
3727                    depth,
3728                });
3729            }
3730        }
3731        traversal_ceiling_reached = row_count >= 200;
3732    }
3733
3734    // 2. Discover stem-matched test files in the workspace
3735    let mut file_stems = Vec::new();
3736    for p in seed_paths {
3737        if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
3738            && stem.len() >= 3
3739            && !file_stems.contains(&stem.to_string())
3740        {
3741            file_stems.push(stem.to_string());
3742        }
3743    }
3744    for symbol in &resolved_seed_symbols {
3745        if let Some(stem) = std::path::Path::new(&symbol.path)
3746            .file_stem()
3747            .and_then(|s| s.to_str())
3748            && stem.len() >= 3
3749            && !file_stems.contains(&stem.to_string())
3750        {
3751            file_stems.push(stem.to_string());
3752        }
3753    }
3754
3755    let has_files: bool = conn
3756        .query_row(
3757            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3758            [],
3759            |_| Ok(true),
3760        )
3761        .unwrap_or(false);
3762
3763    if has_files {
3764        let doc_file = format!(
3765            "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3766            not_documentation(conn, "d")
3767        );
3768        let mut test_files_stmt = conn.prepare(&format!(
3769            "SELECT DISTINCT path FROM files
3770             WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3771               AND NOT {doc_file}
3772             LIMIT 10"
3773        ))?;
3774        for stem in file_stems {
3775            let stem_pattern = format!("%{}%", escape_like(&stem));
3776            let t_rows =
3777                test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
3778            for p in t_rows.flatten() {
3779                let p = p.replace('\\', "/");
3780                let key = format!("{}:1", p);
3781                if seen_test_keys.insert(key) {
3782                    likely_tests.push(TestTarget {
3783                        name: p.clone(),
3784                        path: p,
3785                        line: 1,
3786                        reason: "stem-matched test file".to_string(),
3787                    });
3788                }
3789            }
3790        }
3791    }
3792
3793    // Truncate to limit
3794    if likely_tests.len() > limit {
3795        likely_tests.truncate(limit);
3796    }
3797    if impacted_symbols.len() > limit {
3798        impacted_symbols.truncate(limit);
3799    }
3800
3801    Ok(BlastRadiusResult {
3802        seed_type,
3803        seeds,
3804        likely_tests,
3805        impacted_symbols,
3806        traversal_ceiling_reached,
3807    })
3808}
3809
3810/// Compute blast radius and likely tests for given seed symbols or seed file paths.
3811pub fn compute_blast_radius(
3812    conn: &Connection,
3813    seed_symbols: &[&str],
3814    seed_paths: &[&str],
3815    max_depth: usize,
3816    limit: usize,
3817) -> Result<BlastRadiusResult, QueryError> {
3818    compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3819}
3820
3821#[cfg(test)]
3822mod tests {
3823    #[test]
3824    fn result_limit_rejects_values_above_the_shared_ceiling() {
3825        assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3826        assert!(matches!(
3827            validate_result_limit(usize::MAX),
3828            Err(QueryError::InvalidResultLimit(usize::MAX))
3829        ));
3830    }
3831
3832    #[test]
3833    fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
3834        let conn = Connection::open_in_memory().unwrap();
3835
3836        assert!(matches!(
3837            find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
3838            Err(QueryError::InvalidResultLimit(usize::MAX))
3839        ));
3840    }
3841
3842    use super::*;
3843    use crate::db::{ensure_fts_index, open_read_write};
3844
3845    #[test]
3846    fn count_parse_diagnostics_counts_rows_for_one_file() {
3847        let dir = crate::safe_tempdir();
3848        let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
3849
3850        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
3851
3852        conn.execute_batch(
3853            "CREATE TABLE parse_diagnostics (
3854                diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
3855            );
3856            INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
3857            INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
3858            INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
3859        )
3860        .unwrap();
3861
3862        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
3863        assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
3864        assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
3865    }
3866
3867    #[test]
3868    fn test_sanitize_fts5_query() {
3869        let (and_q, or_q) = sanitize_fts5_query("parse tokens");
3870        assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
3871        assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
3872
3873        let (and_q, or_q) = sanitize_fts5_query("  Option<T>  ");
3874        assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
3875        assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
3876
3877        let (and_q, or_q) = sanitize_fts5_query("   ");
3878        assert!(and_q.is_empty());
3879        assert!(or_q.is_empty());
3880    }
3881
3882    #[test]
3883    fn sanitize_splits_case_boundaries_and_drops_stop_words() {
3884        let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
3885        assert_eq!(
3886            and_q,
3887            "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
3888        );
3889        assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
3890
3891        let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
3892        assert_eq!(
3893            and_q,
3894            "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
3895        );
3896
3897        let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
3898        assert_eq!(
3899            and_q,
3900            "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
3901        );
3902        assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
3903
3904        let (and_q, _) = sanitize_fts5_query("validate_syntax");
3905        assert_eq!(
3906            and_q,
3907            "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
3908        );
3909
3910        let (and_q, _) = sanitize_fts5_query("isReady");
3911        assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
3912
3913        let (and_q, _) = sanitize_fts5_query("before");
3914        assert_eq!(and_q, "\"before\"*");
3915
3916        let (and_q, _) = sanitize_fts5_query("fooBar quux");
3917        assert_eq!(
3918            and_q,
3919            "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
3920        );
3921
3922        let (and_q, _) = sanitize_fts5_query("the for a");
3923        assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
3924    }
3925
3926    fn search_fixture(rows: &str) -> Connection {
3927        let conn = Connection::open_in_memory().unwrap();
3928        conn.execute_batch(&format!(
3929            "CREATE TABLE symbols (
3930                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3931                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
3932                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3933                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3934                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3935                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3936                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
3937            );
3938            INSERT INTO symbols VALUES {rows};"
3939        ))
3940        .unwrap();
3941        ensure_fts_index(&conn).unwrap();
3942        conn
3943    }
3944
3945    fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
3946        format!(
3947            "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
3948              10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
3949        )
3950    }
3951
3952    fn doc_row(id: &str, name: &str, doc: &str) -> String {
3953        format!(
3954            "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
3955              3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
3956        )
3957    }
3958
3959    fn search_names(conn: &Connection, query: &str) -> Vec<String> {
3960        fts_search_symbols_scoped(conn, query, None, None, false, 10)
3961            .unwrap()
3962            .into_iter()
3963            .map(|r| r.symbol.name)
3964            .collect()
3965    }
3966
3967    const TEST_PATH_CASES: &[(&str, bool)] = &[
3968        ("tests/foo.py", true),
3969        ("tests/x.py", true),
3970        ("tests/tools/test_web.py", true),
3971        ("src/tests/x.rs", true),
3972        ("__tests__/a.ts", true),
3973        ("a/__tests__/b.ts", true),
3974        ("test/x.java", true),
3975        ("src/test/Helper.java", true),
3976        ("src/test_utils.py", true),
3977        ("lib/test_helper.rb", true),
3978        ("test_config.py", true),
3979        ("pkg/test_data/x.json", false),
3980        ("src/test_detection.rs", false),
3981        ("autotests/tst_pagerow.qml", true),
3982        ("autotests/helper.qml", true),
3983        ("src/autotests/columnview.cpp", true),
3984        ("tst_foo.qml", true),
3985        ("src/tst_columnview.qml", true),
3986        ("autotests\\tst_bar.qml", true),
3987        ("autotests_helper/x.rs", false),
3988        ("src/autotest.rs", false),
3989        ("src/tstamp.rs", false),
3990        ("src/tst.rs", false),
3991        ("crates/julie-index/src/analysis/test_quality.rs", false),
3992        ("x/foo_test.go", true),
3993        ("x/foo.test.ts", true),
3994        ("x/foo.spec.js", true),
3995        ("src/lib_test.rs", true),
3996        ("src/test.rs", true),
3997        ("tests.rs", true),
3998        ("src/tests.rs", true),
3999        ("Foo.Tests.cs", true),
4000        ("x/FooTests.cs", true),
4001        ("src/FooTests.cs", true),
4002        ("src/Foo.Tests.cs", true),
4003        ("tests/Foo.cs", true),
4004        ("x/parser.spec.ts", true),
4005        ("test_x.py", true),
4006        ("test.rs", true),
4007        ("tests\\x.py", true),
4008        ("src/protocol.spec.v1/parser.rs", false),
4009        ("pkg/test_support/runtime.py", false),
4010        ("src/Contests.cs", false),
4011        ("spec/x.rb", false),
4012        ("crates/x/src/impact/likely_tests.rs", false),
4013        ("x/foo_tests.rs", false),
4014        ("src/latest.rs", false),
4015        ("x/latest.go", false),
4016        ("x/manifest.rs", false),
4017        ("src/attest.rs", false),
4018        ("contest/x.py", false),
4019        ("src/testing.rs", false),
4020        ("src/main.rs", false),
4021        ("pkg/service.go", false),
4022    ];
4023
4024    #[test]
4025    fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
4026        let conn = Connection::open_in_memory().unwrap();
4027        let sql = format!(
4028            "SELECT {} FROM (SELECT :path AS path) s",
4029            test_path_predicate("s")
4030        );
4031        let mut stmt = conn.prepare(&sql).unwrap();
4032        for (path, expected) in TEST_PATH_CASES {
4033            assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
4034            let from_sql: bool = stmt
4035                .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
4036                .unwrap();
4037            assert_eq!(from_sql, *expected, "sql mirror: {path}");
4038        }
4039    }
4040
4041    #[test]
4042    fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
4043        let conn = search_fixture(
4044            &[
4045                code_row(
4046                    "a",
4047                    "src/parser.rs",
4048                    "rust",
4049                    "parse_sidecar",
4050                    "Parse a sidecar.",
4051                ),
4052                code_row(
4053                    "b",
4054                    "src/tests/helpers.py",
4055                    "python",
4056                    "parse_sidecar_fixture",
4057                    "Parse a sidecar.",
4058                ),
4059            ]
4060            .join(", "),
4061        );
4062
4063        let default_search: Vec<String> =
4064            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
4065                .unwrap()
4066                .into_iter()
4067                .map(|r| r.symbol.name)
4068                .collect();
4069        assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
4070
4071        let with_tests: Vec<String> =
4072            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
4073                .unwrap()
4074                .into_iter()
4075                .map(|r| r.symbol.name)
4076                .collect();
4077        assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
4078
4079        let default_lookup: Vec<String> =
4080            search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
4081                .unwrap()
4082                .into_iter()
4083                .map(|s| s.name)
4084                .collect();
4085        assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
4086
4087        let lookup_with_tests: Vec<String> =
4088            search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
4089                .unwrap()
4090                .into_iter()
4091                .map(|s| s.name)
4092                .collect();
4093        assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
4094    }
4095
4096    #[test]
4097    fn count_file_symbols_prefers_the_exact_case_path_like_the_loader() {
4098        let conn = search_fixture(
4099            &[
4100                code_row("a", "src/Foo.rs", "rust", "one", ""),
4101                code_row("b", "src/foo.rs", "rust", "two", ""),
4102                code_row("c", "src/foo.rs", "rust", "three", ""),
4103            ]
4104            .join(", "),
4105        );
4106        for path in ["src/Foo.rs", "src/foo.rs", "src/FOO.rs", "src\\foo.rs"] {
4107            assert_eq!(
4108                count_file_symbols(&conn, path),
4109                load_file_symbols(&conn, path).unwrap().len(),
4110                "{path}"
4111            );
4112        }
4113        assert_eq!(count_file_symbols(&conn, "src/Foo.rs"), 1);
4114        assert_eq!(count_file_symbols(&conn, "src/FOO.rs"), 3);
4115    }
4116
4117    #[test]
4118    fn lookup_statement_is_not_planned_as_a_multi_index_or() {
4119        let conn = search_fixture(&code_row("a", "src/lib.rs", "rust", "needle", ""));
4120        conn.execute_batch(
4121            "CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
4122             CREATE INDEX idx_symbols_test_container ON symbols(test_container);
4123             CREATE INDEX idx_symbols_is_test ON symbols(is_test);",
4124        )
4125        .unwrap();
4126        let sql = format!(
4127            "EXPLAIN QUERY PLAN {}",
4128            search_symbols_sql(false, false, 20)
4129        );
4130        let plan: Vec<String> = conn
4131            .prepare(&sql)
4132            .unwrap()
4133            .query_map(
4134                rusqlite::named_params! {
4135                    ":query": "needle",
4136                    ":pattern": "%needle%",
4137                    ":kind": None::<&str>,
4138                    ":path": None::<&str>,
4139                    ":path_like": None::<&str>,
4140                },
4141                |row| row.get::<_, String>(3),
4142            )
4143            .unwrap()
4144            .collect::<Result<_, _>>()
4145            .unwrap();
4146        assert!(
4147            !plan.iter().any(|step| step.contains("MULTI-INDEX OR")),
4148            "{plan:?}"
4149        );
4150    }
4151
4152    #[test]
4153    fn qualified_lookup_in_a_test_file_returns_the_named_row() {
4154        let conn = search_fixture(
4155            &[
4156                "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
4157                  1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
4158                "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
4159                  10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
4160            ]
4161            .join(", "),
4162        );
4163
4164        assert_eq!(
4165            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
4166                .unwrap()
4167                .len(),
4168            1
4169        );
4170        assert_eq!(
4171            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
4172                .unwrap()
4173                .len(),
4174            1
4175        );
4176        assert!(
4177            search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
4178                .unwrap()
4179                .is_empty()
4180        );
4181    }
4182
4183    #[test]
4184    fn concept_query_prefers_partial_code_match_over_full_doc_match() {
4185        let conn = search_fixture(
4186            &[
4187                doc_row(
4188                    "d1",
4189                    "Safety guarantees",
4190                    "Pre-flight syntax validation runs before the edit touches disk",
4191                ),
4192                doc_row(
4193                    "d2",
4194                    "Audit",
4195                    "The syntax validation before an edit is the invariant",
4196                ),
4197                code_row(
4198                    "c1",
4199                    "src/syntax.rs",
4200                    "rust",
4201                    "validate_syntax",
4202                    "Validate the syntax of a file",
4203                ),
4204                code_row(
4205                    "c2",
4206                    "src/edit.rs",
4207                    "rust",
4208                    "replace_symbol_body",
4209                    "Atomic edit with validation",
4210                ),
4211            ]
4212            .join(","),
4213        );
4214
4215        let names = search_names(&conn, "syntax validation before edit");
4216
4217        assert_eq!(names[0], "validate_syntax");
4218        assert!(names.contains(&"replace_symbol_body".to_string()));
4219        assert!(names.contains(&"Safety guarantees".to_string()));
4220    }
4221
4222    #[test]
4223    fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
4224        let conn = search_fixture(
4225            &[
4226                code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
4227                code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
4228            ]
4229            .join(","),
4230        );
4231
4232        let mut camel = search_names(&conn, "ValidateSyntax");
4233        camel.sort();
4234        assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
4235        let mut words = search_names(&conn, "validate syntax");
4236        words.sort();
4237        assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
4238    }
4239
4240    #[test]
4241    fn stop_word_prefixed_camel_case_symbol_is_still_found() {
4242        let conn = search_fixture(
4243            &[
4244                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4245                code_row("c2", "src/hooks.rs", "rust", "before", ""),
4246                code_row(
4247                    "c3",
4248                    "src/x.rs",
4249                    "rust",
4250                    "fooBar",
4251                    "has fooBar but not the other word",
4252                ),
4253            ]
4254            .join(","),
4255        );
4256
4257        assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
4258        assert_eq!(search_names(&conn, "before"), vec!["before"]);
4259    }
4260
4261    #[test]
4262    fn related_tests_use_the_name_as_typed_without_splitting() {
4263        let conn = search_fixture(
4264            &[
4265                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4266                "('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(),
4267                "('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(),
4268            ]
4269            .join(","),
4270        );
4271        let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
4272
4273        let names: Vec<String> = find_related_tests(&conn, &target, 5)
4274            .unwrap()
4275            .into_iter()
4276            .map(|t| t.name)
4277            .collect();
4278
4279        assert_eq!(names, vec!["isReady_reports_true"]);
4280    }
4281
4282    #[test]
4283    fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
4284        let conn = search_fixture(
4285            &[
4286                code_row(
4287                    "c1",
4288                    "src/queries.rs",
4289                    "rust",
4290                    "fts_search_symbols_scoped",
4291                    "search symbols scoped with fts",
4292                ),
4293                code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
4294            ]
4295            .join(","),
4296        );
4297
4298        assert_eq!(
4299            search_names(&conn, "search_symbols_scoped")[0],
4300            "search_symbols_scoped"
4301        );
4302    }
4303
4304    fn sidecar_fixture() -> Connection {
4305        search_fixture(
4306            &[
4307                code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
4308                code_row(
4309                    "c2",
4310                    "src/sidecar.rs",
4311                    "rust",
4312                    "parse_sidecar_file",
4313                    "parse the sha256 sidecar file",
4314                ),
4315            ]
4316            .join(","),
4317        )
4318    }
4319
4320    fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
4321        candidates
4322            .iter()
4323            .find(|c| c.result.symbol.name == name)
4324            .unwrap_or_else(|| panic!("{name} is not a candidate"))
4325    }
4326
4327    #[test]
4328    fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
4329        let conn = sidecar_fixture();
4330
4331        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4332
4333        let target = candidate(&candidates, "parseSha256Sidecar");
4334        assert!(target.name_match);
4335        assert!(!target.word_match);
4336        assert!(!target.exact_name);
4337        assert!(target.name_terms.contains(&"sha256".to_string()));
4338    }
4339
4340    #[test]
4341    fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
4342        let conn = search_fixture(
4343            &[
4344                code_row(
4345                    "c1",
4346                    "examples/demo.rs",
4347                    "rust",
4348                    "demo",
4349                    "restore offline state",
4350                ),
4351                code_row(
4352                    "c2",
4353                    "src/replay.rs",
4354                    "rust",
4355                    "replay",
4356                    "restore offline records",
4357                ),
4358            ]
4359            .join(","),
4360        );
4361
4362        let candidates =
4363            collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
4364                .unwrap();
4365
4366        assert!(candidate(&candidates, "demo").word_match);
4367        assert!(candidate(&candidates, "replay").word_match);
4368        assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
4369    }
4370
4371    #[test]
4372    fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
4373        let mut rows: Vec<String> = (1..=170)
4374            .map(|i| {
4375                code_row(
4376                    &format!("h{i:03}"),
4377                    "src/sidecar.rs",
4378                    "rust",
4379                    &format!("sidecar_helper_{i:03}"),
4380                    "parse sidecar file",
4381                )
4382            })
4383            .collect();
4384        rows.push(code_row(
4385            "c1",
4386            "src/sidecar.rs",
4387            "rust",
4388            "parseSha256Sidecar",
4389            "",
4390        ));
4391        let conn = search_fixture(&rows.join(","));
4392
4393        let candidates = collect_search_candidates(
4394            &conn,
4395            "parse the sha256 sidecar file",
4396            None,
4397            None,
4398            false,
4399            40,
4400        )
4401        .unwrap();
4402
4403        assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
4404        assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
4405    }
4406
4407    #[test]
4408    fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
4409        let mut rows: Vec<String> = (1..=20)
4410            .map(|i| {
4411                code_row(
4412                    &format!("a{i:02}"),
4413                    "src/a.rs",
4414                    "rust",
4415                    &format!("both_{i:02}"),
4416                    "restore offline",
4417                )
4418            })
4419            .collect();
4420        rows.extend((1..=50).map(|i| {
4421            code_row(
4422                &format!("p{i:02}"),
4423                "src/p.rs",
4424                "rust",
4425                &format!("partial_{i:02}"),
4426                "restore records",
4427            )
4428        }));
4429        let conn = search_fixture(&rows.join(","));
4430
4431        let candidates =
4432            collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
4433
4434        let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
4435        assert_eq!(word_rows.len(), 40);
4436        assert_eq!(
4437            word_rows
4438                .iter()
4439                .filter(|c| c.result.symbol.name.starts_with("both_"))
4440                .count(),
4441            20
4442        );
4443    }
4444
4445    #[test]
4446    fn exact_name_is_admitted_regardless_of_case() {
4447        let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
4448
4449        let candidates =
4450            collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
4451        assert!(candidate(&candidates, "xyzzy_q").exact_name);
4452
4453        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4454        let candidates =
4455            collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
4456        assert!(candidate(&candidates, "xyzzy_q").exact_name);
4457    }
4458
4459    #[test]
4460    fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
4461        let conn = search_fixture(&code_row(
4462            "c1",
4463            "src/say.js",
4464            "javascript",
4465            "say \"hi\"",
4466            "",
4467        ));
4468
4469        let candidates =
4470            collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
4471
4472        let target = candidate(&candidates, "say \"hi\"");
4473        assert!(target.exact_name && target.name_match);
4474    }
4475
4476    #[test]
4477    fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
4478        let conn = search_fixture(
4479            &[
4480                code_row("c1", "src/a.rs", "rust", "sidecar", ""),
4481                code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
4482            ]
4483            .join(","),
4484        );
4485
4486        let candidates =
4487            collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
4488
4489        assert_eq!(candidates.len(), 2);
4490        let target = candidate(&candidates, "sidecar");
4491        assert!(target.exact_name && target.word_match && target.name_match);
4492        assert!(target.bm25.is_some());
4493        let helper = candidate(&candidates, "sidecar_helper");
4494        assert!(!helper.exact_name && helper.word_match && helper.name_match);
4495    }
4496
4497    #[test]
4498    fn an_index_without_the_trigram_table_returns_word_rows_only() {
4499        let conn = sidecar_fixture();
4500        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4501
4502        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4503
4504        let names: Vec<&str> = candidates
4505            .iter()
4506            .map(|c| c.result.symbol.name.as_str())
4507            .collect();
4508        assert_eq!(names, vec!["parse_sidecar_file"]);
4509        assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
4510        assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
4511    }
4512
4513    #[test]
4514    fn words_under_three_characters_skip_the_name_branch() {
4515        let conn = search_fixture(
4516            &[
4517                code_row("c1", "src/a.rs", "rust", "ab", ""),
4518                code_row("c2", "src/b.rs", "rust", "cab", ""),
4519            ]
4520            .join(","),
4521        );
4522
4523        let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
4524
4525        assert!(candidates.iter().all(|c| !c.name_match));
4526        assert!(candidate(&candidates, "ab").exact_name);
4527    }
4528
4529    #[test]
4530    fn trigram_terms_include_the_identifier_parts_of_each_word() {
4531        assert_eq!(
4532            trigram_name_terms("collapse_name"),
4533            vec!["collapse_name", "collapse", "name"]
4534        );
4535        assert_eq!(
4536            trigram_name_terms("parse the sha256 sidecar"),
4537            vec!["parse", "sha256", "sha", "256", "sidecar"]
4538        );
4539        assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
4540        assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
4541        assert!(trigram_name_terms("ab").is_empty());
4542    }
4543
4544    #[test]
4545    fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
4546        let conn = search_fixture(
4547            &[
4548                code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
4549                code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
4550            ]
4551            .join(","),
4552        );
4553
4554        let candidates =
4555            collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
4556
4557        let target = candidate(&candidates, "CollapseName");
4558        assert!(target.name_match);
4559        assert_eq!(target.name_terms, vec!["collapse", "name"]);
4560        assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
4561    }
4562
4563    fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
4564        Candidate {
4565            result: SymbolSearchResult {
4566                symbol: Symbol {
4567                    symbol_id: format!("{path}:{name}"),
4568                    file_id: "f".into(),
4569                    path: path.into(),
4570                    language: "rust".into(),
4571                    name: name.into(),
4572                    kind: kind.into(),
4573                    signature: None,
4574                    doc_comment: None,
4575                    visibility: None,
4576                    parent_symbol_id: None,
4577                    start_line: 1,
4578                    start_column: 0,
4579                    end_line: 1,
4580                    end_column: 0,
4581                    start_byte: 0,
4582                    end_byte: 0,
4583                    body_start_line: None,
4584                    body_start_column: None,
4585                    body_end_line: None,
4586                    body_end_column: None,
4587                    body_start_byte: None,
4588                    body_end_byte: None,
4589                    body_hash: None,
4590                    semantic_group: None,
4591                    is_test: false,
4592                    test_container: false,
4593                },
4594                score: 0.0,
4595                snippet: None,
4596                explain: None,
4597            },
4598            bm25: None,
4599            exact_name: false,
4600            word_match: false,
4601            name_match: false,
4602            name_terms: Vec::new(),
4603            documentation: false,
4604        }
4605    }
4606
4607    fn function(name: &str) -> Candidate {
4608        plain_candidate(name, "function", "src/lib.rs")
4609    }
4610
4611    fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
4612        rerank_with(candidates, query, false, None)
4613    }
4614
4615    fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
4616        ranked(candidates, query)
4617            .into_iter()
4618            .map(|(r, _)| r.symbol.name)
4619            .collect()
4620    }
4621
4622    fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
4623        let mut candidate = function(name);
4624        candidate.result.symbol.signature = signature.map(str::to_string);
4625        candidate.result.symbol.doc_comment = Some(doc.into());
4626        candidate
4627    }
4628
4629    fn strip_ansi_case() -> Vec<Candidate> {
4630        vec![
4631            documented("strip_ansi", None, "Remove ANSI escape sequences"),
4632            documented(
4633                "_strip_code_fences",
4634                Some("def _strip_code_fences(text: str)"),
4635                "The first fenced code block's body, or the stripped text",
4636            ),
4637        ]
4638    }
4639
4640    #[test]
4641    fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4642        assert_eq!(
4643            rerank_words("parse the sha256 sidecar file"),
4644            vec!["parse", "sha", "256", "sidecar", "file"]
4645        );
4646        assert_eq!(
4647            rerank_words("parse_sha256_sidecar"),
4648            vec!["parse", "sha", "256", "sidecar"]
4649        );
4650        assert_eq!(
4651            rerank_words("ParseHTTPResponse"),
4652            vec!["parse", "http", "response"]
4653        );
4654        assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4655        assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4656    }
4657
4658    #[test]
4659    fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4660        for word in ["was", "whether", "another"] {
4661            assert!(is_stop_word(word), "{word} must be a stop word");
4662        }
4663        for word in ["down", "into", "run"] {
4664            assert!(!is_stop_word(word), "{word} must stay a content word");
4665        }
4666        assert_eq!(rerank_words("what was the file"), vec!["file"]);
4667    }
4668
4669    #[test]
4670    fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4671        let mut private = function("_create_skill");
4672        private.bm25 = Some(-9.0);
4673        let mut public = function("create_skill");
4674        public.bm25 = Some(-1.0);
4675
4676        let rows = ranked(vec![private, public], "create skill");
4677
4678        assert_eq!(rows[0].0.score, rows[1].0.score);
4679        assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4680        assert_eq!(
4681            rows.iter()
4682                .map(|(r, _)| r.symbol.name.as_str())
4683                .collect::<Vec<_>>(),
4684            vec!["create_skill", "_create_skill"]
4685        );
4686    }
4687
4688    #[test]
4689    fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4690        let rows = ranked(
4691            vec![
4692                plain_candidate("Glob", "constant", "src/glob.rs"),
4693                function("matches_glob_pattern"),
4694            ],
4695            "glob",
4696        );
4697
4698        assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4699        assert_eq!(rows[1].1.name_tier, "whole");
4700        assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4701    }
4702
4703    #[test]
4704    fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4705        let rows = ranked(
4706            vec![
4707                function("validate_everything"),
4708                function("validate_syntax_now"),
4709                function("validate_syntax"),
4710                function("unrelated"),
4711            ],
4712            "validate syntax",
4713        );
4714        let tiers: Vec<(&str, &str, f64)> = rows
4715            .iter()
4716            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4717            .collect();
4718
4719        assert_eq!(
4720            tiers,
4721            vec![
4722                ("validate_syntax", "whole", W_NAME_WHOLE),
4723                ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4724                ("validate_everything", "partial", 0.0),
4725                ("unrelated", "none", 0.0),
4726            ]
4727        );
4728        assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4729        assert_eq!(
4730            rows[1].0.score,
4731            W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4732        );
4733        assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4734        assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4735    }
4736
4737    #[test]
4738    fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4739        assert_eq!(
4740            ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4741            vec!["strip_ansi", "_strip_code_fences"]
4742        );
4743    }
4744
4745    #[test]
4746    fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4747        let candidates = vec![
4748            function("execute_julie_extract"),
4749            documented("slice_bytes", None, "cut a byte range"),
4750        ];
4751
4752        let rows = ranked(candidates, "cut");
4753        let bonuses: Vec<(&str, &str, f64)> = rows
4754            .iter()
4755            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4756            .collect();
4757
4758        assert_eq!(
4759            bonuses,
4760            vec![
4761                ("execute_julie_extract", "partial", 0.0),
4762                ("slice_bytes", "none", 0.0),
4763            ]
4764        );
4765        assert_eq!(rows[0].0.score, rows[1].0.score);
4766    }
4767
4768    #[test]
4769    fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4770        let candidates = vec![
4771            function("validate_everything"),
4772            function("validate_syntax_now"),
4773            function("validate_syntax"),
4774            function("unrelated"),
4775        ];
4776
4777        assert_eq!(
4778            ranked_names(candidates, "validate syntax"),
4779            vec![
4780                "validate_syntax",
4781                "validate_syntax_now",
4782                "validate_everything",
4783                "unrelated"
4784            ]
4785        );
4786    }
4787
4788    #[test]
4789    fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4790        let mut rows: Vec<String> = (0..10)
4791            .map(|i| {
4792                code_row(
4793                    &format!("s{i}"),
4794                    &format!("src/f{i}.rs"),
4795                    "rust",
4796                    &format!("search_{i}"),
4797                    "searches the index",
4798                )
4799            })
4800            .collect();
4801        rows.push(code_row(
4802            "rare",
4803            "src/rare.rs",
4804            "rust",
4805            "sanitize_input",
4806            "sanitize the input",
4807        ));
4808        let conn = search_fixture(&rows.join(", "));
4809        let terms = vec!["sanitize".to_string(), "search".to_string()];
4810
4811        let weights = idf_weights(&conn, &terms);
4812        assert!(weights[0] > weights[1]);
4813    }
4814
4815    #[test]
4816    fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
4817        let conn = search_fixture(&code_row(
4818            "n",
4819            "src/news.rs",
4820            "rust",
4821            "fetch_news",
4822            "fetch the news feed",
4823        ));
4824        let terms = vec!["news".to_string(), "unseen".to_string()];
4825
4826        let weights = idf_weights(&conn, &terms);
4827
4828        assert!(weights[0] < weights[1]);
4829    }
4830
4831    #[test]
4832    fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4833        let crediting_field = |padding: usize| {
4834            let mut row = function("handler");
4835            row.result.symbol.signature = Some(format!(
4836                "fn handler({}sidecar: u8)",
4837                "a: u8, ".repeat(padding)
4838            ));
4839            ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
4840        };
4841
4842        assert_eq!(crediting_field(4), "signature");
4843        assert_eq!(crediting_field(80), "none");
4844    }
4845
4846    #[test]
4847    fn explain_terms_name_the_crediting_field_of_every_query_term() {
4848        let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
4849        let terms: Vec<(&str, &str, f64)> = rows[0]
4850            .1
4851            .terms
4852            .iter()
4853            .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
4854            .collect();
4855
4856        assert_eq!(rows[0].0.symbol.name, "strip_ansi");
4857        assert_eq!(
4858            terms,
4859            vec![
4860                ("strip", "name", 3.0),
4861                ("ansi", "name", 3.0),
4862                ("escape", "doc", TEXT_CREDIT),
4863                ("codes", "none", 0.0),
4864            ]
4865        );
4866    }
4867
4868    #[test]
4869    fn name_coverage_accepts_token_runs_substrings_and_stems() {
4870        let strengths = |name: &str, query: &str| {
4871            let stemmer = Stemmer::create(Algorithm::English);
4872            let words: Vec<QueryWord> = rerank_words(query)
4873                .into_iter()
4874                .map(|word| QueryWord {
4875                    stem: stemmer.stem(&word).into_owned(),
4876                    word,
4877                })
4878                .collect();
4879            name_hits(name, &words, &stemmer)
4880        };
4881
4882        assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
4883        assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
4884        assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
4885        assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
4886        assert_eq!(strengths("is_ok", "ok"), vec![3]);
4887        assert_eq!(strengths("isReady", "is"), vec![3]);
4888        assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
4889        assert_eq!(
4890            strengths("parseSha256Sidecar", "sidecar checksum"),
4891            vec![3, 0]
4892        );
4893        assert_eq!(
4894            strengths("parseSha256Sidecar", "checksum digest"),
4895            vec![0, 0]
4896        );
4897    }
4898
4899    #[test]
4900    fn a_rarer_term_moves_the_score_more_than_a_common_one() {
4901        let weights = [4.0, 1.0];
4902        let rows = rerank_with(
4903            vec![function("rare_helper"), function("common_helper")],
4904            "rare common",
4905            false,
4906            Some(&weights),
4907        );
4908
4909        assert_eq!(
4910            rows[0].1.word_weights,
4911            vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
4912        );
4913        assert_eq!(rows[0].0.symbol.name, "rare_helper");
4914        assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
4915        assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
4916    }
4917
4918    #[test]
4919    fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
4920        let rows = ranked(
4921            vec![
4922                function("render_mode"),
4923                plain_candidate("retry_count", "constant", "src/scan.rs"),
4924            ],
4925            "how many times a failed download is tried again retry limit",
4926        );
4927
4928        assert_eq!(rows[0].0.symbol.name, "retry_count");
4929        assert_eq!(rows[0].1.name_tier, "partial");
4930        assert!(rows[0].0.score > rows[1].0.score);
4931        assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
4932    }
4933
4934    #[test]
4935    fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4936        let mut row = function("load");
4937        row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
4938        row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
4939        let (result, explain) = ranked(vec![row], "config settings").remove(0);
4940
4941        assert_eq!(
4942            explain.terms,
4943            vec![
4944                ("config".into(), "signature".into(), TEXT_CREDIT),
4945                ("settings".into(), "none".into(), 0.0),
4946            ]
4947        );
4948        assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
4949    }
4950
4951    #[test]
4952    fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
4953        let crediting_field =
4954            |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
4955        let doc_field = |doc: &str, query: &str| {
4956            let mut row = function("row");
4957            row.result.symbol.doc_comment = Some(doc.into());
4958            crediting_field(row, query)
4959        };
4960        let sig_field = |signature: &str, query: &str| {
4961            let mut row = function("row");
4962            row.result.symbol.signature = Some(signature.into());
4963            crediting_field(row, query)
4964        };
4965
4966        assert_eq!(doc_field("The system runs.", "stemming"), "none");
4967        assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
4968        assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
4969        assert_eq!(doc_field("An important port.", "porter"), "none");
4970        assert_eq!(
4971            sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
4972            "signature"
4973        );
4974        assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
4975        assert_eq!(sig_field("fn okay()", "ok"), "none");
4976        assert_eq!(
4977            sig_field("fn parseSha256Sidecar(text)", "sidecar"),
4978            "signature"
4979        );
4980    }
4981
4982    #[test]
4983    fn text_tokens_split_like_query_words_then_identifiers() {
4984        fn two_pass(text: &str) -> Vec<&str> {
4985            query_words(text)
4986                .into_iter()
4987                .flat_map(split_identifier)
4988                .collect()
4989        }
4990        fn one_pass(text: &str) -> Vec<&str> {
4991            let mut out = Vec::new();
4992            text_tokens_into(text, &mut out);
4993            out
4994        }
4995        let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
4996        let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
4997
4998        assert_eq!(one_pass(ascii), two_pass(ascii));
4999        assert_eq!(
5000            one_pass(ascii),
5001            vec![
5002                "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
5003                "sha", "256", "sum",
5004            ]
5005        );
5006        assert_eq!(one_pass(unicode), two_pass(unicode));
5007        assert!(one_pass("").is_empty());
5008        assert!(one_pass("_ __ ...").is_empty());
5009    }
5010
5011    #[test]
5012    fn a_doc_credits_a_term_by_its_stem() {
5013        let mut row = function("check");
5014        row.result.symbol.doc_comment = Some("Validates the input.".into());
5015        let explain = ranked(vec![row], "validation").remove(0).1;
5016
5017        assert_eq!(
5018            explain.terms,
5019            vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
5020        );
5021    }
5022
5023    #[test]
5024    fn kind_prior_orders_definitions_over_members_over_imports() {
5025        let rows = ranked(
5026            vec![
5027                plain_candidate("Scan", "import", "src/a.rs"),
5028                plain_candidate("Scan", "enum_member", "src/b.rs"),
5029                plain_candidate("Scan", "function", "src/c.rs"),
5030            ],
5031            "scan",
5032        );
5033        let order: Vec<(&str, f64)> = rows
5034            .iter()
5035            .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
5036            .collect();
5037
5038        assert_eq!(
5039            order,
5040            vec![
5041                ("src/c.rs", W_KIND_DEFINITION),
5042                ("src/b.rs", W_KIND_MEMBER),
5043                ("src/a.rs", W_KIND_IMPORT),
5044            ]
5045        );
5046    }
5047
5048    #[test]
5049    fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
5050        let names = ranked_names(
5051            vec![
5052                function("RenderMode"),
5053                plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
5054            ],
5055            "retry download limit timeout",
5056        );
5057
5058        assert_eq!(names[0], "MaxRetryCount");
5059    }
5060
5061    #[test]
5062    fn path_role_demotes_role_directories_unless_the_query_names_them() {
5063        let rows = |query: &str| {
5064            ranked(
5065                vec![
5066                    plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
5067                    plain_candidate("verify_checksum", "function", "src/archive.rs"),
5068                ],
5069                query,
5070            )
5071        };
5072
5073        let plain = rows("verify checksum");
5074        assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
5075        assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
5076
5077        let named = rows("launcher script verify checksum");
5078        assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
5079
5080        let only_launcher = rows("launcher verify checksum");
5081        assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
5082        assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
5083
5084        let windows = ranked(
5085            vec![plain_candidate(
5086                "verifyChecksum",
5087                "function",
5088                "scripts\\launcher.ts",
5089            )],
5090            "verify checksum",
5091        );
5092        assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
5093    }
5094
5095    #[test]
5096    fn documentation_rows_sort_after_every_code_row() {
5097        let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
5098        heading.documentation = true;
5099        heading.result.symbol.language = "markdown".into();
5100        heading.result.symbol.signature = Some("Verify checksum".into());
5101        heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
5102        let rows = ranked(
5103            vec![
5104                heading,
5105                plain_candidate("unrelated", "variable", "src/a.rs"),
5106            ],
5107            "verify checksum",
5108        );
5109
5110        assert_eq!(rows[0].0.symbol.name, "unrelated");
5111        assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
5112        assert_eq!(rows[1].1.name_tier, "whole");
5113        assert!(rows[1].0.score < 0.0);
5114    }
5115
5116    #[test]
5117    fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
5118        let rows = |query: &str, include_tests: bool| {
5119            let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
5120            test_row.result.symbol.is_test = true;
5121            let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
5122            rerank_with(vec![plain_row, test_row], query, include_tests, None)
5123        };
5124
5125        let boosted = rows("payment flow tests", true);
5126        assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
5127        assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
5128        assert_eq!(boosted[1].1.test_intent, 0.0);
5129
5130        assert!(
5131            rows("payment flow tests", false)
5132                .iter()
5133                .all(|(_, e)| e.test_intent == 0.0)
5134        );
5135        assert!(
5136            rows("payment flow", true)
5137                .iter()
5138                .all(|(_, e)| e.test_intent == 0.0)
5139        );
5140    }
5141
5142    #[test]
5143    fn ties_break_by_bm25_then_name_length_then_path() {
5144        let mut word_row = plain_candidate("payment", "function", "src/z.rs");
5145        word_row.word_match = true;
5146        word_row.bm25 = Some(-4.0);
5147        let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
5148        weaker_word_row.word_match = true;
5149        weaker_word_row.bm25 = Some(-2.0);
5150        let mut name_only = plain_candidate("payment", "function", "src/b.rs");
5151        name_only.name_match = true;
5152        let rows = ranked(
5153            vec![
5154                plain_candidate("payment", "function", "src/y.rs"),
5155                name_only,
5156                weaker_word_row,
5157                word_row,
5158            ],
5159            "payment",
5160        );
5161        let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
5162
5163        assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
5164
5165        let by_length = ranked_names(
5166            vec![
5167                function("payment_gateway_client"),
5168                function("payment_gateway"),
5169            ],
5170            "gateway",
5171        );
5172        assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
5173    }
5174
5175    #[test]
5176    fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
5177        let mut token = function("csr");
5178        token.bm25 = Some(-1.0);
5179        let mut substring = function("action_csrf_token");
5180        substring.bm25 = Some(-5.0);
5181
5182        let rows = ranked(vec![substring, token], "csr adjacency");
5183
5184        assert!(rows[0].0.score > rows[1].0.score);
5185        assert_eq!(rows[0].0.symbol.name, "csr");
5186    }
5187
5188    #[test]
5189    fn an_acronym_token_outranks_a_name_that_only_contains_it() {
5190        let mut token = function("http_client");
5191        token.bm25 = Some(-1.0);
5192        let mut substring = function("shttpd_config");
5193        substring.bm25 = Some(-5.0);
5194
5195        let rows = ranked(vec![substring, token], "http");
5196
5197        assert!(rows[0].0.score > rows[1].0.score);
5198        assert_eq!(rows[0].0.symbol.name, "http_client");
5199    }
5200
5201    #[test]
5202    fn explain_reports_the_sum_of_the_name_strengths() {
5203        let rows = ranked(vec![function("action_csrf_token")], "csr token");
5204
5205        assert_eq!(rows[0].1.name_strength, 4);
5206    }
5207
5208    #[test]
5209    fn snippets_follow_the_admitting_branch() {
5210        let mut word_row = function("parse_sidecar_file");
5211        word_row.word_match = true;
5212        word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
5213        let mut name_row = function("parseSha256Sidecar");
5214        name_row.name_match = true;
5215        name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
5216        let mut exact_row = function("sha256");
5217        exact_row.exact_name = true;
5218        let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
5219        let snippets: Vec<(&str, &str)> = rows
5220            .iter()
5221            .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
5222            .collect();
5223
5224        assert_eq!(
5225            snippets,
5226            vec![
5227                ("sha256", "sha256"),
5228                ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
5229                ("parse_sidecar_file", "parse the [sha256] sidecar file"),
5230            ]
5231        );
5232        assert_eq!(rows[1].1.branches, vec!["name"]);
5233        assert_eq!(rows[0].1.branches, vec!["exact"]);
5234    }
5235
5236    #[test]
5237    fn explain_is_attached_only_when_requested() {
5238        let conn = sidecar_fixture();
5239        let query = "sha256";
5240
5241        let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
5242        assert!(silent.iter().all(|r| r.explain.is_none()));
5243        assert!(silent[0].score > 0.0);
5244        assert_eq!(
5245            serde_json::to_value(&silent[0]).unwrap().get("explain"),
5246            None
5247        );
5248
5249        let explained =
5250            fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
5251        let by_name = |name: &str| {
5252            explained
5253                .iter()
5254                .find(|r| r.symbol.name == name)
5255                .and_then(|r| r.explain.as_ref())
5256                .unwrap()
5257        };
5258        let name_only = by_name("parseSha256Sidecar");
5259        assert_eq!(name_only.candidates, 2);
5260        assert_eq!(name_only.branches, vec!["name"]);
5261        assert_eq!(name_only.bm25, None);
5262        let word_row = by_name("parse_sidecar_file");
5263        assert!(word_row.bm25.unwrap() < 0.0);
5264        assert_eq!(word_row.candidates, 2);
5265        assert!(
5266            serde_json::to_value(&explained[0])
5267                .unwrap()
5268                .get("explain")
5269                .is_some()
5270        );
5271    }
5272
5273    #[test]
5274    fn search_symbols_treats_like_wildcards_as_literals() {
5275        let dir = crate::safe_tempdir();
5276        let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
5277        let conn = open_read_write(&db_path).unwrap();
5278        conn.execute_batch(
5279            "CREATE TABLE symbols (
5280                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5281                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5282                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5283                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5284                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5285                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5286                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5287            );
5288            INSERT INTO symbols VALUES (
5289                's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
5290                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5291            );
5292            INSERT INTO symbols VALUES (
5293                'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
5294                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5295            );
5296            INSERT INTO symbols VALUES (
5297                'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
5298                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5299            );
5300            CREATE TABLE files (
5301                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
5302                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
5303            );
5304            INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
5305            INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
5306            );",
5307        )
5308        .unwrap();
5309
5310        assert_eq!(
5311            search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
5312            "literal%name"
5313        );
5314        assert_eq!(
5315            search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
5316            "literal_name"
5317        );
5318        assert_eq!(
5319            load_scoped_files(&conn, Some("src/literal_path"))
5320                .unwrap()
5321                .len(),
5322            1
5323        );
5324    }
5325
5326    #[test]
5327    fn find_references_for_symbol_limits_callees_by_symbol_id() {
5328        let dir = crate::safe_tempdir();
5329        let db_path = dir.path().join("find_references_for_symbol.db");
5330        let conn = open_read_write(&db_path).unwrap();
5331        conn.execute_batch(
5332            "CREATE TABLE symbols (
5333                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5334                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5335                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5336                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5337                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5338                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5339                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5340            );
5341            CREATE TABLE relationships (
5342                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5343                start_line INTEGER, start_column INTEGER
5344            );
5345            CREATE TABLE pending_relationships (
5346                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5347                start_line INTEGER, start_column INTEGER
5348            );
5349            INSERT INTO symbols VALUES
5350                ('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),
5351                ('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),
5352                ('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),
5353                ('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);
5354            INSERT INTO relationships VALUES
5355                ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
5356                ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
5357        )
5358        .unwrap();
5359
5360        let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
5361        assert_eq!(references.len(), 1);
5362        assert_eq!(references[0].to_symbol_name, "wanted_dep");
5363    }
5364
5365    #[test]
5366    fn test_fts_search_symbols_and_porter_stemming() {
5367        let dir = crate::safe_tempdir();
5368        let db_path = dir.path().join("fts_search_symbols.db");
5369        let conn = open_read_write(&db_path).unwrap();
5370
5371        conn.execute_batch(
5372            "CREATE TABLE symbols (
5373                symbol_id TEXT PRIMARY KEY,
5374                file_id TEXT,
5375                path TEXT,
5376                language TEXT,
5377                name TEXT,
5378                kind TEXT,
5379                signature TEXT,
5380                doc_comment TEXT,
5381                visibility TEXT,
5382                parent_symbol_id TEXT,
5383                start_line INTEGER,
5384                start_column INTEGER,
5385                end_line INTEGER,
5386                end_column INTEGER,
5387                start_byte INTEGER,
5388                end_byte INTEGER,
5389                body_start_line INTEGER,
5390                body_start_column INTEGER,
5391                body_end_line INTEGER,
5392                body_end_column INTEGER,
5393                body_start_byte INTEGER,
5394                body_end_byte INTEGER,
5395                body_hash TEXT,
5396                semantic_group TEXT,
5397                is_test INTEGER,
5398                test_container INTEGER
5399            );
5400            INSERT INTO symbols VALUES (
5401                's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
5402                'pub trait PaymentGateway', 'Core payment provider interface for transactions',
5403                'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
5404            );
5405            INSERT INTO symbols VALUES (
5406                's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
5407                'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
5408                'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
5409            );
5410            INSERT INTO symbols VALUES (
5411                's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
5412                'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
5413                'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
5414            );
5415            INSERT INTO symbols VALUES (
5416                's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
5417                'fn test_payment_flow()', 'Tests payment charge workflow',
5418                NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
5419            );",
5420        )
5421        .unwrap();
5422
5423        ensure_fts_index(&conn).unwrap();
5424
5425        // 1. Porter stemming match: 'parsing' matches 'parse_tokens' and 'Parses' docstring
5426        let results =
5427            fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
5428        assert_eq!(results.len(), 1);
5429        assert_eq!(results[0].symbol.name, "parse_tokens");
5430        assert!(results[0].snippet.is_some());
5431
5432        // 2. Docstring conceptual search: 'transactions' matches 'PaymentGateway'
5433        let results =
5434            fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
5435        assert_eq!(results.len(), 1);
5436        assert_eq!(results[0].symbol.name, "PaymentGateway");
5437
5438        // 3. Test filter: searching 'payment' with include_tests=false ignores 'test_payment_flow'
5439        let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
5440        assert_eq!(results.len(), 2);
5441        assert!(results.iter().all(|r| !r.symbol.is_test));
5442
5443        // 4. Test filter: searching 'payment' with include_tests=true includes 'test_payment_flow'
5444        let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
5445        assert_eq!(results.len(), 3);
5446
5447        // 5. Fallback OR matching: multi-term where only some match
5448        let results =
5449            fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
5450        assert_eq!(results.len(), 1);
5451        assert_eq!(results[0].symbol.name, "StripeClient");
5452    }
5453
5454    #[test]
5455    fn find_related_tests_returns_each_test_once_under_the_limit() {
5456        let dir = crate::safe_tempdir();
5457        let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
5458        conn.execute_batch(
5459            "CREATE TABLE symbols (
5460                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5461                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5462                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5463                start_column INTEGER, end_line INTEGER, end_column INTEGER,
5464                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5465                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5466                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5467                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5468            );
5469            CREATE TABLE relationships (
5470                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5471                start_line INTEGER, start_column INTEGER
5472            );
5473            CREATE TABLE pending_relationships (
5474                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5475                start_line INTEGER, start_column INTEGER,
5476                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5477            );
5478            CREATE TABLE type_facts (
5479                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5480            );
5481            INSERT INTO symbols VALUES
5482                ('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),
5483                ('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),
5484                ('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);
5485            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
5486                ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
5487                ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
5488                ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
5489                ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
5490                ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
5491                ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
5492        )
5493        .unwrap();
5494        let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
5495
5496        let tests = find_related_tests(&conn, &target, 5).unwrap();
5497
5498        let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
5499        names.sort();
5500        assert_eq!(names, vec!["first_case", "second_case"]);
5501    }
5502
5503    #[test]
5504    fn documentation_rows_rank_after_code_in_search() {
5505        let dir = crate::safe_tempdir();
5506        let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
5507        conn.execute_batch(
5508            "CREATE TABLE symbols (
5509                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5510                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5511                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5512                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5513                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5514                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5515                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5516            );
5517            INSERT INTO symbols VALUES
5518                ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
5519                 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
5520                 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
5521                ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
5522                 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
5523                 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
5524        )
5525        .unwrap();
5526        ensure_fts_index(&conn).unwrap();
5527
5528        let results =
5529            fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
5530                .unwrap();
5531
5532        assert_eq!(results.len(), 2);
5533        assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
5534        assert_eq!(results[1].symbol.name, "Reconcile offline edits");
5535    }
5536
5537    #[test]
5538    fn test_queries_nocase_and_path_normalization() {
5539        let conn = Connection::open_in_memory().unwrap();
5540        conn.execute_batch(
5541            "CREATE TABLE files (
5542                file_id TEXT PRIMARY KEY,
5543                path TEXT NOT NULL,
5544                language TEXT,
5545                content_hash TEXT,
5546                content_bytes INTEGER,
5547                line_count INTEGER,
5548                indexed_at INTEGER
5549            );
5550            CREATE TABLE symbols (
5551                symbol_id TEXT PRIMARY KEY,
5552                file_id TEXT,
5553                path TEXT NOT NULL,
5554                language TEXT,
5555                name TEXT,
5556                kind TEXT,
5557                signature TEXT,
5558                doc_comment TEXT,
5559                visibility TEXT,
5560                parent_symbol_id TEXT,
5561                start_line INTEGER,
5562                start_column INTEGER,
5563                end_line INTEGER,
5564                end_column INTEGER,
5565                start_byte INTEGER,
5566                end_byte INTEGER,
5567                body_start_line INTEGER,
5568                body_start_column INTEGER,
5569                body_end_line INTEGER,
5570                body_end_column INTEGER,
5571                body_start_byte INTEGER,
5572                body_end_byte INTEGER,
5573                body_hash TEXT,
5574                semantic_group TEXT,
5575                is_test INTEGER,
5576                test_container INTEGER
5577            );
5578            -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
5579            INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
5580            INSERT INTO symbols VALUES (
5581                's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
5582                'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5583                2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
5584            );",
5585        )
5586        .unwrap();
5587
5588        // 1. get_file: query with uppercase, lowercase, and forward slashes
5589        let file = get_file(&conn, "SRC/PAYMENT.RS")
5590            .unwrap()
5591            .expect("File should be found");
5592        assert_eq!(
5593            file.path, "src/Payment.rs",
5594            "Path should be normalized to forward slashes"
5595        );
5596
5597        let file2 = get_file(&conn, "src/payment.rs")
5598            .unwrap()
5599            .expect("File should be found");
5600        assert_eq!(file2.path, "src/Payment.rs");
5601
5602        // 2. load_file_symbols: query with uppercase and forward slashes
5603        let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
5604        assert_eq!(syms.len(), 1);
5605        assert_eq!(
5606            syms[0].path, "src/Payment.rs",
5607            "Symbol path should be normalized to forward slashes"
5608        );
5609
5610        // 3. get_symbol_by_name with path filter
5611        let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
5612            .unwrap()
5613            .expect("Symbol should be found with case-insensitive path filter");
5614        assert_eq!(sym.path, "src/Payment.rs");
5615    }
5616
5617    #[test]
5618    fn test_exact_case_prioritized_over_nocase() {
5619        let conn = Connection::open_in_memory().unwrap();
5620        conn.execute_batch(
5621            "CREATE TABLE files (
5622                file_id TEXT PRIMARY KEY,
5623                path TEXT NOT NULL,
5624                language TEXT,
5625                content_hash TEXT,
5626                content_bytes INTEGER,
5627                line_count INTEGER,
5628                indexed_at TEXT
5629            );
5630            CREATE TABLE symbols (
5631                symbol_id TEXT PRIMARY KEY,
5632                file_id TEXT,
5633                path TEXT NOT NULL,
5634                language TEXT,
5635                name TEXT NOT NULL,
5636                kind TEXT NOT NULL,
5637                signature TEXT,
5638                doc_comment TEXT,
5639                visibility TEXT,
5640                parent_symbol_id TEXT,
5641                start_line INTEGER,
5642                start_column INTEGER,
5643                end_line INTEGER,
5644                end_column INTEGER,
5645                start_byte INTEGER,
5646                end_byte INTEGER,
5647                body_start_line INTEGER,
5648                body_start_column INTEGER,
5649                body_end_line INTEGER,
5650                body_end_column INTEGER,
5651                body_start_byte INTEGER,
5652                body_end_byte INTEGER,
5653                body_hash TEXT,
5654                semantic_group TEXT,
5655                is_test INTEGER,
5656                test_container INTEGER
5657            );
5658            INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5659            INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5660            INSERT INTO symbols VALUES (
5661                's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5662                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5663                2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5664            );
5665            INSERT INTO symbols VALUES (
5666                's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5667                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5668                2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5669            );",
5670        )
5671        .unwrap();
5672
5673        // Exact match should return exact file, not conflate with sibling differing only by case
5674        let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5675        assert_eq!(f_lower.path, "src/payment.rs");
5676        assert_eq!(f_lower.file_id, "f2");
5677
5678        let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5679        assert_eq!(f_upper.path, "src/Payment.rs");
5680        assert_eq!(f_upper.file_id, "f1");
5681
5682        let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5683        assert_eq!(syms_lower.len(), 1);
5684        assert_eq!(syms_lower[0].file_id, "f2");
5685
5686        let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5687        assert_eq!(syms_upper.len(), 1);
5688        assert_eq!(syms_upper[0].file_id, "f1");
5689    }
5690
5691    #[test]
5692    fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5693        let dir = crate::safe_tempdir();
5694        let db_path = dir.path().join("conservative_resolution.db");
5695        let conn = open_read_write(&db_path).unwrap();
5696
5697        conn.execute_batch(
5698            "CREATE TABLE symbols (
5699                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5700                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5701                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5702                start_column INTEGER, end_line INTEGER, end_column INTEGER,
5703                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5704                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5705                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5706                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5707            );
5708            CREATE TABLE relationships (
5709                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5710                start_line INTEGER, start_column INTEGER
5711            );
5712            CREATE TABLE pending_relationships (
5713                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5714                start_line INTEGER, start_column INTEGER,
5715                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5716            );
5717            CREATE TABLE type_facts (
5718                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5719            );
5720            -- Workspace struct Workspace and method Workspace::new
5721            INSERT INTO symbols VALUES
5722                ('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),
5723                ('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),
5724                ('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);
5725
5726            -- my_func calls Vec::new() (external namespace 'Vec')
5727            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
5728                ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5729        )
5730        .unwrap();
5731
5732        // When include_external is false, calling Vec::new() should NOT resolve to Workspace::new()
5733        let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5734        assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5735
5736        let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5737        assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5738
5739        // Caller references for Workspace::new should NOT list my_func
5740        let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5741        assert!(
5742            callers.is_empty(),
5743            "Expected 0 callers for Workspace::new, got: {:?}",
5744            callers
5745        );
5746
5747        // Blast radius for Workspace::new should NOT impact my_func (which only called Vec::new)
5748        let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5749        assert!(
5750            !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5751            "my_func should not be impacted before calling Workspace::new: {:?}",
5752            blast.impacted_symbols
5753        );
5754
5755        // Now add a call to Workspace::new()
5756        conn.execute(
5757            "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')",
5758            [],
5759        )
5760        .unwrap();
5761
5762        let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5763        assert_eq!(
5764            sigs2.len(),
5765            1,
5766            "Expected 1 signature for Workspace::new, got: {:?}",
5767            sigs2
5768        );
5769        assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5770
5771        // Blast radius for Workspace::new should now include my_func
5772        let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5773        assert!(
5774            blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5775            "my_func should be impacted after calling Workspace::new: {:?}",
5776            blast2.impacted_symbols
5777        );
5778
5779        // Add a bare call to new() from an unrelated caller s_other
5780        conn.execute(
5781            "INSERT INTO symbols VALUES
5782                ('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);",
5783            [],
5784        )
5785        .unwrap();
5786        conn.execute(
5787            "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')",
5788            [],
5789        )
5790        .unwrap();
5791
5792        // Bare call from unrelated function should NOT resolve to Workspace::new
5793        let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
5794        assert!(
5795            sigs_other.is_empty(),
5796            "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
5797            sigs_other
5798        );
5799
5800        // A sibling method inside Workspace calling bare new() SHOULD resolve to Workspace::new
5801        conn.execute(
5802            "INSERT INTO symbols VALUES
5803                ('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);",
5804            [],
5805        )
5806        .unwrap();
5807        conn.execute(
5808            "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')",
5809            [],
5810        )
5811        .unwrap();
5812
5813        let sigs_sibling =
5814            find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
5815        assert_eq!(
5816            sigs_sibling.len(),
5817            1,
5818            "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
5819            sigs_sibling
5820        );
5821
5822        // With include_external: true, external calls should be returned
5823        let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
5824        assert!(
5825            ext_sigs.iter().any(|s| s.contains("Vec")),
5826            "include_external: true should include external Vec::new: {:?}",
5827            ext_sigs
5828        );
5829    }
5830
5831    #[test]
5832    fn test_find_structural_facts_and_literals_scoped() {
5833        let dir = crate::safe_tempdir();
5834        let db_path = dir.path().join("facts_test.db");
5835        let conn = open_read_write(&db_path).unwrap();
5836        conn.execute_batch(
5837            "CREATE TABLE symbols (
5838                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5839                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5840                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5841                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5842                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5843                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5844                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5845            );
5846            CREATE TABLE structural_facts (
5847                structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5848                pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
5849                start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
5850            );
5851            CREATE TABLE literals (
5852                literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5853                kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
5854                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5855                start_byte INTEGER, end_byte INTEGER
5856            );
5857            INSERT INTO structural_facts VALUES
5858                ('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\"}'),
5859                ('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\"}'),
5860                ('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\"}'),
5861                ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
5862                ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
5863                ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
5864                ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
5865            INSERT INTO literals VALUES
5866                ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
5867                ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
5868                ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
5869                ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
5870        )
5871        .unwrap();
5872
5873        // 1. "config" alias
5874        let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
5875        assert_eq!(facts_config.len(), 2);
5876        assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
5877        assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
5878        assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
5879        assert_eq!(
5880            facts_config[1].key.as_deref(),
5881            Some("mcp_servers.code-kb.command")
5882        );
5883        let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
5884        assert_eq!(lits_config.len(), 1);
5885        assert_eq!(lits_config[0].kind, "toml_key");
5886
5887        // 2. "route" and "routes" aliases
5888        let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
5889        assert_eq!(facts_route.len(), 1);
5890        assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
5891        assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
5892        let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
5893        assert_eq!(facts_routes.len(), 1);
5894        let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
5895        assert_eq!(lits_route.len(), 1);
5896        assert_eq!(lits_route[0].kind, "http_route");
5897
5898        // 3. "query", "queries", "sql" aliases
5899        for q in &["query", "queries", "sql"] {
5900            let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
5901            assert_eq!(facts.len(), 2, "Failed for {}", q);
5902            assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
5903            let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
5904            assert_eq!(lits.len(), 1, "Failed for {}", q);
5905            assert_eq!(lits[0].kind, "sql_query");
5906        }
5907
5908        // 4. "model" and "models" aliases
5909        for m in &["model", "models"] {
5910            let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
5911            assert_eq!(facts.len(), 1, "Failed for {}", m);
5912            assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
5913            let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
5914            assert_eq!(lits.len(), 1, "Failed for {}", m);
5915            assert_eq!(lits[0].kind, "model_table");
5916        }
5917
5918        // 5. Custom / unknown category
5919        let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
5920        assert_eq!(facts_custom.len(), 1);
5921        assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
5922        assert_eq!(facts_custom[0].key, None);
5923
5924        // 6. Path filter: exact file match
5925        let facts_exact =
5926            find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
5927        assert_eq!(facts_exact.len(), 1);
5928        let facts_miss =
5929            find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
5930        assert_eq!(facts_miss.len(), 0);
5931
5932        // 7. Path filter: directory prefix
5933        let facts_dir =
5934            find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
5935        assert_eq!(facts_dir.len(), 1);
5936        let facts_dir_miss =
5937            find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
5938        assert_eq!(facts_dir_miss.len(), 0);
5939
5940        // 8. Delegating find_structural_facts and find_literals
5941        let f_del = find_structural_facts(&conn, "config", 10).unwrap();
5942        assert_eq!(f_del.len(), 2);
5943        let l_del = find_literals(&conn, "config", 10).unwrap();
5944        assert_eq!(l_del.len(), 1);
5945    }
5946
5947    fn local_variable_fixture() -> Connection {
5948        let conn = Connection::open_in_memory().unwrap();
5949        conn.execute_batch(
5950            "CREATE TABLE symbols (
5951                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5952                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5953                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5954                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5955                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5956                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5957                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5958            );
5959            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5960                                 parent_symbol_id, start_line, start_column, end_line, end_column,
5961                                 start_byte, end_byte, is_test, test_container)
5962            VALUES
5963                ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
5964                 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5965                ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5966                 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5967                ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
5968                 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
5969                ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5970                 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
5971                ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5972                 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
5973                ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
5974                 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
5975                ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5976                 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
5977        )
5978        .unwrap();
5979        conn
5980    }
5981
5982    fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
5983        let mut stmt = conn
5984            .prepare(
5985                "SELECT s.symbol_id FROM symbols_fts f
5986                 JOIN symbols s ON s.rowid = f.rowid
5987                 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
5988            )
5989            .unwrap();
5990        let mut ids = stmt
5991            .query_map(params![query], |row| row.get::<_, String>(0))
5992            .unwrap()
5993            .collect::<Result<Vec<_>, _>>()
5994            .unwrap();
5995        ids.sort();
5996        ids
5997    }
5998
5999    #[test]
6000    fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
6001        let conn = local_variable_fixture();
6002        conn.execute_batch(
6003            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
6004                name, signature, doc_comment,
6005                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
6006            );
6007            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
6008            SELECT rowid, name, signature, doc_comment FROM symbols;",
6009        )
6010        .unwrap();
6011
6012        ensure_fts_index(&conn).unwrap();
6013
6014        assert_eq!(
6015            matched_symbol_ids(&conn, "sqlite"),
6016            vec!["field", "func", "global", "pool"]
6017        );
6018    }
6019
6020    #[test]
6021    fn lookup_excludes_locals_and_parameters() {
6022        let conn = local_variable_fixture();
6023
6024        let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
6025            .unwrap()
6026            .into_iter()
6027            .map(|s| s.symbol_id)
6028            .collect();
6029
6030        assert!(!ids.contains(&"local".to_string()));
6031        assert!(!ids.contains(&"nested".to_string()));
6032        assert!(ids.contains(&"field".to_string()));
6033        assert!(ids.contains(&"global".to_string()));
6034    }
6035
6036    #[test]
6037    fn search_excludes_locals_and_parameters() {
6038        let conn = local_variable_fixture();
6039        ensure_fts_index(&conn).unwrap();
6040
6041        let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
6042            .unwrap()
6043            .into_iter()
6044            .map(|r| r.symbol.symbol_id)
6045            .collect();
6046
6047        assert!(!ids.contains(&"local".to_string()));
6048        assert!(ids.contains(&"func".to_string()));
6049    }
6050
6051    #[test]
6052    fn variable_kind_search_keeps_full_text_matching() {
6053        let conn = local_variable_fixture();
6054        ensure_fts_index(&conn).unwrap();
6055
6056        let ids: Vec<String> = fts_search_symbols_scoped(
6057            &conn,
6058            "sqlite connection",
6059            Some("variable"),
6060            None,
6061            false,
6062            10,
6063        )
6064        .unwrap()
6065        .into_iter()
6066        .map(|r| r.symbol.symbol_id)
6067        .collect();
6068
6069        assert!(ids.contains(&"global".to_string()));
6070        assert!(ids.contains(&"field".to_string()));
6071    }
6072
6073    #[test]
6074    fn qualified_lookup_returns_the_named_local_variable() {
6075        let conn = local_variable_fixture();
6076
6077        let ids: Vec<String> =
6078            search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
6079                .unwrap()
6080                .into_iter()
6081                .map(|s| s.symbol_id)
6082                .collect();
6083
6084        assert_eq!(ids, vec!["local".to_string()]);
6085    }
6086
6087    #[test]
6088    fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
6089        let conn = Connection::open_in_memory().unwrap();
6090        conn.execute_batch(
6091            "CREATE TABLE symbols (
6092                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
6093                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
6094                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
6095                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
6096                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
6097                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
6098                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
6099            );
6100            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
6101                                 parent_symbol_id, start_line, start_column, end_line, end_column,
6102                                 start_byte, end_byte, is_test, test_container)
6103            VALUES
6104                ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
6105                 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
6106                ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
6107                 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
6108                ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
6109                 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
6110        )
6111        .unwrap();
6112        ensure_fts_index(&conn).unwrap();
6113
6114        let rows =
6115            fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
6116                .unwrap();
6117
6118        assert_eq!(rows.len(), 1);
6119        assert_eq!(rows[0].symbol.symbol_id, "local");
6120        let explain = rows[0].explain.as_ref().unwrap();
6121        assert_eq!(explain.name_tier, "whole");
6122        assert_eq!(explain.branches, vec!["exact", "name"]);
6123        assert_eq!(explain.candidates, 2);
6124    }
6125
6126    #[test]
6127    fn variable_kind_filter_returns_locals_and_parameters() {
6128        let conn = local_variable_fixture();
6129        ensure_fts_index(&conn).unwrap();
6130
6131        let lookup_ids: Vec<String> =
6132            search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6133                .unwrap()
6134                .into_iter()
6135                .map(|s| s.symbol_id)
6136                .collect();
6137        assert!(lookup_ids.contains(&"local".to_string()));
6138        assert!(lookup_ids.contains(&"nested".to_string()));
6139
6140        let search_ids: Vec<String> =
6141            fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6142                .unwrap()
6143                .into_iter()
6144                .map(|r| r.symbol.symbol_id)
6145                .collect();
6146        assert!(search_ids.contains(&"local".to_string()));
6147    }
6148}