Skip to main content

code_kb_core/
queries.rs

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