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