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