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