Skip to main content

code_kb_core/
queries.rs

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