Skip to main content

code_kb_core/
queries.rs

1use rusqlite::{Connection, OptionalExtension, Row, ToSql, params};
2use rust_stemmers::{Algorithm, Stemmer};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5
6use crate::db::local_variable_predicate;
7use crate::models::{
8    BlastRadiusResult, FileFact, ImpactedSymbol, LiteralFact, ReferenceSite, SearchExplain,
9    StructuralFact, Symbol, SymbolSearchResult, TestTarget, TypeFact,
10};
11
12#[derive(Debug, Error)]
13pub enum QueryError {
14    #[error("Database query error: {0}")]
15    Sqlite(#[from] rusqlite::Error),
16    #[error("Symbol '{name}' not found in {workspace}. {hint}")]
17    SymbolNotFound {
18        name: String,
19        workspace: String,
20        hint: String,
21    },
22    #[error(
23        "Ambiguous symbol '{0}': found {1} matching candidates. Specify file_path or qualified name to disambiguate:\n{2}"
24    )]
25    AmbiguousSymbol(String, usize, String),
26    #[error("Invalid direction '{0}': must be 'callers' or 'callees'")]
27    InvalidDirection(String),
28    #[error("Result limit must be between 0 and {MAX_RESULT_LIMIT}, got {0}")]
29    InvalidResultLimit(usize),
30}
31
32pub const MAX_RESULT_LIMIT: usize = 200;
33
34pub fn validate_result_limit(limit: usize) -> Result<(), QueryError> {
35    if limit > MAX_RESULT_LIMIT {
36        return Err(QueryError::InvalidResultLimit(limit));
37    }
38    Ok(())
39}
40
41fn map_symbol(row: &Row) -> rusqlite::Result<Symbol> {
42    Ok(Symbol {
43        symbol_id: row.get("symbol_id")?,
44        file_id: row.get("file_id")?,
45        path: row.get::<_, String>("path")?.replace('\\', "/"),
46        language: row.get("language")?,
47        name: row.get("name")?,
48        kind: row.get("kind")?,
49        signature: row.get("signature")?,
50        doc_comment: row.get("doc_comment")?,
51        visibility: row.get("visibility")?,
52        parent_symbol_id: row.get("parent_symbol_id")?,
53        start_line: row.get::<_, i64>("start_line")? as usize,
54        start_column: row.get::<_, i64>("start_column")? as usize,
55        end_line: row.get::<_, i64>("end_line")? as usize,
56        end_column: row.get::<_, i64>("end_column")? as usize,
57        start_byte: row.get::<_, i64>("start_byte")? as usize,
58        end_byte: row.get::<_, i64>("end_byte")? as usize,
59        body_start_line: row
60            .get::<_, Option<i64>>("body_start_line")?
61            .map(|v| v as usize),
62        body_start_column: row
63            .get::<_, Option<i64>>("body_start_column")?
64            .map(|v| v as usize),
65        body_end_line: row
66            .get::<_, Option<i64>>("body_end_line")?
67            .map(|v| v as usize),
68        body_end_column: row
69            .get::<_, Option<i64>>("body_end_column")?
70            .map(|v| v as usize),
71        body_start_byte: row
72            .get::<_, Option<i64>>("body_start_byte")?
73            .map(|v| v as usize),
74        body_end_byte: row
75            .get::<_, Option<i64>>("body_end_byte")?
76            .map(|v| v as usize),
77        body_hash: row.get("body_hash")?,
78        semantic_group: row.get("semantic_group")?,
79        is_test: row.get::<_, i64>("is_test")? != 0,
80        test_container: row.get::<_, i64>("test_container")? != 0,
81    })
82}
83
84pub(crate) fn escape_like(value: &str) -> String {
85    value
86        .replace('\\', "\\\\")
87        .replace('%', "\\%")
88        .replace('_', "\\_")
89}
90
91/// Retrieve indexed files optionally scoped by path filter, pushed down to SQLite.
92pub fn load_scoped_files(
93    conn: &Connection,
94    path_filter: Option<&str>,
95) -> Result<Vec<FileFact>, QueryError> {
96    let norm = path_filter
97        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
98        .filter(|p| !p.is_empty());
99    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
100    let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
101    let prefix_bs = norm_bs
102        .as_ref()
103        .map(|path| format!("{}\\\\%", escape_like(path)));
104
105    let sql = "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
106               FROM files
107               WHERE (:path IS NULL
108                  OR path = :path COLLATE NOCASE
109                  OR path = :path_bs COLLATE NOCASE
110                  OR path LIKE :path_prefix ESCAPE '\\'
111                  OR path LIKE :path_prefix_bs ESCAPE '\\')
112               ORDER BY (:path IS NOT NULL AND (path = :path OR path = :path_bs)) DESC, path ASC";
113
114    let mut stmt = conn.prepare(sql)?;
115    let files = stmt
116        .query_map(
117            rusqlite::named_params! {
118                ":path": norm.as_deref(),
119                ":path_bs": norm_bs.as_deref(),
120                ":path_prefix": prefix.as_deref(),
121                ":path_prefix_bs": prefix_bs.as_deref(),
122            },
123            |row| {
124                Ok(FileFact {
125                    file_id: row.get(0)?,
126                    path: row.get::<_, String>(1)?.replace('\\', "/"),
127                    language: row.get(2)?,
128                    content_hash: row.get(3)?,
129                    content_bytes: row.get(4)?,
130                    line_count: row.get(5)?,
131                    indexed_at: row.get(6)?,
132                })
133            },
134        )?
135        .collect::<Result<Vec<_>, _>>()?;
136
137    Ok(files)
138}
139
140/// The most files one outline lists by name.
141pub const OUTLINE_FILE_CAP: usize = 1000;
142
143/// The path filter and depth of one outline, as SQL parameters.
144pub struct OutlineScope {
145    path: Option<String>,
146    path_bs: Option<String>,
147    prefix: Option<String>,
148    prefix_bs: Option<String>,
149    /// The most path separators a file the outline lists by name can have.
150    max_slashes: i64,
151}
152
153impl OutlineScope {
154    pub fn new(path_filter: Option<&str>, depth: usize) -> Self {
155        let path = path_filter
156            .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
157            .filter(|p| !p.is_empty());
158        let path_bs = path.as_ref().map(|p| p.replace('/', "\\"));
159        let max_slashes = match &path {
160            None => depth.saturating_sub(1),
161            Some(p) => p.matches('/').count() + depth,
162        } as i64;
163        Self {
164            prefix: path.as_ref().map(|p| format!("{}/%", escape_like(p))),
165            prefix_bs: path_bs.as_ref().map(|p| format!("{}\\\\%", escape_like(p))),
166            path,
167            path_bs,
168            max_slashes,
169        }
170    }
171
172    /// True when the outline lists `path` by name instead of counting it in its folder.
173    pub fn lists(&self, path: &str) -> bool {
174        path.matches(['/', '\\']).count() as i64 <= self.max_slashes
175    }
176
177    fn params(&self) -> [(&'static str, &dyn ToSql); 5] {
178        [
179            (":path", &self.path),
180            (":path_bs", &self.path_bs),
181            (":path_prefix", &self.prefix),
182            (":path_prefix_bs", &self.prefix_bs),
183            (":max_slashes", &self.max_slashes),
184        ]
185    }
186}
187
188/// The `WHERE` terms for the files in an outline scope that have an extractor.
189fn outline_file_terms(conn: &Connection) -> String {
190    let supported = if has_column(conn, "files", "status") {
191        "COALESCE(status, '') != 'unsupported'"
192    } else {
193        "1 = 1"
194    };
195    format!(
196        "(:path IS NULL
197            OR path = :path COLLATE NOCASE
198            OR path = :path_bs COLLATE NOCASE
199            OR path LIKE :path_prefix ESCAPE '\\'
200            OR path LIKE :path_prefix_bs ESCAPE '\\')
201         AND {supported}"
202    )
203}
204
205/// The files an outline lists by name, in path order.
206fn outline_files_cte(conn: &Connection) -> String {
207    format!(
208        "outline_files AS (
209            SELECT path FROM files
210            WHERE {}
211              AND length(path) - length(replace(replace(path, '/', ''), '\\', '')) <= :max_slashes
212            ORDER BY path ASC
213            LIMIT {OUTLINE_FILE_CAP}
214        )",
215        outline_file_terms(conn)
216    )
217}
218
219/// Streams every file path in the outline scope that has an extractor, in path order.
220pub fn for_each_outline_path(
221    conn: &Connection,
222    scope: &OutlineScope,
223    mut visit: impl FnMut(String) -> bool,
224) -> Result<(), QueryError> {
225    let sql = format!(
226        "SELECT path FROM files WHERE {} ORDER BY path ASC",
227        outline_file_terms(conn)
228    );
229    let mut stmt = conn.prepare(&sql)?;
230    let params = scope.params();
231    let mut rows = stmt.query(&params[..4])?;
232    while let Some(row) = rows.next()? {
233        if !visit(row.get(0)?) {
234            break;
235        }
236    }
237    Ok(())
238}
239
240/// True when `alias` names a definition at the top of its file: no parent, or only a namespace,
241/// module, or package around it.
242fn outline_level(alias: &str) -> String {
243    format!(
244        "({alias}.parent_symbol_id IS NULL OR EXISTS (
245            SELECT 1 FROM symbols ns WHERE ns.symbol_id = {alias}.parent_symbol_id
246              AND ns.kind IN ('namespace', 'module', 'package')))"
247    )
248}
249
250const OUTLINE_KINDS: &str =
251    "('function', 'method', 'struct', 'enum', 'trait', 'class', 'interface', 'type')";
252
253/// Load up to `limit_per_file` top-level definitions for each file the outline lists by name.
254/// Files deeper than `depth` are filtered out in SQLite to keep memory strictly bounded. The
255/// unary `+` keeps the overload check on the name index: the parent index would walk every
256/// top-level symbol of the repository for each row.
257pub fn load_scoped_outline_symbols(
258    conn: &Connection,
259    path_filter: Option<&str>,
260    depth: usize,
261    limit_per_file: usize,
262) -> Result<HashMap<String, Vec<Symbol>>, QueryError> {
263    let scope = OutlineScope::new(path_filter, depth);
264    let sql = format!(
265        "WITH {files},
266        ranked AS (
267            SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
268                   s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
269                   s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
270                   s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
271                   s.is_test, s.test_container,
272                   ROW_NUMBER() OVER (
273                       PARTITION BY s.path ORDER BY (s.is_test = 1 OR s.test_container = 1), s.start_line ASC
274                   ) as rn
275            FROM symbols s
276            JOIN outline_files bf ON s.path = bf.path
277            WHERE s.kind IN {OUTLINE_KINDS}
278              AND {top}
279              AND NOT EXISTS (SELECT 1 FROM symbols overload
280                              WHERE +overload.path = s.path AND overload.name = s.name
281                                AND overload.kind = s.kind
282                                AND +overload.parent_symbol_id IS s.parent_symbol_id
283                                AND overload.start_line < s.start_line)
284        )
285        SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
286               visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
287               start_byte, end_byte, body_start_line, body_start_column, body_end_line,
288               body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
289               is_test, test_container
290        FROM ranked
291        WHERE rn <= :limit
292        ORDER BY path ASC, start_line ASC",
293        files = outline_files_cte(conn),
294        top = outline_level("s"),
295    );
296
297    let mut stmt = conn.prepare(&sql)?;
298    let limit = limit_per_file as i64;
299    let mut params = scope.params().to_vec();
300    params.push((":limit", &limit));
301    let mut rows = stmt.query(&params[..])?;
302
303    let mut symbols_by_file: HashMap<String, Vec<Symbol>> = HashMap::new();
304    while let Some(row) = rows.next()? {
305        let sym = map_symbol(row)?;
306        symbols_by_file
307            .entry(sym.path.clone())
308            .or_default()
309            .push(sym);
310    }
311
312    Ok(symbols_by_file)
313}
314
315/// How many names one outline row stands for.
316#[derive(Debug, Clone, Copy, Default, PartialEq)]
317pub struct OutlineCounts {
318    /// Distinct top-level definitions that are not tests; overloads count once.
319    pub definitions: usize,
320    /// Tests at any level, without setup and fixtures.
321    pub tests: usize,
322    /// Setup, teardown, and fixture members.
323    pub fixtures: usize,
324}
325
326/// For each file the outline lists by name: its top-level definitions that are not tests, its
327/// tests, and its fixtures, so the outline can say how many names it left out.
328pub fn load_outline_counts(
329    conn: &Connection,
330    path_filter: Option<&str>,
331    depth: usize,
332) -> Result<HashMap<String, OutlineCounts>, QueryError> {
333    let scope = OutlineScope::new(path_filter, depth);
334    let lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
335        "COALESCE(s.test_lifecycle, 0) != 0"
336    } else {
337        "0"
338    };
339    let mut stmt = conn.prepare(&format!(
340        "WITH {files}
341         SELECT replace(s.path, '\\', '/'),
342                COUNT(DISTINCT CASE WHEN {top} AND s.is_test = 0 AND s.test_container = 0
343                    AND s.kind IN {OUTLINE_KINDS}
344                    THEN s.kind || ' ' || s.name END),
345                SUM(s.is_test = 1 AND NOT {lifecycle}),
346                SUM(s.is_test = 1 AND {lifecycle})
347         FROM symbols s
348         JOIN outline_files bf ON s.path = bf.path
349         GROUP BY replace(s.path, '\\', '/')",
350        files = outline_files_cte(conn),
351        top = outline_level("s"),
352    ))?;
353    let rows = stmt.query_map(&scope.params()[..], |row| {
354        Ok((
355            row.get::<_, String>(0)?,
356            OutlineCounts {
357                definitions: row.get::<_, i64>(1)? as usize,
358                tests: row.get::<_, i64>(2)? as usize,
359                fixtures: row.get::<_, i64>(3)? as usize,
360            },
361        ))
362    })?;
363    Ok(rows.collect::<Result<_, _>>()?)
364}
365
366/// The largest number of files one answer is measured against.
367pub const BASELINE_PATH_CAP: usize = 20;
368
369/// Sums the indexed byte size of up to `BASELINE_PATH_CAP` distinct paths.
370/// Returns 0 when no path is indexed, so telemetry never fails a tool call.
371pub fn file_sizes_for_paths(conn: &Connection, paths: &[String]) -> usize {
372    let mut seen = std::collections::HashSet::new();
373    let distinct: Vec<&String> = paths
374        .iter()
375        .filter(|path| seen.insert(path.as_str()))
376        .take(BASELINE_PATH_CAP)
377        .collect();
378    if distinct.is_empty() {
379        return 0;
380    }
381
382    let placeholders = vec!["?"; distinct.len()].join(", ");
383    let sql =
384        format!("SELECT COALESCE(SUM(content_bytes), 0) FROM files WHERE path IN ({placeholders})");
385    conn.query_row(&sql, rusqlite::params_from_iter(distinct), |row| {
386        row.get::<_, i64>(0)
387    })
388    .map(|bytes| bytes.max(0) as usize)
389    .unwrap_or(0)
390}
391
392/// Lookup single file metadata by path with slash-boundary matching.
393pub fn get_file(conn: &Connection, path: &str) -> Result<Option<FileFact>, QueryError> {
394    let normalized = path.replace('\\', "/");
395    let backslash = path.replace('/', "\\");
396
397    // Check exact path match first, prioritizing exact case before case-insensitive fallback
398    let mut stmt = conn.prepare(
399        "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
400         FROM files
401         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
402         ORDER BY (path = ?1 OR path = ?2) DESC
403         LIMIT 1",
404    )?;
405
406    let mut rows = stmt.query(params![normalized, backslash])?;
407    if let Some(row) = rows.next()? {
408        Ok(Some(FileFact {
409            file_id: row.get(0)?,
410            path: row.get::<_, String>(1)?.replace('\\', "/"),
411            language: row.get(2)?,
412            content_hash: row.get(3)?,
413            content_bytes: row.get(4)?,
414            line_count: row.get(5)?,
415            indexed_at: row.get(6)?,
416        }))
417    } else {
418        Ok(None)
419    }
420}
421
422/// Count parse diagnostics recorded for a file, returning 0 when the index has none.
423/// Number of indexed symbols in one file, without loading the rows. Like `load_file_symbols`,
424/// an exact-case path wins and the case-insensitive match is only the fallback.
425pub fn count_file_symbols(conn: &Connection, path: &str) -> usize {
426    let count = |sql: &str| {
427        conn.query_row(
428            sql,
429            params![path.replace('\\', "/"), path.replace('/', "\\")],
430            |row| row.get::<_, i64>(0),
431        )
432        .map(|count| count.max(0) as usize)
433        .unwrap_or(0)
434    };
435    match count("SELECT COUNT(*) FROM symbols WHERE path = ?1 OR path = ?2") {
436        0 => count(
437            "SELECT COUNT(*) FROM symbols
438             WHERE path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE",
439        ),
440        exact => exact,
441    }
442}
443
444pub fn count_parse_diagnostics(conn: &Connection, path: &str) -> usize {
445    conn.query_row(
446        "SELECT COUNT(*) FROM parse_diagnostics
447         WHERE path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE",
448        params![path.replace('\\', "/"), path.replace('/', "\\")],
449        |row| row.get::<_, i64>(0),
450    )
451    .map(|count| count as usize)
452    .unwrap_or(0)
453}
454
455/// Streams the paths of files in the outline scope that no extractor reads.
456pub fn for_each_unsupported_path(
457    conn: &Connection,
458    scope: &OutlineScope,
459    mut visit: impl FnMut(&str),
460) -> Result<(), QueryError> {
461    if !has_column(conn, "files", "status") {
462        return Ok(());
463    }
464    let mut stmt = conn.prepare(
465        "SELECT path FROM files
466         WHERE status = 'unsupported'
467           AND (:path IS NULL
468             OR path = :path COLLATE NOCASE
469             OR path = :path_bs COLLATE NOCASE
470             OR path LIKE :path_prefix ESCAPE '\\'
471             OR path LIKE :path_prefix_bs ESCAPE '\\')",
472    )?;
473    let params = scope.params();
474    let mut rows = stmt.query(&params[..4])?;
475    while let Some(row) = rows.next()? {
476        visit(&row.get::<_, String>(0)?);
477    }
478    Ok(())
479}
480
481/// Load all symbols declared inside a specific file.
482pub fn load_file_symbols(conn: &Connection, file_path: &str) -> Result<Vec<Symbol>, QueryError> {
483    // Normalizing slashes for path matching
484    let normalized = file_path.replace('\\', "/");
485    let backslash = file_path.replace('/', "\\");
486
487    // Try exact case matching first to avoid conflating sibling files on case-sensitive filesystems
488    let mut stmt = conn.prepare(
489        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
490                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
491                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
492                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
493                is_test, test_container
494         FROM symbols
495         WHERE (path = ?1 OR path = ?2)
496         ORDER BY start_line ASC, start_column ASC",
497    )?;
498
499    let rows = stmt
500        .query_map(params![&normalized, &backslash], map_symbol)?
501        .collect::<Result<Vec<_>, _>>()?;
502
503    if !rows.is_empty() {
504        return Ok(rows);
505    }
506
507    // Fall back to case-insensitive match (for Windows or case-variant requests)
508    let mut stmt = conn.prepare(
509        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
510                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
511                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
512                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
513                is_test, test_container
514         FROM symbols
515         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
516         ORDER BY start_line ASC, start_column ASC",
517    )?;
518
519    let rows = stmt
520        .query_map(params![normalized, backslash], map_symbol)?
521        .collect::<Result<Vec<_>, _>>()?;
522
523    Ok(rows)
524}
525
526/// Normalizes common symbol kind aliases to their canonical database representation.
527pub fn normalize_kind(kind: &str) -> String {
528    let lower = kind.trim().to_lowercase();
529    match lower.as_str() {
530        "fn" | "func" | "function" => "function".to_string(),
531        "method" => "method".to_string(),
532        "struct" => "struct".to_string(),
533        "class" => "class".to_string(),
534        "enum" => "enum".to_string(),
535        "trait" => "trait".to_string(),
536        "interface" => "interface".to_string(),
537        "type" | "typedef" => "type".to_string(),
538        "mod" | "module" => "module".to_string(),
539        "const" | "constant" => "constant".to_string(),
540        "var" | "variable" => "variable".to_string(),
541        _ => lower,
542    }
543}
544
545/// Search symbols by name query, kind filter, and test flag.
546pub fn search_symbols(
547    conn: &Connection,
548    query: &str,
549    kind_filter: Option<&str>,
550    include_tests: bool,
551    limit: usize,
552) -> Result<Vec<Symbol>, QueryError> {
553    search_symbols_scoped(conn, query, kind_filter, None, include_tests, limit)
554}
555
556/// Search symbols with optional path scoping filter. Locals and parameters are left out unless
557/// the caller passes `kind = "variable"` or names one explicitly as a qualified name such as
558/// `open_conn::conn`. With `kind = "variable"` they match by name only, because they are not in
559/// the full-text index.
560/// The lookup statement. The exact-name bypass of the test filter is written as `+name` so
561/// SQLite cannot plan it as a multi-index OR, which scans every non-test row through the
562/// test-path predicate (about 8 ms on this repository's index against 1 ms).
563/// True when `alias` is an attribute row such as `self.debug = …` whose name a base class already
564/// defines with a value or a property. A base class named by a pending `extends` row counts only
565/// when no class of that name is as close to the subclass's file, so an unclear base keeps the row. The row is a write to an inherited attribute, not a
566/// definition, so lookup, search, and skeletons leave it out. `CASE` keeps SQLite from walking
567/// the base classes of every row a search touches: it evaluates an `AND` with `EXISTS` eagerly.
568pub(crate) fn inherited_attribute_write(alias: &str) -> String {
569    let rival_rank = path_proximity("rival.path", "pe.path");
570    let base_rank = path_proximity("c.path", "pe.path");
571    format!(
572        "(CASE WHEN {alias}.kind = 'property' AND {alias}.parent_symbol_id IS NOT NULL
573          AND ({alias}.signature LIKE 'self.%' OR {alias}.signature LIKE 'this.%' OR {alias}.signature LIKE 'cls.%')
574          THEN EXISTS (
575            WITH RECURSIVE base(symbol_id, depth) AS (
576                SELECT {alias}.parent_symbol_id, 0
577                UNION
578                SELECT r.to_symbol_id, base.depth + 1
579                FROM relationships r JOIN base ON r.from_symbol_id = base.symbol_id
580                WHERE +r.kind = 'extends' AND base.depth < 8
581                UNION
582                SELECT c.symbol_id, base.depth + 1
583                FROM pending_relationships pe
584                JOIN base ON pe.from_symbol_id = base.symbol_id
585                JOIN symbols c ON c.name = pe.target_terminal_name
586                WHERE +pe.kind = 'extends' AND base.depth < 8
587                  AND c.kind IN ('class', 'interface', 'struct', 'trait', 'protocol')
588                  AND NOT EXISTS (
589                      SELECT 1 FROM symbols rival
590                      WHERE rival.name = c.name AND rival.symbol_id != c.symbol_id
591                        AND rival.kind IN ('class', 'interface', 'struct', 'trait', 'protocol')
592                        AND {rival_rank} >= {base_rank})
593            )
594            SELECT 1 FROM base JOIN symbols inherited ON inherited.parent_symbol_id = base.symbol_id
595            WHERE base.depth > 0 AND inherited.name = {alias}.name
596              AND (inherited.kind NOT IN ('variable', 'field') OR inherited.signature LIKE '%=%'))
597          ELSE 0 END)"
598    )
599}
600
601/// ` AND NOT` [`inherited_attribute_write`] for the row `s`, or nothing for an index without
602/// relationship tables.
603fn inherited_write_exclusion(conn: &Connection) -> String {
604    if has_table(conn, "relationships") && has_table(conn, "pending_relationships") {
605        format!(" AND NOT {}", inherited_attribute_write("s"))
606    } else {
607        String::new()
608    }
609}
610
611/// The ids among `ids` that [`inherited_attribute_write`] leaves out. Callers check only the rows
612/// they return, because the check walks base classes and a common word touches thousands of rows.
613pub fn inherited_writes_among<'a>(
614    conn: &Connection,
615    ids: impl Iterator<Item = &'a str>,
616) -> Result<HashSet<String>, QueryError> {
617    if inherited_write_exclusion(conn).is_empty() {
618        return Ok(HashSet::new());
619    }
620    let ids = serde_json::to_string(&ids.collect::<Vec<_>>()).unwrap_or_default();
621    let mut stmt = conn.prepare(&format!(
622        "SELECT s.symbol_id FROM symbols s
623         WHERE s.symbol_id IN (SELECT value FROM json_each(?1)) AND {}",
624        inherited_attribute_write("s")
625    ))?;
626    let found = stmt
627        .query_map(params![ids], |row| row.get(0))?
628        .collect::<Result<_, _>>()?;
629    Ok(found)
630}
631
632fn search_symbols_sql(variables_wanted: bool, include_tests: bool, limit: usize) -> String {
633    let mut sql = format!(
634        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
635                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
636                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
637                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
638                is_test, test_container
639         FROM symbols s
640         WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
641           AND {KIND_FILTER}
642           AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
643    );
644    sql.push_str(&document_link_exclusion());
645
646    if !variables_wanted {
647        sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
648    }
649
650    if !include_tests {
651        sql.push_str(&format!(
652            " AND (+name = :query OR (is_test = 0 AND test_container = 0 AND NOT {}))",
653            test_path_predicate("s")
654        ));
655    }
656
657    sql.push_str(
658        " ORDER BY (name = :query) DESC, (name LIKE :prefix ESCAPE '\\') DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, (COALESCE(signature, '') LIKE 'self.%') ASC, length(name) ASC, path ASC LIMIT ",
659    );
660    sql.push_str(&limit.to_string());
661    sql
662}
663
664pub fn search_symbols_scoped(
665    conn: &Connection,
666    query: &str,
667    kind_filter: Option<&str>,
668    path_filter: Option<&str>,
669    include_tests: bool,
670    limit: usize,
671) -> Result<Vec<Symbol>, QueryError> {
672    validate_result_limit(limit)?;
673    if limit == 0 {
674        return Ok(Vec::new());
675    }
676    let norm_kind = kind_filter.map(normalize_kind);
677    if let Some(owner) = query
678        .strip_suffix("::")
679        .or_else(|| query.strip_suffix('.'))
680        .and_then(|owner| owner.rsplit(['.', ':']).next())
681        .filter(|owner| !owner.is_empty())
682    {
683        return owner_members(
684            conn,
685            owner,
686            norm_kind.as_deref(),
687            path_filter,
688            include_tests,
689            limit,
690        );
691    }
692    if (query.contains("::") || query.contains('.'))
693        && let Some(sym) = get_symbol_by_name(conn, query, path_filter)?
694    {
695        let kind_matches = norm_kind
696            .as_deref()
697            .is_none_or(|kind| sym.kind == kind || crate::formatters::display_kind(&sym) == kind);
698        return Ok(if kind_matches { vec![sym] } else { Vec::new() });
699    }
700
701    let pattern = format!("%{}%", escape_like(query));
702    let prefix = format!("{}%", escape_like(query));
703    let normalized_path = path_filter.map(|p| {
704        p.replace('\\', "/")
705            .trim_start_matches("./")
706            .trim_matches('/')
707            .to_string()
708    });
709    let escaped_path = normalized_path.as_deref().map(escape_like);
710
711    let path_val = normalized_path.as_deref();
712    let path_like = escaped_path.as_deref();
713    let kind_val = norm_kind.as_deref();
714    let mut fetch = 4 * limit;
715    loop {
716        let sql = search_symbols_sql(
717            norm_kind.as_deref() == Some("variable"),
718            include_tests,
719            fetch,
720        );
721        let rows = conn
722            .prepare(&sql)?
723            .query_map(
724                rusqlite::named_params! {
725                    ":query": query,
726                    ":pattern": pattern,
727                    ":prefix": prefix,
728                    ":kind": kind_val,
729                    ":path": path_val,
730                    ":path_like": path_like,
731                },
732                map_symbol,
733            )?
734            .collect::<Result<Vec<Symbol>, _>>()?;
735        let fetched = rows.len();
736        let inherited = inherited_writes_among(conn, rows.iter().map(|s| s.symbol_id.as_str()))?;
737        let mut kept: Vec<Symbol> = rows
738            .into_iter()
739            .filter(|s| !inherited.contains(&s.symbol_id))
740            .collect();
741        let has_definition = kept.iter().any(|s| s.kind != "import");
742        let mut counted = 0;
743        let past_limit = kept.iter().position(|s| {
744            if !folds_into_import_line(query, s, has_definition) {
745                counted += 1;
746            }
747            counted > limit
748        });
749        if let Some(cut) = past_limit {
750            kept.truncate(cut);
751            return Ok(kept);
752        }
753        if fetched < fetch || fetch >= 4 * MAX_RESULT_LIMIT {
754            return Ok(kept);
755        }
756        fetch *= 2;
757    }
758}
759
760const MEMBER_LIST_OWNER_KINDS: &str = "'class', 'struct', 'interface', 'trait', 'enum', 'record', 'object', \
761                           'protocol', 'union', 'module', 'namespace'";
762
763/// SQL predicate for the `:kind` filter over `symbols s`: the stored kind or the kind that
764/// `formatters::display_kind` shows, so a kind copied from the output always filters.
765const KIND_FILTER: &str = "(:kind IS NULL OR s.kind = :kind OR :kind = CASE
766      WHEN s.language = 'html' AND s.kind = 'class' THEN 'element'
767      WHEN s.language = 'sql' AND s.kind = 'class' THEN 'table'
768      WHEN s.language = 'markdown' AND s.kind = 'module' THEN 'section'
769      WHEN s.language = 'markdown' AND s.kind = 'import' THEN 'link'
770      WHEN s.kind = 'property' AND s.language <> 'fsharp'
771           AND (s.signature GLOB 'self.*' OR s.signature GLOB 'this.*' OR s.signature GLOB 'cls.*')
772        THEN 'attribute'
773    END)";
774
775/// The members of the classes, modules, and namespaces named `owner`, in source order: what
776/// `lookup_symbol("App.")` lists.
777fn owner_members(
778    conn: &Connection,
779    owner: &str,
780    kind: Option<&str>,
781    path_filter: Option<&str>,
782    include_tests: bool,
783    limit: usize,
784) -> Result<Vec<Symbol>, QueryError> {
785    let path = path_filter.map(|p| {
786        p.replace('\\', "/")
787            .trim_start_matches("./")
788            .trim_matches('/')
789            .to_string()
790    });
791    let path_like = path.as_deref().map(escape_like);
792    let tests = if include_tests {
793        String::new()
794    } else {
795        format!(
796            " AND +s.is_test = 0 AND +s.test_container = 0 AND NOT {}",
797            test_path_predicate("s")
798        )
799    };
800    let docs = documentation_language_list();
801    let sql = format!(
802        "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
803                s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
804                s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
805                s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
806                s.is_test, s.test_container
807         FROM symbols o JOIN symbols s ON s.parent_symbol_id = o.symbol_id
808         WHERE o.name = :owner
809           AND o.kind IN ({MEMBER_LIST_OWNER_KINDS})
810           AND (o.language NOT IN ({docs})
811                OR NOT EXISTS (SELECT 1 FROM symbols c WHERE c.name = :owner
812                                 AND c.kind IN ({MEMBER_LIST_OWNER_KINDS}) AND c.language NOT IN ({docs})))
813           AND s.kind NOT IN ('parameter', 'import')
814           AND {KIND_FILTER}
815           AND (:path IS NULL OR replace(s.path, '\\', '/') = :path COLLATE NOCASE
816                OR replace(s.path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\'
817                OR replace(s.path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\'){tests}
818         ORDER BY s.path, s.start_line"
819    );
820    let rows = conn
821        .prepare(&sql)?
822        .query_map(
823            rusqlite::named_params! {
824                ":owner": owner,
825                ":kind": kind,
826                ":path": path.as_deref(),
827                ":path_like": path_like.as_deref(),
828            },
829            map_symbol,
830        )?
831        .collect::<Result<Vec<Symbol>, _>>()?;
832    let inherited = inherited_writes_among(conn, rows.iter().map(|s| s.symbol_id.as_str()))?;
833    Ok(rows
834        .into_iter()
835        .filter(|s| !inherited.contains(&s.symbol_id))
836        .take(limit)
837        .collect())
838}
839
840/// True when lookup shows `row` inside the one line that counts the imports of `query`, not as a
841/// row of its own: an import of that exact name, when some row is a definition. Such rows do not
842/// count toward the lookup limit.
843pub fn folds_into_import_line(query: &str, row: &Symbol, has_definition: bool) -> bool {
844    has_definition
845        && row.kind == "import"
846        && (row.name == query
847            || row.name.ends_with(&format!(".{query}"))
848            || row.name.ends_with(&format!("::{query}")))
849}
850
851/// Sanitizes a free-form user query into `(and_query, or_query)` formatted for SQLite FTS5.
852/// Each alphanumeric/underscore token is quoted and given a prefix wildcard: `"token"*`.
853/// Identifiers are split at case boundaries too (`parseHTTPResponse` -> `parse HTTP Response`),
854/// so a camelCase query finds a snake_case symbol. Each split identifier keeps its unsplit form
855/// as an alternative inside its own required term, so a camelCase symbol, which FTS5 indexes as
856/// one token, still matches. A query of two or three words also tries their concatenation.
857/// English stop words are dropped unless the whole query is stop words, and tokens under three
858/// characters get no prefix wildcard.
859pub fn sanitize_fts5_query(query: &str) -> (String, String) {
860    let raw_words = query_words(query);
861    let split: Vec<(Vec<&str>, Option<&str>)> = raw_words
862        .iter()
863        .map(|raw| {
864            let parts = split_identifier(raw);
865            let whole = (parts.len() > 1).then_some(*raw);
866            (parts, whole)
867        })
868        .collect();
869    let any_content = split
870        .iter()
871        .any(|(parts, _)| parts.iter().any(|p| !is_stop_word(p)));
872
873    let mut and_groups: Vec<String> = Vec::new();
874    let mut or_terms: Vec<String> = Vec::new();
875    for (parts, whole) in split {
876        let parts: Vec<String> = parts
877            .into_iter()
878            .filter(|p| !any_content || !is_stop_word(p))
879            .map(fts5_term)
880            .collect();
881        let whole = whole.map(fts5_term);
882        let group = match (parts.is_empty(), whole.as_deref()) {
883            (true, None) => continue,
884            (true, Some(w)) => w.to_string(),
885            (false, None) => parts.join(" "),
886            (false, Some(w)) => format!("(({}) OR {w})", parts.join(" ")),
887        };
888        and_groups.push(group);
889        or_terms.extend(parts);
890        or_terms.extend(whole);
891    }
892
893    if and_groups.is_empty() {
894        return (String::new(), String::new());
895    }
896
897    let mut and_query = and_groups.join(" AND ");
898    if (2..=3).contains(&raw_words.len()) {
899        let all: String = raw_words.concat();
900        if all.len() <= 64 {
901            let all = fts5_term(&all);
902            and_query = format!("({and_query}) OR {all}");
903            or_terms.push(all);
904        }
905    }
906    (and_query, or_terms.join(" OR "))
907}
908
909const STOP_WORDS: &[&str] = &[
910    "a", "about", "after", "again", "all", "already", "also", "always", "an", "and", "another",
911    "any", "are", "as", "at", "be", "because", "been", "before", "being", "between", "both", "but",
912    "by", "can", "could", "did", "do", "does", "each", "either", "else", "ever", "every", "for",
913    "from", "had", "has", "have", "here", "how", "if", "in", "instead", "is", "it", "its",
914    "itself", "just", "many", "may", "might", "more", "most", "much", "must", "neither", "never",
915    "no", "nor", "not", "of", "on", "once", "one", "only", "or", "other", "our", "per", "rather",
916    "same", "should", "since", "so", "some", "still", "such", "than", "that", "the", "their",
917    "them", "then", "there", "these", "they", "this", "those", "through", "to", "too", "two",
918    "until", "very", "via", "was", "we", "were", "what", "when", "where", "whether", "which",
919    "while", "who", "whom", "why", "will", "with", "within", "without", "would", "yet", "you",
920    "your",
921];
922
923fn is_stop_word(word: &str) -> bool {
924    STOP_WORDS.contains(&word.to_ascii_lowercase().as_str())
925}
926
927/// Splits a query at every character that is not alphanumeric or `_`.
928fn query_words(query: &str) -> Vec<&str> {
929    query
930        .split(|c: char| !c.is_alphanumeric() && c != '_')
931        .filter(|s| !s.is_empty())
932        .collect()
933}
934
935/// Lowercase terms of three or more characters for the trigram name index: every query word
936/// and every identifier part of it (`collapse_name` -> `collapse_name`, `collapse`, `name`),
937/// deduplicated. Stop words are dropped unless every term is a stop word. Empty when no term
938/// qualifies.
939fn trigram_name_terms(query: &str) -> Vec<String> {
940    let mut terms: Vec<String> = Vec::new();
941    for word in query_words(query) {
942        for term in std::iter::once(word).chain(split_identifier(word)) {
943            let lower = term.to_lowercase();
944            if lower.chars().count() >= 3 && !terms.contains(&lower) {
945                terms.push(lower);
946            }
947        }
948    }
949    let any_content = terms.iter().any(|t| !is_stop_word(t));
950    terms.retain(|t| !any_content || !is_stop_word(t));
951    terms
952}
953
954/// Quotes one token for FTS5 with a prefix wildcard, except for tokens under three characters.
955fn fts5_term(token: &str) -> String {
956    if token.chars().count() < 3 {
957        format!("\"{token}\"")
958    } else {
959        format!("\"{token}\"*")
960    }
961}
962
963/// FTS5 query for a symbol name as typed: no case splitting, no stop words.
964/// `isReady` -> `"isReady"*`, so related-test lookup stays as strict as the name.
965fn name_prefix_query(name: &str) -> String {
966    name.split(|c: char| !c.is_alphanumeric() && c != '_')
967        .filter(|s| !s.is_empty())
968        .map(|s| format!("\"{s}\"*"))
969        .collect::<Vec<_>>()
970        .join(" ")
971}
972
973/// Splits one identifier into words at `_`, digit runs, and case boundaries.
974/// `parseHTTPResponse2` -> `["parse", "HTTP", "Response", "2"]`.
975fn split_identifier(word: &str) -> Vec<&str> {
976    let mut out = Vec::new();
977    split_identifier_into(word, &mut out);
978    out
979}
980
981/// `split_identifier` that appends to a caller-owned vector, so tokenizing a whole doc
982/// comment costs one allocation instead of one per word.
983fn split_identifier_into<'a>(word: &'a str, out: &mut Vec<&'a str>) {
984    let mut chars = word.char_indices().peekable();
985    let Some((_, first)) = chars.next() else {
986        return;
987    };
988    let mut prev = char_class(first);
989    let mut start = 0;
990    while let Some((idx, c)) = chars.next() {
991        let cur = char_class(c);
992        let next = chars.peek().map_or(OTHER, |(_, n)| char_class(*n));
993        if identifier_boundary(prev, cur, next) {
994            push_piece(out, &word[start..idx]);
995            start = idx;
996        }
997        prev = cur;
998    }
999    push_piece(out, &word[start..]);
1000}
1001
1002const OTHER: u8 = 0;
1003const UNDERSCORE: u8 = 1;
1004const UPPER: u8 = 2;
1005const LOWER: u8 = 3;
1006const DIGIT: u8 = 4;
1007
1008fn char_class(c: char) -> u8 {
1009    if c == '_' {
1010        UNDERSCORE
1011    } else if c.is_uppercase() {
1012        UPPER
1013    } else if c.is_lowercase() {
1014        LOWER
1015    } else if c.is_ascii_digit() {
1016        DIGIT
1017    } else {
1018        OTHER
1019    }
1020}
1021
1022fn byte_class(b: u8) -> u8 {
1023    match b {
1024        b'_' => UNDERSCORE,
1025        b'A'..=b'Z' => UPPER,
1026        b'a'..=b'z' => LOWER,
1027        b'0'..=b'9' => DIGIT,
1028        _ => OTHER,
1029    }
1030}
1031
1032/// The identifier split rule over character classes: `_` on either side, lower/digit to
1033/// upper, the last upper of an acronym before a lower (`HTTPResponse`), and digit runs.
1034fn identifier_boundary(prev: u8, cur: u8, next: u8) -> bool {
1035    cur == UNDERSCORE
1036        || prev == UNDERSCORE
1037        || (cur == UPPER && (prev == LOWER || prev == DIGIT))
1038        || (cur == UPPER && prev == UPPER && next == LOWER)
1039        || ((cur == DIGIT) != (prev == DIGIT))
1040}
1041
1042fn push_piece<'a>(out: &mut Vec<&'a str>, piece: &'a str) {
1043    if !piece.is_empty() && piece != "_" {
1044        out.push(piece);
1045    }
1046}
1047
1048/// Tokens of a signature or doc comment: the text split at non-word characters, each word
1049/// split like an identifier. ASCII text is walked byte by byte in one pass; other text takes
1050/// the char path with the same boundary rule.
1051fn text_tokens_into<'a>(text: &'a str, out: &mut Vec<&'a str>) {
1052    if !text.is_ascii() {
1053        for word in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
1054            split_identifier_into(word, out);
1055        }
1056        return;
1057    }
1058    let bytes = text.as_bytes();
1059    let mut start: Option<usize> = None;
1060    let mut prev = OTHER;
1061    for (i, &b) in bytes.iter().enumerate() {
1062        let cur = byte_class(b);
1063        if cur == OTHER {
1064            if let Some(s) = start.take() {
1065                push_piece(out, &text[s..i]);
1066            }
1067            continue;
1068        }
1069        match start {
1070            None => start = Some(i),
1071            Some(s) => {
1072                let next = bytes.get(i + 1).map_or(OTHER, |n| byte_class(*n));
1073                if identifier_boundary(prev, cur, next) {
1074                    push_piece(out, &text[s..i]);
1075                    start = Some(i);
1076                }
1077            }
1078        }
1079        prev = cur;
1080    }
1081    if let Some(s) = start {
1082        push_piece(out, &text[s..]);
1083    }
1084}
1085
1086/// One admitted search row with the recall branches that reached it.
1087/// `result.score` is the word-branch BM25 for word rows and `0.0` otherwise until the
1088/// rerank replaces it.
1089pub(crate) struct Candidate {
1090    pub result: SymbolSearchResult,
1091    pub bm25: Option<f64>,
1092    pub exact_name: bool,
1093    pub word_match: bool,
1094    pub name_match: bool,
1095    /// Admitted as a member of a class that a query word names.
1096    pub owner_match: bool,
1097    pub name_terms: Vec<String>,
1098    pub documentation: bool,
1099    /// A function or method declared inside another function or method.
1100    pub nested: bool,
1101    /// The class or other type the row is a member of.
1102    pub owner: Option<String>,
1103}
1104
1105/// The kinds of symbol whose members search and lookup name as `Owner.member`.
1106const OWNER_KINDS: &str =
1107    "('class', 'struct', 'interface', 'trait', 'enum', 'record', 'object', 'protocol', 'union')";
1108
1109fn candidate_columns(conn: &Connection) -> String {
1110    format!(
1111        "s.rowid AS row_id, s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind,
1112                s.signature, s.doc_comment, s.visibility, s.parent_symbol_id, s.start_line,
1113                s.start_column, s.end_line, s.end_column, s.start_byte, s.end_byte,
1114                s.body_start_line, s.body_start_column, s.body_end_line, s.body_end_column,
1115                s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group, s.is_test,
1116                s.test_container,
1117                (s.language IN ({doc_langs}) OR NOT ({not_doc})) AS documentation,
1118                (s.kind IN ('function', 'method')
1119                 AND EXISTS (SELECT 1 FROM symbols nest WHERE nest.symbol_id = s.parent_symbol_id
1120                             AND nest.kind IN ('function', 'method', 'constructor')
1121                             AND s.start_byte > nest.start_byte AND s.end_byte <= nest.end_byte)) AS nested,
1122                (SELECT o.name FROM symbols o WHERE o.symbol_id = s.parent_symbol_id
1123                   AND o.kind IN {OWNER_KINDS}) AS owner_name",
1124        doc_langs = documentation_language_list(),
1125        not_doc = not_documentation(conn, "s")
1126    )
1127}
1128
1129/// julie records a Markdown or other documentation link as an `import` row; no lookup or search
1130/// for code wants it.
1131fn document_link_exclusion() -> String {
1132    format!(
1133        " AND NOT (s.kind = 'import' AND s.language IN ({}))",
1134        documentation_language_list()
1135    )
1136}
1137
1138fn candidate_filters(searching_variables: bool, include_tests: bool) -> String {
1139    let mut sql = format!(
1140        " AND {KIND_FILTER}
1141          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 '\\')",
1142    );
1143    sql.push_str(&document_link_exclusion());
1144    if !searching_variables {
1145        sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
1146    }
1147    if !include_tests {
1148        // Unary `+` keeps the planner off the test-flag indexes: without ANALYZE statistics it
1149        // would otherwise prefer them over the name index and walk nearly every row.
1150        sql.push_str(" AND +s.is_test = 0 AND +s.test_container = 0");
1151        sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
1152    }
1153    sql
1154}
1155
1156/// Runs the word, trigram-name, and exact-name branches with the same filters and merges
1157/// them by `rowid`. The word branch admits the rows that match every query word first and
1158/// then fills its cap with rows that match any word, so one full match never hides a
1159/// better partial one. Word BM25 exists only for word rows and never orders across branches.
1160/// A `variable` kind filter adds locals and parameters by name, because the FTS tables
1161/// exclude them.
1162pub(crate) fn collect_search_candidates(
1163    conn: &Connection,
1164    query: &str,
1165    kind_filter: Option<&str>,
1166    path_filter: Option<&str>,
1167    include_tests: bool,
1168    limit: usize,
1169) -> Result<Vec<Candidate>, QueryError> {
1170    let (and_q, or_q) = sanitize_fts5_query(query);
1171    let terms = trigram_name_terms(query);
1172    let normalized_path = path_filter.map(|p| {
1173        p.replace('\\', "/")
1174            .trim_start_matches("./")
1175            .trim_matches('/')
1176            .to_string()
1177    });
1178    let escaped_path = normalized_path.as_deref().map(escape_like);
1179    let norm_kind = kind_filter.map(normalize_kind);
1180    let searching_variables = norm_kind.as_deref() == Some("variable");
1181    let path_val = normalized_path.as_deref();
1182    let path_like = escaped_path.as_deref();
1183    let kind_val = norm_kind.as_deref();
1184    let columns = candidate_columns(conn);
1185    let filters = candidate_filters(searching_variables, include_tests);
1186    let word_cap = (limit * 4).clamp(40, 160);
1187    let name_cap = (limit * 2).clamp(20, 40);
1188
1189    let new_candidate = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
1190        let symbol = map_symbol(row)?;
1191        let lower_name = symbol.name.to_lowercase();
1192        let name_terms = terms
1193            .iter()
1194            .filter(|t| lower_name.contains(t.as_str()))
1195            .cloned()
1196            .collect();
1197        let candidate = Candidate {
1198            result: SymbolSearchResult {
1199                symbol,
1200                score: 0.0,
1201                snippet: None,
1202                explain: None,
1203            },
1204            bm25: None,
1205            exact_name: false,
1206            word_match: false,
1207            name_match: false,
1208            owner_match: false,
1209            name_terms,
1210            documentation: row.get::<_, Option<i64>>("documentation")? == Some(1),
1211            nested: row.get::<_, Option<i64>>("nested")? == Some(1),
1212            owner: row.get("owner_name")?,
1213        };
1214        Ok((row.get("row_id")?, candidate))
1215    };
1216
1217    let mut candidates: Vec<Candidate> = Vec::new();
1218    let mut by_rowid: HashMap<i64, usize> = HashMap::new();
1219    let mut admit = |rowid: i64, incoming: Candidate| match by_rowid.get(&rowid).copied() {
1220        Some(i) => {
1221            let existing = &mut candidates[i];
1222            existing.exact_name |= incoming.exact_name;
1223            existing.word_match |= incoming.word_match;
1224            existing.name_match |= incoming.name_match;
1225            existing.owner_match |= incoming.owner_match;
1226            if incoming.bm25.is_some() && existing.bm25.is_none() {
1227                existing.bm25 = incoming.bm25;
1228                existing.result = incoming.result;
1229            }
1230        }
1231        None => {
1232            by_rowid.insert(rowid, candidates.len());
1233            candidates.push(incoming);
1234        }
1235    };
1236
1237    let has_trigram = has_table(conn, "symbol_names_tri");
1238    let exact_query = query.trim();
1239    let exact_phrase = format!("\"{}\"", exact_query.replace('"', "\"\""));
1240    let exact_via_trigram = has_trigram && exact_query.chars().count() >= 3;
1241    let exact_sql = if exact_via_trigram {
1242        format!(
1243            "SELECT {columns} FROM symbol_names_tri
1244             CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
1245             WHERE symbol_names_tri MATCH :exact AND length(s.name) = length(:query) {filters}
1246             ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
1247        )
1248    } else {
1249        format!(
1250            "SELECT {columns} FROM symbols s WHERE s.name = :query {filters}
1251             ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
1252        )
1253    };
1254    let mut exact_params: Vec<(&str, &dyn ToSql)> = vec![
1255        (":query", &exact_query),
1256        (":kind", &kind_val),
1257        (":path", &path_val),
1258        (":path_like", &path_like),
1259    ];
1260    if exact_via_trigram {
1261        exact_params.push((":exact", &exact_phrase));
1262    }
1263    let exact_rows = conn
1264        .prepare(&exact_sql)?
1265        .query_map(exact_params.as_slice(), new_candidate)?
1266        .collect::<Result<Vec<_>, _>>()?;
1267    for (rowid, mut candidate) in exact_rows {
1268        candidate.exact_name = true;
1269        admit(rowid, candidate);
1270    }
1271
1272    let owner_sql = if has_trigram {
1273        format!(
1274            "SELECT {columns} FROM symbol_names_tri
1275             CROSS JOIN symbols o ON o.rowid = symbol_names_tri.rowid
1276             CROSS JOIN symbols s ON s.parent_symbol_id = o.symbol_id
1277             WHERE symbol_names_tri MATCH :phrase AND length(o.name) = length(:word)
1278               AND o.kind IN {OWNER_KINDS} {filters}
1279             ORDER BY s.path ASC, s.start_line ASC LIMIT {name_cap}"
1280        )
1281    } else {
1282        format!(
1283            "SELECT {columns} FROM symbols o
1284             CROSS JOIN symbols s ON s.parent_symbol_id = o.symbol_id
1285             WHERE o.name = :word COLLATE NOCASE AND :phrase IS NOT NULL
1286               AND o.kind IN {OWNER_KINDS} {filters}
1287             ORDER BY s.path ASC, s.start_line ASC LIMIT {name_cap}"
1288        )
1289    };
1290    let mut owner_stmt = conn.prepare(&owner_sql)?;
1291    for word in rerank_words(query).iter().filter(|w| w.len() >= 3) {
1292        let phrase = format!("\"{word}\"");
1293        let owner_rows = owner_stmt
1294            .query_map(
1295                rusqlite::named_params! {
1296                    ":word": word,
1297                    ":phrase": phrase,
1298                    ":kind": kind_val,
1299                    ":path": path_val,
1300                    ":path_like": path_like,
1301                },
1302                new_candidate,
1303            )?
1304            .collect::<Result<Vec<_>, _>>()?;
1305        for (rowid, mut candidate) in owner_rows {
1306            candidate.owner_match = true;
1307            admit(rowid, candidate);
1308        }
1309    }
1310
1311    if searching_variables {
1312        let pattern = format!("%{}%", escape_like(exact_query));
1313        let local_sql = format!(
1314            "SELECT {columns} FROM symbols s
1315             WHERE {local} AND (s.name = :query OR s.name LIKE :pattern ESCAPE '\\') {filters}
1316             ORDER BY (s.name = :query) DESC, length(s.name) ASC, s.path ASC LIMIT {limit}",
1317            local = local_variable_predicate("s")
1318        );
1319        let local_rows = conn
1320            .prepare(&local_sql)?
1321            .query_map(
1322                rusqlite::named_params! {
1323                    ":query": exact_query,
1324                    ":pattern": pattern,
1325                    ":kind": kind_val,
1326                    ":path": path_val,
1327                    ":path_like": path_like,
1328                },
1329                new_candidate,
1330            )?
1331            .collect::<Result<Vec<_>, _>>()?;
1332        for (rowid, mut candidate) in local_rows {
1333            candidate.exact_name = candidate.result.symbol.name == exact_query;
1334            candidate.name_match = true;
1335            admit(rowid, candidate);
1336        }
1337    }
1338
1339    let word_sql = format!(
1340        "SELECT {columns},
1341                bm25(symbols_fts, 10.0, 5.0, 1.0) AS rank_score,
1342                snippet(symbols_fts, 2, '[', ']', '...', 12) AS doc_snippet,
1343                snippet(symbols_fts, 1, '[', ']', '...', 12) AS sig_snippet,
1344                snippet(symbols_fts, 0, '[', ']', '...', 12) AS name_snippet
1345         FROM symbols_fts
1346         CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
1347         WHERE symbols_fts MATCH :match {filters}
1348         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}",
1349        doc_langs = documentation_language_list(),
1350        not_doc = not_documentation(conn, "s")
1351    );
1352    let word_rows = |match_clause: &str| -> Result<Vec<(i64, Candidate)>, QueryError> {
1353        let map_fn = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
1354            let (rowid, mut candidate) = new_candidate(row)?;
1355            let score: f64 = row.get("rank_score")?;
1356            let doc_snip: Option<String> = row.get("doc_snippet").ok();
1357            let sig_snip: Option<String> = row.get("sig_snippet").ok();
1358            let name_snip: Option<String> = row.get("name_snippet").ok();
1359            let highlighted = |s: &Option<String>| s.as_ref().is_some_and(|s| s.contains('['));
1360            candidate.result.snippet = if highlighted(&doc_snip) {
1361                doc_snip
1362            } else if highlighted(&sig_snip) {
1363                sig_snip
1364            } else if highlighted(&name_snip) {
1365                name_snip
1366            } else {
1367                doc_snip.or(sig_snip).or(name_snip)
1368            };
1369            candidate.result.score = score;
1370            candidate.bm25 = Some(score);
1371            candidate.word_match = true;
1372            Ok((rowid, candidate))
1373        };
1374        Ok(conn
1375            .prepare(&word_sql)?
1376            .query_map(
1377                rusqlite::named_params! {
1378                    ":match": match_clause,
1379                    ":query": query.trim(),
1380                    ":kind": kind_val,
1381                    ":path": path_val,
1382                    ":path_like": path_like,
1383                },
1384                map_fn,
1385            )?
1386            .collect::<Result<Vec<_>, _>>()?)
1387    };
1388    if !and_q.is_empty() {
1389        let and_rows = word_rows(&and_q)?;
1390        let mut word_admitted: HashSet<i64> = and_rows.iter().map(|(rowid, _)| *rowid).collect();
1391        for (rowid, candidate) in and_rows {
1392            admit(rowid, candidate);
1393        }
1394        if and_q != or_q {
1395            for (rowid, candidate) in word_rows(&or_q)? {
1396                if word_admitted.len() >= word_cap && !word_admitted.contains(&rowid) {
1397                    break;
1398                }
1399                word_admitted.insert(rowid);
1400                admit(rowid, candidate);
1401            }
1402        }
1403    }
1404
1405    if !terms.is_empty() && has_trigram {
1406        let match_clause = terms
1407            .iter()
1408            .map(|t| format!("\"{t}\""))
1409            .collect::<Vec<_>>()
1410            .join(" OR ");
1411        let name_sql = format!(
1412            "SELECT {columns} FROM symbol_names_tri
1413             CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
1414             WHERE symbol_names_tri MATCH :match {filters}
1415             ORDER BY bm25(symbol_names_tri) ASC, length(s.name) ASC, s.path ASC LIMIT {name_cap}"
1416        );
1417        let name_rows = conn
1418            .prepare(&name_sql)?
1419            .query_map(
1420                rusqlite::named_params! {
1421                    ":match": match_clause,
1422                    ":kind": kind_val,
1423                    ":path": path_val,
1424                    ":path_like": path_like,
1425                },
1426                new_candidate,
1427            )?
1428            .collect::<Result<Vec<_>, _>>()?;
1429        for (rowid, mut candidate) in name_rows {
1430            candidate.name_match = true;
1431            admit(rowid, candidate);
1432        }
1433    }
1434
1435    let inherited = inherited_writes_among(
1436        conn,
1437        candidates
1438            .iter()
1439            .map(|c| c.result.symbol.symbol_id.as_str()),
1440    )?;
1441    candidates.retain(|c| !inherited.contains(&c.result.symbol.symbol_id));
1442    Ok(candidates)
1443}
1444
1445/// Conceptual full-text search with optional path scoping filter.
1446pub fn fts_search_symbols_scoped(
1447    conn: &Connection,
1448    query: &str,
1449    kind_filter: Option<&str>,
1450    path_filter: Option<&str>,
1451    include_tests: bool,
1452    limit: usize,
1453) -> Result<Vec<SymbolSearchResult>, QueryError> {
1454    fts_search_symbols_explained(
1455        conn,
1456        query,
1457        kind_filter,
1458        path_filter,
1459        include_tests,
1460        limit,
1461        false,
1462    )
1463}
1464
1465/// Conceptual full-text search that also attaches the rerank breakdown to every row when
1466/// `explain` is true. Without it, `explain` stays `None` on every row.
1467pub fn fts_search_symbols_explained(
1468    conn: &Connection,
1469    query: &str,
1470    kind_filter: Option<&str>,
1471    path_filter: Option<&str>,
1472    include_tests: bool,
1473    limit: usize,
1474    explain: bool,
1475) -> Result<Vec<SymbolSearchResult>, QueryError> {
1476    validate_result_limit(limit)?;
1477    if limit == 0 {
1478        return Ok(Vec::new());
1479    }
1480    let (and_q, _) = sanitize_fts5_query(query);
1481    if and_q.is_empty() {
1482        return Ok(Vec::new());
1483    }
1484
1485    let normalized_path = path_filter.map(|p| {
1486        p.replace('\\', "/")
1487            .trim_start_matches("./")
1488            .trim_matches('/')
1489            .to_string()
1490    });
1491    let norm_kind = kind_filter.map(normalize_kind);
1492    let escaped_path = normalized_path.as_deref().map(escape_like);
1493    let searching_variables = norm_kind.as_deref() == Some("variable");
1494
1495    let name_search = |local_clause: &str| -> Result<Vec<SymbolSearchResult>, QueryError> {
1496        let pattern = format!("%{}%", escape_like(query));
1497        let mut sql = format!(
1498            "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
1499                    visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
1500                    start_byte, end_byte, body_start_line, body_start_column, body_end_line,
1501                    body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
1502                    is_test, test_container
1503              FROM symbols s
1504              WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
1505                AND {KIND_FILTER}
1506                AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
1507        );
1508        sql.push_str(&document_link_exclusion());
1509        sql.push_str(local_clause);
1510        if !include_tests {
1511            sql.push_str(" AND is_test = 0 AND test_container = 0");
1512            sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
1513        }
1514        sql.push_str(
1515            " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
1516        );
1517        sql.push_str(&limit.to_string());
1518
1519        let mut stmt = conn.prepare(&sql)?;
1520        let path_val = normalized_path.as_deref();
1521        let path_like = escaped_path.as_deref();
1522        let kind_val = norm_kind.as_deref();
1523        let rows = stmt
1524            .query_map(
1525                rusqlite::named_params! {
1526                    ":query": query,
1527                    ":pattern": pattern,
1528                    ":kind": kind_val,
1529                    ":path": path_val,
1530                    ":path_like": path_like,
1531                },
1532                map_symbol,
1533            )?
1534            .collect::<Result<Vec<_>, _>>()?;
1535
1536        Ok(rows
1537            .into_iter()
1538            .map(|s| SymbolSearchResult {
1539                symbol: s,
1540                score: 0.0,
1541                snippet: None,
1542                explain: None,
1543            })
1544            .collect())
1545    };
1546
1547    if !has_table(conn, "symbols_fts") {
1548        let local_clause = if searching_variables {
1549            String::new()
1550        } else {
1551            format!(" AND NOT {}", local_variable_predicate("s"))
1552        };
1553        return name_search(&local_clause);
1554    }
1555
1556    let candidates =
1557        collect_search_candidates(conn, query, kind_filter, path_filter, include_tests, limit)?;
1558    let candidate_count = candidates.len();
1559    let started = std::time::Instant::now();
1560    let idf = idf_weights(conn, &rerank_words(query));
1561    let ranked = rerank_with(candidates, query, include_tests, Some(&idf));
1562    let rerank_us = started.elapsed().as_micros();
1563    Ok(ranked
1564        .into_iter()
1565        .take(limit)
1566        .map(|(mut result, mut breakdown)| {
1567            if explain {
1568                breakdown.candidates = candidate_count;
1569                breakdown.rerank_us = rerank_us;
1570                result.explain = Some(breakdown);
1571            }
1572            result
1573        })
1574        .collect())
1575}
1576
1577const W_NAME_WHOLE: f64 = 100.0;
1578const W_NAME_ALL_WORDS: f64 = 60.0;
1579const W_KIND_DEFINITION: f64 = 4.0;
1580const W_KIND_MEMBER: f64 = 0.0;
1581const W_KIND_IMPORT: f64 = -50.0;
1582/// A helper declared inside a function ranks below a module or class member of equal score.
1583const W_NESTED: f64 = -2.0;
1584const W_PATH_ROLE: f64 = -10.0;
1585const W_DOCUMENTATION_ROW: f64 = -200.0;
1586const W_TEST_INTENT: f64 = 5.0;
1587const W_TERMS: f64 = 52.0;
1588const MAX_TERM_CREDIT: f64 = 3.0;
1589const TEXT_CREDIT: f64 = 1.0;
1590/// A word that names the class a member belongs to: `Flask init` means `Flask.__init__`.
1591const OWNER_CREDIT: f64 = 3.0;
1592const TEXT_HEAD_BYTES: usize = 400;
1593
1594/// Symbol kinds that define a body: the kinds a touched-symbol or ranking rule prefers over locals.
1595pub const DEFINITION_KINDS: &[&str] = &[
1596    "function",
1597    "method",
1598    "class",
1599    "struct",
1600    "trait",
1601    "interface",
1602    "enum",
1603    "type",
1604];
1605const MEMBER_KINDS: &[&str] = &["enum_member", "field", "property", "constant", "variable"];
1606const DEMOTED_PATH_SEGMENTS: &[&str] = &["scripts", "examples", "benchmarks", "fixtures", "vendor"];
1607const TEST_INTENT_WORDS: &[&str] = &["test", "tests", "spec", "specs"];
1608
1609struct QueryWord {
1610    word: String,
1611    stem: String,
1612}
1613
1614/// Per-word hits of one candidate: which query words its name, signature, and capped doc
1615/// cover. The name carries a match strength per word, the others a plain hit.
1616struct Hits {
1617    name: Vec<u8>,
1618    /// The query word is the whole name of the class the row belongs to, or its stem. Counted
1619    /// only when another word matches the row's own name, as in `Flask init`.
1620    owner: Vec<bool>,
1621    signature: Vec<bool>,
1622    doc: Vec<bool>,
1623}
1624
1625/// Symbols a query word may be counted in before it is called common: the count walks the
1626/// word's postings, so the cap bounds the cost of a word held by most of a million symbols.
1627const DF_CAP: usize = 20_000;
1628
1629/// Global rarity of each lowercase rerank term: `ln(1 + N / (df + 1))`, where `df` is the
1630/// number of indexed symbols holding the term (capped at `DF_CAP`) and `N` an upper bound on
1631/// the index size. The count is an FTS5 `MATCH`, so the term is stemmed by the index's own
1632/// tokenizer and `news` finds the rows indexed as `new`.
1633fn idf_weights(conn: &Connection, words: &[String]) -> Vec<f64> {
1634    let n = conn
1635        .query_row("SELECT max(rowid) FROM symbols", [], |r| {
1636            r.get::<_, Option<i64>>(0)
1637        })
1638        .ok()
1639        .flatten()
1640        .unwrap_or(0) as f64;
1641    let mut count = conn
1642        .prepare(&format!(
1643            "SELECT count(*) FROM (SELECT rowid FROM symbols_fts WHERE symbols_fts MATCH ?1 LIMIT {DF_CAP})"
1644        ))
1645        .ok();
1646    words
1647        .iter()
1648        .map(|word| {
1649            let df = count
1650                .as_mut()
1651                .and_then(|stmt| document_frequency(stmt, &format!("\"{word}\"")))
1652                .unwrap_or(0);
1653            (1.0 + n / (df as f64 + 1.0)).ln()
1654        })
1655        .collect()
1656}
1657
1658fn document_frequency(stmt: &mut rusqlite::Statement<'_>, term: &str) -> Option<i64> {
1659    stmt.query_row([term], |r| r.get(0)).ok()
1660}
1661
1662/// The field that credits each query term and the credit it is worth: a name whole token 3,
1663/// a name stem 2, the owning class `OWNER_CREDIT`, a signature or doc hit `TEXT_CREDIT`, a name
1664/// substring 1, nothing 0.
1665fn term_credits(hits: &Hits, words: &[QueryWord]) -> Vec<(String, String, f64)> {
1666    words
1667        .iter()
1668        .enumerate()
1669        .map(|(i, w)| {
1670            let (field, credit) = match hits.name[i] {
1671                3 => ("name", 3.0),
1672                2 => ("name", 2.0),
1673                _ if hits.owner[i] => ("owner", OWNER_CREDIT),
1674                _ if hits.signature[i] => ("signature", TEXT_CREDIT),
1675                _ if hits.doc[i] => ("doc", TEXT_CREDIT),
1676                1 => ("name", 1.0),
1677                _ => ("none", 0.0),
1678            };
1679            (w.word.clone(), field.to_string(), credit)
1680        })
1681        .collect()
1682}
1683
1684fn term_score(terms: &[(String, String, f64)], weights: &[f64]) -> f64 {
1685    let total: f64 = weights.iter().sum();
1686    if total == 0.0 {
1687        return 0.0;
1688    }
1689    let credited: f64 = terms
1690        .iter()
1691        .zip(weights)
1692        .map(|((_, _, credit), weight)| credit * weight)
1693        .sum();
1694    W_TERMS * credited / (MAX_TERM_CREDIT * total)
1695}
1696
1697/// Lowercase query words for the rerank: every `query_words` token is split like an
1698/// identifier, and stop words are dropped only when a content word remains.
1699fn rerank_words(query: &str) -> Vec<String> {
1700    let words: Vec<String> = query_words(query)
1701        .into_iter()
1702        .flat_map(split_identifier)
1703        .map(str::to_lowercase)
1704        .collect();
1705    let any_content = words.iter().any(|w| !is_stop_word(w));
1706    words
1707        .into_iter()
1708        .filter(|w| !any_content || !is_stop_word(w))
1709        .collect()
1710}
1711
1712fn collapse(text: &str) -> String {
1713    text.chars()
1714        .filter(|c| c.is_alphanumeric())
1715        .flat_map(char::to_lowercase)
1716        .collect()
1717}
1718
1719fn head_bytes(text: &str, bytes: usize) -> &str {
1720    let mut end = bytes.min(text.len());
1721    while !text.is_char_boundary(end) {
1722        end -= 1;
1723    }
1724    &text[..end]
1725}
1726
1727fn token_run_equals(tokens: &[String], word: &str) -> bool {
1728    (0..tokens.len()).any(|start| {
1729        let mut joined = String::new();
1730        for token in &tokens[start..] {
1731            joined.push_str(token);
1732            if joined.len() >= word.len() {
1733                return joined == word;
1734            }
1735        }
1736        false
1737    })
1738}
1739
1740/// Match strength of a name per query word: `3` when a run of its tokens equals the word,
1741/// `2` when a token stem equals the word stem, `1` when the collapsed name contains the word,
1742/// `0` when nothing matches.
1743fn name_hits(name: &str, words: &[QueryWord], stemmer: &Stemmer) -> Vec<u8> {
1744    let tokens: Vec<String> = split_identifier(name)
1745        .into_iter()
1746        .map(str::to_lowercase)
1747        .collect();
1748    let stems: Vec<String> = tokens
1749        .iter()
1750        .map(|t| stemmer.stem(t).into_owned())
1751        .collect();
1752    let collapsed = collapse(name);
1753    words
1754        .iter()
1755        .map(|w| {
1756            if token_run_equals(&tokens, &w.word) {
1757                3
1758            } else if stems.contains(&w.stem) {
1759                2
1760            } else if w.word.chars().count() >= 3 && collapsed.contains(&w.word) {
1761                1
1762            } else {
1763                0
1764            }
1765        })
1766        .collect()
1767}
1768
1769/// True when `token` lowercased starts with `prefix` (or equals it when `exact`).
1770/// `prefix` is already lowercase.
1771fn lowercase_prefix_match(token: &str, prefix: &str, exact: bool) -> bool {
1772    if token.is_ascii() && prefix.is_ascii() {
1773        let Some(head) = token.as_bytes().get(..prefix.len()) else {
1774            return false;
1775        };
1776        return head.eq_ignore_ascii_case(prefix.as_bytes())
1777            && (!exact || token.len() == prefix.len());
1778    }
1779    let mut lower = token.chars().flat_map(char::to_lowercase);
1780    for expected in prefix.chars() {
1781        if lower.next() != Some(expected) {
1782            return false;
1783        }
1784    }
1785    !exact || lower.next().is_none()
1786}
1787
1788/// Token-level coverage of a signature or doc: a word is covered when some token equals it,
1789/// or starts with its stem or with the word itself (three or more characters), so `stemming`
1790/// covers `stemmer` and `stems` but not `system`.
1791fn text_hits<'a>(
1792    text: Option<&'a str>,
1793    words: &[QueryWord],
1794    tokens: &mut Vec<&'a str>,
1795) -> Vec<bool> {
1796    tokens.clear();
1797    text_tokens_into(text.unwrap_or(""), tokens);
1798    words
1799        .iter()
1800        .map(|w| {
1801            let stem_prefix = w.stem.chars().count() >= 3;
1802            let exact_word = w.word.chars().count() < 3;
1803            tokens.iter().any(|t| {
1804                lowercase_prefix_match(t, &w.word, exact_word)
1805                    || (stem_prefix && lowercase_prefix_match(t, &w.stem, false))
1806            })
1807        })
1808        .collect()
1809}
1810
1811fn kind_prior(kind: &str) -> f64 {
1812    let kind = normalize_kind(kind);
1813    match kind.as_str() {
1814        "import" => W_KIND_IMPORT,
1815        k if DEFINITION_KINDS.contains(&k) => W_KIND_DEFINITION,
1816        k if MEMBER_KINDS.contains(&k) => W_KIND_MEMBER,
1817        _ => 0.0,
1818    }
1819}
1820
1821fn path_role(path: &str, words: &[QueryWord], stemmer: &Stemmer) -> f64 {
1822    let Some(segment) = path.split(['/', '\\']).find(|seg| {
1823        DEMOTED_PATH_SEGMENTS
1824            .iter()
1825            .any(|d| d.eq_ignore_ascii_case(seg))
1826    }) else {
1827        return 0.0;
1828    };
1829    let segment = segment.to_lowercase();
1830    let segment_stem = stemmer.stem(&segment);
1831    let named = words.iter().any(|w| {
1832        w.word == segment || w.word == segment_stem || w.stem == segment || w.stem == segment_stem
1833    });
1834    if named { 0.0 } else { W_PATH_ROLE }
1835}
1836
1837fn bracket_longest_term(name: &str, terms: &[String]) -> String {
1838    let lower = name.to_lowercase();
1839    if lower.len() != name.len() {
1840        return name.to_string();
1841    }
1842    let mut best: Option<(usize, usize)> = None;
1843    for term in terms {
1844        if let Some(start) = lower.find(term.as_str()) {
1845            let end = start + term.len();
1846            let longer = best.is_none_or(|(s, e)| end - start > e - s);
1847            if longer && name.is_char_boundary(start) && name.is_char_boundary(end) {
1848                best = Some((start, end));
1849            }
1850        }
1851    }
1852    match best {
1853        Some((start, end)) => {
1854            format!("{}[{}]{}", &name[..start], &name[start..end], &name[end..])
1855        }
1856        None => name.to_string(),
1857    }
1858}
1859
1860fn branch_snippet(candidate: &Candidate) -> Option<String> {
1861    let name = &candidate.result.symbol.name;
1862    if candidate.word_match {
1863        candidate.result.snippet.clone()
1864    } else if candidate.exact_name {
1865        Some(name.clone())
1866    } else {
1867        Some(bracket_longest_term(name, &candidate.name_terms))
1868    }
1869}
1870
1871/// Scores every admitted candidate and returns them best first. Each query term is credited
1872/// once from its strongest field and weighted by `idf`, the term's rarity across the index;
1873/// a weight vector of the wrong length, or none, makes every term count the same. Ties fall
1874/// to name strength, then word BM25 (rows without one last), then name length, path, name.
1875fn rerank_with(
1876    candidates: Vec<Candidate>,
1877    query: &str,
1878    include_tests: bool,
1879    idf: Option<&[f64]>,
1880) -> Vec<(SymbolSearchResult, SearchExplain)> {
1881    let stemmer = Stemmer::create(Algorithm::English);
1882    let words: Vec<QueryWord> = rerank_words(query)
1883        .into_iter()
1884        .map(|word| QueryWord {
1885            stem: stemmer.stem(&word).into_owned(),
1886            word,
1887        })
1888        .collect();
1889    let collapsed_query = collapse(query);
1890    let test_intent = include_tests
1891        && words
1892            .iter()
1893            .any(|w| TEST_INTENT_WORDS.contains(&w.word.as_str()));
1894
1895    let mut tokens: Vec<&str> = Vec::new();
1896    let hits: Vec<Hits> = candidates
1897        .iter()
1898        .map(|candidate| {
1899            let symbol = &candidate.result.symbol;
1900            let name = name_hits(&symbol.name, &words, &stemmer);
1901            let named = name.iter().any(|strength| *strength > 0);
1902            Hits {
1903                owner: match candidate.owner.as_deref().map(collapse) {
1904                    Some(owner) if named => {
1905                        let owner_stem = stemmer.stem(&owner);
1906                        words
1907                            .iter()
1908                            .map(|w| owner == w.word || owner_stem == w.stem)
1909                            .collect()
1910                    }
1911                    _ => vec![false; words.len()],
1912                },
1913                name,
1914                signature: text_hits(
1915                    symbol
1916                        .signature
1917                        .as_deref()
1918                        .map(|signature| head_bytes(signature, TEXT_HEAD_BYTES)),
1919                    &words,
1920                    &mut tokens,
1921                ),
1922                doc: text_hits(
1923                    symbol
1924                        .doc_comment
1925                        .as_deref()
1926                        .map(|doc| head_bytes(doc, TEXT_HEAD_BYTES)),
1927                    &words,
1928                    &mut tokens,
1929                ),
1930            }
1931        })
1932        .collect();
1933    let term_weights: Vec<f64> = match idf {
1934        Some(weights) if weights.len() == words.len() => weights.to_vec(),
1935        _ => vec![1.0; words.len()],
1936    };
1937    let word_weights: Vec<(String, f64)> = words
1938        .iter()
1939        .zip(&term_weights)
1940        .map(|(w, weight)| (w.word.clone(), *weight))
1941        .collect();
1942
1943    let mut scored: Vec<(SymbolSearchResult, SearchExplain)> = candidates
1944        .into_iter()
1945        .zip(hits)
1946        .map(|(candidate, hits)| {
1947            let symbol = &candidate.result.symbol;
1948            let name_strength: u32 = hits.name.iter().map(|s| u32::from(*s)).sum();
1949            let tier = if !collapsed_query.is_empty() && collapse(&symbol.name) == collapsed_query {
1950                "whole"
1951            } else if !hits.name.is_empty() && hits.name.iter().all(|s| *s >= 2) {
1952                "all"
1953            } else if hits.name.iter().any(|s| *s > 0) {
1954                "partial"
1955            } else {
1956                "none"
1957            };
1958            let terms = term_credits(&hits, &words);
1959            let explain = SearchExplain {
1960                bm25: candidate.bm25,
1961                branches: [
1962                    (candidate.exact_name, "exact"),
1963                    (candidate.word_match, "word"),
1964                    (candidate.name_match, "name"),
1965                    (candidate.owner_match, "owner"),
1966                ]
1967                .into_iter()
1968                .filter(|(hit, _)| *hit)
1969                .map(|(_, branch)| branch.to_string())
1970                .collect(),
1971                name_tier: tier.to_string(),
1972                name_strength,
1973                term_score: term_score(&terms, &term_weights),
1974                name_bonus: match tier {
1975                    "whole"
1976                        if DEFINITION_KINDS.contains(&normalize_kind(&symbol.kind).as_str()) =>
1977                    {
1978                        W_NAME_WHOLE
1979                    }
1980                    "whole" | "all" => W_NAME_ALL_WORDS,
1981                    _ => 0.0,
1982                },
1983                kind_prior: kind_prior(&symbol.kind),
1984                path_role: path_role(&symbol.path, &words, &stemmer),
1985                documentation: if candidate.documentation {
1986                    W_DOCUMENTATION_ROW
1987                } else {
1988                    0.0
1989                },
1990                test_intent: if test_intent && (symbol.is_test || symbol.test_container) {
1991                    W_TEST_INTENT
1992                } else {
1993                    0.0
1994                },
1995                nested: if candidate.nested { W_NESTED } else { 0.0 },
1996                terms,
1997                word_weights: word_weights.clone(),
1998                candidates: 0,
1999                rerank_us: 0,
2000            };
2001            let score = explain.term_score
2002                + explain.name_bonus
2003                + explain.kind_prior
2004                + explain.path_role
2005                + explain.documentation
2006                + explain.test_intent
2007                + explain.nested;
2008            let snippet = branch_snippet(&candidate);
2009            let mut result = candidate.result;
2010            result.score = score;
2011            result.snippet = snippet;
2012            (result, explain)
2013        })
2014        .collect();
2015
2016    scored.sort_by(|(a, ea), (b, eb)| {
2017        b.score
2018            .total_cmp(&a.score)
2019            .then_with(|| eb.name_strength.cmp(&ea.name_strength))
2020            .then_with(|| {
2021                a.symbol
2022                    .name
2023                    .starts_with('_')
2024                    .cmp(&b.symbol.name.starts_with('_'))
2025            })
2026            .then_with(|| ea.bm25.is_none().cmp(&eb.bm25.is_none()))
2027            .then_with(|| ea.bm25.unwrap_or(0.0).total_cmp(&eb.bm25.unwrap_or(0.0)))
2028            .then_with(|| a.symbol.name.len().cmp(&b.symbol.name.len()))
2029            .then_with(|| a.symbol.path.cmp(&b.symbol.path))
2030            .then_with(|| a.symbol.name.cmp(&b.symbol.name))
2031    });
2032    scored
2033}
2034
2035const LOW_SIGNAL_KINDS_SQL: &str =
2036    "'import','variable','parameter','field','property','module','namespace'";
2037
2038/// Find tests related to a target symbol by caller relationships, naming pattern, or FTS matching.
2039pub fn find_related_tests(
2040    conn: &Connection,
2041    target_symbol: &Symbol,
2042    limit: usize,
2043) -> Result<Vec<Symbol>, QueryError> {
2044    if limit == 0 || DOCUMENTATION_LANGUAGES.contains(&target_symbol.language.as_str()) {
2045        return Ok(Vec::new());
2046    }
2047
2048    const COLUMNS: &str = "s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
2049            s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
2050            s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
2051            s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
2052            s.is_test, s.test_container";
2053    // Rows a scan writes can land in any order, so every query below needs a full ORDER BY.
2054    const TEST_ORDER: &str = "s.is_test DESC, s.path, s.start_line, s.symbol_id";
2055    let not_setup = if has_column(conn, "symbols", "test_lifecycle") {
2056        "COALESCE(s.test_lifecycle, 0) = 0"
2057    } else {
2058        "1 = 1"
2059    };
2060    let is_test = format!(
2061        "((s.is_test = 1 OR s.test_container = 1 OR ({} AND s.kind IN ('function', 'method'))) AND {not_setup})",
2062        test_path_predicate("s")
2063    );
2064    let not_documentation = not_documentation(conn, "s");
2065
2066    let mut tests = Vec::new();
2067    let mut seen_ids = std::collections::HashSet::new();
2068
2069    let callers_sql = format!(
2070        "SELECT {COLUMNS}
2071     FROM symbols s
2072     JOIN relationships r ON r.from_symbol_id = s.symbol_id
2073     WHERE r.to_symbol_id = ?1 AND {is_test} AND {not_documentation}
2074     ORDER BY {TEST_ORDER}
2075     LIMIT ?2"
2076    );
2077
2078    if let Ok(mut stmt) = conn.prepare(&callers_sql)
2079        && let Ok(rows) = stmt.query_map(params![target_symbol.symbol_id, limit as i64], map_symbol)
2080    {
2081        for row in rows.flatten() {
2082            if seen_ids.insert(row.symbol_id.clone()) {
2083                tests.push(row);
2084                if tests.len() >= limit {
2085                    return Ok(tests);
2086                }
2087            }
2088        }
2089    }
2090
2091    // julie resolves call edges inside one file only; every cross-file caller is a pending edge
2092    let remaining = limit - tests.len();
2093    if remaining > 0 && has_pending_namespace_column(conn) {
2094        let pending_sql = format!(
2095            "SELECT DISTINCT {COLUMNS}
2096     FROM pending_relationships p
2097     JOIN symbols s ON p.from_symbol_id = s.symbol_id
2098     JOIN symbols s_from ON s_from.symbol_id = s.symbol_id
2099     JOIN symbols s_target ON s_target.symbol_id = ?1
2100     LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2101     WHERE p.target_terminal_name = s_target.name
2102       AND {is_test}
2103       AND {not_documentation}
2104       AND {pred}
2105     ORDER BY {TEST_ORDER}
2106     LIMIT ?2",
2107            pred = pending_target_predicate(conn, "s_target", "s_target_parent")
2108        );
2109
2110        if let Ok(mut stmt) = conn.prepare(&pending_sql)
2111            && let Ok(rows) = stmt.query_map(
2112                params![target_symbol.symbol_id, remaining as i64],
2113                map_symbol,
2114            )
2115        {
2116            for row in rows.flatten() {
2117                if seen_ids.insert(row.symbol_id.clone()) {
2118                    tests.push(row);
2119                    if tests.len() >= limit {
2120                        return Ok(tests);
2121                    }
2122                }
2123            }
2124        }
2125    }
2126
2127    // A test that reads or assigns the symbol (`app.wsgi_app = ...`) has no call edge.
2128    let mut test_by_id = conn.prepare(&format!(
2129        "SELECT {COLUMNS} FROM symbols s WHERE s.symbol_id = ?1 AND {is_test} AND {not_documentation}"
2130    ))?;
2131    for site in find_references_for_symbol(
2132        conn,
2133        &target_symbol.name,
2134        "callers",
2135        MAX_RESULT_LIMIT,
2136        &target_symbol.symbol_id,
2137    )
2138    .unwrap_or_default()
2139    {
2140        if !seen_ids.contains(&site.from_symbol_id)
2141            && let Some(test) = test_by_id
2142                .query_row(params![site.from_symbol_id], map_symbol)
2143                .optional()?
2144        {
2145            seen_ids.insert(test.symbol_id.clone());
2146            tests.push(test);
2147            if tests.len() >= limit {
2148                return Ok(tests);
2149            }
2150        }
2151    }
2152
2153    // `Flask(...)` runs `Flask.__init__`, so the tests that build the class test its constructor.
2154    if (target_symbol.kind == "constructor" || target_symbol.name == "__init__")
2155        && tests.len() < limit
2156        && let Some(class) = target_symbol
2157            .parent_symbol_id
2158            .as_deref()
2159            .map(|id| get_symbol_by_id(conn, id))
2160            .transpose()?
2161            .flatten()
2162            .filter(|class| class.kind == "class" || class.kind == "struct")
2163    {
2164        for test in find_related_tests(conn, &class, limit - tests.len())? {
2165            if seen_ids.insert(test.symbol_id.clone()) {
2166                tests.push(test);
2167            }
2168        }
2169        return Ok(tests);
2170    }
2171
2172    let name = &target_symbol.name;
2173    let dunder = name.len() > 4 && name.starts_with("__") && name.ends_with("__");
2174    let definitions: i64 = conn.query_row(
2175        "SELECT COUNT(*) FROM symbols WHERE name = ?1 AND kind NOT IN ('import', 'export')",
2176        params![name],
2177        |row| row.get(0),
2178    )?;
2179    if dunder || definitions > 1 {
2180        return Ok(tests);
2181    }
2182
2183    let remaining = limit - tests.len();
2184    let name_sql = format!(
2185        "SELECT {COLUMNS}
2186     FROM symbols s
2187     WHERE {is_test}
2188       AND {not_documentation}
2189       AND (s.name LIKE ?1 ESCAPE '\\' OR s.signature LIKE ?1 ESCAPE '\\')
2190     ORDER BY (s.name LIKE ?1 ESCAPE '\\') DESC, {TEST_ORDER}
2191     LIMIT ?2"
2192    );
2193
2194    if let Ok(mut stmt) = conn.prepare(&name_sql)
2195        && let Ok(rows) = stmt.query_map(
2196            params![format!("%{}%", escape_like(name)), (remaining * 2) as i64],
2197            map_symbol,
2198        )
2199    {
2200        for row in rows.flatten() {
2201            if seen_ids.insert(row.symbol_id.clone()) {
2202                tests.push(row);
2203                if tests.len() >= limit {
2204                    return Ok(tests);
2205                }
2206            }
2207        }
2208    }
2209
2210    let remaining = limit - tests.len();
2211    let fts_exists: bool = conn
2212        .query_row(
2213            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
2214            [],
2215            |_| Ok(true),
2216        )
2217        .unwrap_or(false);
2218
2219    if remaining > 0 && fts_exists {
2220        let fts_sql = format!(
2221            "SELECT {COLUMNS}
2222         FROM symbols_fts
2223         CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
2224         WHERE symbols_fts MATCH ?1 AND {is_test} AND {not_documentation}
2225         ORDER BY {TEST_ORDER}
2226         LIMIT ?2"
2227        );
2228
2229        let and_q = name_prefix_query(&target_symbol.name);
2230        if !and_q.is_empty()
2231            && let Ok(mut stmt) = conn.prepare(&fts_sql)
2232            && let Ok(rows) = stmt.query_map(params![and_q, (remaining * 2) as i64], map_symbol)
2233        {
2234            for row in rows.flatten() {
2235                if seen_ids.insert(row.symbol_id.clone()) {
2236                    tests.push(row);
2237                    if tests.len() >= limit {
2238                        break;
2239                    }
2240                }
2241            }
2242        }
2243    }
2244
2245    Ok(tests)
2246}
2247
2248/// Renames each row whose parent is a class or another code type to `Owner.name`, so a list of
2249/// `__init__` rows says which class each one belongs to. For text output only: other tools take
2250/// the qualified name, and JSON keeps the raw name.
2251pub fn qualify_members<'a>(
2252    conn: &Connection,
2253    symbols: impl IntoIterator<Item = &'a mut Symbol>,
2254) -> Result<(), QueryError> {
2255    let mut stmt = conn.prepare(&format!(
2256        "SELECT name FROM symbols
2257         WHERE symbol_id = ?1
2258           AND kind IN ('class', 'struct', 'interface', 'trait', 'enum', 'record', 'object', 'protocol', 'union')
2259           AND language NOT IN ({})",
2260        documentation_language_list()
2261    ))?;
2262    for symbol in symbols {
2263        let Some(parent) = symbol.parent_symbol_id.as_deref() else {
2264            continue;
2265        };
2266        if let Some(owner) = stmt
2267            .query_row(params![parent], |row| row.get::<_, String>(0))
2268            .optional()?
2269            && !symbol.name.starts_with(&format!("{owner}."))
2270        {
2271            symbol.name = format!("{owner}.{}", symbol.name);
2272        }
2273    }
2274    Ok(())
2275}
2276
2277/// Find a specific symbol by name, with an optional path filter for disambiguation.
2278pub fn get_symbol_by_name(
2279    conn: &Connection,
2280    name: &str,
2281    path_filter: Option<&str>,
2282) -> Result<Option<Symbol>, QueryError> {
2283    get_symbol_by_name_internal(conn, name, path_filter, false)
2284}
2285
2286/// Find a specific symbol by name, requiring exact path match (used for atomic edits).
2287pub fn get_symbol_by_name_exact(
2288    conn: &Connection,
2289    name: &str,
2290    exact_path: &str,
2291) -> Result<Option<Symbol>, QueryError> {
2292    get_symbol_by_name_internal(conn, name, Some(exact_path), true)
2293}
2294
2295/// Retrieve one indexed symbol by its exact current-index identifier.
2296pub fn get_symbol_by_id(conn: &Connection, symbol_id: &str) -> Result<Option<Symbol>, QueryError> {
2297    conn.query_row(
2298        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
2299                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
2300                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
2301                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
2302                is_test, test_container
2303         FROM symbols WHERE symbol_id = ?1",
2304        [symbol_id],
2305        map_symbol,
2306    )
2307    .optional()
2308    .map_err(QueryError::from)
2309}
2310
2311/// Split `Outer::Inner::run` or `Outer.Inner.run` into its ancestor segments, outermost first, and the terminal name.
2312fn split_qualified_name(name: &str) -> (Vec<&str>, &str) {
2313    let separator = if name.contains("::") {
2314        "::"
2315    } else if name.contains('.') {
2316        "."
2317    } else {
2318        return (Vec::new(), name);
2319    };
2320    let mut segments: Vec<&str> = name.split(separator).collect();
2321    let terminal = segments.pop().unwrap_or(name);
2322    (segments, terminal)
2323}
2324
2325/// Names of the parents of `symbol_id`, innermost first.
2326fn ancestor_names(conn: &Connection, symbol_id: &str) -> Result<Vec<String>, QueryError> {
2327    let mut stmt = conn.prepare(
2328        "SELECT p.symbol_id, p.name FROM symbols s
2329         JOIN symbols p ON s.parent_symbol_id = p.symbol_id
2330         WHERE s.symbol_id = ?1",
2331    )?;
2332    let mut names = Vec::new();
2333    let mut current = symbol_id.to_string();
2334    for _ in 0..32 {
2335        let mut rows = stmt.query(params![current])?;
2336        let Some(row) = rows.next()? else { break };
2337        let parent_id: String = row.get(0)?;
2338        let parent_name: String = row.get(1)?;
2339        names.push(parent_name);
2340        current = parent_id;
2341    }
2342    Ok(names)
2343}
2344
2345/// A qualified name with several ancestor segments is filtered in Rust after the SQL, so the SQL
2346/// must not truncate the candidate set the way the 25-row cap does for plain and one-segment names.
2347const ANCESTOR_WALK_ROW_CAP: i64 = 2000;
2348
2349fn chain_contains(chain: &[String], wanted: &[&str]) -> bool {
2350    let mut remaining = chain.iter();
2351    wanted
2352        .iter()
2353        .all(|segment| remaining.any(|name| name == segment))
2354}
2355
2356fn get_symbol_by_name_internal(
2357    conn: &Connection,
2358    name: &str,
2359    path_filter: Option<&str>,
2360    exact_path: bool,
2361) -> Result<Option<Symbol>, QueryError> {
2362    let (ancestor_segments, terminal_name) = split_qualified_name(name);
2363    let parent_name = ancestor_segments.last().copied();
2364
2365    let sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
2366                s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
2367                s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
2368                s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
2369                s.is_test, s.test_container
2370         FROM symbols s
2371         LEFT JOIN symbols p ON s.parent_symbol_id = p.symbol_id
2372         WHERE (s.name = :name OR (s.name = :term AND (:parent IS NULL OR p.name = :parent)))
2373           AND (:path IS NULL OR s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE OR (:exact = 0 AND (s.path LIKE '%/' || :path_like ESCAPE '\\' OR s.path LIKE '%\\\\' || :path_like_bs ESCAPE '\\' OR s.path LIKE :path_like || '/%' ESCAPE '\\' OR s.path LIKE :path_like_bs || '\\\\%' ESCAPE '\\')))
2374         ORDER BY (s.kind != 'import') DESC,
2375                  (s.kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC,
2376                  (s.name = :name) DESC,
2377                  (:path IS NOT NULL AND (s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE)) DESC,
2378                  s.is_test ASC
2379         LIMIT :limit";
2380
2381    let row_cap: i64 = if ancestor_segments.len() > 1 {
2382        ANCESTOR_WALK_ROW_CAP
2383    } else {
2384        25
2385    };
2386    let mut stmt = conn.prepare(sql)?;
2387    let normalized_path = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
2388    let backslash_path = normalized_path.as_deref().map(|p| p.replace('/', "\\"));
2389    let path_like = normalized_path.as_deref().map(escape_like);
2390    let path_like_bs = backslash_path.as_deref().map(escape_like);
2391
2392    let mut rows = stmt.query(rusqlite::named_params! {
2393        ":name": name,
2394        ":term": terminal_name,
2395        ":parent": parent_name,
2396        ":path": normalized_path.as_deref(),
2397        ":path_bs": backslash_path.as_deref(),
2398        ":path_like": path_like.as_deref(),
2399        ":path_like_bs": path_like_bs.as_deref(),
2400        ":exact": if exact_path { 1 } else { 0 },
2401        ":limit": row_cap,
2402    })?;
2403
2404    let mut matches: Vec<Symbol> = Vec::new();
2405    while let Some(row) = rows.next()? {
2406        matches.push(map_symbol(row)?);
2407    }
2408    drop(rows);
2409
2410    if ancestor_segments.len() > 1 {
2411        let required: Vec<&str> = ancestor_segments.iter().rev().copied().collect();
2412        let mut kept = Vec::with_capacity(matches.len());
2413        for symbol in matches {
2414            if symbol.name == name
2415                || chain_contains(&ancestor_names(conn, &symbol.symbol_id)?, &required)
2416            {
2417                kept.push(symbol);
2418            }
2419        }
2420        matches = kept;
2421    }
2422
2423    if matches.is_empty() {
2424        return Ok(None);
2425    }
2426
2427    if matches.len() == 1 {
2428        return Ok(Some(matches.remove(0)));
2429    }
2430
2431    // Exclude imports if non-import candidates exist
2432    let candidates: Vec<Symbol> = if matches.iter().any(|s| s.kind != "import") {
2433        matches.into_iter().filter(|s| s.kind != "import").collect()
2434    } else {
2435        matches
2436    };
2437
2438    if candidates.len() == 1 {
2439        return Ok(Some(candidates.into_iter().next().unwrap()));
2440    }
2441
2442    // Check if there's an exact match on full name among candidates
2443    let exact_name_matches: Vec<_> = candidates
2444        .iter()
2445        .filter(|s| s.name == name)
2446        .cloned()
2447        .collect();
2448    if exact_name_matches.len() == 1 {
2449        return Ok(Some(exact_name_matches.into_iter().next().unwrap()));
2450    }
2451
2452    let definition_candidates = if exact_name_matches.is_empty() {
2453        &candidates
2454    } else {
2455        &exact_name_matches
2456    };
2457    let def_matches: Vec<_> = definition_candidates
2458        .iter()
2459        .filter(|s| {
2460            matches!(
2461                s.kind.as_str(),
2462                "function"
2463                    | "struct"
2464                    | "class"
2465                    | "trait"
2466                    | "method"
2467                    | "enum"
2468                    | "interface"
2469                    | "type"
2470            )
2471        })
2472        .cloned()
2473        .collect();
2474    if def_matches.len() == 1 {
2475        return Ok(Some(def_matches.into_iter().next().unwrap()));
2476    }
2477
2478    let active_pool = if !def_matches.is_empty() {
2479        def_matches
2480    } else if !exact_name_matches.is_empty() {
2481        exact_name_matches
2482    } else {
2483        candidates
2484    };
2485
2486    // If path_filter was given and there's an exact path match
2487    if let Some(ref p) = normalized_path {
2488        let exact_path_matches: Vec<_> = active_pool
2489            .iter()
2490            .filter(|s| s.path == *p)
2491            .cloned()
2492            .collect();
2493        if exact_path_matches.len() == 1 {
2494            return Ok(Some(exact_path_matches.into_iter().next().unwrap()));
2495        }
2496    }
2497
2498    if active_pool.len() == 1 {
2499        return Ok(Some(active_pool.into_iter().next().unwrap()));
2500    }
2501
2502    let mut outside_tests = active_pool.iter().filter(|s| !is_test_path(&s.path));
2503    if let (Some(only), None) = (outside_tests.next(), outside_tests.next()) {
2504        return Ok(Some(only.clone()));
2505    }
2506
2507    let mut outer = outer_definitions(conn, &active_pool)?;
2508    if outer.len() == 1 {
2509        return Ok(Some(outer.remove(0)));
2510    }
2511
2512    let mut candidate_list = String::new();
2513    for s in &active_pool {
2514        candidate_list.push_str(&format!(
2515            "- {} `{}` in {}:{}\n",
2516            s.kind, s.name, s.path, s.start_line
2517        ));
2518    }
2519
2520    Err(QueryError::AmbiguousSymbol(
2521        name.to_string(),
2522        active_pool.len(),
2523        candidate_list,
2524    ))
2525}
2526
2527/// The candidates left after dropping an `export` row that repeats a definition on its line and,
2528/// when anything else remains, the locals of a function or method.
2529fn outer_definitions(conn: &Connection, pool: &[Symbol]) -> Result<Vec<Symbol>, QueryError> {
2530    let definitions: Vec<&Symbol> = pool
2531        .iter()
2532        .filter(|s| {
2533            s.kind != "export"
2534                || !pool.iter().any(|other| {
2535                    other.kind != "export"
2536                        && other.path == s.path
2537                        && other.start_line == s.start_line
2538                })
2539        })
2540        .collect();
2541    let mut parent_kind = conn.prepare("SELECT kind FROM symbols WHERE symbol_id = ?1")?;
2542    let mut outer = Vec::new();
2543    for symbol in &definitions {
2544        let kind = match &symbol.parent_symbol_id {
2545            Some(parent) => parent_kind
2546                .query_row([parent], |row| row.get::<_, String>(0))
2547                .optional()?,
2548            None => None,
2549        };
2550        if !matches!(kind.as_deref(), Some("function" | "method" | "constructor")) {
2551            outer.push((*symbol).clone());
2552        }
2553    }
2554    Ok(if outer.is_empty() {
2555        definitions.into_iter().cloned().collect()
2556    } else {
2557        outer
2558    })
2559}
2560
2561/// The name of the repository this index was built for, for not-found messages.
2562pub fn workspace_name(conn: &Connection) -> String {
2563    conn.query_row(
2564        "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
2565        [],
2566        |row| row.get::<_, String>(0),
2567    )
2568    .ok()
2569    .map(|root| root.replace('\\', "/"))
2570    .and_then(|root| {
2571        root.trim_end_matches('/')
2572            .rsplit('/')
2573            .next()
2574            .filter(|name| !name.is_empty())
2575            .map(str::to_string)
2576    })
2577    .unwrap_or_else(|| "this workspace".to_string())
2578}
2579
2580fn edit_distance(left: &str, right: &str) -> usize {
2581    let left: Vec<char> = left.chars().collect();
2582    let right: Vec<char> = right.chars().collect();
2583    let mut previous: Vec<usize> = (0..=right.len()).collect();
2584    let mut current = vec![0usize; right.len() + 1];
2585    for (i, a) in left.iter().enumerate() {
2586        current[0] = i + 1;
2587        for (j, b) in right.iter().enumerate() {
2588            let substitution = previous[j] + usize::from(a != b);
2589            current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
2590        }
2591        std::mem::swap(&mut previous, &mut current);
2592    }
2593    previous[right.len()]
2594}
2595
2596/// Names within a small edit distance of `name`, recalled through the name trigram index.
2597fn near_names_by_trigram(
2598    conn: &Connection,
2599    name: &str,
2600    path_filter: Option<&str>,
2601) -> Result<Vec<Symbol>, QueryError> {
2602    if !has_table(conn, "symbol_names_tri") {
2603        return Ok(Vec::new());
2604    }
2605    let lower = name.to_lowercase();
2606    let chars: Vec<char> = lower.chars().collect();
2607    let chunks: Vec<String> = chars
2608        .windows(3)
2609        .map(|window| window.iter().collect::<String>())
2610        .filter(|chunk| chunk.chars().all(|c| c.is_alphanumeric() || c == '_'))
2611        .collect();
2612    if chunks.is_empty() {
2613        return Ok(Vec::new());
2614    }
2615    let match_clause = chunks
2616        .iter()
2617        .map(|chunk| format!("\"{chunk}\""))
2618        .collect::<Vec<_>>()
2619        .join(" OR ");
2620
2621    let normalized_path = path_filter.map(|p| {
2622        p.replace('\\', "/")
2623            .trim_start_matches("./")
2624            .trim_matches('/')
2625            .to_string()
2626    });
2627    let escaped_path = normalized_path.as_deref().map(escape_like);
2628    let kind_val: Option<&str> = None;
2629    let columns = candidate_columns(conn);
2630    let filters = candidate_filters(false, false);
2631    let sql = format!(
2632        "SELECT {columns} FROM symbol_names_tri
2633         CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
2634         WHERE symbol_names_tri MATCH :match {filters}
2635         ORDER BY bm25(symbol_names_tri) ASC, length(s.name) ASC, s.path ASC LIMIT 20"
2636    );
2637    let rows = conn
2638        .prepare(&sql)?
2639        .query_map(
2640            rusqlite::named_params! {
2641                ":match": match_clause,
2642                ":kind": kind_val,
2643                ":path": normalized_path.as_deref(),
2644                ":path_like": escaped_path.as_deref(),
2645            },
2646            map_symbol,
2647        )?
2648        .collect::<Result<Vec<_>, _>>()?;
2649
2650    let budget = (chars.len() / 4).max(2);
2651    let mut scored: Vec<(usize, Symbol)> = rows
2652        .into_iter()
2653        .map(|symbol| (edit_distance(&lower, &symbol.name.to_lowercase()), symbol))
2654        .filter(|(distance, _)| *distance <= budget)
2655        .collect();
2656    scored.sort_by_key(|(distance, symbol)| (*distance, symbol.name.chars().count()));
2657    Ok(scored
2658        .into_iter()
2659        .map(|(_, symbol)| symbol)
2660        .take(3)
2661        .collect())
2662}
2663
2664/// Up to three indexed symbols whose names are close to `name`. Never fails.
2665pub fn suggest_symbol_names(
2666    conn: &Connection,
2667    name: &str,
2668    path_filter: Option<&str>,
2669) -> Vec<Symbol> {
2670    let (ancestors, terminal) = split_qualified_name(name);
2671    let mut found =
2672        search_symbols_scoped(conn, terminal, None, path_filter, false, 3).unwrap_or_default();
2673    if found.is_empty() && terminal.chars().count() >= 4 {
2674        found = near_names_by_trigram(conn, terminal, path_filter).unwrap_or_default();
2675    }
2676    if let Some(parent) = ancestors.last().copied() {
2677        let mut ranked: Vec<(bool, Symbol)> = found
2678            .into_iter()
2679            .map(|symbol| {
2680                let shares_parent = ancestor_names(conn, &symbol.symbol_id)
2681                    .unwrap_or_default()
2682                    .first()
2683                    .is_some_and(|found_parent| found_parent == parent);
2684                (!shares_parent, symbol)
2685            })
2686            .collect();
2687        ranked.sort_by_key(|(demoted, _)| *demoted);
2688        found = ranked.into_iter().map(|(_, symbol)| symbol).collect();
2689    }
2690    found.truncate(3);
2691    found
2692}
2693
2694/// Up to three indexed file paths close to `rel_path`. Never fails.
2695pub fn suggest_file_paths(conn: &Connection, rel_path: &str) -> Vec<String> {
2696    let wanted = rel_path.replace('\\', "/");
2697    let wanted = wanted.trim_start_matches("./").trim_matches('/');
2698    let basename = wanted.rsplit('/').next().unwrap_or(wanted).to_lowercase();
2699    if basename.is_empty() {
2700        return Vec::new();
2701    }
2702    let stem = basename.split('.').next().unwrap_or(&basename).to_string();
2703    let segments: Vec<&str> = wanted.split('/').collect();
2704    let tail = if segments.len() >= 2 {
2705        segments[segments.len() - 2..].join("/").to_lowercase()
2706    } else {
2707        basename.clone()
2708    };
2709
2710    let path_expr = "replace(files.path, '\\', '/')";
2711    let file_name = format!(
2712        "lower(replace({path_expr}, rtrim({path_expr}, replace({path_expr}, '/', '')), ''))"
2713    );
2714    let rules = [
2715        (format!("{file_name} = :value"), basename.clone()),
2716        (
2717            format!("{file_name} LIKE :value ESCAPE '\\'"),
2718            format!("{}%", escape_like(&stem)),
2719        ),
2720        (
2721            format!("lower({path_expr}) LIKE :value ESCAPE '\\'"),
2722            format!("%{}", escape_like(&tail)),
2723        ),
2724    ];
2725
2726    for (predicate, value) in rules {
2727        let sql = format!(
2728            "SELECT {path_expr} FROM files WHERE {predicate}
2729             ORDER BY length(files.path) ASC, files.path ASC LIMIT 3"
2730        );
2731        let found: Vec<String> = conn
2732            .prepare(&sql)
2733            .and_then(|mut stmt| {
2734                stmt.query_map(rusqlite::named_params! { ":value": value }, |row| {
2735                    row.get::<_, String>(0)
2736                })?
2737                .collect()
2738            })
2739            .unwrap_or_default();
2740        if !found.is_empty() {
2741            return found;
2742        }
2743    }
2744
2745    let max_edits = (basename.chars().count() / 4).clamp(1, 2);
2746    let mut near: Vec<(usize, String)> = Vec::new();
2747    let _ = conn
2748        .prepare(&format!("SELECT {path_expr} FROM files"))
2749        .and_then(|mut stmt| {
2750            for path in stmt.query_map([], |row| row.get::<_, String>(0))?.flatten() {
2751                let name = path.rsplit('/').next().unwrap_or(&path).to_lowercase();
2752                let edits = edit_distance(&name, &basename);
2753                if edits <= max_edits {
2754                    near.push((edits, path));
2755                }
2756            }
2757            Ok(())
2758        });
2759    near.sort();
2760    near.into_iter().take(3).map(|(_, path)| path).collect()
2761}
2762
2763/// The workspace name and the recovery hint for a symbol that is not indexed.
2764pub fn symbol_not_found_parts(
2765    conn: &Connection,
2766    name: &str,
2767    path_filter: Option<&str>,
2768) -> (String, String) {
2769    let candidates = suggest_symbol_names(conn, name, path_filter);
2770    let hint = if candidates.is_empty() {
2771        "No similar name is indexed; check the workspace and spelling.".to_string()
2772    } else {
2773        let list = candidates
2774            .iter()
2775            .map(|s| format!("  - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
2776            .collect::<Vec<_>>()
2777            .join("\n");
2778        format!("Did you mean one of:\n{list}")
2779    };
2780    (workspace_name(conn), hint)
2781}
2782
2783/// The workspace name and the recovery hint for a file that is not indexed.
2784pub fn file_not_found_parts(conn: &Connection, rel_path: &str) -> (String, String) {
2785    let candidates = suggest_file_paths(conn, rel_path);
2786    let hint = if candidates.is_empty() {
2787        "No similar path is indexed; check the workspace and spelling.".to_string()
2788    } else {
2789        let list = candidates
2790            .iter()
2791            .map(|path| format!("  - {path}"))
2792            .collect::<Vec<_>>()
2793            .join("\n");
2794        format!("Did you mean one of:\n{list}")
2795    };
2796    (workspace_name(conn), hint)
2797}
2798
2799/// Find callers or callees of a symbol (filters unresolved external stdlib/runtime primitives by default).
2800pub fn find_references(
2801    conn: &Connection,
2802    symbol_name: &str,
2803    direction: &str,
2804    limit: usize,
2805) -> Result<Vec<ReferenceSite>, QueryError> {
2806    find_references_ext(conn, symbol_name, direction, limit, false)
2807}
2808
2809/// Find callers or callees with option to include external runtime/stdlib primitives.
2810pub fn find_references_ext(
2811    conn: &Connection,
2812    symbol_name: &str,
2813    direction: &str,
2814    limit: usize,
2815    include_external: bool,
2816) -> Result<Vec<ReferenceSite>, QueryError> {
2817    find_references_scoped(conn, symbol_name, direction, limit, include_external, None)
2818}
2819
2820/// Find callers or callees with optional file path disambiguation filter and external symbols toggle.
2821pub fn find_references_scoped(
2822    conn: &Connection,
2823    symbol_name: &str,
2824    direction: &str,
2825    limit: usize,
2826    include_external: bool,
2827    path_filter: Option<&str>,
2828) -> Result<Vec<ReferenceSite>, QueryError> {
2829    validate_result_limit(limit)?;
2830    if direction != "callers" && direction != "callees" {
2831        return Err(QueryError::InvalidDirection(direction.to_string()));
2832    }
2833
2834    match get_symbol_by_name(conn, symbol_name, path_filter)? {
2835        Some(target) => find_references_internal(
2836            conn,
2837            &target.name,
2838            direction,
2839            limit,
2840            Some(&target.symbol_id),
2841            include_external,
2842        ),
2843        None => {
2844            let (workspace, hint) = symbol_not_found_parts(conn, symbol_name, path_filter);
2845            Err(QueryError::SymbolNotFound {
2846                name: symbol_name.to_string(),
2847                workspace,
2848                hint,
2849            })
2850        }
2851    }
2852}
2853
2854pub fn find_references_for_symbol(
2855    conn: &Connection,
2856    symbol_name: &str,
2857    direction: &str,
2858    limit: usize,
2859    symbol_id: &str,
2860) -> Result<Vec<ReferenceSite>, QueryError> {
2861    find_references_for_symbol_ext(conn, symbol_name, direction, limit, symbol_id, false)
2862}
2863
2864pub fn find_references_for_symbol_ext(
2865    conn: &Connection,
2866    symbol_name: &str,
2867    direction: &str,
2868    limit: usize,
2869    symbol_id: &str,
2870    include_external: bool,
2871) -> Result<Vec<ReferenceSite>, QueryError> {
2872    find_references_internal(
2873        conn,
2874        symbol_name,
2875        direction,
2876        limit,
2877        Some(symbol_id),
2878        include_external,
2879    )
2880}
2881
2882/// SQL expression ranking a candidate path against the call site `p.path`:
2883/// 2 for the same file, 1 for the same directory, 0 otherwise.
2884fn call_site_proximity(candidate_path: &str) -> String {
2885    path_proximity(candidate_path, "p.path")
2886}
2887
2888/// SQL expression ranking `candidate_path` against `site_path`: 2 for the same file, 1 for the
2889/// same directory, 0 otherwise.
2890fn path_proximity(candidate_path: &str, site_path: &str) -> String {
2891    let normalized = format!("replace({candidate_path}, '\\', '/')");
2892    let call_site = format!("replace({site_path}, '\\', '/')");
2893    format!(
2894        "CASE WHEN {normalized} = {call_site} THEN 2
2895              WHEN rtrim({normalized}, replace({normalized}, '/', '')) = rtrim({call_site}, replace({call_site}, '/', '')) THEN 1
2896              ELSE 0 END"
2897    )
2898}
2899
2900/// True when the import source `value` is a relative file path (`../core/util.js`).
2901fn is_relative_import_path(value: &str) -> String {
2902    format!("({value} LIKE '.%' AND instr({value}, '/') > 0)")
2903}
2904
2905/// True when `{target}` is the file the relative import source `value` names, resolved from the
2906/// folder of the call site `p.path`: `../core/util.js` from `src/v4/locales/ru.ts` names
2907/// `src/v4/core/util.ts`, `.js`, `.tsx`, and the other script files, or that folder's `index`.
2908fn relative_import_matches(value: &str, target: &str) -> String {
2909    let stem = format!(
2910        "CASE WHEN {value} GLOB '*.[jt]s' THEN substr({value}, 1, length({value}) - 3)
2911              WHEN {value} GLOB '*.[mc][jt]s' OR {value} GLOB '*.[jt]sx'
2912                   THEN substr({value}, 1, length({value}) - 4)
2913              ELSE {value} END"
2914    );
2915    let caller = "replace(p.path, '\\', '/')";
2916    let parent_dir = "rtrim(substr(hop.dir, 1, length(hop.dir) - 1), replace(substr(hop.dir, 1, length(hop.dir) - 1), '/', ''))";
2917    let files = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "d.ts"]
2918        .iter()
2919        .flat_map(|ext| {
2920            [
2921                format!("hop.dir || hop.rest || '.{ext}'"),
2922                format!("hop.dir || hop.rest || '/index.{ext}'"),
2923            ]
2924        })
2925        .collect::<Vec<_>>()
2926        .join(", ");
2927    format!(
2928        "EXISTS (
2929            WITH RECURSIVE hop(dir, rest) AS (
2930                SELECT rtrim({caller}, replace({caller}, '/', '')),
2931                       CASE WHEN ({stem}) LIKE './%' THEN substr(({stem}), 3) ELSE ({stem}) END
2932                UNION ALL
2933                SELECT {parent_dir}, substr(hop.rest, 4) FROM hop WHERE hop.rest LIKE '../%'
2934            )
2935            SELECT 1 FROM hop
2936            WHERE hop.rest NOT LIKE '../%' AND hop.rest != ''
2937              AND replace({target}.path, '\\', '/') IN ({files})
2938        )"
2939    )
2940}
2941
2942/// SQL predicate that decides whether a pending call edge `p` (with caller `s_from`) points at
2943/// the candidate definition `target` (whose parent symbol is joined as `parent`).
2944fn pending_target_predicate(conn: &Connection, target: &str, parent: &str) -> String {
2945    let ns = "json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END)";
2946    let target_path = format!("('/' || replace({target}.path, '\\', '/'))");
2947    let like_value = "replace(replace(replace(value, '\\', '\\\\'), '%', '\\%'), '_', '\\_')";
2948    let closer_rank = call_site_proximity("closer.path");
2949    let target_rank = call_site_proximity(&format!("{target}.path"));
2950    let dotted = "replace(module_path.value, '/', '.')";
2951    let segment = format!("replace({dotted}, rtrim({dotted}, replace({dotted}, '.', '')), '')");
2952    let relative = is_relative_import_path("import_source.value");
2953    let relative_match = relative_import_matches("import_source.value", target);
2954    // `import { util } from "../helpers/util.js"`: `util.f()` reaches only that file's `util`.
2955    let receiver_import_elsewhere = if has_column(conn, "symbols", "metadata_json") {
2956        format!(
2957            "AND NOT EXISTS (
2958                            SELECT 1 FROM symbols receiver_import
2959                            CROSS JOIN json_each(json_array(
2960                                json_extract(receiver_import.metadata_json, '$.source')
2961                            )) import_source
2962                            WHERE receiver_import.kind = 'import'
2963                              AND receiver_import.path = p.path
2964                              AND receiver_import.name = p.target_receiver
2965                              AND json_valid(receiver_import.metadata_json)
2966                              AND {relative}
2967                              AND NOT {relative_match}
2968                        )"
2969        )
2970    } else {
2971        String::new()
2972    };
2973    let import_alias_receiver = if has_column(conn, "symbols", "metadata_json") {
2974        format!("OR EXISTS (
2975                        SELECT 1 FROM symbols alias_import
2976                        WHERE alias_import.kind = 'import'
2977                          AND alias_import.path = p.path
2978                          AND json_valid(alias_import.metadata_json)
2979                          AND (json_extract(alias_import.metadata_json, '$.alias') = p.target_receiver
2980                               OR json_extract(alias_import.metadata_json, '$.local_name') = p.target_receiver)
2981                          AND COALESCE(json_extract(alias_import.metadata_json, '$.source'), '') NOT LIKE 'Qt%'
2982                    )
2983                    OR EXISTS (
2984                        SELECT 1 FROM symbols module_import
2985                        CROSS JOIN json_each(json_array(
2986                            json_extract(module_import.metadata_json, '$.source')
2987                        )) import_source
2988                        CROSS JOIN json_each(json_array(
2989                            json_extract(module_import.metadata_json, '$.importedName'),
2990                            import_source.value
2991                        )) module_path
2992                        WHERE module_import.kind = 'import'
2993                          AND module_import.path = p.path
2994                          AND module_import.name = p.target_receiver
2995                          AND json_valid(module_import.metadata_json)
2996                          AND CASE WHEN {relative} THEN {relative_match}
2997                              ELSE {segment} != ''
2998                                   AND ({target_path} LIKE '%/' || {segment} || '/%'
2999                                        OR {target_path} LIKE '%/' || {segment} || '.%')
3000                              END
3001                    )"
3002        )
3003    } else {
3004        String::new()
3005    };
3006    // A pytest test gets its object from a fixture: `def test_run(app): app.run()`, where the
3007    // fixture `app` builds `Flask(...)`. The receiver then names the class the fixture builds.
3008    let fixture_receiver = if has_column(conn, "symbols", "test_lifecycle") {
3009        format!(
3010            "OR EXISTS (
3011                        WITH RECURSIVE built(symbol_id, depth) AS (
3012                            SELECT built_class.symbol_id, 0
3013                            FROM symbols fixture
3014                            JOIN pending_relationships fixture_call ON fixture_call.from_symbol_id = fixture.symbol_id
3015                            JOIN symbols built_class ON built_class.name = fixture_call.target_terminal_name
3016                            WHERE fixture.name = p.target_receiver
3017                              AND fixture.test_lifecycle = 1
3018                              AND built_class.kind = 'class'
3019                              AND EXISTS (
3020                                  SELECT 1 FROM symbols taken
3021                                  WHERE taken.parent_symbol_id = p.from_symbol_id
3022                                    AND taken.name = p.target_receiver
3023                                    AND taken.kind IN ('parameter', 'variable')
3024                              )
3025                            UNION
3026                            SELECT r.to_symbol_id, built.depth + 1
3027                            FROM relationships r JOIN built ON r.from_symbol_id = built.symbol_id
3028                            WHERE +r.kind = 'extends' AND built.depth < 8
3029                            UNION
3030                            SELECT c.symbol_id, built.depth + 1
3031                            FROM pending_relationships pe
3032                            JOIN built ON pe.from_symbol_id = built.symbol_id
3033                            JOIN symbols c ON c.name = pe.target_terminal_name
3034                            WHERE +pe.kind = 'extends' AND built.depth < 8
3035                              AND c.kind IN ('class', 'interface', 'struct', 'trait', 'protocol')
3036                        )
3037                        SELECT 1 FROM built WHERE built.symbol_id = {target}.parent_symbol_id
3038                    )"
3039        )
3040    } else {
3041        String::new()
3042    };
3043    format!(
3044        "(
3045            NOT (p.kind IS 'extends' AND p.from_symbol_id = {target}.symbol_id)
3046            AND (
3047            (
3048                {target}.parent_symbol_id IS NOT NULL
3049                AND {parent}.name IS NOT NULL
3050                AND (
3051                    EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
3052                    OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
3053                        AND s_from.parent_symbol_id = {target}.parent_symbol_id)
3054                    OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver
3055                        {receiver_import_elsewhere})
3056                    OR ((p.target_receiver IN ('self', 'this', 'cls', 'Self', 'super')
3057                         OR EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self'))
3058                        AND EXISTS (
3059                            WITH RECURSIVE base(symbol_id, depth) AS (
3060                                SELECT s_from.parent_symbol_id, 0
3061                                UNION
3062                                SELECT r.to_symbol_id, base.depth + 1
3063                                FROM relationships r JOIN base ON r.from_symbol_id = base.symbol_id
3064                                WHERE +r.kind = 'extends' AND base.depth < 8
3065                                UNION
3066                                SELECT c.symbol_id, base.depth + 1
3067                                FROM pending_relationships pe
3068                                JOIN base ON pe.from_symbol_id = base.symbol_id
3069                                JOIN symbols c ON c.name = pe.target_terminal_name
3070                                WHERE +pe.kind = 'extends' AND base.depth < 8
3071                                  AND c.kind IN ('class', 'interface', 'struct', 'trait', 'protocol')
3072                            )
3073                            SELECT 1 FROM base
3074                            WHERE base.symbol_id = {target}.parent_symbol_id AND base.depth > 0
3075                              AND NOT EXISTS (
3076                                  SELECT 1 FROM base closer
3077                                  JOIN symbols nearer ON nearer.parent_symbol_id = closer.symbol_id
3078                                  WHERE nearer.name = {target}.name
3079                                    AND nearer.kind IN ('method', 'function', 'constructor', 'property')
3080                                    AND closer.depth < base.depth
3081                                    AND (closer.depth > 0 OR p.target_receiver IS NOT 'super')
3082                              )
3083                        ))
3084                    OR EXISTS (
3085                        SELECT 1 FROM symbols receiver
3086                        JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
3087                        WHERE receiver.name = p.target_receiver
3088                          AND receiver.path = p.path
3089                          AND receiver_type.resolved_type = {parent}.name
3090                          AND NOT EXISTS (
3091                              SELECT 1 FROM symbols shadow
3092                              WHERE shadow.name = receiver.name
3093                                AND shadow.path = receiver.path
3094                                AND shadow.parent_symbol_id = p.from_symbol_id
3095                                AND shadow.kind IN ('variable', 'parameter')
3096                                AND shadow.symbol_id != receiver.symbol_id
3097                          )
3098                    )
3099                    {fixture_receiver}
3100                )
3101                AND NOT EXISTS (
3102                    SELECT 1 FROM {ns}
3103                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
3104                      AND NOT EXISTS (
3105                          WITH RECURSIVE ancestor(symbol_id, depth) AS (
3106                              SELECT {target}.parent_symbol_id, 0
3107                              UNION ALL
3108                              SELECT s.parent_symbol_id, ancestor.depth + 1
3109                              FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
3110                              WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
3111                          )
3112                          SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
3113                          WHERE a.name = value
3114                      )
3115                      AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
3116                      AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
3117                )
3118            )
3119            OR (
3120                (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
3121                AND (
3122                    p.target_receiver IS NULL
3123                    OR p.target_receiver = ''
3124                    {import_alias_receiver}
3125                )
3126                AND ({target}.parent_symbol_id IS NULL
3127                     OR s_from.parent_symbol_id = {target}.parent_symbol_id
3128                     OR {target}.parent_symbol_id = p.from_symbol_id)
3129                AND (p.target_receiver IS NOT NULL AND p.target_receiver != '' OR {target}.path = p.path OR NOT EXISTS (
3130                    SELECT 1 FROM symbols shadow
3131                    WHERE shadow.name = {target}.name
3132                      AND shadow.path = p.path
3133                      AND shadow.symbol_id != p.from_symbol_id
3134                      AND shadow.kind NOT IN ('import', 'export', 'module', 'namespace')
3135                      AND (shadow.parent_symbol_id IS NULL OR shadow.parent_symbol_id = p.from_symbol_id)
3136                ))
3137                AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
3138                    SELECT 1 FROM symbols closer
3139                    WHERE closer.name = {target}.name
3140                      AND closer.symbol_id != {target}.symbol_id
3141                      AND closer.parent_symbol_id IS NULL
3142                      AND closer.kind = {target}.kind
3143                      AND NOT (p.kind IS 'extends' AND closer.symbol_id = p.from_symbol_id)
3144                      AND {closer_rank} > {target_rank}
3145                ))
3146            )
3147            OR (
3148                {target}.parent_symbol_id IS NULL
3149                AND EXISTS (
3150                    SELECT 1 FROM {ns}
3151                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
3152                      AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
3153                )
3154            )
3155            )
3156        )"
3157    )
3158}
3159
3160/// SQL predicate excluding rows julie marked as documentation, or the always-true `1 = 1` when
3161/// the column is absent, because a bare `1` in ORDER BY means the first result column in SQLite.
3162const DOCUMENTATION_LANGUAGES: &[&str] = &[
3163    "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
3164];
3165
3166fn documentation_language_list() -> String {
3167    DOCUMENTATION_LANGUAGES
3168        .iter()
3169        .map(|l| format!("'{l}'"))
3170        .collect::<Vec<_>>()
3171        .join(", ")
3172}
3173
3174fn not_documentation(conn: &Connection, alias: &str) -> String {
3175    let has_content_type: bool = conn
3176        .query_row(
3177            "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
3178            [],
3179            |_| Ok(true),
3180        )
3181        .unwrap_or(false);
3182    if has_content_type {
3183        format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
3184    } else {
3185        "1 = 1".to_string()
3186    }
3187}
3188
3189pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
3190    conn.query_row(
3191        "SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2",
3192        [table, column],
3193        |_| Ok(true),
3194    )
3195    .unwrap_or(false)
3196}
3197
3198pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
3199    conn.query_row(
3200        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
3201        [name],
3202        |_| Ok(true),
3203    )
3204    .unwrap_or(false)
3205}
3206
3207fn has_pending_namespace_column(conn: &Connection) -> bool {
3208    let has_ns: bool = conn
3209        .query_row(
3210            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
3211            [],
3212            |_| Ok(true),
3213        )
3214        .unwrap_or(false);
3215    let has_display: bool = conn
3216        .query_row(
3217            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
3218            [],
3219            |_| Ok(true),
3220        )
3221        .unwrap_or(false);
3222    let has_receiver: bool = conn
3223        .query_row(
3224            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
3225            [],
3226            |_| Ok(true),
3227        )
3228        .unwrap_or(false);
3229    has_ns && has_display && has_receiver
3230}
3231
3232fn find_references_internal(
3233    conn: &Connection,
3234    symbol_name: &str,
3235    direction: &str,
3236    limit: usize,
3237    symbol_id: Option<&str>,
3238    include_external: bool,
3239) -> Result<Vec<ReferenceSite>, QueryError> {
3240    let mut results = Vec::new();
3241
3242    if direction == "callers" {
3243        // Find callers: references pointing to target symbol
3244        let mut stmt = conn.prepare(
3245            "SELECT s_from.name AS from_name,
3246                    r.from_symbol_id,
3247                    s_to.name AS to_name,
3248                    r.kind,
3249                    r.path,
3250                    r.start_line,
3251                    r.start_column
3252             FROM relationships r
3253             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3254             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3255             WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
3256             LIMIT ?2",
3257        )?;
3258
3259        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
3260            Ok(ReferenceSite {
3261                from_symbol_name: row.get(0)?,
3262                from_symbol_id: row.get(1)?,
3263                to_symbol_name: row.get(2)?,
3264                kind: row.get(3)?,
3265                path: row.get::<_, String>(4)?.replace('\\', "/"),
3266                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3267                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3268                occurrences: None,
3269                target: None,
3270            })
3271        })?;
3272
3273        for r in rows {
3274            results.push(r?);
3275        }
3276
3277        // Also query pending_relationships for callers if results < limit
3278        if results.len() < limit {
3279            let remaining = limit - results.len();
3280            if has_pending_namespace_column(conn) {
3281                if let Some(sid) = symbol_id {
3282                    let mut pending_stmt = conn.prepare(
3283                        &format!("SELECT s_from.name AS from_name,
3284                                p.from_symbol_id,
3285                                p.target_terminal_name AS to_name,
3286                                p.kind,
3287                                p.path,
3288                                p.start_line,
3289                                p.start_column
3290                         FROM pending_relationships p
3291                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3292                         JOIN symbols s_target ON s_target.symbol_id = ?3
3293                         LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3294                          WHERE p.target_terminal_name = ?1
3295                            AND {pred}
3296                          LIMIT ?2", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
3297                    )?;
3298
3299                    let p_rows = pending_stmt.query_map(
3300                        params![symbol_name, remaining as i64, sid],
3301                        |row| {
3302                            Ok(ReferenceSite {
3303                                from_symbol_name: row.get(0)?,
3304                                from_symbol_id: row.get(1)?,
3305                                to_symbol_name: row.get(2)?,
3306                                kind: row.get(3)?,
3307                                path: row.get::<_, String>(4)?.replace('\\', "/"),
3308                                start_line: Some(row.get::<_, i64>(5)? as usize),
3309                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3310                                occurrences: None,
3311                                target: None,
3312                            })
3313                        },
3314                    )?;
3315                    for r in p_rows {
3316                        results.push(r?);
3317                    }
3318                } else {
3319                    let mut pending_stmt = conn.prepare(
3320                        "SELECT s_from.name AS from_name,
3321                                p.from_symbol_id,
3322                                p.target_terminal_name AS to_name,
3323                                p.kind,
3324                                p.path,
3325                                p.start_line,
3326                                p.start_column
3327                         FROM pending_relationships p
3328                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3329                         WHERE p.target_terminal_name = ?1
3330                           AND (
3331                               (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
3332                               OR EXISTS (
3333                                   SELECT 1 FROM symbols s_any
3334                                   JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
3335                                   WHERE s_any.name = p.target_terminal_name
3336                                     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)
3337                               )
3338                           )
3339                         LIMIT ?2",
3340                    )?;
3341
3342                    let p_rows =
3343                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
3344                            Ok(ReferenceSite {
3345                                from_symbol_name: row.get(0)?,
3346                                from_symbol_id: row.get(1)?,
3347                                to_symbol_name: row.get(2)?,
3348                                kind: row.get(3)?,
3349                                path: row.get::<_, String>(4)?.replace('\\', "/"),
3350                                start_line: Some(row.get::<_, i64>(5)? as usize),
3351                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3352                                occurrences: None,
3353                                target: None,
3354                            })
3355                        })?;
3356                    for r in p_rows {
3357                        results.push(r?);
3358                    }
3359                }
3360            } else {
3361                let is_nested = if let Some(sid) = symbol_id {
3362                    conn.query_row(
3363                        "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
3364                        params![sid],
3365                        |_| Ok(true),
3366                    )
3367                    .unwrap_or(false)
3368                } else {
3369                    false
3370                };
3371
3372                if !is_nested {
3373                    let mut pending_stmt = conn.prepare(
3374                        "SELECT s_from.name AS from_name,
3375                                p.from_symbol_id,
3376                                p.target_terminal_name AS to_name,
3377                                p.kind,
3378                                p.path,
3379                                p.start_line,
3380                                p.start_column
3381                         FROM pending_relationships p
3382                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3383                         WHERE p.target_terminal_name = ?1
3384                         LIMIT ?2",
3385                    )?;
3386
3387                    let p_rows =
3388                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
3389                            Ok(ReferenceSite {
3390                                from_symbol_name: row.get(0)?,
3391                                from_symbol_id: row.get(1)?,
3392                                to_symbol_name: row.get(2)?,
3393                                kind: row.get(3)?,
3394                                path: row.get::<_, String>(4)?.replace('\\', "/"),
3395                                start_line: Some(row.get::<_, i64>(5)? as usize),
3396                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3397                                occurrences: None,
3398                                target: None,
3399                            })
3400                        })?;
3401
3402                    for r in p_rows {
3403                        results.push(r?);
3404                    }
3405                }
3406            }
3407        }
3408
3409        if results.len() < limit && has_table(conn, "identifiers") {
3410            let remaining = limit - results.len();
3411            let mut ident_stmt = conn.prepare(
3412                "SELECT COALESCE(s.name, ''),
3413                        COALESCE(i.containing_symbol_id, ''),
3414                        i.name,
3415                        CASE WHEN json_valid(i.metadata_json)
3416                                  AND json_extract(i.metadata_json, '$.role') = 'signal_handler'
3417                             THEN CASE WHEN json_extract(i.metadata_json, '$.receiver') = (
3418                                      SELECT CASE WHEN t.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
3419                                                  THEN t.name ELSE tp.name END
3420                                      FROM symbols t
3421                                      LEFT JOIN symbols tp ON tp.symbol_id = t.parent_symbol_id
3422                                      WHERE t.symbol_id = ?3)
3423                                  THEN 'handler' ELSE 'handler (candidate)' END
3424                             ELSE i.kind END,
3425                        i.path,
3426                        i.start_line,
3427                        i.start_column
3428                 FROM identifiers i
3429                 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
3430                 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
3431                   AND COALESCE(s.kind, '') != 'import'
3432                   AND NOT EXISTS (
3433                       SELECT 1 FROM relationships covered
3434                       JOIN symbols covered_to ON covered.to_symbol_id = covered_to.symbol_id
3435                       WHERE covered_to.name = i.name
3436                         AND covered.path = i.path
3437                         AND covered.start_line = i.start_line
3438                         AND (?3 IS NULL OR covered.to_symbol_id = ?3)
3439                   )
3440                   AND NOT EXISTS (
3441                       SELECT 1 FROM pending_relationships covered
3442                       WHERE covered.target_terminal_name = i.name
3443                         AND covered.path = i.path
3444                         AND covered.start_line = i.start_line
3445                   )
3446                   AND (?3 IS NULL OR NOT EXISTS (
3447                       SELECT 1 FROM symbols owner
3448                       JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
3449                       WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
3450                         AND member.name = i.name
3451                         AND owner.name IS NOT (SELECT parent.name FROM symbols target
3452                                                JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
3453                                                WHERE target.symbol_id = ?3)
3454                   ))
3455                   AND NOT (i.kind = 'member_access' AND EXISTS (
3456                       SELECT 1 FROM symbols target
3457                       LEFT JOIN symbols scope ON scope.symbol_id = target.parent_symbol_id
3458                       WHERE target.symbol_id = ?3
3459                         AND (scope.kind IN ('function', 'method', 'constructor')
3460                              OR (scope.symbol_id IS NULL
3461                                  AND target.path != i.path
3462                                  AND target.language IN ('javascript', 'typescript', 'tsx')
3463                                  AND COALESCE(target.visibility, 'private') = 'private'))
3464                   ))
3465                   AND NOT (i.kind = 'member_access'
3466                        AND json_valid(i.metadata_json)
3467                        AND json_extract(i.metadata_json, '$.role') IS NOT 'signal_handler'
3468                        AND json_extract(i.metadata_json, '$.receiver') GLOB '[A-Z]*'
3469                        AND NOT EXISTS (SELECT 1 FROM symbols known
3470                                        WHERE known.name = json_extract(i.metadata_json, '$.receiver')
3471                                          AND known.kind NOT IN ('variable', 'parameter', 'method')))
3472                 ORDER BY i.path, i.start_line
3473                 LIMIT ?2",
3474            )?;
3475            let rows =
3476                ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
3477                    Ok(ReferenceSite {
3478                        from_symbol_name: row.get(0)?,
3479                        from_symbol_id: row.get(1)?,
3480                        to_symbol_name: row.get(2)?,
3481                        kind: row.get(3)?,
3482                        path: row.get::<_, String>(4)?.replace('\\', "/"),
3483                        start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3484                        start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3485                        occurrences: None,
3486                        target: None,
3487                    })
3488                })?;
3489            for r in rows {
3490                results.push(r?);
3491            }
3492
3493            if let Some(sid) = symbol_id.filter(|_| results.len() < limit) {
3494                let remaining = limit - results.len();
3495                let mut receiver_stmt = conn.prepare(
3496                    "SELECT COALESCE(s.name, ''),
3497                            COALESCE(i.containing_symbol_id, ''),
3498                            i.name,
3499                            i.kind,
3500                            i.path,
3501                            MIN(i.start_line),
3502                            i.start_column,
3503                            COUNT(*)
3504                     FROM identifiers i
3505                     LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
3506                     JOIN symbols target ON target.symbol_id = ?2
3507                     LEFT JOIN symbols target_parent ON target_parent.symbol_id = target.parent_symbol_id
3508                     WHERE i.kind = 'member_access'
3509                       AND i.name != target.name
3510                       AND COALESCE(s.kind, '') != 'import'
3511                       AND target.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
3512                       AND json_valid(i.metadata_json)
3513                       AND json_extract(i.metadata_json, '$.receiver') = target.name
3514                       AND (json_extract(i.metadata_json, '$.receiver_qualifier') IS NULL
3515                            OR json_extract(i.metadata_json, '$.receiver_qualifier') = target_parent.name)
3516                     GROUP BY i.path
3517                     ORDER BY i.path, i.start_line
3518                     LIMIT ?1",
3519                )?;
3520                let rows = receiver_stmt.query_map(params![remaining as i64, sid], |row| {
3521                    Ok(ReferenceSite {
3522                        from_symbol_name: row.get(0)?,
3523                        from_symbol_id: row.get(1)?,
3524                        to_symbol_name: row.get(2)?,
3525                        kind: row.get(3)?,
3526                        path: row.get::<_, String>(4)?.replace('\\', "/"),
3527                        start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3528                        start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3529                        occurrences: Some(row.get::<_, i64>(7)? as usize),
3530                        target: None,
3531                    })
3532                })?;
3533                for r in rows {
3534                    results.push(r?);
3535                }
3536            }
3537        }
3538    } else {
3539        // Find callees: symbols called by target symbol
3540        let mut stmt = conn.prepare(
3541            "SELECT s_from.name AS from_name,
3542                    r.from_symbol_id,
3543                    s_to.name AS to_name,
3544                    r.kind,
3545                    r.path,
3546                    r.start_line,
3547                    r.start_column
3548             FROM relationships r
3549             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3550             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3551             WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
3552             LIMIT ?2",
3553        )?;
3554
3555        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
3556            Ok(ReferenceSite {
3557                from_symbol_name: row.get(0)?,
3558                from_symbol_id: row.get(1)?,
3559                to_symbol_name: row.get(2)?,
3560                kind: row.get(3)?,
3561                path: row.get::<_, String>(4)?.replace('\\', "/"),
3562                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3563                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3564                occurrences: None,
3565                target: None,
3566            })
3567        })?;
3568
3569        for r in rows {
3570            results.push(r?);
3571        }
3572
3573        // Also query pending_relationships for callees
3574        if results.len() < limit {
3575            let remaining = limit - results.len();
3576            let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
3577                let sql = if include_external {
3578                    String::from("SELECT DISTINCT s_from.name AS from_name,
3579                            p.from_symbol_id,
3580                            COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
3581                            p.kind,
3582                            p.path,
3583                            p.start_line,
3584                            p.start_column
3585                     FROM pending_relationships p
3586                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3587                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3588                     LIMIT ?2")
3589                } else {
3590                    format!("SELECT DISTINCT s_from.name AS from_name,
3591                            p.from_symbol_id,
3592                            p.target_terminal_name AS to_name,
3593                            p.kind,
3594                            p.path,
3595                            p.start_line,
3596                            p.start_column
3597                     FROM pending_relationships p
3598                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3599                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3600                       AND EXISTS (
3601                           SELECT 1 FROM symbols s_to
3602                           LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
3603                           WHERE s_to.name = p.target_terminal_name
3604                             AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3605                             AND {pred}
3606                       )
3607                     LIMIT ?2", pred = pending_target_predicate(conn, "s_to", "s_to_parent"))
3608                };
3609                let mut pending_stmt = conn.prepare(&sql)?;
3610                let rows = pending_stmt.query_map(
3611                    params![symbol_name, remaining as i64, symbol_id],
3612                    |row| {
3613                        Ok(ReferenceSite {
3614                            from_symbol_name: row.get(0)?,
3615                            from_symbol_id: row.get(1)?,
3616                            to_symbol_name: row.get(2)?,
3617                            kind: row.get(3)?,
3618                            path: row.get::<_, String>(4)?.replace('\\', "/"),
3619                            start_line: Some(row.get::<_, i64>(5)? as usize),
3620                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3621                            occurrences: None,
3622                            target: None,
3623                        })
3624                    },
3625                )?;
3626                let mut out = Vec::new();
3627                for r in rows {
3628                    out.push(r?);
3629                }
3630                out
3631            } else {
3632                let sql = if include_external {
3633                    "SELECT DISTINCT s_from.name AS from_name,
3634                            p.from_symbol_id,
3635                            p.target_terminal_name AS to_name,
3636                            p.kind,
3637                            p.path,
3638                            p.start_line,
3639                            p.start_column
3640                     FROM pending_relationships p
3641                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3642                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3643                     LIMIT ?2"
3644                } else {
3645                    "SELECT DISTINCT s_from.name AS from_name,
3646                            p.from_symbol_id,
3647                            p.target_terminal_name AS to_name,
3648                            p.kind,
3649                            p.path,
3650                            p.start_line,
3651                            p.start_column
3652                     FROM pending_relationships p
3653                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3654                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3655                       AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
3656                     LIMIT ?2"
3657                };
3658                let mut pending_stmt = conn.prepare(sql)?;
3659
3660                let rows = pending_stmt.query_map(
3661                    params![symbol_name, remaining as i64, symbol_id],
3662                    |row| {
3663                        Ok(ReferenceSite {
3664                            from_symbol_name: row.get(0)?,
3665                            from_symbol_id: row.get(1)?,
3666                            to_symbol_name: row.get(2)?,
3667                            kind: row.get(3)?,
3668                            path: row.get::<_, String>(4)?.replace('\\', "/"),
3669                            start_line: Some(row.get::<_, i64>(5)? as usize),
3670                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3671                            occurrences: None,
3672                            target: None,
3673                        })
3674                    },
3675                )?;
3676                let mut out = Vec::new();
3677                for r in rows {
3678                    out.push(r?);
3679                }
3680                out
3681            };
3682
3683            for r in p_rows {
3684                results.push(r);
3685            }
3686        }
3687        results.sort_by(|a, b| {
3688            a.path
3689                .cmp(&b.path)
3690                .then(a.start_line.cmp(&b.start_line))
3691                .then(a.start_column.cmp(&b.start_column))
3692        });
3693        let mut sites = merge_same_site(results);
3694        describe_callee_targets(conn, &mut sites)?;
3695        return Ok(sites);
3696    }
3697
3698    Ok(merge_same_site(results))
3699}
3700
3701/// Names the definitions each callee site can reach: the resolved target, or the definitions a
3702/// pending call matches. Several matches are listed, so a call to a common name stays clear.
3703fn describe_callee_targets(
3704    conn: &Connection,
3705    sites: &mut [ReferenceSite],
3706) -> Result<(), QueryError> {
3707    let owner = "CASE WHEN par.kind IN ('class', 'struct', 'interface', 'trait', 'enum', 'record', 'object', 'protocol', 'union')
3708                 THEN par.name || '.' ELSE '' END";
3709    let pending = if has_pending_namespace_column(conn) {
3710        format!(
3711            "UNION
3712             SELECT {owner} || s_to.name, replace(s_to.path, '\\', '/'), s_to.start_line
3713             FROM pending_relationships p
3714             JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3715             JOIN symbols s_to ON s_to.name = p.target_terminal_name
3716             LEFT JOIN symbols par ON s_to.parent_symbol_id = par.symbol_id
3717             LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
3718             WHERE p.from_symbol_id = ?1 AND p.start_line = ?2
3719               AND (p.target_terminal_name = ?3 OR p.target_display_name = ?3)
3720               AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3721               AND {pred}",
3722            pred = pending_target_predicate(conn, "s_to", "s_to_parent")
3723        )
3724    } else {
3725        String::new()
3726    };
3727    let mut stmt = conn.prepare(&format!(
3728        "SELECT {owner} || s_to.name, replace(s_to.path, '\\', '/'), s_to.start_line
3729         FROM relationships r
3730         JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3731         LEFT JOIN symbols par ON s_to.parent_symbol_id = par.symbol_id
3732         WHERE r.from_symbol_id = ?1 AND r.start_line = ?2 AND s_to.name = ?3
3733         {pending}
3734         ORDER BY 2, 3
3735         LIMIT 4"
3736    ))?;
3737    for site in sites.iter_mut() {
3738        let Some(line) = site.start_line else {
3739            continue;
3740        };
3741        let targets = stmt
3742            .query_map(
3743                params![site.from_symbol_id, line as i64, site.to_symbol_name],
3744                |row| {
3745                    Ok(format!(
3746                        "`{}` ({}:{})",
3747                        row.get::<_, String>(0)?,
3748                        row.get::<_, String>(1)?,
3749                        row.get::<_, i64>(2)?
3750                    ))
3751                },
3752            )?
3753            .collect::<Result<Vec<_>, _>>()?;
3754        site.target = match targets.len() {
3755            0 => None,
3756            1 => targets.into_iter().next(),
3757            2 | 3 => Some(format!("one of {}", targets.join(", "))),
3758            _ => Some(format!(
3759                "more than 3 definitions named `{}`",
3760                site.to_symbol_name
3761            )),
3762        };
3763    }
3764    Ok(())
3765}
3766
3767/// The import statements of the top-level definition `name` (under `path_filter` when given): an
3768/// import row with that name whose source module names a folder or file on the definition's path.
3769/// `find_references` lists calls and type uses, so these go in a summary line after them.
3770pub fn import_sites(
3771    conn: &Connection,
3772    name: &str,
3773    path_filter: Option<&str>,
3774) -> Result<Vec<(String, usize)>, QueryError> {
3775    if !has_column(conn, "symbols", "metadata_json") {
3776        return Ok(Vec::new());
3777    }
3778    let dotted = "replace(module_path.value, '/', '.')";
3779    let segment = format!("replace({dotted}, rtrim({dotted}, replace({dotted}, '.', '')), '')");
3780    let target_path = "('/' || replace(target.path, '\\', '/'))";
3781    // `from . import Flask`: a source of dots only names the importing file's package.
3782    let dir = |path: &str| {
3783        format!("rtrim(replace({path}, '\\', '/'), replace(replace({path}, '\\', '/'), '/', ''))")
3784    };
3785    let (import_dir, target_dir) = (dir("i.path"), dir("target.path"));
3786    let mut stmt = conn.prepare(&format!(
3787        "SELECT DISTINCT replace(i.path, '\\', '/'), i.start_line
3788         FROM symbols target
3789         JOIN symbols i ON i.name = target.name AND i.kind = 'import'
3790         CROSS JOIN json_each(json_array(json_extract(i.metadata_json, '$.source'))) module_path
3791         WHERE target.name = ?1
3792           AND target.parent_symbol_id IS NULL
3793           AND target.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3794           AND (?2 IS NULL OR replace(target.path, '\\', '/') = ?2
3795                OR replace(target.path, '\\', '/') LIKE ?3 ESCAPE '\\')
3796           AND json_valid(i.metadata_json)
3797           AND i.language NOT IN ({docs})
3798           AND (
3799               {segment} != ''
3800               AND ({target_path} GLOB '*/' || {segment} || '/*'
3801                    OR {target_path} GLOB '*/' || {segment} || '.*')
3802               OR module_path.value != '' AND replace(module_path.value, '.', '') = ''
3803               AND substr({import_dir}, 1, length({target_dir})) = {target_dir}
3804           )
3805         ORDER BY 1, 2",
3806        docs = documentation_language_list()
3807    ))?;
3808    let norm = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
3809    let prefix = norm.as_ref().map(|p| format!("{}/%", escape_like(p)));
3810    let rows = stmt.query_map(params![name, norm, prefix], |row| {
3811        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
3812    })?;
3813    Ok(rows.collect::<Result<_, _>>()?)
3814}
3815
3816/// One row for each caller, target, kind, and line: `app.wsgi_app = Wrap(app.wsgi_app)` is one
3817/// site, not two identical rows.
3818fn merge_same_site(sites: Vec<ReferenceSite>) -> Vec<ReferenceSite> {
3819    let mut merged: Vec<ReferenceSite> = Vec::with_capacity(sites.len());
3820    let mut index = HashMap::new();
3821    for site in sites {
3822        let key = (
3823            site.from_symbol_id.clone(),
3824            site.to_symbol_name.clone(),
3825            site.kind.clone(),
3826            site.path.clone(),
3827            site.start_line,
3828        );
3829        match index.get(&key) {
3830            Some(&at) => {
3831                let kept: &mut ReferenceSite = &mut merged[at];
3832                kept.start_column = kept.start_column.min(site.start_column);
3833            }
3834            None => {
3835                index.insert(key, merged.len());
3836                merged.push(site);
3837            }
3838        }
3839    }
3840    merged
3841}
3842
3843/// Resolve callee signatures directly in a single joined query, avoiding N+1 queries
3844/// and preserving ambiguous methods across types. Prioritizes functions/methods over enum variants.
3845pub fn find_callee_signatures(
3846    conn: &Connection,
3847    symbol_name: &str,
3848    symbol_id: &str,
3849    limit: usize,
3850    include_external: bool,
3851) -> Result<Vec<String>, QueryError> {
3852    let mut stmt = conn.prepare(
3853        "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
3854         FROM relationships r
3855         JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3856         JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3857         WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
3858         LIMIT ?3",
3859    )?;
3860
3861    let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
3862        Ok((
3863            row.get::<_, String>(0)?,
3864            row.get::<_, Option<String>>(1)?,
3865            row.get::<_, String>(2)?.replace('\\', "/"),
3866            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
3867            row.get::<_, String>(4)?,
3868        ))
3869    })?;
3870
3871    let mut signatures = Vec::new();
3872    let mut variants = Vec::new();
3873
3874    for r in rows.flatten() {
3875        let (name, sig_opt, path, line, kind) = r;
3876        let sig = sig_opt.unwrap_or(name);
3877        let entry = format!("{sig} ({path}:{line})");
3878        if kind == "variant" {
3879            if !variants.contains(&entry) {
3880                variants.push(entry);
3881            }
3882        } else if !signatures.contains(&entry) {
3883            signatures.push(entry);
3884        }
3885    }
3886
3887    if signatures.len() < limit {
3888        let remaining = (limit - signatures.len()) * 2;
3889        let p_rows: Vec<(String, Option<String>, String, usize, String)> =
3890            if has_pending_namespace_column(conn) {
3891                let mut p_stmt = conn.prepare(
3892                &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
3893                 FROM pending_relationships p
3894                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3895                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
3896                 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
3897                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3898                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3899                    AND {pred}
3900                 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
3901            )?;
3902
3903                let rows =
3904                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3905                        Ok((
3906                            row.get::<_, String>(0)?,
3907                            row.get::<_, Option<String>>(1)?,
3908                            row.get::<_, String>(2)?.replace('\\', "/"),
3909                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
3910                            row.get::<_, String>(4)?,
3911                        ))
3912                    })?;
3913                rows.flatten().collect()
3914            } else {
3915                let mut p_stmt = conn.prepare(
3916                "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
3917                 FROM pending_relationships p
3918                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3919                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
3920                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3921                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3922                 LIMIT ?3",
3923            )?;
3924
3925                let rows =
3926                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3927                        Ok((
3928                            row.get::<_, String>(0)?,
3929                            row.get::<_, Option<String>>(1)?,
3930                            row.get::<_, String>(2)?.replace('\\', "/"),
3931                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
3932                            row.get::<_, String>(4)?,
3933                        ))
3934                    })?;
3935                rows.flatten().collect()
3936            };
3937
3938        for r in p_rows {
3939            let (name, sig_opt, path, line, kind) = r;
3940            let sig = sig_opt.unwrap_or(name);
3941            let entry = format!("{sig} ({path}:{line})");
3942            if kind == "variant" {
3943                if !variants.contains(&entry) {
3944                    variants.push(entry);
3945                }
3946            } else if !signatures.contains(&entry) {
3947                signatures.push(entry);
3948            }
3949        }
3950    }
3951
3952    if include_external && signatures.len() < limit {
3953        let remaining = (limit - signatures.len()) * 2;
3954        let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
3955            let mut ext_stmt = conn.prepare(
3956                &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
3957                 FROM pending_relationships p
3958                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3959                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3960                   AND NOT EXISTS (
3961                       SELECT 1 FROM symbols s_to
3962                       LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
3963                       WHERE s_to.name = p.target_terminal_name
3964                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3965                         AND {pred}
3966                   )
3967                 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
3968            )?;
3969
3970            let rows =
3971                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3972                    Ok((
3973                        row.get::<_, String>(0)?,
3974                        row.get::<_, String>(1)?.replace('\\', "/"),
3975                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
3976                    ))
3977                })?;
3978            rows.flatten().collect()
3979        } else {
3980            let mut ext_stmt = conn.prepare(
3981                "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
3982                 FROM pending_relationships p
3983                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3984                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3985                   AND NOT EXISTS (
3986                       SELECT 1 FROM symbols s_to
3987                       WHERE s_to.name = p.target_terminal_name
3988                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3989                   )
3990                 LIMIT ?3",
3991            )?;
3992
3993            let rows =
3994                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3995                    Ok((
3996                        row.get::<_, String>(0)?,
3997                        row.get::<_, String>(1)?.replace('\\', "/"),
3998                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
3999                    ))
4000                })?;
4001            rows.flatten().collect()
4002        };
4003
4004        for r in ext_rows {
4005            let (name, path, line) = r;
4006            let entry = format!("{name} ({path}:{line})");
4007            if !signatures.contains(&entry) {
4008                signatures.push(entry);
4009            }
4010        }
4011    }
4012
4013    for v in variants {
4014        if signatures.len() >= limit {
4015            break;
4016        }
4017        if !signatures.contains(&v) {
4018            signatures.push(v);
4019        }
4020    }
4021
4022    signatures.truncate(limit);
4023    Ok(signatures)
4024}
4025
4026const SQL_FAMILIES: &[&str] = &["sql."];
4027
4028const ROUTE_FAMILIES: &[&str] = &[
4029    ".route.",
4030    ".attribute_route.",
4031    ".scope_route.",
4032    ".file_route.",
4033    ".route_handler.",
4034    ".route_reference.",
4035    ".route_group.",
4036    ".route_prefix.",
4037    ".resource_route.",
4038    ".router_mount.",
4039    ".include_router.",
4040    ".server_route.",
4041    ".route_definition.",
4042];
4043
4044const CONFIG_FAMILIES: &[&str] = &["toml.", "yaml.", "json.", "env."];
4045
4046const MODEL_FAMILIES: &[&str] = &[
4047    "sql.table_definition.v1",
4048    "sql.column_definition.v1",
4049    "sql.constraint.v1",
4050    "sql.foreign_key.v1",
4051    "sql.index_definition.v1",
4052    "json.schema.v1",
4053];
4054
4055const SIGNAL_FAMILIES: &[&str] = &[".signal_declaration."];
4056
4057const IMPORT_FAMILIES: &[&str] = &[".import_statement.", ".import."];
4058
4059const BINDING_FAMILIES: &[&str] = &[".binding."];
4060
4061const COMPONENT_FAMILIES: &[&str] = &[".object_instantiation.", ".object_type."];
4062
4063const MODULE_FAMILIES: &[&str] = &[".module."];
4064
4065const PRAGMA_FAMILIES: &[&str] = &[".pragma.", "javascript.qml_directive.v1"];
4066
4067const PROPERTY_FAMILIES: &[&str] = &[".property_declaration.", ".qt_property."];
4068
4069/// Category aliases mapped to the pattern-id families they name.
4070///
4071/// A rule that starts with a dot matches any pattern id that contains it, a rule that ends with a
4072/// dot matches any pattern id that starts with it, and any other rule matches one exact id.
4073pub const CATEGORY_ALIASES: &[(&str, &[&str])] = &[
4074    ("sql", SQL_FAMILIES),
4075    ("query", SQL_FAMILIES),
4076    ("queries", SQL_FAMILIES),
4077    ("route", ROUTE_FAMILIES),
4078    ("routes", ROUTE_FAMILIES),
4079    ("config", CONFIG_FAMILIES),
4080    ("model", MODEL_FAMILIES),
4081    ("models", MODEL_FAMILIES),
4082    ("signal", SIGNAL_FAMILIES),
4083    ("signals", SIGNAL_FAMILIES),
4084    ("import", IMPORT_FAMILIES),
4085    ("imports", IMPORT_FAMILIES),
4086    ("binding", BINDING_FAMILIES),
4087    ("bindings", BINDING_FAMILIES),
4088    ("component", COMPONENT_FAMILIES),
4089    ("components", COMPONENT_FAMILIES),
4090    ("module", MODULE_FAMILIES),
4091    ("modules", MODULE_FAMILIES),
4092    ("pragma", PRAGMA_FAMILIES),
4093    ("property", PROPERTY_FAMILIES),
4094    ("properties", PROPERTY_FAMILIES),
4095];
4096
4097fn category_families(category: &str) -> Option<&'static [&'static str]> {
4098    let wanted = category.trim().to_ascii_lowercase();
4099    CATEGORY_ALIASES
4100        .iter()
4101        .find(|(alias, _)| *alias == wanted)
4102        .map(|(_, families)| *families)
4103}
4104
4105fn matches_family(pattern_id: &str, rule: &str) -> bool {
4106    if rule.starts_with('.') {
4107        pattern_id.contains(rule)
4108    } else if rule.ends_with('.') {
4109        pattern_id.starts_with(rule)
4110    } else {
4111        pattern_id == rule
4112    }
4113}
4114
4115fn family_clause(column: &str, families: &[&str]) -> String {
4116    let alternatives: Vec<String> = families
4117        .iter()
4118        .map(|rule| {
4119            let escaped = escape_like(rule);
4120            if rule.starts_with('.') {
4121                format!("{column} LIKE '%{escaped}%' ESCAPE '\\'")
4122            } else if rule.ends_with('.') {
4123                format!("{column} LIKE '{escaped}%' ESCAPE '\\'")
4124            } else {
4125                format!("{column} = '{rule}'")
4126            }
4127        })
4128        .collect();
4129    format!("({})", alternatives.join(" OR "))
4130}
4131
4132/// True when the category text names one of the aliases in `CATEGORY_ALIASES`.
4133pub fn is_category_alias(category: &str) -> bool {
4134    category_families(category).is_some()
4135}
4136
4137/// Counts the patterns and facts each category alias reaches, given a category listing.
4138///
4139/// Only one alias per family list is reported, and an alias with no facts is left out.
4140pub fn alias_fact_counts(categories: &[(String, usize)]) -> Vec<(&'static str, usize, usize)> {
4141    let mut reported: Vec<&[&str]> = Vec::new();
4142    let mut counts = Vec::new();
4143    for (alias, families) in CATEGORY_ALIASES {
4144        if reported.contains(families) {
4145            continue;
4146        }
4147        reported.push(families);
4148        let mut patterns = 0;
4149        let mut facts = 0;
4150        for (pattern_id, count) in categories {
4151            if families.iter().any(|rule| matches_family(pattern_id, rule)) {
4152                patterns += 1;
4153                facts += count;
4154            }
4155        }
4156        if facts > 0 {
4157            counts.push((*alias, patterns, facts));
4158        }
4159    }
4160    counts
4161}
4162
4163/// Find structural facts by category (e.g. route, query, model, config), optionally scoped by path.
4164pub fn find_structural_facts_scoped(
4165    conn: &Connection,
4166    category: &str,
4167    path_filter: Option<&str>,
4168    limit: usize,
4169) -> Result<Vec<StructuralFact>, QueryError> {
4170    validate_result_limit(limit)?;
4171    let norm_path = path_filter
4172        .map(|p| {
4173            p.replace('\\', "/")
4174                .trim_start_matches("./")
4175                .trim_matches('/')
4176                .to_string()
4177        })
4178        .filter(|p| !p.is_empty());
4179    let dir_prefix = norm_path
4180        .as_deref()
4181        .map(|p| format!("{}/%", escape_like(p)));
4182    let cat_pattern = format!("%{}%", escape_like(category));
4183
4184    let cat_clause = match category_families(category) {
4185        Some(families) => family_clause("sf.pattern_id", families),
4186        None => {
4187            "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
4188                .to_string()
4189        }
4190    };
4191
4192    let sql = format!(
4193        "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
4194                sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
4195                sf.start_line, sf.end_line, sf.confidence,
4196                COALESCE(
4197                    CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
4198                         THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
4199                         ELSE json_extract(sf.metadata_json, '$.key_path') END,
4200                    json_extract(sf.metadata_json, '$.key'),
4201                    CASE WHEN json_extract(sf.metadata_json, '$.normalized_route_template') IS NOT NULL
4202                         THEN COALESCE(json_extract(sf.metadata_json, '$.verb') || ' ', '')
4203                              || json_extract(sf.metadata_json, '$.normalized_route_template') END,
4204                    CASE WHEN sf.pattern_id = 'cpp.qt_property.v1'
4205                         THEN json_extract(sf.metadata_json, '$.name') END
4206                ) AS display_key,
4207                sf.metadata_json
4208         FROM structural_facts sf
4209         LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
4210         WHERE (:cat IS NOT NULL AND {cat_clause})
4211           AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4212         ORDER BY sf.path ASC, sf.start_line ASC
4213         LIMIT :limit"
4214    );
4215
4216    let mut stmt = conn.prepare(&sql)?;
4217    let rows = stmt.query_map(
4218        rusqlite::named_params! {
4219            ":cat": cat_pattern,
4220            ":path": norm_path.as_deref(),
4221            ":dir_prefix": dir_prefix.as_deref(),
4222            ":limit": limit as i64,
4223        },
4224        |row| {
4225            Ok(StructuralFact {
4226                structural_fact_id: row.get(0)?,
4227                path: row.get::<_, String>(1)?.replace('\\', "/"),
4228                language: row.get(2)?,
4229                pattern_id: row.get(3)?,
4230                capture_name: row.get(4)?,
4231                node_kind: row.get(5)?,
4232                key: row.get(10)?,
4233                metadata: row
4234                    .get::<_, Option<String>>(11)?
4235                    .map(|json| serde_json::from_str(&json))
4236                    .transpose()
4237                    .map_err(|err| {
4238                        rusqlite::Error::FromSqlConversionFailure(
4239                            11,
4240                            rusqlite::types::Type::Text,
4241                            Box::new(err),
4242                        )
4243                    })?,
4244                containing_symbol_name: row.get(6)?,
4245                start_line: row.get::<_, i64>(7)? as usize,
4246                end_line: row.get::<_, i64>(8)? as usize,
4247                confidence: row.get(9)?,
4248            })
4249        },
4250    )?;
4251
4252    let mut results = Vec::new();
4253    for r in rows {
4254        let mut fact = r?;
4255        let display = fact.metadata.as_ref().and_then(|metadata| {
4256            route_display(metadata).or_else(|| mount_display(&fact.capture_name, metadata))
4257        });
4258        if let Some(display) = display {
4259            fact.key = Some(display);
4260        }
4261        results.push(fact);
4262    }
4263    Ok(results)
4264}
4265
4266/// A route as its source spells it, with its verb: `GET /<int:id>/update`, not the normalized
4267/// `/:id/update`. The prefixed source template is used when the extractor joined a prefix;
4268/// otherwise the raw template, unless the normalized one holds a prefix the raw one lacks.
4269fn route_display(metadata: &serde_json::Value) -> Option<String> {
4270    let text = |key: &str| metadata.get(key).and_then(|value| value.as_str());
4271    let normalized = text("normalized_route_template")?;
4272    let template = match (text("effective_route_template"), text("route_template")) {
4273        (Some(effective), _) => effective,
4274        (None, Some(raw)) if route_shape(raw) == route_shape(normalized) => raw,
4275        _ => normalized,
4276    };
4277    Some(match text("verb") {
4278        Some(verb) => format!("{verb} {template}"),
4279        None => template.to_string(),
4280    })
4281}
4282
4283/// A router or blueprint mount with what it mounts and where: ``blueprint_registration `auth.bp` ``,
4284/// ``router_mount `router` at /api``. The path includes any enclosing scope the extractor joined.
4285fn mount_display(capture: &str, metadata: &serde_json::Value) -> Option<String> {
4286    let text = |key: &str| metadata.get(key).and_then(|value| value.as_str());
4287    if text("query_family") != Some("framework") {
4288        return None;
4289    }
4290    let target = text("mount_target")?;
4291    Some(
4292        match text("normalized_mount_path").or_else(|| text("mount_path")) {
4293            Some(path) => format!("{capture} `{target}` at {path}"),
4294            None => format!("{capture} `{target}`"),
4295        },
4296    )
4297}
4298
4299/// A route template with every parameter (`<int:id>`, `{id}`, `:id`) replaced by `*`.
4300fn route_shape(template: &str) -> String {
4301    let mut shape = String::new();
4302    let mut chars = template.chars().peekable();
4303    while let Some(c) = chars.next() {
4304        match c {
4305            '<' | '{' => {
4306                let close = if c == '<' { '>' } else { '}' };
4307                for inner in chars.by_ref() {
4308                    if inner == close {
4309                        break;
4310                    }
4311                }
4312                shape.push('*');
4313            }
4314            ':' => {
4315                while chars.peek().is_some_and(|next| *next != '/') {
4316                    chars.next();
4317                }
4318                shape.push('*');
4319            }
4320            _ => shape.push(c),
4321        }
4322    }
4323    shape
4324}
4325
4326/// Find structural facts by category (e.g. route, query, model, config).
4327pub fn find_structural_facts(
4328    conn: &Connection,
4329    category: &str,
4330    limit: usize,
4331) -> Result<Vec<StructuralFact>, QueryError> {
4332    find_structural_facts_scoped(conn, category, None, limit)
4333}
4334
4335/// Find literals (endpoints, SQL queries, configs) matching category, optionally scoped by path.
4336pub fn find_literals_scoped(
4337    conn: &Connection,
4338    category: &str,
4339    path_filter: Option<&str>,
4340    limit: usize,
4341) -> Result<Vec<LiteralFact>, QueryError> {
4342    validate_result_limit(limit)?;
4343    let norm_path = path_filter
4344        .map(|p| {
4345            p.replace('\\', "/")
4346                .trim_start_matches("./")
4347                .trim_matches('/')
4348                .to_string()
4349        })
4350        .filter(|p| !p.is_empty());
4351    let dir_prefix = norm_path
4352        .as_deref()
4353        .map(|p| format!("{}/%", escape_like(p)));
4354    let cat_pattern = format!("%{}%", escape_like(category));
4355
4356    let cat_lower = category.trim().to_ascii_lowercase();
4357    let cat_clause = match cat_lower.as_str() {
4358        "config" => {
4359            "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
4360        }
4361        "route" | "routes" => "l.kind LIKE '%route%'",
4362        "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
4363        "model" | "models" => "l.kind LIKE '%model%'",
4364        _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
4365    };
4366
4367    let sql = format!(
4368        "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
4369                l.start_line, s.name AS containing_symbol_name
4370         FROM literals l
4371         LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
4372         WHERE (:cat IS NOT NULL AND {cat_clause})
4373           AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4374         ORDER BY l.path ASC, l.start_line ASC
4375         LIMIT :limit"
4376    );
4377
4378    let mut stmt = conn.prepare(&sql)?;
4379    let rows = stmt.query_map(
4380        rusqlite::named_params! {
4381            ":cat": cat_pattern,
4382            ":path": norm_path.as_deref(),
4383            ":dir_prefix": dir_prefix.as_deref(),
4384            ":limit": limit as i64,
4385        },
4386        |row| {
4387            Ok(LiteralFact {
4388                literal_id: row.get(0)?,
4389                path: row.get::<_, String>(1)?.replace('\\', "/"),
4390                literal_text: row.get(2)?,
4391                kind: row.get(3)?,
4392                carrier: row.get(4)?,
4393                start_line: row.get::<_, i64>(5)? as usize,
4394                containing_symbol_name: row.get(6)?,
4395            })
4396        },
4397    )?;
4398
4399    let mut results = Vec::new();
4400    for r in rows {
4401        results.push(r?);
4402    }
4403    Ok(results)
4404}
4405
4406/// Find literals (endpoints, SQL queries, configs) matching category.
4407pub fn find_literals(
4408    conn: &Connection,
4409    category: &str,
4410    limit: usize,
4411) -> Result<Vec<LiteralFact>, QueryError> {
4412    find_literals_scoped(conn, category, None, limit)
4413}
4414
4415/// List available structural fact and literal categories with counts, optionally scoped by path.
4416pub fn list_structural_fact_categories_scoped(
4417    conn: &Connection,
4418    path_filter: Option<&str>,
4419) -> Result<Vec<(String, usize)>, QueryError> {
4420    let norm_path = path_filter
4421        .map(|p| {
4422            p.replace('\\', "/")
4423                .trim_start_matches("./")
4424                .trim_matches('/')
4425                .to_string()
4426        })
4427        .filter(|p| !p.is_empty());
4428    let dir_prefix = norm_path
4429        .as_deref()
4430        .map(|p| format!("{}/%", escape_like(p)));
4431
4432    let mut categories = Vec::new();
4433
4434    let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
4435               WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4436               GROUP BY pattern_id ORDER BY cnt DESC";
4437    let mut stmt = conn.prepare(sql)?;
4438    let rows = stmt.query_map(
4439        rusqlite::named_params! {
4440            ":path": norm_path.as_deref(),
4441            ":dir_prefix": dir_prefix.as_deref(),
4442        },
4443        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
4444    )?;
4445    for r in rows {
4446        categories.push(r?);
4447    }
4448
4449    let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
4450                   WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4451                   GROUP BY kind ORDER BY cnt DESC";
4452    let mut lit_stmt = conn.prepare(lit_sql)?;
4453    let lit_rows = lit_stmt.query_map(
4454        rusqlite::named_params! {
4455            ":path": norm_path.as_deref(),
4456            ":dir_prefix": dir_prefix.as_deref(),
4457        },
4458        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
4459    )?;
4460    for r in lit_rows {
4461        categories.push(r?);
4462    }
4463
4464    Ok(categories)
4465}
4466
4467/// List all available structural fact and literal categories with counts.
4468pub fn list_structural_fact_categories(
4469    conn: &Connection,
4470) -> Result<Vec<(String, usize)>, QueryError> {
4471    list_structural_fact_categories_scoped(conn, None)
4472}
4473
4474/// Find type facts for a symbol.
4475pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
4476    let has_table: bool = conn
4477        .query_row(
4478            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
4479            [],
4480            |_| Ok(true),
4481        )
4482        .unwrap_or(false);
4483    if !has_table {
4484        return Ok(Vec::new());
4485    }
4486
4487    let mut stmt = conn.prepare(
4488        "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
4489         FROM type_facts
4490         WHERE symbol_id = ?1",
4491    )?;
4492
4493    let rows = stmt.query_map(params![symbol_id], |row| {
4494        Ok(TypeFact {
4495            type_fact_id: row.get(0)?,
4496            symbol_id: row.get(1)?,
4497            language: row.get(2)?,
4498            resolved_type: row.get(3)?,
4499            generic_params: row.get(4)?,
4500        })
4501    })?;
4502
4503    let mut results = Vec::new();
4504    for r in rows {
4505        results.push(r?);
4506    }
4507    Ok(results)
4508}
4509
4510/// The workspace type definitions named by the type facts of `target` and of its parameters and
4511/// locals, one per name, as `signature (path:line)`. A name with no type definition in the
4512/// index, such as `Result` or `String`, is left out. Same-file definitions win, then shorter paths.
4513pub fn find_related_types(
4514    conn: &Connection,
4515    target: &Symbol,
4516    limit: usize,
4517) -> Result<Vec<String>, QueryError> {
4518    if !has_table(conn, "type_facts") {
4519        return Ok(Vec::new());
4520    }
4521    let mut stmt = conn.prepare(
4522        "SELECT d.name, d.kind, d.signature, d.path, d.start_line
4523         FROM symbols d
4524         WHERE d.kind IN ('struct', 'enum', 'trait', 'class', 'interface', 'type', 'union')
4525           AND d.name IN (
4526               SELECT t.resolved_type FROM type_facts t
4527               JOIN symbols s ON s.symbol_id = t.symbol_id
4528               WHERE s.symbol_id = ?1 OR s.parent_symbol_id = ?1
4529           )
4530         ORDER BY d.path = ?2 DESC, length(d.path), d.path, d.start_line",
4531    )?;
4532    let rows = stmt.query_map(params![target.symbol_id, target.path], |row| {
4533        Ok((
4534            row.get::<_, String>(0)?,
4535            row.get::<_, String>(1)?,
4536            row.get::<_, Option<String>>(2)?,
4537            row.get::<_, String>(3)?,
4538            row.get::<_, i64>(4)?,
4539        ))
4540    })?;
4541
4542    let mut seen = std::collections::HashSet::new();
4543    let mut types = Vec::new();
4544    for row in rows {
4545        let (name, kind, signature, path, line) = row?;
4546        if types.len() >= limit {
4547            break;
4548        }
4549        if !seen.insert(name.clone()) {
4550            continue;
4551        }
4552        let signature = signature.unwrap_or_else(|| format!("{kind} {name}"));
4553        types.push(format!(
4554            "{} ({path}:{line})",
4555            type_declaration_line(&signature)
4556        ));
4557    }
4558    Ok(types)
4559}
4560
4561/// A type signature without leading attributes, on one line of at most 120 characters.
4562fn type_declaration_line(signature: &str) -> String {
4563    let mut declaration = signature;
4564    while declaration.starts_with("#[")
4565        && let Some((_, rest)) = declaration.split_once("] ")
4566    {
4567        declaration = rest;
4568    }
4569    let line = declaration.split_whitespace().collect::<Vec<_>>().join(" ");
4570    if line.chars().count() <= 120 {
4571        return line;
4572    }
4573    line.chars().take(119).collect::<String>() + "…"
4574}
4575
4576/// True when a repository-relative path looks like a test file. Directory rules and file-name
4577/// rules are kept apart: a `test`, `tests`, `autotests`, or `__tests__` directory anywhere
4578/// including the repository root, or a file name that starts with Qt's `tst_`, starts with
4579/// `test_` in Python or Ruby, contains `_test.`,
4580/// `.test.`, or `.spec.`, is exactly `test.rs` or `tests.rs`, or ends with the C# `Tests.cs`
4581/// (case-sensitive, so `Contests.cs` is a production file).
4582pub fn is_test_path(path: &str) -> bool {
4583    let p = path.replace('\\', "/");
4584    let cut = p.rfind('/').map_or(0, |i| i + 1);
4585    let directories = format!("/{}/", p[..cut].to_lowercase());
4586    let file_name = &p[cut..];
4587    let lower_name = file_name.to_lowercase();
4588    directories.contains("/test/")
4589        || directories.contains("/tests/")
4590        || directories.contains("/autotests/")
4591        || directories.contains("/__tests__/")
4592        || lower_name.starts_with("tst_")
4593        || (lower_name.starts_with("test_")
4594            && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
4595        || lower_name.contains("_test.")
4596        || lower_name.contains(".test.")
4597        || lower_name.contains(".spec.")
4598        || lower_name == "test.rs"
4599        || lower_name == "tests.rs"
4600        || file_name.ends_with("Tests.cs")
4601}
4602
4603/// SQL boolean over `alias.path` that mirrors [`is_test_path`] rule for rule.
4604///
4605/// `test_path_rule_and_its_sql_mirror_agree_on_every_path` runs both forms over one path list so
4606/// the two cannot drift apart.
4607///
4608/// Every rule needs `test`, `spec`, or `tst_` in the path, so a cheap substring test guards the
4609/// rules and lets most rows skip the path split. Without the guard the split costs about seven
4610/// times more over a half-million rows.
4611pub(crate) fn test_path_predicate(alias: &str) -> String {
4612    let guard = format!(
4613        "(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%' OR lower({alias}.path) LIKE '%tst\\_%' ESCAPE '\\')"
4614    );
4615    let p = format!("replace({alias}.path, '\\', '/')");
4616    let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
4617    let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
4618    let lower_name = format!("lower({file_name})");
4619    let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
4620    let clauses = [
4621        like(&directories, "%/test/%"),
4622        like(&directories, "%/tests/%"),
4623        like(&directories, "%/autotests/%"),
4624        like(&directories, "%/\\_\\_tests\\_\\_/%"),
4625        like(&lower_name, "tst\\_%"),
4626        like(&lower_name, "test\\_%.py"),
4627        like(&lower_name, "test\\_%.rb"),
4628        like(&lower_name, "%\\_test.%"),
4629        like(&lower_name, "%.test.%"),
4630        like(&lower_name, "%.spec.%"),
4631        format!("{lower_name} = 'test.rs'"),
4632        format!("{lower_name} = 'tests.rs'"),
4633        format!("{file_name} GLOB '*Tests.cs'"),
4634    ]
4635    .join(" OR ");
4636    format!("({guard} AND ({clauses}))")
4637}
4638
4639/// Compute blast radius and likely tests for given seed symbols or seed file paths.
4640/// Recursively walks reverse reachability (transitive callers) up to `max_depth` in SQLite.
4641pub fn compute_blast_radius_scoped(
4642    conn: &Connection,
4643    seed_symbols: &[&str],
4644    symbol_path_filter: Option<&str>,
4645    seed_paths: &[&str],
4646    max_depth: usize,
4647    limit: usize,
4648) -> Result<BlastRadiusResult, QueryError> {
4649    compute_blast_radius_scoped_with_ids(
4650        conn,
4651        seed_symbols,
4652        &[],
4653        symbol_path_filter,
4654        seed_paths,
4655        max_depth,
4656        limit,
4657    )
4658}
4659
4660/// Compute blast radius with exact current-index IDs kept as traversal seeds.
4661pub fn compute_blast_radius_scoped_with_ids(
4662    conn: &Connection,
4663    seed_symbols: &[&str],
4664    seed_ids: &[&str],
4665    symbol_path_filter: Option<&str>,
4666    seed_paths: &[&str],
4667    max_depth: usize,
4668    limit: usize,
4669) -> Result<BlastRadiusResult, QueryError> {
4670    validate_result_limit(limit)?;
4671    let max_depth = max_depth.min(5);
4672    let mut resolved_seed_symbols = seed_symbols
4673        .iter()
4674        .map(|name| {
4675            get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
4676                let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
4677                QueryError::SymbolNotFound {
4678                    name: (*name).to_string(),
4679                    workspace,
4680                    hint,
4681                }
4682            })
4683        })
4684        .collect::<Result<Vec<_>, _>>()?;
4685    for id in seed_ids {
4686        let symbol = get_symbol_by_id(conn, id)?.ok_or_else(|| {
4687            let workspace = workspace_name(conn);
4688            QueryError::SymbolNotFound {
4689                name: format!("symbol id {id}"),
4690                workspace,
4691                hint: "Run lookup_symbol or search_symbols again and select a current id."
4692                    .to_string(),
4693            }
4694        })?;
4695        resolved_seed_symbols.push(symbol);
4696    }
4697    let mut seeds = Vec::new();
4698    let seed_type = if (!seed_symbols.is_empty() || !seed_ids.is_empty()) && !seed_paths.is_empty()
4699    {
4700        for s in seed_symbols {
4701            seeds.push(s.to_string());
4702        }
4703        for id in seed_ids {
4704            seeds.push(id.to_string());
4705        }
4706        for p in seed_paths {
4707            seeds.push(p.to_string());
4708        }
4709        "mixed".to_string()
4710    } else if !seed_symbols.is_empty() || !seed_ids.is_empty() {
4711        for s in seed_symbols {
4712            seeds.push(s.to_string());
4713        }
4714        for id in seed_ids {
4715            seeds.push(id.to_string());
4716        }
4717        "symbol".to_string()
4718    } else if !seed_paths.is_empty() {
4719        for p in seed_paths {
4720            seeds.push(p.to_string());
4721        }
4722        "file".to_string()
4723    } else {
4724        return Ok(BlastRadiusResult {
4725            seed_type: "none".to_string(),
4726            seeds: Vec::new(),
4727            likely_tests: Vec::new(),
4728            impacted_symbols: Vec::new(),
4729            likely_tests_truncated: false,
4730            impacted_symbols_truncated: false,
4731            traversal_ceiling_reached: false,
4732            test_file_ceiling_reached: false,
4733            limit_at_maximum: false,
4734            likely_tests_found: 0,
4735            impacted_symbols_found: 0,
4736        });
4737    };
4738
4739    let mut where_clauses = Vec::new();
4740    let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
4741
4742    if !resolved_seed_symbols.is_empty() {
4743        let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
4744            .map(|i| format!("?{}", i))
4745            .collect();
4746        where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
4747        for symbol in &resolved_seed_symbols {
4748            params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
4749        }
4750    }
4751
4752    if !seed_paths.is_empty() {
4753        let mut path_conds = Vec::new();
4754        for p in seed_paths.iter() {
4755            let raw = p
4756                .replace('\\', "/")
4757                .trim_start_matches("./")
4758                .trim_matches('/')
4759                .to_string();
4760            let exact_idx = params_vec.len() + 1;
4761            params_vec.push(rusqlite::types::Value::Text(raw.clone()));
4762            let dir_pattern = format!("{}/%", escape_like(&raw));
4763            let like_idx = params_vec.len() + 1;
4764            params_vec.push(rusqlite::types::Value::Text(dir_pattern));
4765            path_conds.push(format!(
4766                "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
4767            ));
4768        }
4769        where_clauses.push(format!("({})", path_conds.join(" OR ")));
4770    }
4771
4772    let seed_condition = where_clauses.join(" OR ");
4773    let max_depth_idx = params_vec.len() + 1;
4774    params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
4775
4776    let mut traversal_ceiling_reached = false;
4777    let mut test_file_ceiling_reached = false;
4778
4779    let has_relationships: bool = conn
4780        .query_row(
4781            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
4782            [],
4783            |_| Ok(true),
4784        )
4785        .unwrap_or(false);
4786
4787    let has_pending: bool = conn
4788        .query_row(
4789            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
4790            [],
4791            |_| Ok(true),
4792        )
4793        .unwrap_or(false);
4794
4795    let mut likely_tests = Vec::new();
4796    let mut impacted_symbols = Vec::new();
4797    let mut seen_test_keys = HashSet::new();
4798
4799    let mut recursive_branches = Vec::new();
4800
4801    if has_relationships {
4802        recursive_branches.push(format!(
4803            "SELECT r.from_symbol_id, iw.depth + 1
4804             FROM relationships r
4805             JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
4806             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
4807             WHERE iw.depth < ?{max_depth_idx}
4808               AND s_from.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})"
4809        ));
4810    }
4811
4812    if has_pending {
4813        let (parent_join, ns_condition) = if conn
4814            .query_row(
4815                "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
4816                [],
4817                |_| Ok(true),
4818            )
4819            .unwrap_or(false)
4820        {
4821            (
4822                "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
4823            LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
4824                format!("AND {pred}", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
4825            )
4826        } else {
4827            ("", String::new())
4828        };
4829
4830        recursive_branches.push(format!(
4831            "SELECT p.from_symbol_id, iw.depth + 1
4832             FROM pending_relationships p
4833             JOIN symbols s_target ON p.target_terminal_name = s_target.name
4834             JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
4835             {parent_join}
4836             WHERE iw.depth < ?{max_depth_idx}
4837               AND s_target.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
4838               {ns_condition}"
4839        ));
4840    }
4841
4842    let lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
4843        "COALESCE(s.test_lifecycle, 0) != 0"
4844    } else {
4845        "0"
4846    };
4847    let mut fixtures = Vec::new();
4848    let mut setups = Vec::new();
4849    let mut entry_classes = Vec::new();
4850    let mut walked: Vec<String> = resolved_seed_symbols
4851        .iter()
4852        .map(|symbol| symbol.symbol_id.clone())
4853        .collect();
4854    if !recursive_branches.is_empty() {
4855        let recursive_sql = recursive_branches.join("\n UNION \n");
4856        let not_documentation = not_documentation(conn, "s");
4857        let sql = format!(
4858            "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
4859                SELECT symbol_id, 0
4860                FROM symbols
4861                WHERE ({seed_condition})
4862                  AND kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
4863
4864                UNION
4865
4866                {recursive_sql}
4867            )
4868            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,
4869                   {lifecycle} AS is_fixture, s.parent_symbol_id
4870            FROM impact_walk iw
4871            CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
4872            WHERE s.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
4873              AND {not_documentation}
4874            GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
4875            HAVING MIN(iw.depth) > 0
4876            ORDER BY min_depth ASC, s.path ASC, s.name ASC
4877            LIMIT 201"
4878        );
4879
4880        let mut stmt = conn.prepare(&sql)?;
4881        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
4882            .iter()
4883            .map(|v| v as &dyn rusqlite::ToSql)
4884            .collect();
4885
4886        let rows = stmt.query_map(param_refs.as_slice(), |row| {
4887            Ok((
4888                row.get::<_, String>(0)?,
4889                row.get::<_, String>(1)?,
4890                row.get::<_, String>(2)?,
4891                row.get::<_, String>(3)?,
4892                row.get::<_, i64>(4)? as usize,
4893                row.get::<_, bool>(5)?,
4894                row.get::<_, bool>(6)?,
4895                row.get::<_, i64>(7)? as usize,
4896                row.get::<_, bool>(8)?,
4897                row.get::<_, Option<String>>(9)?,
4898            ))
4899        })?;
4900
4901        for (index, r) in rows.enumerate() {
4902            if index == 200 {
4903                traversal_ceiling_reached = true;
4904                break;
4905            }
4906            let (
4907                sym_id,
4908                name,
4909                kind,
4910                raw_path,
4911                line,
4912                is_test,
4913                test_container,
4914                depth,
4915                is_fixture,
4916                parent,
4917            ) = r?;
4918            walked.push(sym_id);
4919            let path = raw_path.replace('\\', "/");
4920            let is_test_target = is_test || test_container || is_test_path(&path);
4921            if name == "__call__"
4922                && let Some(parent) = &parent
4923            {
4924                entry_classes.push(parent.clone());
4925            }
4926
4927            if is_test_target {
4928                let key = format!("{}:{}", path, line);
4929                if seen_test_keys.insert(key) {
4930                    let lowered = name.to_ascii_lowercase();
4931                    let pytest_fixture = path.ends_with(".py")
4932                        && !["setup", "teardown", "asyncsetup", "asyncteardown"]
4933                            .iter()
4934                            .any(|prefix| lowered.starts_with(prefix));
4935                    let caller = if depth == 1 {
4936                        "direct caller".to_string()
4937                    } else {
4938                        format!("indirect caller [depth {depth}]")
4939                    };
4940                    let reason = if is_fixture && pytest_fixture {
4941                        fixtures.push((name.clone(), path.clone(), line));
4942                        format!("fixture ({caller})")
4943                    } else if is_fixture {
4944                        if let Some(class_id) = parent {
4945                            setups.push((name.clone(), class_id, path.clone(), line));
4946                        }
4947                        format!("setup ({caller})")
4948                    } else {
4949                        caller
4950                    };
4951                    likely_tests.push(TestTarget {
4952                        name,
4953                        path,
4954                        line,
4955                        reason,
4956                    });
4957                }
4958            } else {
4959                impacted_symbols.push(ImpactedSymbol {
4960                    name,
4961                    kind,
4962                    path,
4963                    line,
4964                    depth,
4965                });
4966            }
4967        }
4968    }
4969
4970    // A fixture or setup member is not a test to run; the tests it serves stand in for it.
4971    let mut replaced = HashSet::new();
4972    for (fixture, fixture_path, line) in &fixtures {
4973        let shown = fixture_name(conn, fixture, fixture_path);
4974        let users = fixture_users(conn, fixture, fixture_path)?;
4975        if !users.is_empty() {
4976            replaced.insert((fixture_path.clone(), *line));
4977        }
4978        for test in users {
4979            if seen_test_keys.insert(format!("{}:{}", test.path, test.line)) {
4980                likely_tests.push(TestTarget {
4981                    reason: format!("uses fixture `{shown}`"),
4982                    ..test
4983                });
4984            }
4985        }
4986    }
4987
4988    for (setup, class_id, setup_path, line) in &setups {
4989        let tests = tests_in_class(conn, class_id)?;
4990        if !tests.is_empty() {
4991            replaced.insert((setup_path.clone(), *line));
4992        }
4993        for test in tests {
4994            if seen_test_keys.insert(format!("{}:{}", test.path, test.line)) {
4995                likely_tests.push(TestTarget {
4996                    reason: format!("setup `{setup}` runs before it"),
4997                    ..test
4998                });
4999            }
5000        }
5001    }
5002    likely_tests.retain(|test| !replaced.contains(&(test.path.clone(), test.line)));
5003
5004    let mut test_name_terms = Vec::new();
5005    let mut module_terms = Vec::new();
5006    for path in seed_paths.iter().copied().chain(
5007        resolved_seed_symbols
5008            .iter()
5009            .map(|symbol| symbol.path.as_str()),
5010    ) {
5011        let mut parts = path.rsplit(['/', '\\']);
5012        let file = parts.next().unwrap_or(path);
5013        if let Some(stem) = std::path::Path::new(file)
5014            .file_stem()
5015            .and_then(|s| s.to_str())
5016            && stem.len() >= 3
5017            && !test_name_terms.iter().any(|(name, _)| name == stem)
5018        {
5019            test_name_terms.push((stem.to_string(), "stem-matched test file"));
5020        }
5021        let module = parts.next();
5022        if parts.next() == Some("src")
5023            && let Some(module) = module
5024            && module.len() >= 3
5025            && !["test", "tests", "spec", "specs", "bin", "lib"]
5026                .iter()
5027                .any(|generic| module.eq_ignore_ascii_case(generic))
5028            && !test_name_terms.iter().any(|(name, _)| name == module)
5029            && !module_terms.iter().any(|name| name == module)
5030        {
5031            module_terms.push(module.to_string());
5032        }
5033    }
5034    test_name_terms.extend(
5035        module_terms
5036            .into_iter()
5037            .map(|module| (module, "module-matched test file")),
5038    );
5039
5040    let has_files: bool = conn
5041        .query_row(
5042            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
5043            [],
5044            |_| Ok(true),
5045        )
5046        .unwrap_or(false);
5047
5048    if has_files {
5049        let doc_file = format!(
5050            "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
5051            not_documentation(conn, "d")
5052        );
5053        let slashed = "replace(path, '\\', '/')";
5054        let file_name =
5055            format!("replace({slashed}, rtrim({slashed}, replace({slashed}, '/', '')), '')");
5056        let name_tokens =
5057            format!("'_' || lower(replace(replace({file_name}, '-', '_'), '.', '_')) || '_'");
5058        let names_or_holds_tests = format!(
5059            "({name_tokens} LIKE '%\\_test\\_%' ESCAPE '\\' OR {name_tokens} LIKE '%\\_tests\\_%' ESCAPE '\\'
5060              OR {name_tokens} LIKE '%\\_spec\\_%' ESCAPE '\\' OR {name_tokens} LIKE '%\\_specs\\_%' ESCAPE '\\'
5061              OR {name_tokens} LIKE '%\\_tst\\_%' ESCAPE '\\'
5062              OR {file_name} GLOB '*Test.*' OR {file_name} GLOB '*Tests.*'
5063              OR {file_name} GLOB '*Spec.*' OR {file_name} GLOB '*Specs.*'
5064              OR EXISTS (SELECT 1 FROM symbols t WHERE t.path = files.path AND t.is_test = 1))"
5065        );
5066        let mut test_files_stmt = conn.prepare(&format!(
5067            "SELECT DISTINCT path FROM files
5068             WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND {file_name} LIKE ?1 ESCAPE '\\'
5069               AND {names_or_holds_tests}
5070               AND NOT {doc_file}
5071             ORDER BY path ASC
5072             LIMIT 201"
5073        ))?;
5074        let mut module_test_files_stmt = conn.prepare(&format!(
5075            "SELECT DISTINCT path FROM files
5076             WHERE (path LIKE '%test%' OR path LIKE '%spec%')
5077               AND (path LIKE ?1 ESCAPE '\\' OR path LIKE ?2 ESCAPE '\\')
5078               AND {names_or_holds_tests}
5079               AND NOT {doc_file}
5080             ORDER BY path ASC
5081             LIMIT 201"
5082        ))?;
5083        for (term, reason) in test_name_terms {
5084            let module_match = reason == "module-matched test file";
5085            let escaped_term = escape_like(&term);
5086            let mut rows = if module_match {
5087                module_test_files_stmt.query(rusqlite::params![
5088                    format!("%/{escaped_term}%"),
5089                    format!("%/test\\_{escaped_term}%")
5090                ])?
5091            } else {
5092                test_files_stmt.query([format!("%{escaped_term}%")])?
5093            };
5094            let mut candidates = 0;
5095            let mut matches = 0;
5096            while let Some(row) = rows.next()? {
5097                candidates += 1;
5098                if candidates == 201 {
5099                    test_file_ceiling_reached = true;
5100                    break;
5101                }
5102                let p: String = row.get(0)?;
5103                let basename = p.rsplit(['/', '\\']).next().unwrap_or_default();
5104                if !module_match && !has_word_run(&file_words(basename), &file_words(&term)) {
5105                    continue;
5106                }
5107                if module_match {
5108                    let basename = p
5109                        .rsplit(['/', '\\'])
5110                        .next()
5111                        .unwrap_or_default()
5112                        .to_lowercase();
5113                    let term = term.to_lowercase();
5114                    if !basename.starts_with(&term)
5115                        && !basename.starts_with(&format!("test_{term}"))
5116                    {
5117                        continue;
5118                    }
5119                }
5120                if matches == 10 {
5121                    test_file_ceiling_reached = true;
5122                    break;
5123                }
5124                matches += 1;
5125                let p = p.replace('\\', "/");
5126                let key = format!("{}:1", p);
5127                if seen_test_keys.insert(key) {
5128                    likely_tests.push(TestTarget {
5129                        name: p.clone(),
5130                        path: p,
5131                        line: 1,
5132                        reason: reason.to_string(),
5133                    });
5134                }
5135            }
5136        }
5137    }
5138
5139    // A `__call__` one hop past the walk still makes its class an entry point: the runtime calls it.
5140    if has_table(conn, "relationships") {
5141        let mut entry_callers = conn.prepare(
5142            "SELECT DISTINCT caller.parent_symbol_id
5143         FROM relationships r
5144         JOIN symbols caller ON caller.symbol_id = r.from_symbol_id
5145         WHERE caller.name = '__call__' AND caller.parent_symbol_id IS NOT NULL
5146           AND r.to_symbol_id IN (SELECT value FROM json_each(?1))",
5147        )?;
5148        let walked_json = serde_json::to_string(&walked).unwrap_or_else(|_| "[]".into());
5149        for class_id in entry_callers.query_map([walked_json], |row| row.get::<_, String>(0))? {
5150            let class_id = class_id?;
5151            if !entry_classes.contains(&class_id) {
5152                entry_classes.push(class_id);
5153            }
5154        }
5155    }
5156
5157    let mut seed_words: Vec<String> = Vec::new();
5158    for symbol in &resolved_seed_symbols {
5159        let callees =
5160            find_references_for_symbol(conn, &symbol.name, "callees", 50, &symbol.symbol_id)
5161                .unwrap_or_default();
5162        for name in std::iter::once(symbol.name.as_str())
5163            .chain(callees.iter().map(|site| site.to_symbol_name.as_str()))
5164        {
5165            for word in name_words(name) {
5166                if !seed_words.contains(&word) && !GENERIC_NAME_WORDS.contains(&word.as_str()) {
5167                    seed_words.push(word);
5168                }
5169            }
5170        }
5171    }
5172    for class_id in entry_classes {
5173        for test in implicit_entry_tests(conn, &class_id, &seed_words)? {
5174            if seen_test_keys.insert(format!("{}:{}", test.path, test.line)) {
5175                likely_tests.push(test);
5176            }
5177        }
5178    }
5179
5180    qualify_test_methods(conn, &mut likely_tests)?;
5181    let whole_files: HashSet<String> = likely_tests
5182        .iter()
5183        .filter(|test| test.reason.ends_with("matched test file"))
5184        .map(|test| test.path.clone())
5185        .collect();
5186    likely_tests.retain(|test| {
5187        test.reason.ends_with("matched test file") || !whole_files.contains(&test.path)
5188    });
5189    // Whole test files named for the target first, possible tests last, callers in between.
5190    likely_tests.sort_by_key(|test| {
5191        if test.reason.ends_with("matched test file") {
5192            0
5193        } else if test.reason.starts_with("possible:") {
5194            2
5195        } else {
5196            1
5197        }
5198    });
5199    let likely_tests_found = likely_tests.len();
5200    let impacted_symbols_found = impacted_symbols.len();
5201    let likely_tests_truncated = likely_tests.len() > limit;
5202    let impacted_symbols_truncated = impacted_symbols.len() > limit;
5203    if likely_tests.len() > limit {
5204        likely_tests.truncate(limit);
5205    }
5206    if impacted_symbols.len() > limit {
5207        impacted_symbols.truncate(limit);
5208    }
5209
5210    Ok(BlastRadiusResult {
5211        seed_type,
5212        seeds,
5213        likely_tests,
5214        impacted_symbols,
5215        likely_tests_truncated,
5216        impacted_symbols_truncated,
5217        traversal_ceiling_reached,
5218        test_file_ceiling_reached,
5219        limit_at_maximum: limit >= MAX_RESULT_LIMIT,
5220        likely_tests_found,
5221        impacted_symbols_found,
5222    })
5223}
5224
5225/// Prefixes a test method with its class, as `TestRoutes::test_simple`, so the name selects the
5226/// test the way pytest and most runners address it.
5227fn qualify_test_methods(conn: &Connection, tests: &mut [TestTarget]) -> Result<(), QueryError> {
5228    let mut stmt = conn.prepare(
5229        "SELECT class.name FROM symbols t
5230         JOIN symbols class ON class.symbol_id = t.parent_symbol_id
5231         WHERE replace(t.path, '\\', '/') = ?1 AND t.start_line = ?2 AND t.name = ?3
5232           AND class.kind = 'class'
5233         LIMIT 1",
5234    )?;
5235    for test in tests.iter_mut().filter(|test| test.name != test.path) {
5236        if let Some(class) = stmt
5237            .query_row(params![test.path, test.line as i64, test.name], |row| {
5238                row.get::<_, String>(0)
5239            })
5240            .optional()?
5241        {
5242            test.name = format!("{class}::{}", test.name);
5243        }
5244    }
5245    Ok(())
5246}
5247
5248/// The name tests use for the fixture function `function` in `path`: the `name=` argument of its
5249/// `@pytest.fixture(...)` decorator when it has one, else the function name.
5250fn fixture_name(conn: &Connection, function: &str, path: &str) -> String {
5251    let signature: Option<String> = conn
5252        .query_row(
5253            "SELECT signature FROM symbols
5254             WHERE name = ?1 AND replace(path, '\\', '/') = ?2 AND signature LIKE '%fixture(%'
5255             LIMIT 1",
5256            params![function, path],
5257            |row| row.get(0),
5258        )
5259        .optional()
5260        .ok()
5261        .flatten();
5262    signature
5263        .as_deref()
5264        .and_then(declared_fixture_name)
5265        .unwrap_or_else(|| function.to_string())
5266}
5267
5268/// The `name=` argument of a `fixture(...)` decorator in `signature`.
5269fn declared_fixture_name(signature: &str) -> Option<String> {
5270    let args = &signature[signature.find("fixture(")? + "fixture(".len()..];
5271    let args = &args[..args.find(')').unwrap_or(args.len())];
5272    let value = args[args.find("name=")? + "name=".len()..].trim_start();
5273    let quote = value.chars().next().filter(|c| *c == '"' || *c == '\'')?;
5274    let end = value[1..].find(quote)?;
5275    Some(value[1..1 + end].to_string())
5276}
5277
5278/// The test functions that take the fixture `name` as a parameter: in the fixture's own file,
5279/// or anywhere under the directory of the `conftest.py` that defines it. A fixture that takes it
5280/// is not a test, so its own users are listed in its place.
5281fn fixture_users(
5282    conn: &Connection,
5283    name: &str,
5284    fixture_path: &str,
5285) -> Result<Vec<TestTarget>, QueryError> {
5286    let lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
5287        "COALESCE(t.test_lifecycle, 0) != 0"
5288    } else {
5289        "0"
5290    };
5291    let mut stmt = conn.prepare(&format!(
5292        "SELECT DISTINCT t.name, t.path, t.start_line, {lifecycle}
5293         FROM symbols parameter
5294         JOIN symbols t ON t.symbol_id = parameter.parent_symbol_id
5295         WHERE parameter.name = ?1
5296           AND (parameter.kind = 'parameter'
5297                OR (parameter.kind = 'variable' AND parameter.start_byte < t.body_start_byte))
5298           AND t.is_test = 1
5299           AND replace(t.path, '\\', '/') LIKE ?2 ESCAPE '\\'
5300         ORDER BY t.path, t.start_line
5301         LIMIT 2000"
5302    ))?;
5303    // ponytail: the callers rank these rows by name, so the cap must hold a whole test suite's
5304    // users of one fixture; raise it if a suite passes 2,000 users of a single fixture.
5305    let mut users = Vec::new();
5306    let mut listed = HashSet::new();
5307    let mut visited = HashSet::new();
5308    let mut pending = vec![(name.to_string(), fixture_path.to_string())];
5309    while let Some((function, fixture_path)) = pending.pop() {
5310        if !visited.insert((function.clone(), fixture_path.clone())) {
5311            continue;
5312        }
5313        let name = fixture_name(conn, &function, &fixture_path);
5314        let scope = match fixture_path.rsplit_once('/') {
5315            Some((dir, "conftest.py")) => format!("{}/%", escape_like(dir)),
5316            None if fixture_path == "conftest.py" => "%".to_string(),
5317            _ => escape_like(&fixture_path),
5318        };
5319        let rows = stmt.query_map(params![name, scope], |row| {
5320            Ok((
5321                TestTarget {
5322                    name: row.get(0)?,
5323                    path: row.get::<_, String>(1)?.replace('\\', "/"),
5324                    line: row.get::<_, i64>(2)? as usize,
5325                    reason: String::new(),
5326                },
5327                row.get::<_, bool>(3)?,
5328            ))
5329        })?;
5330        for row in rows {
5331            let (user, is_fixture) = row?;
5332            if is_fixture {
5333                pending.push((user.name, user.path));
5334            } else if listed.insert((user.path.clone(), user.line)) {
5335                users.push(user);
5336            }
5337        }
5338    }
5339    Ok(users)
5340}
5341
5342/// The tests declared in the class `class_id`, without its setup and teardown members.
5343fn tests_in_class(conn: &Connection, class_id: &str) -> Result<Vec<TestTarget>, QueryError> {
5344    let not_lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
5345        "COALESCE(test_lifecycle, 0) = 0"
5346    } else {
5347        "1"
5348    };
5349    let mut stmt = conn.prepare(&format!(
5350        "SELECT name, path, start_line FROM symbols
5351         WHERE parent_symbol_id = ?1 AND is_test = 1 AND {not_lifecycle}
5352         ORDER BY start_line"
5353    ))?;
5354    let rows = stmt.query_map([class_id], |row| {
5355        Ok(TestTarget {
5356            name: row.get(0)?,
5357            path: row.get::<_, String>(1)?.replace('\\', "/"),
5358            line: row.get::<_, i64>(2)? as usize,
5359            reason: String::new(),
5360        })
5361    })?;
5362    Ok(rows.collect::<Result<_, _>>()?)
5363}
5364
5365/// The lowercase words of a file name split at `_`, `-`, `.`, and case changes, of any length.
5366fn file_words(name: &str) -> Vec<String> {
5367    let mut words = Vec::new();
5368    let mut word = String::new();
5369    let mut previous_lower = false;
5370    for c in name.chars() {
5371        if (!c.is_alphanumeric() || (c.is_uppercase() && previous_lower)) && !word.is_empty() {
5372            words.push(std::mem::take(&mut word).to_lowercase());
5373        }
5374        if c.is_alphanumeric() {
5375            word.push(c);
5376        }
5377        previous_lower = c.is_lowercase() || c.is_ascii_digit();
5378    }
5379    if !word.is_empty() {
5380        words.push(word.to_lowercase());
5381    }
5382    words
5383}
5384
5385/// Whether `run` occurs in `words` as consecutive whole words.
5386fn has_word_run(words: &[String], run: &[String]) -> bool {
5387    !run.is_empty() && words.windows(run.len()).any(|window| window == run)
5388}
5389
5390/// The lowercase words of an identifier split at `_`, `-`, and case changes, three letters or more.
5391fn name_words(name: &str) -> Vec<String> {
5392    let mut words = Vec::new();
5393    let mut word = String::new();
5394    let mut previous_lower = false;
5395    for c in name.chars() {
5396        if !c.is_alphanumeric() || (c.is_uppercase() && previous_lower) {
5397            if word.len() >= 3 {
5398                words.push(word.to_lowercase());
5399            }
5400            word.clear();
5401        }
5402        if c.is_alphanumeric() {
5403            word.push(c);
5404        }
5405        previous_lower = c.is_lowercase();
5406    }
5407    if word.len() >= 3 {
5408        words.push(word.to_lowercase());
5409    }
5410    words
5411}
5412
5413/// Words too common in code names to link a test to a target.
5414const GENERIC_NAME_WORDS: &[&str] = &[
5415    "get", "set", "new", "the", "and", "for", "self", "init", "ensure", "sync", "find", "make",
5416    "create", "load", "call", "add",
5417];
5418
5419/// The number of leading characters two words share.
5420fn common_prefix_len(a: &str, b: &str) -> usize {
5421    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
5422}
5423
5424/// The `seed_words` that the test's name or file name shares, with a count that credits the name
5425/// and the file name separately, so a test in a file named for the target ranks first. A word
5426/// matches when one of the two is a prefix of the other or they share five leading letters, so
5427/// `handle` matches `handler` and `handling`.
5428fn shared_words(seed_words: &[String], test: &TestTarget) -> (usize, Vec<String>) {
5429    let mut found: Vec<String> = Vec::new();
5430    let mut score = 0;
5431    let file = test.path.rsplit('/').next().unwrap_or(&test.path);
5432    for text in [test.name.as_str(), file] {
5433        let words = name_words(text);
5434        for seed in seed_words {
5435            if words.iter().any(|word| {
5436                word.starts_with(seed.as_str())
5437                    || seed.starts_with(word.as_str())
5438                    || common_prefix_len(word, seed) >= 5
5439            }) {
5440                score += 1;
5441                if !found.contains(seed) {
5442                    found.push(seed.clone());
5443                }
5444            }
5445        }
5446    }
5447    (score, found)
5448}
5449
5450/// Tests that build the class `class_id`, whose `__call__` the impact walk reached, or a subclass
5451/// of it, directly or through a fixture. The runtime makes that call, so no call edge links them,
5452/// and the index cannot see whether a test sends a call that reaches the target: each row is a
5453/// possible test. Only tests whose name or file shares a word with the seed or the code it calls
5454/// are kept; the most shared words come first.
5455fn implicit_entry_tests(
5456    conn: &Connection,
5457    class_id: &str,
5458    seed_words: &[String],
5459) -> Result<Vec<TestTarget>, QueryError> {
5460    let Some(class) = get_symbol_by_id(conn, class_id)? else {
5461        return Ok(Vec::new());
5462    };
5463    let lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
5464        "COALESCE(test_lifecycle, 0) != 0"
5465    } else {
5466        "0"
5467    };
5468    let mut classes = vec![class];
5469    let mut sites_by_class = Vec::new();
5470    let mut next = 0;
5471    while next < classes.len() && classes.len() <= 50 {
5472        let built = classes[next].clone();
5473        next += 1;
5474        let sites =
5475            find_references_for_symbol(conn, &built.name, "callers", 200, &built.symbol_id)?;
5476        for site in sites.iter().filter(|site| site.kind == "extends") {
5477            if !classes.iter().any(|c| c.symbol_id == site.from_symbol_id)
5478                && let Some(subclass) = get_symbol_by_id(conn, &site.from_symbol_id)?
5479            {
5480                classes.push(subclass);
5481            }
5482        }
5483        sites_by_class.push((built, sites));
5484    }
5485    let mut builders = Vec::new();
5486    for (built, sites) in &sites_by_class {
5487        for site in sites {
5488            if site.kind != "calls" {
5489                continue;
5490            }
5491            let Some(builder) = get_symbol_by_id(conn, &site.from_symbol_id)? else {
5492                continue;
5493            };
5494            if !(builder.is_test || is_test_path(&builder.path)) {
5495                continue;
5496            }
5497            let is_fixture: bool = conn
5498                .query_row(
5499                    &format!("SELECT {lifecycle} FROM symbols WHERE symbol_id = ?1"),
5500                    [&builder.symbol_id],
5501                    |row| row.get(0),
5502                )
5503                .unwrap_or(false);
5504            if is_fixture {
5505                let via = format!(
5506                    "builds `{}` through fixture `{}`",
5507                    built.name,
5508                    fixture_name(conn, &builder.name, &builder.path)
5509                );
5510                builders.extend(
5511                    fixture_users(conn, &builder.name, &builder.path)?
5512                        .into_iter()
5513                        .map(|test| TestTarget {
5514                            reason: via.clone(),
5515                            ..test
5516                        }),
5517                );
5518            } else if builder.is_test {
5519                builders.push(TestTarget {
5520                    name: builder.name,
5521                    path: builder.path,
5522                    line: builder.start_line,
5523                    reason: format!("builds `{}`", built.name),
5524                });
5525            }
5526        }
5527    }
5528    let mut ranked: Vec<(usize, TestTarget)> = builders
5529        .into_iter()
5530        .filter(|test| uses_a_client(conn, test))
5531        .filter_map(|test| {
5532            let (score, words) = shared_words(seed_words, &test);
5533            (score > 0).then(|| {
5534                let shared = words
5535                    .iter()
5536                    .map(|word| format!("`{word}`"))
5537                    .collect::<Vec<_>>()
5538                    .join(", ");
5539                let reason = format!(
5540                    "possible: {}, and a test client calls its `__call__`, which can reach the target; shares {shared}",
5541                    test.reason
5542                );
5543                (score, TestTarget { reason, ..test })
5544            })
5545        })
5546        .collect();
5547    ranked.sort_by(|(a_score, a), (b_score, b)| {
5548        b_score
5549            .cmp(a_score)
5550            .then_with(|| a.path.cmp(&b.path))
5551            .then_with(|| a.line.cmp(&b.line))
5552    });
5553    Ok(ranked.into_iter().map(|(_, test)| test).collect())
5554}
5555
5556/// Whether `test` drives an app through a test client: it takes a parameter or holds a variable,
5557/// or calls a function, method, class, or member of a receiver, whose name contains `client`. WSGI, ASGI, and Rack apps are called through
5558/// such clients by convention (`app.test_client()`, `TestClient(app)`, a `client` fixture), and a
5559/// test that only builds the app never reaches its `__call__`.
5560fn uses_a_client(conn: &Connection, test: &TestTarget) -> bool {
5561    conn.query_row(
5562        "SELECT EXISTS (
5563            SELECT 1 FROM symbols t
5564            WHERE t.name = ?3 AND t.start_line = ?2 AND replace(t.path, '\\', '/') = ?1
5565              AND (EXISTS (SELECT 1 FROM symbols p
5566                           WHERE p.parent_symbol_id = t.symbol_id
5567                             AND p.kind IN ('parameter', 'variable')
5568                             AND lower(p.name) LIKE '%client%')
5569                   OR EXISTS (SELECT 1 FROM pending_relationships pr
5570                              WHERE pr.from_symbol_id = t.symbol_id
5571                                AND (lower(pr.target_terminal_name) LIKE '%client%'
5572                                     OR lower(COALESCE(pr.target_receiver, '')) LIKE '%client%'))
5573                   OR EXISTS (SELECT 1 FROM relationships r
5574                              JOIN symbols callee ON callee.symbol_id = r.to_symbol_id
5575                              WHERE r.from_symbol_id = t.symbol_id
5576                                AND lower(callee.name) LIKE '%client%')))",
5577        params![test.path, test.line as i64, test.name],
5578        |row| row.get(0),
5579    )
5580    .unwrap_or(false)
5581}
5582
5583/// Compute blast radius and likely tests for given seed symbols or seed file paths.
5584pub fn compute_blast_radius(
5585    conn: &Connection,
5586    seed_symbols: &[&str],
5587    seed_paths: &[&str],
5588    max_depth: usize,
5589    limit: usize,
5590) -> Result<BlastRadiusResult, QueryError> {
5591    compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
5592}
5593
5594#[cfg(test)]
5595mod tests {
5596    #[test]
5597    fn a_type_declaration_drops_attributes_and_fits_on_one_short_line() {
5598        assert_eq!(
5599            super::type_declaration_line(
5600                "#[derive(Debug)] #[serde(tag = \"t\")] pub struct Workspace"
5601            ),
5602            "pub struct Workspace"
5603        );
5604        let record = format!(
5605            "public sealed record Node(\n{})",
5606            "    string Id,\n".repeat(20)
5607        );
5608        let line = super::type_declaration_line(&record);
5609        assert!(
5610            line.starts_with("public sealed record Node( string Id, string Id,"),
5611            "{line}"
5612        );
5613        assert_eq!(line.chars().count(), 120);
5614        assert!(line.ends_with('…'));
5615    }
5616
5617    #[test]
5618    fn a_mount_names_what_it_mounts_and_where() {
5619        let mount = |capture: &str, json: &str| {
5620            super::mount_display(capture, &serde_json::from_str(json).unwrap())
5621        };
5622        assert_eq!(
5623            mount(
5624                "blueprint_registration",
5625                r#"{"framework":"flask","mount_target":"auth.bp","query_family":"framework"}"#
5626            )
5627            .as_deref(),
5628            Some("blueprint_registration `auth.bp`")
5629        );
5630        assert_eq!(
5631            mount(
5632                "mount",
5633                r#"{"mount_path":"/jobs","mount_target":"Sidekiq::Web","normalized_mount_path":"/admin/jobs","query_family":"framework"}"#
5634            )
5635            .as_deref(),
5636            Some("mount `Sidekiq::Web` at /admin/jobs")
5637        );
5638        assert_eq!(
5639            mount(
5640                "property",
5641                r#"{"mount_target":"x","query_family":"metadata"}"#
5642            ),
5643            None
5644        );
5645    }
5646
5647    #[test]
5648    fn a_route_shows_its_source_template_and_keeps_a_joined_prefix() {
5649        let route = |json: &str| super::route_display(&serde_json::from_str(json).unwrap());
5650        assert_eq!(
5651            route(
5652                r#"{"verb":"GET","route_template":"/<int:id>/update","normalized_route_template":"/:id/update"}"#
5653            ),
5654            Some("GET /<int:id>/update".into())
5655        );
5656        assert_eq!(
5657            route(
5658                r#"{"verb":"POST","route_template":"/register","effective_route_template":"/auth/register","normalized_route_template":"/auth/register"}"#
5659            ),
5660            Some("POST /auth/register".into())
5661        );
5662        assert_eq!(
5663            route(
5664                r#"{"verb":"GET","route_template":"/users/{id}","normalized_route_template":"/api/v1/users/:id"}"#
5665            ),
5666            Some("GET /api/v1/users/:id".into())
5667        );
5668        assert_eq!(route(r#"{"key":"x"}"#), None);
5669    }
5670
5671    #[test]
5672    fn a_fixture_declared_with_a_name_is_used_by_that_name() {
5673        assert_eq!(
5674            super::declared_fixture_name("@pytest.fixture(name=\"async_app\") def _async_app()"),
5675            Some("async_app".into())
5676        );
5677        assert_eq!(
5678            super::declared_fixture_name(
5679                "@pytest.fixture(scope='session', name='db') def make_db()"
5680            ),
5681            Some("db".into())
5682        );
5683        assert_eq!(
5684            super::declared_fixture_name("@pytest.fixture def app()"),
5685            None
5686        );
5687        assert_eq!(
5688            super::declared_fixture_name("@pytest.fixture(params=[1]) def f(name='x')"),
5689            None
5690        );
5691    }
5692
5693    #[test]
5694    fn a_stem_matches_whole_words_of_a_test_file_name() {
5695        let matches = |file: &str, stem: &str| {
5696            super::has_word_run(&super::file_words(file), &super::file_words(stem))
5697        };
5698        assert!(matches("test_app.py", "app"));
5699        assert!(matches("AppTest.java", "App"));
5700        assert!(matches("test_user_error_handler.py", "user_error_handler"));
5701        assert!(matches("widget_test_3.rs", "widget"));
5702        assert!(!matches("test_appctx.py", "app"));
5703        assert!(!matches("test_mapper.py", "app"));
5704    }
5705
5706    #[test]
5707    fn a_test_with_a_client_is_found_under_a_backslash_path() {
5708        let conn = rusqlite::Connection::open_in_memory().unwrap();
5709        conn.execute_batch(
5710            "CREATE TABLE symbols (symbol_id TEXT, name TEXT, path TEXT, start_line INTEGER,
5711                 parent_symbol_id TEXT, kind TEXT);
5712             CREATE TABLE pending_relationships (from_symbol_id TEXT, target_terminal_name TEXT,
5713                 target_receiver TEXT);
5714             CREATE TABLE relationships (from_symbol_id TEXT, to_symbol_id TEXT);
5715             INSERT INTO symbols VALUES ('t', 'test_user', 'tests\\test_user.py', 4, NULL, 'function');
5716             INSERT INTO symbols VALUES ('c', 'client', 'tests\\test_user.py', 4, 't', 'parameter');",
5717        )
5718        .unwrap();
5719        let test = crate::models::TestTarget {
5720            name: "test_user".into(),
5721            path: "tests/test_user.py".into(),
5722            line: 4,
5723            reason: String::new(),
5724        };
5725
5726        assert!(super::uses_a_client(&conn, &test));
5727    }
5728
5729    #[test]
5730    fn result_limit_rejects_values_above_the_shared_ceiling() {
5731        assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
5732        assert!(matches!(
5733            validate_result_limit(usize::MAX),
5734            Err(QueryError::InvalidResultLimit(usize::MAX))
5735        ));
5736    }
5737
5738    #[test]
5739    fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
5740        let conn = Connection::open_in_memory().unwrap();
5741
5742        assert!(matches!(
5743            find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
5744            Err(QueryError::InvalidResultLimit(usize::MAX))
5745        ));
5746    }
5747
5748    use super::*;
5749    use crate::db::{ensure_fts_index, open_read_write};
5750
5751    #[test]
5752    fn count_parse_diagnostics_counts_rows_for_one_file() {
5753        let dir = crate::safe_tempdir();
5754        let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
5755
5756        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
5757
5758        conn.execute_batch(
5759            "CREATE TABLE parse_diagnostics (
5760                diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
5761            );
5762            INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
5763            INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
5764            INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
5765        )
5766        .unwrap();
5767
5768        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
5769        assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
5770        assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
5771    }
5772
5773    #[test]
5774    fn test_sanitize_fts5_query() {
5775        let (and_q, or_q) = sanitize_fts5_query("parse tokens");
5776        assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
5777        assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
5778
5779        let (and_q, or_q) = sanitize_fts5_query("  Option<T>  ");
5780        assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
5781        assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
5782
5783        let (and_q, or_q) = sanitize_fts5_query("   ");
5784        assert!(and_q.is_empty());
5785        assert!(or_q.is_empty());
5786    }
5787
5788    #[test]
5789    fn sanitize_splits_case_boundaries_and_drops_stop_words() {
5790        let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
5791        assert_eq!(
5792            and_q,
5793            "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
5794        );
5795        assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
5796
5797        let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
5798        assert_eq!(
5799            and_q,
5800            "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
5801        );
5802
5803        let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
5804        assert_eq!(
5805            and_q,
5806            "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
5807        );
5808        assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
5809
5810        let (and_q, _) = sanitize_fts5_query("validate_syntax");
5811        assert_eq!(
5812            and_q,
5813            "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
5814        );
5815
5816        let (and_q, _) = sanitize_fts5_query("isReady");
5817        assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
5818
5819        let (and_q, _) = sanitize_fts5_query("before");
5820        assert_eq!(and_q, "\"before\"*");
5821
5822        let (and_q, _) = sanitize_fts5_query("fooBar quux");
5823        assert_eq!(
5824            and_q,
5825            "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
5826        );
5827
5828        let (and_q, _) = sanitize_fts5_query("the for a");
5829        assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
5830    }
5831
5832    fn search_fixture(rows: &str) -> Connection {
5833        let conn = Connection::open_in_memory().unwrap();
5834        conn.execute_batch(&format!(
5835            "CREATE TABLE symbols (
5836                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5837                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5838                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5839                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5840                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5841                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5842                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5843            );
5844            INSERT INTO symbols VALUES {rows};"
5845        ))
5846        .unwrap();
5847        ensure_fts_index(&conn).unwrap();
5848        conn
5849    }
5850
5851    fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
5852        format!(
5853            "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
5854              10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
5855        )
5856    }
5857
5858    fn doc_row(id: &str, name: &str, doc: &str) -> String {
5859        format!(
5860            "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
5861              3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
5862        )
5863    }
5864
5865    #[test]
5866    fn lookup_and_search_skip_documentation_links_recorded_as_imports() {
5867        let conn = search_fixture(
5868            &[
5869                "('i1', 'f_i1', 'src/app.py', 'python', 'Flask', 'import', 'from flask import Flask', NULL, NULL, NULL, 1, 0, 1, 20, 0, 20, NULL, NULL, NULL, NULL, NULL, NULL, 'h_i1', NULL, 0, 0, 'code')".to_string(),
5870                "('i2', 'f_i2', 'README.md', 'markdown', 'Flask', 'import', '[Flask](https://flask.dev)', NULL, NULL, NULL, 1, 0, 1, 20, 0, 20, NULL, NULL, NULL, NULL, NULL, NULL, 'h_i2', NULL, 0, 0, 'documentation')".to_string(),
5871            ]
5872            .join(","),
5873        );
5874
5875        let lookup: Vec<String> = search_symbols(&conn, "Flask", Some("import"), true, 10)
5876            .unwrap()
5877            .into_iter()
5878            .map(|s| s.path)
5879            .collect();
5880        assert_eq!(lookup, vec!["src/app.py"]);
5881        let search: Vec<String> =
5882            fts_search_symbols_scoped(&conn, "Flask", Some("import"), None, true, 10)
5883                .unwrap()
5884                .into_iter()
5885                .map(|r| r.symbol.path)
5886                .collect();
5887        assert_eq!(search, vec!["src/app.py"]);
5888    }
5889
5890    #[test]
5891    fn lookup_ranks_a_prefix_match_above_a_substring_match() {
5892        let conn = search_fixture(
5893            &[
5894                code_row("c1", "tests/a.rs", "rust", "test_test_client", ""),
5895                code_row("c2", "tests/b.rs", "rust", "test_client_open_environ", ""),
5896            ]
5897            .join(","),
5898        );
5899
5900        let names: Vec<String> = search_symbols(&conn, "test_cli", None, true, 10)
5901            .unwrap()
5902            .into_iter()
5903            .map(|s| s.name)
5904            .collect();
5905
5906        assert_eq!(names, vec!["test_client_open_environ", "test_test_client"]);
5907    }
5908
5909    #[test]
5910    fn lookup_ranks_a_declared_property_above_a_self_assignment() {
5911        let property = |id: &str, path: &str, signature: &str| {
5912            format!(
5913                "('{id}', 'f_{id}', '{path}', 'python', 'debug', 'property', '{signature}', NULL, NULL, NULL, 1, 0, 1, 20, 0, 20, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'code')"
5914            )
5915        };
5916        let conn = search_fixture(
5917            &[
5918                property("p1", "src/flask/app.py", "self.debug = get_debug_flag()"),
5919                property(
5920                    "p2",
5921                    "src/flask/sansio/app.py",
5922                    "@property def debug(self) -> bool",
5923                ),
5924            ]
5925            .join(","),
5926        );
5927
5928        let paths: Vec<String> = search_symbols(&conn, "debug", None, false, 10)
5929            .unwrap()
5930            .into_iter()
5931            .map(|s| s.path)
5932            .collect();
5933
5934        assert_eq!(paths, vec!["src/flask/sansio/app.py", "src/flask/app.py"]);
5935    }
5936
5937    fn search_names(conn: &Connection, query: &str) -> Vec<String> {
5938        fts_search_symbols_scoped(conn, query, None, None, false, 10)
5939            .unwrap()
5940            .into_iter()
5941            .map(|r| r.symbol.name)
5942            .collect()
5943    }
5944
5945    const TEST_PATH_CASES: &[(&str, bool)] = &[
5946        ("tests/foo.py", true),
5947        ("tests/x.py", true),
5948        ("tests/tools/test_web.py", true),
5949        ("src/tests/x.rs", true),
5950        ("__tests__/a.ts", true),
5951        ("a/__tests__/b.ts", true),
5952        ("test/x.java", true),
5953        ("src/test/Helper.java", true),
5954        ("src/test_utils.py", true),
5955        ("lib/test_helper.rb", true),
5956        ("test_config.py", true),
5957        ("pkg/test_data/x.json", false),
5958        ("src/test_detection.rs", false),
5959        ("autotests/tst_pagerow.qml", true),
5960        ("autotests/helper.qml", true),
5961        ("src/autotests/columnview.cpp", true),
5962        ("tst_foo.qml", true),
5963        ("src/tst_columnview.qml", true),
5964        ("autotests\\tst_bar.qml", true),
5965        ("autotests_helper/x.rs", false),
5966        ("src/autotest.rs", false),
5967        ("src/tstamp.rs", false),
5968        ("src/tst.rs", false),
5969        ("crates/julie-index/src/analysis/test_quality.rs", false),
5970        ("x/foo_test.go", true),
5971        ("x/foo.test.ts", true),
5972        ("x/foo.spec.js", true),
5973        ("src/lib_test.rs", true),
5974        ("src/test.rs", true),
5975        ("tests.rs", true),
5976        ("src/tests.rs", true),
5977        ("Foo.Tests.cs", true),
5978        ("x/FooTests.cs", true),
5979        ("src/FooTests.cs", true),
5980        ("src/Foo.Tests.cs", true),
5981        ("tests/Foo.cs", true),
5982        ("x/parser.spec.ts", true),
5983        ("test_x.py", true),
5984        ("test.rs", true),
5985        ("tests\\x.py", true),
5986        ("src/protocol.spec.v1/parser.rs", false),
5987        ("pkg/test_support/runtime.py", false),
5988        ("src/Contests.cs", false),
5989        ("spec/x.rb", false),
5990        ("crates/x/src/impact/likely_tests.rs", false),
5991        ("x/foo_tests.rs", false),
5992        ("src/latest.rs", false),
5993        ("x/latest.go", false),
5994        ("x/manifest.rs", false),
5995        ("src/attest.rs", false),
5996        ("contest/x.py", false),
5997        ("src/testing.rs", false),
5998        ("src/main.rs", false),
5999        ("pkg/service.go", false),
6000    ];
6001
6002    #[test]
6003    fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
6004        let conn = Connection::open_in_memory().unwrap();
6005        let sql = format!(
6006            "SELECT {} FROM (SELECT :path AS path) s",
6007            test_path_predicate("s")
6008        );
6009        let mut stmt = conn.prepare(&sql).unwrap();
6010        for (path, expected) in TEST_PATH_CASES {
6011            assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
6012            let from_sql: bool = stmt
6013                .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
6014                .unwrap();
6015            assert_eq!(from_sql, *expected, "sql mirror: {path}");
6016        }
6017    }
6018
6019    #[test]
6020    fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
6021        let conn = search_fixture(
6022            &[
6023                code_row(
6024                    "a",
6025                    "src/parser.rs",
6026                    "rust",
6027                    "parse_sidecar",
6028                    "Parse a sidecar.",
6029                ),
6030                code_row(
6031                    "b",
6032                    "src/tests/helpers.py",
6033                    "python",
6034                    "parse_sidecar_fixture",
6035                    "Parse a sidecar.",
6036                ),
6037            ]
6038            .join(", "),
6039        );
6040
6041        let default_search: Vec<String> =
6042            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
6043                .unwrap()
6044                .into_iter()
6045                .map(|r| r.symbol.name)
6046                .collect();
6047        assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
6048
6049        let with_tests: Vec<String> =
6050            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
6051                .unwrap()
6052                .into_iter()
6053                .map(|r| r.symbol.name)
6054                .collect();
6055        assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
6056
6057        let default_lookup: Vec<String> =
6058            search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
6059                .unwrap()
6060                .into_iter()
6061                .map(|s| s.name)
6062                .collect();
6063        assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
6064
6065        let lookup_with_tests: Vec<String> =
6066            search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
6067                .unwrap()
6068                .into_iter()
6069                .map(|s| s.name)
6070                .collect();
6071        assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
6072    }
6073
6074    #[test]
6075    fn count_file_symbols_prefers_the_exact_case_path_like_the_loader() {
6076        let conn = search_fixture(
6077            &[
6078                code_row("a", "src/Foo.rs", "rust", "one", ""),
6079                code_row("b", "src/foo.rs", "rust", "two", ""),
6080                code_row("c", "src/foo.rs", "rust", "three", ""),
6081            ]
6082            .join(", "),
6083        );
6084        for path in ["src/Foo.rs", "src/foo.rs", "src/FOO.rs", "src\\foo.rs"] {
6085            assert_eq!(
6086                count_file_symbols(&conn, path),
6087                load_file_symbols(&conn, path).unwrap().len(),
6088                "{path}"
6089            );
6090        }
6091        assert_eq!(count_file_symbols(&conn, "src/Foo.rs"), 1);
6092        assert_eq!(count_file_symbols(&conn, "src/FOO.rs"), 3);
6093    }
6094
6095    #[test]
6096    fn lookup_statement_is_not_planned_as_a_multi_index_or() {
6097        let conn = search_fixture(&code_row("a", "src/lib.rs", "rust", "needle", ""));
6098        conn.execute_batch(
6099            "CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
6100             CREATE INDEX idx_symbols_test_container ON symbols(test_container);
6101             CREATE INDEX idx_symbols_is_test ON symbols(is_test);",
6102        )
6103        .unwrap();
6104        let sql = format!(
6105            "EXPLAIN QUERY PLAN {}",
6106            search_symbols_sql(false, false, 20)
6107        );
6108        let plan: Vec<String> = conn
6109            .prepare(&sql)
6110            .unwrap()
6111            .query_map(
6112                rusqlite::named_params! {
6113                    ":query": "needle",
6114                    ":pattern": "%needle%",
6115                    ":kind": None::<&str>,
6116                    ":path": None::<&str>,
6117                    ":path_like": None::<&str>,
6118                },
6119                |row| row.get::<_, String>(3),
6120            )
6121            .unwrap()
6122            .collect::<Result<_, _>>()
6123            .unwrap();
6124        assert!(
6125            !plan.iter().any(|step| step.contains("MULTI-INDEX OR")),
6126            "{plan:?}"
6127        );
6128    }
6129
6130    #[test]
6131    fn qualified_lookup_in_a_test_file_returns_the_named_row() {
6132        let conn = search_fixture(
6133            &[
6134                "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
6135                  1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
6136                "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
6137                  10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
6138            ]
6139            .join(", "),
6140        );
6141
6142        assert_eq!(
6143            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
6144                .unwrap()
6145                .len(),
6146            1
6147        );
6148        assert_eq!(
6149            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
6150                .unwrap()
6151                .len(),
6152            1
6153        );
6154        assert!(
6155            search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
6156                .unwrap()
6157                .is_empty()
6158        );
6159    }
6160
6161    #[test]
6162    fn concept_query_prefers_partial_code_match_over_full_doc_match() {
6163        let conn = search_fixture(
6164            &[
6165                doc_row(
6166                    "d1",
6167                    "Safety guarantees",
6168                    "Pre-flight syntax validation runs before the edit touches disk",
6169                ),
6170                doc_row(
6171                    "d2",
6172                    "Audit",
6173                    "The syntax validation before an edit is the invariant",
6174                ),
6175                code_row(
6176                    "c1",
6177                    "src/syntax.rs",
6178                    "rust",
6179                    "validate_syntax",
6180                    "Validate the syntax of a file",
6181                ),
6182                code_row(
6183                    "c2",
6184                    "src/edit.rs",
6185                    "rust",
6186                    "replace_symbol_body",
6187                    "Atomic edit with validation",
6188                ),
6189            ]
6190            .join(","),
6191        );
6192
6193        let names = search_names(&conn, "syntax validation before edit");
6194
6195        assert_eq!(names[0], "validate_syntax");
6196        assert!(names.contains(&"replace_symbol_body".to_string()));
6197        assert!(names.contains(&"Safety guarantees".to_string()));
6198    }
6199
6200    #[test]
6201    fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
6202        let conn = search_fixture(
6203            &[
6204                code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
6205                code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
6206            ]
6207            .join(","),
6208        );
6209
6210        let mut camel = search_names(&conn, "ValidateSyntax");
6211        camel.sort();
6212        assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
6213        let mut words = search_names(&conn, "validate syntax");
6214        words.sort();
6215        assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
6216    }
6217
6218    #[test]
6219    fn stop_word_prefixed_camel_case_symbol_is_still_found() {
6220        let conn = search_fixture(
6221            &[
6222                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
6223                code_row("c2", "src/hooks.rs", "rust", "before", ""),
6224                code_row(
6225                    "c3",
6226                    "src/x.rs",
6227                    "rust",
6228                    "fooBar",
6229                    "has fooBar but not the other word",
6230                ),
6231            ]
6232            .join(","),
6233        );
6234
6235        assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
6236        assert_eq!(search_names(&conn, "before"), vec!["before"]);
6237    }
6238
6239    #[test]
6240    fn related_tests_use_the_name_as_typed_without_splitting() {
6241        let conn = search_fixture(
6242            &[
6243                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
6244                "('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(),
6245                "('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(),
6246            ]
6247            .join(","),
6248        );
6249        let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
6250
6251        let names: Vec<String> = find_related_tests(&conn, &target, 5)
6252            .unwrap()
6253            .into_iter()
6254            .map(|t| t.name)
6255            .collect();
6256
6257        assert_eq!(names, vec!["isReady_reports_true"]);
6258    }
6259
6260    #[test]
6261    fn related_tests_match_underscores_literally_and_skip_dunder_names() {
6262        let conn = search_fixture(
6263            &[
6264                code_row("c1", "src/app.py", "python", "wsgi_app", ""),
6265                code_row("c2", "src/app.py", "python", "__init__", ""),
6266                "('t1', 'f_t1', 'tests/test_app.py', 'python', 'test_wsgi_app', 'function', 'def test_wsgi_app()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t1', NULL, 1, 0, 'code')".to_string(),
6267                "('t2', 'f_t2', 'tests/test_app.py', 'python', 'test_wsgiXapp', 'function', 'def test_wsgiXapp()', NULL, NULL, NULL, 6, 0, 9, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t2', NULL, 1, 0, 'code')".to_string(),
6268                "('t3', 'f_t3', 'tests/test_views.py', 'python', 'test_init_once', 'function', 'def test_init_once()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t3', NULL, 1, 0, 'code')".to_string(),
6269            ]
6270            .join(","),
6271        );
6272        let names = |name: &str| -> Vec<String> {
6273            let target = get_symbol_by_name(&conn, name, None).unwrap().unwrap();
6274            find_related_tests(&conn, &target, 5)
6275                .unwrap()
6276                .into_iter()
6277                .map(|t| t.name)
6278                .collect()
6279        };
6280
6281        assert_eq!(names("wsgi_app"), vec!["test_wsgi_app"]);
6282        assert!(names("__init__").is_empty());
6283    }
6284
6285    #[test]
6286    fn related_tests_admit_recognized_unflagged_paths_without_low_signal_rows() {
6287        let conn = search_fixture(
6288            "('target', 'f_target', 'src/core.rs', 'rust', 'calculate', 'function', 'fn calculate()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_target', NULL, 0, 0, 'code'),
6289              ('direct', 'f_direct', 'tests/direct.rs', 'rust', 'direct_case', 'function', 'fn direct_case()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_direct', NULL, 0, 0, 'code'),
6290              ('pending', 'f_pending', 'autotests/tst_pending.qml', 'qml', 'pending_case', 'function', 'function pending_case() {}', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_pending', NULL, 0, 0, 'code'),
6291              ('name', 'f_name', 'tests/name.rs', 'rust', 'calculate_named_case', 'function', 'fn calculate_named_case()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_name', NULL, 0, 0, 'code'),
6292              ('fts', 'f_fts', 'tests/fts.rs', 'rust', 'fts_case', 'function', 'fn fts_case()', 'calculate behavior', NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_fts', NULL, 0, 0, 'code'),
6293              ('local', 'f_local', 'tests/name.rs', 'rust', 'calculate_local', 'variable', 'let calculate_local = 1;', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_local', NULL, 0, 0, 'code'),
6294              ('import', 'f_import', 'tests/name.rs', 'rust', 'calculate_import', 'import', 'use calculate_import;', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_import', NULL, 0, 0, 'code'),
6295              ('production', 'f_production', 'src/testing.rs', 'rust', 'calculate_production', 'function', 'fn calculate_production()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_production', NULL, 0, 0, 'code')",
6296        );
6297        conn.execute_batch(
6298            "CREATE TABLE relationships (from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT, start_line INTEGER, start_column INTEGER);
6299             CREATE TABLE pending_relationships (from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT, start_line INTEGER, start_column INTEGER, target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT);
6300             CREATE TABLE type_facts (type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT);
6301             INSERT INTO relationships VALUES ('direct', 'target', 'calls', 'tests/direct.rs', 1, 0);
6302             INSERT INTO pending_relationships VALUES ('pending', 'calculate', 'calls', 'autotests/tst_pending.qml', 1, 0, NULL, '[]', 'calculate');",
6303        )
6304        .unwrap();
6305        let target = get_symbol_by_name(&conn, "calculate", None)
6306            .unwrap()
6307            .unwrap();
6308
6309        let names: Vec<String> = find_related_tests(&conn, &target, 5)
6310            .unwrap()
6311            .into_iter()
6312            .map(|test| test.name)
6313            .collect();
6314
6315        assert_eq!(
6316            names,
6317            vec![
6318                "direct_case",
6319                "pending_case",
6320                "calculate_named_case",
6321                "fts_case"
6322            ]
6323        );
6324    }
6325
6326    #[test]
6327    fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
6328        let conn = search_fixture(
6329            &[
6330                code_row(
6331                    "c1",
6332                    "src/queries.rs",
6333                    "rust",
6334                    "fts_search_symbols_scoped",
6335                    "search symbols scoped with fts",
6336                ),
6337                code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
6338            ]
6339            .join(","),
6340        );
6341
6342        assert_eq!(
6343            search_names(&conn, "search_symbols_scoped")[0],
6344            "search_symbols_scoped"
6345        );
6346    }
6347
6348    fn sidecar_fixture() -> Connection {
6349        search_fixture(
6350            &[
6351                code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
6352                code_row(
6353                    "c2",
6354                    "src/sidecar.rs",
6355                    "rust",
6356                    "parse_sidecar_file",
6357                    "parse the sha256 sidecar file",
6358                ),
6359            ]
6360            .join(","),
6361        )
6362    }
6363
6364    fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
6365        candidates
6366            .iter()
6367            .find(|c| c.result.symbol.name == name)
6368            .unwrap_or_else(|| panic!("{name} is not a candidate"))
6369    }
6370
6371    #[test]
6372    fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
6373        let conn = sidecar_fixture();
6374
6375        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
6376
6377        let target = candidate(&candidates, "parseSha256Sidecar");
6378        assert!(target.name_match);
6379        assert!(!target.word_match);
6380        assert!(!target.exact_name);
6381        assert!(target.name_terms.contains(&"sha256".to_string()));
6382    }
6383
6384    #[test]
6385    fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
6386        let conn = search_fixture(
6387            &[
6388                code_row(
6389                    "c1",
6390                    "examples/demo.rs",
6391                    "rust",
6392                    "demo",
6393                    "restore offline state",
6394                ),
6395                code_row(
6396                    "c2",
6397                    "src/replay.rs",
6398                    "rust",
6399                    "replay",
6400                    "restore offline records",
6401                ),
6402            ]
6403            .join(","),
6404        );
6405
6406        let candidates =
6407            collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
6408                .unwrap();
6409
6410        assert!(candidate(&candidates, "demo").word_match);
6411        assert!(candidate(&candidates, "replay").word_match);
6412        assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
6413    }
6414
6415    #[test]
6416    fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
6417        let mut rows: Vec<String> = (1..=170)
6418            .map(|i| {
6419                code_row(
6420                    &format!("h{i:03}"),
6421                    "src/sidecar.rs",
6422                    "rust",
6423                    &format!("sidecar_helper_{i:03}"),
6424                    "parse sidecar file",
6425                )
6426            })
6427            .collect();
6428        rows.push(code_row(
6429            "c1",
6430            "src/sidecar.rs",
6431            "rust",
6432            "parseSha256Sidecar",
6433            "",
6434        ));
6435        let conn = search_fixture(&rows.join(","));
6436
6437        let candidates = collect_search_candidates(
6438            &conn,
6439            "parse the sha256 sidecar file",
6440            None,
6441            None,
6442            false,
6443            40,
6444        )
6445        .unwrap();
6446
6447        assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
6448        assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
6449    }
6450
6451    #[test]
6452    fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
6453        let mut rows: Vec<String> = (1..=20)
6454            .map(|i| {
6455                code_row(
6456                    &format!("a{i:02}"),
6457                    "src/a.rs",
6458                    "rust",
6459                    &format!("both_{i:02}"),
6460                    "restore offline",
6461                )
6462            })
6463            .collect();
6464        rows.extend((1..=50).map(|i| {
6465            code_row(
6466                &format!("p{i:02}"),
6467                "src/p.rs",
6468                "rust",
6469                &format!("partial_{i:02}"),
6470                "restore records",
6471            )
6472        }));
6473        let conn = search_fixture(&rows.join(","));
6474
6475        let candidates =
6476            collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
6477
6478        let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
6479        assert_eq!(word_rows.len(), 40);
6480        assert_eq!(
6481            word_rows
6482                .iter()
6483                .filter(|c| c.result.symbol.name.starts_with("both_"))
6484                .count(),
6485            20
6486        );
6487    }
6488
6489    #[test]
6490    fn exact_name_is_admitted_regardless_of_case() {
6491        let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
6492
6493        let candidates =
6494            collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
6495        assert!(candidate(&candidates, "xyzzy_q").exact_name);
6496
6497        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
6498        let candidates =
6499            collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
6500        assert!(candidate(&candidates, "xyzzy_q").exact_name);
6501    }
6502
6503    #[test]
6504    fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
6505        let conn = search_fixture(&code_row(
6506            "c1",
6507            "src/say.js",
6508            "javascript",
6509            "say \"hi\"",
6510            "",
6511        ));
6512
6513        let candidates =
6514            collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
6515
6516        let target = candidate(&candidates, "say \"hi\"");
6517        assert!(target.exact_name && target.name_match);
6518    }
6519
6520    #[test]
6521    fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
6522        let conn = search_fixture(
6523            &[
6524                code_row("c1", "src/a.rs", "rust", "sidecar", ""),
6525                code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
6526            ]
6527            .join(","),
6528        );
6529
6530        let candidates =
6531            collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
6532
6533        assert_eq!(candidates.len(), 2);
6534        let target = candidate(&candidates, "sidecar");
6535        assert!(target.exact_name && target.word_match && target.name_match);
6536        assert!(target.bm25.is_some());
6537        let helper = candidate(&candidates, "sidecar_helper");
6538        assert!(!helper.exact_name && helper.word_match && helper.name_match);
6539    }
6540
6541    #[test]
6542    fn an_index_without_the_trigram_table_returns_word_rows_only() {
6543        let conn = sidecar_fixture();
6544        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
6545
6546        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
6547
6548        let names: Vec<&str> = candidates
6549            .iter()
6550            .map(|c| c.result.symbol.name.as_str())
6551            .collect();
6552        assert_eq!(names, vec!["parse_sidecar_file"]);
6553        assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
6554        assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
6555    }
6556
6557    #[test]
6558    fn words_under_three_characters_skip_the_name_branch() {
6559        let conn = search_fixture(
6560            &[
6561                code_row("c1", "src/a.rs", "rust", "ab", ""),
6562                code_row("c2", "src/b.rs", "rust", "cab", ""),
6563            ]
6564            .join(","),
6565        );
6566
6567        let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
6568
6569        assert!(candidates.iter().all(|c| !c.name_match));
6570        assert!(candidate(&candidates, "ab").exact_name);
6571    }
6572
6573    #[test]
6574    fn trigram_terms_include_the_identifier_parts_of_each_word() {
6575        assert_eq!(
6576            trigram_name_terms("collapse_name"),
6577            vec!["collapse_name", "collapse", "name"]
6578        );
6579        assert_eq!(
6580            trigram_name_terms("parse the sha256 sidecar"),
6581            vec!["parse", "sha256", "sha", "256", "sidecar"]
6582        );
6583        assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
6584        assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
6585        assert!(trigram_name_terms("ab").is_empty());
6586    }
6587
6588    #[test]
6589    fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
6590        let conn = search_fixture(
6591            &[
6592                code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
6593                code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
6594            ]
6595            .join(","),
6596        );
6597
6598        let candidates =
6599            collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
6600
6601        let target = candidate(&candidates, "CollapseName");
6602        assert!(target.name_match);
6603        assert_eq!(target.name_terms, vec!["collapse", "name"]);
6604        assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
6605    }
6606
6607    fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
6608        Candidate {
6609            result: SymbolSearchResult {
6610                symbol: Symbol {
6611                    symbol_id: format!("{path}:{name}"),
6612                    file_id: "f".into(),
6613                    path: path.into(),
6614                    language: "rust".into(),
6615                    name: name.into(),
6616                    kind: kind.into(),
6617                    signature: None,
6618                    doc_comment: None,
6619                    visibility: None,
6620                    parent_symbol_id: None,
6621                    start_line: 1,
6622                    start_column: 0,
6623                    end_line: 1,
6624                    end_column: 0,
6625                    start_byte: 0,
6626                    end_byte: 0,
6627                    body_start_line: None,
6628                    body_start_column: None,
6629                    body_end_line: None,
6630                    body_end_column: None,
6631                    body_start_byte: None,
6632                    body_end_byte: None,
6633                    body_hash: None,
6634                    semantic_group: None,
6635                    is_test: false,
6636                    test_container: false,
6637                },
6638                score: 0.0,
6639                snippet: None,
6640                explain: None,
6641            },
6642            bm25: None,
6643            exact_name: false,
6644            word_match: false,
6645            name_match: false,
6646            owner_match: false,
6647            name_terms: Vec::new(),
6648            documentation: false,
6649            nested: false,
6650            owner: None,
6651        }
6652    }
6653
6654    fn function(name: &str) -> Candidate {
6655        plain_candidate(name, "function", "src/lib.rs")
6656    }
6657
6658    fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
6659        rerank_with(candidates, query, false, None)
6660    }
6661
6662    fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
6663        ranked(candidates, query)
6664            .into_iter()
6665            .map(|(r, _)| r.symbol.name)
6666            .collect()
6667    }
6668
6669    fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
6670        let mut candidate = function(name);
6671        candidate.result.symbol.signature = signature.map(str::to_string);
6672        candidate.result.symbol.doc_comment = Some(doc.into());
6673        candidate
6674    }
6675
6676    fn strip_ansi_case() -> Vec<Candidate> {
6677        vec![
6678            documented("strip_ansi", None, "Remove ANSI escape sequences"),
6679            documented(
6680                "_strip_code_fences",
6681                Some("def _strip_code_fences(text: str)"),
6682                "The first fenced code block's body, or the stripped text",
6683            ),
6684        ]
6685    }
6686
6687    #[test]
6688    fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
6689        assert_eq!(
6690            rerank_words("parse the sha256 sidecar file"),
6691            vec!["parse", "sha", "256", "sidecar", "file"]
6692        );
6693        assert_eq!(
6694            rerank_words("parse_sha256_sidecar"),
6695            vec!["parse", "sha", "256", "sidecar"]
6696        );
6697        assert_eq!(
6698            rerank_words("ParseHTTPResponse"),
6699            vec!["parse", "http", "response"]
6700        );
6701        assert_eq!(rerank_words("is_ok"), vec!["ok"]);
6702        assert_eq!(rerank_words("the before"), vec!["the", "before"]);
6703    }
6704
6705    #[test]
6706    fn stop_words_cover_english_function_words_but_not_identifier_directions() {
6707        for word in ["was", "whether", "another"] {
6708            assert!(is_stop_word(word), "{word} must be a stop word");
6709        }
6710        for word in ["down", "into", "run"] {
6711            assert!(!is_stop_word(word), "{word} must stay a content word");
6712        }
6713        assert_eq!(rerank_words("what was the file"), vec!["file"]);
6714    }
6715
6716    #[test]
6717    fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
6718        let mut private = function("_create_skill");
6719        private.bm25 = Some(-9.0);
6720        let mut public = function("create_skill");
6721        public.bm25 = Some(-1.0);
6722
6723        let rows = ranked(vec![private, public], "create skill");
6724
6725        assert_eq!(rows[0].0.score, rows[1].0.score);
6726        assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
6727        assert_eq!(
6728            rows.iter()
6729                .map(|(r, _)| r.symbol.name.as_str())
6730                .collect::<Vec<_>>(),
6731            vec!["create_skill", "_create_skill"]
6732        );
6733    }
6734
6735    #[test]
6736    fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
6737        let rows = ranked(
6738            vec![
6739                plain_candidate("Glob", "constant", "src/glob.rs"),
6740                function("matches_glob_pattern"),
6741            ],
6742            "glob",
6743        );
6744
6745        assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
6746        assert_eq!(rows[1].1.name_tier, "whole");
6747        assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
6748    }
6749
6750    #[test]
6751    fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
6752        let rows = ranked(
6753            vec![
6754                function("validate_everything"),
6755                function("validate_syntax_now"),
6756                function("validate_syntax"),
6757                function("unrelated"),
6758            ],
6759            "validate syntax",
6760        );
6761        let tiers: Vec<(&str, &str, f64)> = rows
6762            .iter()
6763            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
6764            .collect();
6765
6766        assert_eq!(
6767            tiers,
6768            vec![
6769                ("validate_syntax", "whole", W_NAME_WHOLE),
6770                ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
6771                ("validate_everything", "partial", 0.0),
6772                ("unrelated", "none", 0.0),
6773            ]
6774        );
6775        assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
6776        assert_eq!(
6777            rows[1].0.score,
6778            W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
6779        );
6780        assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
6781        assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
6782    }
6783
6784    #[test]
6785    fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
6786        assert_eq!(
6787            ranked_names(strip_ansi_case(), "strip ansi escape codes"),
6788            vec!["strip_ansi", "_strip_code_fences"]
6789        );
6790    }
6791
6792    #[test]
6793    fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
6794        let candidates = vec![
6795            function("execute_julie_extract"),
6796            documented("slice_bytes", None, "cut a byte range"),
6797        ];
6798
6799        let rows = ranked(candidates, "cut");
6800        let bonuses: Vec<(&str, &str, f64)> = rows
6801            .iter()
6802            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
6803            .collect();
6804
6805        assert_eq!(
6806            bonuses,
6807            vec![
6808                ("execute_julie_extract", "partial", 0.0),
6809                ("slice_bytes", "none", 0.0),
6810            ]
6811        );
6812        assert_eq!(rows[0].0.score, rows[1].0.score);
6813    }
6814
6815    #[test]
6816    fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
6817        let candidates = vec![
6818            function("validate_everything"),
6819            function("validate_syntax_now"),
6820            function("validate_syntax"),
6821            function("unrelated"),
6822        ];
6823
6824        assert_eq!(
6825            ranked_names(candidates, "validate syntax"),
6826            vec![
6827                "validate_syntax",
6828                "validate_syntax_now",
6829                "validate_everything",
6830                "unrelated"
6831            ]
6832        );
6833    }
6834
6835    #[test]
6836    fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
6837        let mut rows: Vec<String> = (0..10)
6838            .map(|i| {
6839                code_row(
6840                    &format!("s{i}"),
6841                    &format!("src/f{i}.rs"),
6842                    "rust",
6843                    &format!("search_{i}"),
6844                    "searches the index",
6845                )
6846            })
6847            .collect();
6848        rows.push(code_row(
6849            "rare",
6850            "src/rare.rs",
6851            "rust",
6852            "sanitize_input",
6853            "sanitize the input",
6854        ));
6855        let conn = search_fixture(&rows.join(", "));
6856        let terms = vec!["sanitize".to_string(), "search".to_string()];
6857
6858        let weights = idf_weights(&conn, &terms);
6859        assert!(weights[0] > weights[1]);
6860    }
6861
6862    #[test]
6863    fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
6864        let conn = search_fixture(&code_row(
6865            "n",
6866            "src/news.rs",
6867            "rust",
6868            "fetch_news",
6869            "fetch the news feed",
6870        ));
6871        let terms = vec!["news".to_string(), "unseen".to_string()];
6872
6873        let weights = idf_weights(&conn, &terms);
6874
6875        assert!(weights[0] < weights[1]);
6876    }
6877
6878    #[test]
6879    fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
6880        let crediting_field = |padding: usize| {
6881            let mut row = function("handler");
6882            row.result.symbol.signature = Some(format!(
6883                "fn handler({}sidecar: u8)",
6884                "a: u8, ".repeat(padding)
6885            ));
6886            ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
6887        };
6888
6889        assert_eq!(crediting_field(4), "signature");
6890        assert_eq!(crediting_field(80), "none");
6891    }
6892
6893    #[test]
6894    fn explain_terms_name_the_crediting_field_of_every_query_term() {
6895        let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
6896        let terms: Vec<(&str, &str, f64)> = rows[0]
6897            .1
6898            .terms
6899            .iter()
6900            .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
6901            .collect();
6902
6903        assert_eq!(rows[0].0.symbol.name, "strip_ansi");
6904        assert_eq!(
6905            terms,
6906            vec![
6907                ("strip", "name", 3.0),
6908                ("ansi", "name", 3.0),
6909                ("escape", "doc", TEXT_CREDIT),
6910                ("codes", "none", 0.0),
6911            ]
6912        );
6913    }
6914
6915    #[test]
6916    fn name_coverage_accepts_token_runs_substrings_and_stems() {
6917        let strengths = |name: &str, query: &str| {
6918            let stemmer = Stemmer::create(Algorithm::English);
6919            let words: Vec<QueryWord> = rerank_words(query)
6920                .into_iter()
6921                .map(|word| QueryWord {
6922                    stem: stemmer.stem(&word).into_owned(),
6923                    word,
6924                })
6925                .collect();
6926            name_hits(name, &words, &stemmer)
6927        };
6928
6929        assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
6930        assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
6931        assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
6932        assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
6933        assert_eq!(strengths("is_ok", "ok"), vec![3]);
6934        assert_eq!(strengths("isReady", "is"), vec![3]);
6935        assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
6936        assert_eq!(
6937            strengths("parseSha256Sidecar", "sidecar checksum"),
6938            vec![3, 0]
6939        );
6940        assert_eq!(
6941            strengths("parseSha256Sidecar", "checksum digest"),
6942            vec![0, 0]
6943        );
6944    }
6945
6946    #[test]
6947    fn a_rarer_term_moves_the_score_more_than_a_common_one() {
6948        let weights = [4.0, 1.0];
6949        let rows = rerank_with(
6950            vec![function("rare_helper"), function("common_helper")],
6951            "rare common",
6952            false,
6953            Some(&weights),
6954        );
6955
6956        assert_eq!(
6957            rows[0].1.word_weights,
6958            vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
6959        );
6960        assert_eq!(rows[0].0.symbol.name, "rare_helper");
6961        assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
6962        assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
6963    }
6964
6965    #[test]
6966    fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
6967        let rows = ranked(
6968            vec![
6969                function("render_mode"),
6970                plain_candidate("retry_count", "constant", "src/scan.rs"),
6971            ],
6972            "how many times a failed download is tried again retry limit",
6973        );
6974
6975        assert_eq!(rows[0].0.symbol.name, "retry_count");
6976        assert_eq!(rows[0].1.name_tier, "partial");
6977        assert!(rows[0].0.score > rows[1].0.score);
6978        assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
6979    }
6980
6981    #[test]
6982    fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
6983        let mut row = function("load");
6984        row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
6985        row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
6986        let (result, explain) = ranked(vec![row], "config settings").remove(0);
6987
6988        assert_eq!(
6989            explain.terms,
6990            vec![
6991                ("config".into(), "signature".into(), TEXT_CREDIT),
6992                ("settings".into(), "none".into(), 0.0),
6993            ]
6994        );
6995        assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
6996    }
6997
6998    #[test]
6999    fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
7000        let crediting_field =
7001            |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
7002        let doc_field = |doc: &str, query: &str| {
7003            let mut row = function("row");
7004            row.result.symbol.doc_comment = Some(doc.into());
7005            crediting_field(row, query)
7006        };
7007        let sig_field = |signature: &str, query: &str| {
7008            let mut row = function("row");
7009            row.result.symbol.signature = Some(signature.into());
7010            crediting_field(row, query)
7011        };
7012
7013        assert_eq!(doc_field("The system runs.", "stemming"), "none");
7014        assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
7015        assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
7016        assert_eq!(doc_field("An important port.", "porter"), "none");
7017        assert_eq!(
7018            sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
7019            "signature"
7020        );
7021        assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
7022        assert_eq!(sig_field("fn okay()", "ok"), "none");
7023        assert_eq!(
7024            sig_field("fn parseSha256Sidecar(text)", "sidecar"),
7025            "signature"
7026        );
7027    }
7028
7029    #[test]
7030    fn text_tokens_split_like_query_words_then_identifiers() {
7031        fn two_pass(text: &str) -> Vec<&str> {
7032            query_words(text)
7033                .into_iter()
7034                .flat_map(split_identifier)
7035                .collect()
7036        }
7037        fn one_pass(text: &str) -> Vec<&str> {
7038            let mut out = Vec::new();
7039            text_tokens_into(text, &mut out);
7040            out
7041        }
7042        let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
7043        let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
7044
7045        assert_eq!(one_pass(ascii), two_pass(ascii));
7046        assert_eq!(
7047            one_pass(ascii),
7048            vec![
7049                "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
7050                "sha", "256", "sum",
7051            ]
7052        );
7053        assert_eq!(one_pass(unicode), two_pass(unicode));
7054        assert!(one_pass("").is_empty());
7055        assert!(one_pass("_ __ ...").is_empty());
7056    }
7057
7058    #[test]
7059    fn a_doc_credits_a_term_by_its_stem() {
7060        let mut row = function("check");
7061        row.result.symbol.doc_comment = Some("Validates the input.".into());
7062        let explain = ranked(vec![row], "validation").remove(0).1;
7063
7064        assert_eq!(
7065            explain.terms,
7066            vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
7067        );
7068    }
7069
7070    #[test]
7071    fn kind_prior_orders_definitions_over_members_over_imports() {
7072        let rows = ranked(
7073            vec![
7074                plain_candidate("Scan", "import", "src/a.rs"),
7075                plain_candidate("Scan", "enum_member", "src/b.rs"),
7076                plain_candidate("Scan", "function", "src/c.rs"),
7077            ],
7078            "scan",
7079        );
7080        let order: Vec<(&str, f64)> = rows
7081            .iter()
7082            .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
7083            .collect();
7084
7085        assert_eq!(
7086            order,
7087            vec![
7088                ("src/c.rs", W_KIND_DEFINITION),
7089                ("src/b.rs", W_KIND_MEMBER),
7090                ("src/a.rs", W_KIND_IMPORT),
7091            ]
7092        );
7093    }
7094
7095    #[test]
7096    fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
7097        let names = ranked_names(
7098            vec![
7099                function("RenderMode"),
7100                plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
7101            ],
7102            "retry download limit timeout",
7103        );
7104
7105        assert_eq!(names[0], "MaxRetryCount");
7106    }
7107
7108    #[test]
7109    fn path_role_demotes_role_directories_unless_the_query_names_them() {
7110        let rows = |query: &str| {
7111            ranked(
7112                vec![
7113                    plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
7114                    plain_candidate("verify_checksum", "function", "src/archive.rs"),
7115                ],
7116                query,
7117            )
7118        };
7119
7120        let plain = rows("verify checksum");
7121        assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
7122        assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
7123
7124        let named = rows("launcher script verify checksum");
7125        assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
7126
7127        let only_launcher = rows("launcher verify checksum");
7128        assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
7129        assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
7130
7131        let windows = ranked(
7132            vec![plain_candidate(
7133                "verifyChecksum",
7134                "function",
7135                "scripts\\launcher.ts",
7136            )],
7137            "verify checksum",
7138        );
7139        assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
7140    }
7141
7142    #[test]
7143    fn documentation_rows_sort_after_every_code_row() {
7144        let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
7145        heading.documentation = true;
7146        heading.result.symbol.language = "markdown".into();
7147        heading.result.symbol.signature = Some("Verify checksum".into());
7148        heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
7149        let rows = ranked(
7150            vec![
7151                heading,
7152                plain_candidate("unrelated", "variable", "src/a.rs"),
7153            ],
7154            "verify checksum",
7155        );
7156
7157        assert_eq!(rows[0].0.symbol.name, "unrelated");
7158        assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
7159        assert_eq!(rows[1].1.name_tier, "whole");
7160        assert!(rows[1].0.score < 0.0);
7161    }
7162
7163    #[test]
7164    fn a_helper_nested_in_a_function_ranks_below_an_equal_module_function() {
7165        let mut helper = plain_candidate("run_test", "function", "tests/test_basic.py");
7166        helper.nested = true;
7167        let module_function = plain_candidate("run_dotenv", "function", "tests/test_basic.py");
7168
7169        let rows = rerank_with(vec![helper, module_function], "run", true, None);
7170
7171        assert_eq!(rows[0].0.symbol.name, "run_dotenv");
7172        assert_eq!(rows[1].1.nested, W_NESTED);
7173    }
7174
7175    #[test]
7176    fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
7177        let rows = |query: &str, include_tests: bool| {
7178            let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
7179            test_row.result.symbol.is_test = true;
7180            let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
7181            rerank_with(vec![plain_row, test_row], query, include_tests, None)
7182        };
7183
7184        let boosted = rows("payment flow tests", true);
7185        assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
7186        assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
7187        assert_eq!(boosted[1].1.test_intent, 0.0);
7188
7189        assert!(
7190            rows("payment flow tests", false)
7191                .iter()
7192                .all(|(_, e)| e.test_intent == 0.0)
7193        );
7194        assert!(
7195            rows("payment flow", true)
7196                .iter()
7197                .all(|(_, e)| e.test_intent == 0.0)
7198        );
7199    }
7200
7201    #[test]
7202    fn ties_break_by_bm25_then_name_length_then_path() {
7203        let mut word_row = plain_candidate("payment", "function", "src/z.rs");
7204        word_row.word_match = true;
7205        word_row.bm25 = Some(-4.0);
7206        let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
7207        weaker_word_row.word_match = true;
7208        weaker_word_row.bm25 = Some(-2.0);
7209        let mut name_only = plain_candidate("payment", "function", "src/b.rs");
7210        name_only.name_match = true;
7211        let rows = ranked(
7212            vec![
7213                plain_candidate("payment", "function", "src/y.rs"),
7214                name_only,
7215                weaker_word_row,
7216                word_row,
7217            ],
7218            "payment",
7219        );
7220        let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
7221
7222        assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
7223
7224        let by_length = ranked_names(
7225            vec![
7226                function("payment_gateway_client"),
7227                function("payment_gateway"),
7228            ],
7229            "gateway",
7230        );
7231        assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
7232    }
7233
7234    #[test]
7235    fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
7236        let mut token = function("csr");
7237        token.bm25 = Some(-1.0);
7238        let mut substring = function("action_csrf_token");
7239        substring.bm25 = Some(-5.0);
7240
7241        let rows = ranked(vec![substring, token], "csr adjacency");
7242
7243        assert!(rows[0].0.score > rows[1].0.score);
7244        assert_eq!(rows[0].0.symbol.name, "csr");
7245    }
7246
7247    #[test]
7248    fn an_acronym_token_outranks_a_name_that_only_contains_it() {
7249        let mut token = function("http_client");
7250        token.bm25 = Some(-1.0);
7251        let mut substring = function("shttpd_config");
7252        substring.bm25 = Some(-5.0);
7253
7254        let rows = ranked(vec![substring, token], "http");
7255
7256        assert!(rows[0].0.score > rows[1].0.score);
7257        assert_eq!(rows[0].0.symbol.name, "http_client");
7258    }
7259
7260    #[test]
7261    fn explain_reports_the_sum_of_the_name_strengths() {
7262        let rows = ranked(vec![function("action_csrf_token")], "csr token");
7263
7264        assert_eq!(rows[0].1.name_strength, 4);
7265    }
7266
7267    #[test]
7268    fn snippets_follow_the_admitting_branch() {
7269        let mut word_row = function("parse_sidecar_file");
7270        word_row.word_match = true;
7271        word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
7272        let mut name_row = function("parseSha256Sidecar");
7273        name_row.name_match = true;
7274        name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
7275        let mut exact_row = function("sha256");
7276        exact_row.exact_name = true;
7277        let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
7278        let snippets: Vec<(&str, &str)> = rows
7279            .iter()
7280            .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
7281            .collect();
7282
7283        assert_eq!(
7284            snippets,
7285            vec![
7286                ("sha256", "sha256"),
7287                ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
7288                ("parse_sidecar_file", "parse the [sha256] sidecar file"),
7289            ]
7290        );
7291        assert_eq!(rows[1].1.branches, vec!["name"]);
7292        assert_eq!(rows[0].1.branches, vec!["exact"]);
7293    }
7294
7295    #[test]
7296    fn explain_is_attached_only_when_requested() {
7297        let conn = sidecar_fixture();
7298        let query = "sha256";
7299
7300        let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
7301        assert!(silent.iter().all(|r| r.explain.is_none()));
7302        assert!(silent[0].score > 0.0);
7303        assert_eq!(
7304            serde_json::to_value(&silent[0]).unwrap().get("explain"),
7305            None
7306        );
7307
7308        let explained =
7309            fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
7310        let by_name = |name: &str| {
7311            explained
7312                .iter()
7313                .find(|r| r.symbol.name == name)
7314                .and_then(|r| r.explain.as_ref())
7315                .unwrap()
7316        };
7317        let name_only = by_name("parseSha256Sidecar");
7318        assert_eq!(name_only.candidates, 2);
7319        assert_eq!(name_only.branches, vec!["name"]);
7320        assert_eq!(name_only.bm25, None);
7321        let word_row = by_name("parse_sidecar_file");
7322        assert!(word_row.bm25.unwrap() < 0.0);
7323        assert_eq!(word_row.candidates, 2);
7324        assert!(
7325            serde_json::to_value(&explained[0])
7326                .unwrap()
7327                .get("explain")
7328                .is_some()
7329        );
7330    }
7331
7332    #[test]
7333    fn search_symbols_treats_like_wildcards_as_literals() {
7334        let dir = crate::safe_tempdir();
7335        let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
7336        let conn = open_read_write(&db_path).unwrap();
7337        conn.execute_batch(
7338            "CREATE TABLE symbols (
7339                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
7340                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
7341                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7342                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7343                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7344                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7345                semantic_group TEXT, is_test INTEGER, test_container INTEGER
7346            );
7347            INSERT INTO symbols VALUES (
7348                's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
7349                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
7350            );
7351            INSERT INTO symbols VALUES (
7352                'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
7353                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
7354            );
7355            INSERT INTO symbols VALUES (
7356                'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
7357                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
7358            );
7359            CREATE TABLE files (
7360                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
7361                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
7362            );
7363            INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
7364            INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
7365            );",
7366        )
7367        .unwrap();
7368
7369        assert_eq!(
7370            search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
7371            "literal%name"
7372        );
7373        assert_eq!(
7374            search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
7375            "literal_name"
7376        );
7377        assert_eq!(
7378            load_scoped_files(&conn, Some("src/literal_path"))
7379                .unwrap()
7380                .len(),
7381            1
7382        );
7383    }
7384
7385    #[test]
7386    fn find_references_for_symbol_limits_callees_by_symbol_id() {
7387        let dir = crate::safe_tempdir();
7388        let db_path = dir.path().join("find_references_for_symbol.db");
7389        let conn = open_read_write(&db_path).unwrap();
7390        conn.execute_batch(
7391            "CREATE TABLE symbols (
7392                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
7393                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
7394                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7395                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7396                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7397                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7398                semantic_group TEXT, is_test INTEGER, test_container INTEGER
7399            );
7400            CREATE TABLE relationships (
7401                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
7402                start_line INTEGER, start_column INTEGER
7403            );
7404            CREATE TABLE pending_relationships (
7405                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
7406                start_line INTEGER, start_column INTEGER
7407            );
7408            INSERT INTO symbols VALUES
7409                ('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),
7410                ('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),
7411                ('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),
7412                ('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);
7413            INSERT INTO relationships VALUES
7414                ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
7415                ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
7416        )
7417        .unwrap();
7418
7419        let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
7420        assert_eq!(references.len(), 1);
7421        assert_eq!(references[0].to_symbol_name, "wanted_dep");
7422    }
7423
7424    #[test]
7425    fn test_fts_search_symbols_and_porter_stemming() {
7426        let dir = crate::safe_tempdir();
7427        let db_path = dir.path().join("fts_search_symbols.db");
7428        let conn = open_read_write(&db_path).unwrap();
7429
7430        conn.execute_batch(
7431            "CREATE TABLE symbols (
7432                symbol_id TEXT PRIMARY KEY,
7433                file_id TEXT,
7434                path TEXT,
7435                language TEXT,
7436                name TEXT,
7437                kind TEXT,
7438                signature TEXT,
7439                doc_comment TEXT,
7440                visibility TEXT,
7441                parent_symbol_id TEXT,
7442                start_line INTEGER,
7443                start_column INTEGER,
7444                end_line INTEGER,
7445                end_column INTEGER,
7446                start_byte INTEGER,
7447                end_byte INTEGER,
7448                body_start_line INTEGER,
7449                body_start_column INTEGER,
7450                body_end_line INTEGER,
7451                body_end_column INTEGER,
7452                body_start_byte INTEGER,
7453                body_end_byte INTEGER,
7454                body_hash TEXT,
7455                semantic_group TEXT,
7456                is_test INTEGER,
7457                test_container INTEGER
7458            );
7459            INSERT INTO symbols VALUES (
7460                's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
7461                'pub trait PaymentGateway', 'Core payment provider interface for transactions',
7462                'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
7463            );
7464            INSERT INTO symbols VALUES (
7465                's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
7466                'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
7467                'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
7468            );
7469            INSERT INTO symbols VALUES (
7470                's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
7471                'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
7472                'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
7473            );
7474            INSERT INTO symbols VALUES (
7475                's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
7476                'fn test_payment_flow()', 'Tests payment charge workflow',
7477                NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
7478            );",
7479        )
7480        .unwrap();
7481
7482        ensure_fts_index(&conn).unwrap();
7483
7484        // 1. Porter stemming match: 'parsing' matches 'parse_tokens' and 'Parses' docstring
7485        let results =
7486            fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
7487        assert_eq!(results.len(), 1);
7488        assert_eq!(results[0].symbol.name, "parse_tokens");
7489        assert!(results[0].snippet.is_some());
7490
7491        // 2. Docstring conceptual search: 'transactions' matches 'PaymentGateway'
7492        let results =
7493            fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
7494        assert_eq!(results.len(), 1);
7495        assert_eq!(results[0].symbol.name, "PaymentGateway");
7496
7497        // 3. Test filter: searching 'payment' with include_tests=false ignores 'test_payment_flow'
7498        let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
7499        assert_eq!(results.len(), 2);
7500        assert!(results.iter().all(|r| !r.symbol.is_test));
7501
7502        // 4. Test filter: searching 'payment' with include_tests=true includes 'test_payment_flow'
7503        let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
7504        assert_eq!(results.len(), 3);
7505
7506        // 5. Fallback OR matching: multi-term where only some match
7507        let results =
7508            fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
7509        assert_eq!(results.len(), 1);
7510        assert_eq!(results[0].symbol.name, "StripeClient");
7511    }
7512
7513    #[test]
7514    fn find_related_tests_returns_each_test_once_under_the_limit() {
7515        let dir = crate::safe_tempdir();
7516        let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
7517        conn.execute_batch(
7518            "CREATE TABLE symbols (
7519                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
7520                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
7521                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
7522                start_column INTEGER, end_line INTEGER, end_column INTEGER,
7523                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7524                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7525                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7526                semantic_group TEXT, is_test INTEGER, test_container INTEGER
7527            );
7528            CREATE TABLE relationships (
7529                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
7530                start_line INTEGER, start_column INTEGER
7531            );
7532            CREATE TABLE pending_relationships (
7533                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
7534                start_line INTEGER, start_column INTEGER,
7535                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
7536            );
7537            CREATE TABLE type_facts (
7538                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
7539            );
7540            INSERT INTO symbols VALUES
7541                ('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),
7542                ('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),
7543                ('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);
7544            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
7545                ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
7546                ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
7547                ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
7548                ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
7549                ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
7550                ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
7551        )
7552        .unwrap();
7553        let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
7554
7555        let tests = find_related_tests(&conn, &target, 5).unwrap();
7556
7557        let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
7558        names.sort();
7559        assert_eq!(names, vec!["first_case", "second_case"]);
7560    }
7561
7562    #[test]
7563    fn documentation_rows_rank_after_code_in_search() {
7564        let dir = crate::safe_tempdir();
7565        let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
7566        conn.execute_batch(
7567            "CREATE TABLE symbols (
7568                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
7569                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
7570                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7571                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7572                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7573                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7574                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
7575            );
7576            INSERT INTO symbols VALUES
7577                ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
7578                 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
7579                 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
7580                ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
7581                 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
7582                 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
7583        )
7584        .unwrap();
7585        ensure_fts_index(&conn).unwrap();
7586
7587        let results =
7588            fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
7589                .unwrap();
7590
7591        assert_eq!(results.len(), 2);
7592        assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
7593        assert_eq!(results[1].symbol.name, "Reconcile offline edits");
7594    }
7595
7596    #[test]
7597    fn test_queries_nocase_and_path_normalization() {
7598        let conn = Connection::open_in_memory().unwrap();
7599        conn.execute_batch(
7600            "CREATE TABLE files (
7601                file_id TEXT PRIMARY KEY,
7602                path TEXT NOT NULL,
7603                language TEXT,
7604                content_hash TEXT,
7605                content_bytes INTEGER,
7606                line_count INTEGER,
7607                indexed_at INTEGER
7608            );
7609            CREATE TABLE symbols (
7610                symbol_id TEXT PRIMARY KEY,
7611                file_id TEXT,
7612                path TEXT NOT NULL,
7613                language TEXT,
7614                name TEXT,
7615                kind TEXT,
7616                signature TEXT,
7617                doc_comment TEXT,
7618                visibility TEXT,
7619                parent_symbol_id TEXT,
7620                start_line INTEGER,
7621                start_column INTEGER,
7622                end_line INTEGER,
7623                end_column INTEGER,
7624                start_byte INTEGER,
7625                end_byte INTEGER,
7626                body_start_line INTEGER,
7627                body_start_column INTEGER,
7628                body_end_line INTEGER,
7629                body_end_column INTEGER,
7630                body_start_byte INTEGER,
7631                body_end_byte INTEGER,
7632                body_hash TEXT,
7633                semantic_group TEXT,
7634                is_test INTEGER,
7635                test_container INTEGER
7636            );
7637            -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
7638            INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
7639            INSERT INTO symbols VALUES (
7640                's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
7641                'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
7642                2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
7643            );",
7644        )
7645        .unwrap();
7646
7647        // 1. get_file: query with uppercase, lowercase, and forward slashes
7648        let file = get_file(&conn, "SRC/PAYMENT.RS")
7649            .unwrap()
7650            .expect("File should be found");
7651        assert_eq!(
7652            file.path, "src/Payment.rs",
7653            "Path should be normalized to forward slashes"
7654        );
7655
7656        let file2 = get_file(&conn, "src/payment.rs")
7657            .unwrap()
7658            .expect("File should be found");
7659        assert_eq!(file2.path, "src/Payment.rs");
7660
7661        // 2. load_file_symbols: query with uppercase and forward slashes
7662        let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
7663        assert_eq!(syms.len(), 1);
7664        assert_eq!(
7665            syms[0].path, "src/Payment.rs",
7666            "Symbol path should be normalized to forward slashes"
7667        );
7668
7669        // 3. get_symbol_by_name with path filter
7670        let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
7671            .unwrap()
7672            .expect("Symbol should be found with case-insensitive path filter");
7673        assert_eq!(sym.path, "src/Payment.rs");
7674    }
7675
7676    #[test]
7677    fn test_exact_case_prioritized_over_nocase() {
7678        let conn = Connection::open_in_memory().unwrap();
7679        conn.execute_batch(
7680            "CREATE TABLE files (
7681                file_id TEXT PRIMARY KEY,
7682                path TEXT NOT NULL,
7683                language TEXT,
7684                content_hash TEXT,
7685                content_bytes INTEGER,
7686                line_count INTEGER,
7687                indexed_at TEXT
7688            );
7689            CREATE TABLE symbols (
7690                symbol_id TEXT PRIMARY KEY,
7691                file_id TEXT,
7692                path TEXT NOT NULL,
7693                language TEXT,
7694                name TEXT NOT NULL,
7695                kind TEXT NOT NULL,
7696                signature TEXT,
7697                doc_comment TEXT,
7698                visibility TEXT,
7699                parent_symbol_id TEXT,
7700                start_line INTEGER,
7701                start_column INTEGER,
7702                end_line INTEGER,
7703                end_column INTEGER,
7704                start_byte INTEGER,
7705                end_byte INTEGER,
7706                body_start_line INTEGER,
7707                body_start_column INTEGER,
7708                body_end_line INTEGER,
7709                body_end_column INTEGER,
7710                body_start_byte INTEGER,
7711                body_end_byte INTEGER,
7712                body_hash TEXT,
7713                semantic_group TEXT,
7714                is_test INTEGER,
7715                test_container INTEGER
7716            );
7717            INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
7718            INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
7719            INSERT INTO symbols VALUES (
7720                's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
7721                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
7722                2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
7723            );
7724            INSERT INTO symbols VALUES (
7725                's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
7726                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
7727                2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
7728            );",
7729        )
7730        .unwrap();
7731
7732        // Exact match should return exact file, not conflate with sibling differing only by case
7733        let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
7734        assert_eq!(f_lower.path, "src/payment.rs");
7735        assert_eq!(f_lower.file_id, "f2");
7736
7737        let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
7738        assert_eq!(f_upper.path, "src/Payment.rs");
7739        assert_eq!(f_upper.file_id, "f1");
7740
7741        let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
7742        assert_eq!(syms_lower.len(), 1);
7743        assert_eq!(syms_lower[0].file_id, "f2");
7744
7745        let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
7746        assert_eq!(syms_upper.len(), 1);
7747        assert_eq!(syms_upper[0].file_id, "f1");
7748    }
7749
7750    #[test]
7751    fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
7752        let dir = crate::safe_tempdir();
7753        let db_path = dir.path().join("conservative_resolution.db");
7754        let conn = open_read_write(&db_path).unwrap();
7755
7756        conn.execute_batch(
7757            "CREATE TABLE symbols (
7758                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
7759                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
7760                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
7761                start_column INTEGER, end_line INTEGER, end_column INTEGER,
7762                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7763                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7764                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7765                semantic_group TEXT, is_test INTEGER, test_container INTEGER
7766            );
7767            CREATE TABLE relationships (
7768                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
7769                start_line INTEGER, start_column INTEGER
7770            );
7771            CREATE TABLE pending_relationships (
7772                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
7773                start_line INTEGER, start_column INTEGER,
7774                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
7775            );
7776            CREATE TABLE type_facts (
7777                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
7778            );
7779            -- Workspace struct Workspace and method Workspace::new
7780            INSERT INTO symbols VALUES
7781                ('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),
7782                ('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),
7783                ('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);
7784
7785            -- my_func calls Vec::new() (external namespace 'Vec')
7786            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
7787                ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
7788        )
7789        .unwrap();
7790
7791        // When include_external is false, calling Vec::new() should NOT resolve to Workspace::new()
7792        let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
7793        assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
7794
7795        let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
7796        assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
7797
7798        // Caller references for Workspace::new should NOT list my_func
7799        let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
7800        assert!(
7801            callers.is_empty(),
7802            "Expected 0 callers for Workspace::new, got: {:?}",
7803            callers
7804        );
7805
7806        // Blast radius for Workspace::new should NOT impact my_func (which only called Vec::new)
7807        let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
7808        assert!(
7809            !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
7810            "my_func should not be impacted before calling Workspace::new: {:?}",
7811            blast.impacted_symbols
7812        );
7813
7814        // Now add a call to Workspace::new()
7815        conn.execute(
7816            "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')",
7817            [],
7818        )
7819        .unwrap();
7820
7821        let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
7822        assert_eq!(
7823            sigs2.len(),
7824            1,
7825            "Expected 1 signature for Workspace::new, got: {:?}",
7826            sigs2
7827        );
7828        assert!(sigs2[0].contains("pub fn new() -> Workspace"));
7829
7830        // Blast radius for Workspace::new should now include my_func
7831        let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
7832        assert!(
7833            blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
7834            "my_func should be impacted after calling Workspace::new: {:?}",
7835            blast2.impacted_symbols
7836        );
7837
7838        // Add a bare call to new() from an unrelated caller s_other
7839        conn.execute(
7840            "INSERT INTO symbols VALUES
7841                ('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);",
7842            [],
7843        )
7844        .unwrap();
7845        conn.execute(
7846            "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')",
7847            [],
7848        )
7849        .unwrap();
7850
7851        // Bare call from unrelated function should NOT resolve to Workspace::new
7852        let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
7853        assert!(
7854            sigs_other.is_empty(),
7855            "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
7856            sigs_other
7857        );
7858
7859        // A sibling method inside Workspace calling bare new() SHOULD resolve to Workspace::new
7860        conn.execute(
7861            "INSERT INTO symbols VALUES
7862                ('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);",
7863            [],
7864        )
7865        .unwrap();
7866        conn.execute(
7867            "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')",
7868            [],
7869        )
7870        .unwrap();
7871
7872        let sigs_sibling =
7873            find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
7874        assert_eq!(
7875            sigs_sibling.len(),
7876            1,
7877            "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
7878            sigs_sibling
7879        );
7880
7881        // With include_external: true, external calls should be returned
7882        let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
7883        assert!(
7884            ext_sigs.iter().any(|s| s.contains("Vec")),
7885            "include_external: true should include external Vec::new: {:?}",
7886            ext_sigs
7887        );
7888    }
7889
7890    #[test]
7891    fn test_find_structural_facts_and_literals_scoped() {
7892        let dir = crate::safe_tempdir();
7893        let db_path = dir.path().join("facts_test.db");
7894        let conn = open_read_write(&db_path).unwrap();
7895        conn.execute_batch(
7896            "CREATE TABLE symbols (
7897                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
7898                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
7899                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7900                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7901                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7902                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7903                semantic_group TEXT, is_test INTEGER, test_container INTEGER
7904            );
7905            CREATE TABLE structural_facts (
7906                structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
7907                pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
7908                start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
7909            );
7910            CREATE TABLE literals (
7911                literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
7912                kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
7913                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7914                start_byte INTEGER, end_byte INTEGER
7915            );
7916            INSERT INTO structural_facts VALUES
7917                ('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\"}'),
7918                ('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\"}'),
7919                ('sf_route', 'f2', 'src/routes/api.rs', 'rust', 'axum.route.v1', 'get_users', 'function', NULL, 10, 20, 1.0, '{\"verb\":\"GET\",\"route_template\":\"/api/v1/users/{id}\",\"normalized_route_template\":\"/api/v1/users/:id\"}'),
7920                ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
7921                ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
7922                ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
7923                ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
7924            INSERT INTO literals VALUES
7925                ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
7926                ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
7927                ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
7928                ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
7929        )
7930        .unwrap();
7931
7932        // 1. "config" alias
7933        let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
7934        assert_eq!(facts_config.len(), 2);
7935        assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
7936        assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
7937        assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
7938        assert_eq!(
7939            facts_config[1].key.as_deref(),
7940            Some("mcp_servers.code-kb.command")
7941        );
7942        let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
7943        assert_eq!(lits_config.len(), 1);
7944        assert_eq!(lits_config[0].kind, "toml_key");
7945
7946        // 2. "route" and "routes" aliases
7947        let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
7948        assert_eq!(facts_route.len(), 1);
7949        assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
7950        assert_eq!(
7951            facts_route[0].key.as_deref(),
7952            Some("GET /api/v1/users/{id}")
7953        );
7954        let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
7955        assert_eq!(facts_routes.len(), 1);
7956        let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
7957        assert_eq!(lits_route.len(), 1);
7958        assert_eq!(lits_route[0].kind, "http_route");
7959
7960        // 3. "query", "queries", "sql" aliases
7961        for q in &["query", "queries", "sql"] {
7962            let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
7963            assert_eq!(facts.len(), 2, "Failed for {}", q);
7964            assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
7965            let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
7966            assert_eq!(lits.len(), 1, "Failed for {}", q);
7967            assert_eq!(lits[0].kind, "sql_query");
7968        }
7969
7970        // 4. "model" and "models" aliases
7971        for m in &["model", "models"] {
7972            let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
7973            assert_eq!(facts.len(), 1, "Failed for {}", m);
7974            assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
7975            let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
7976            assert_eq!(lits.len(), 1, "Failed for {}", m);
7977            assert_eq!(lits[0].kind, "model_table");
7978        }
7979
7980        // 5. Custom / unknown category
7981        let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
7982        assert_eq!(facts_custom.len(), 1);
7983        assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
7984        assert_eq!(facts_custom[0].key, None);
7985
7986        // 6. Path filter: exact file match
7987        let facts_exact =
7988            find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
7989        assert_eq!(facts_exact.len(), 1);
7990        let facts_miss =
7991            find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
7992        assert_eq!(facts_miss.len(), 0);
7993
7994        // 7. Path filter: directory prefix
7995        let facts_dir =
7996            find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
7997        assert_eq!(facts_dir.len(), 1);
7998        let facts_dir_miss =
7999            find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
8000        assert_eq!(facts_dir_miss.len(), 0);
8001
8002        // 8. Delegating find_structural_facts and find_literals
8003        let f_del = find_structural_facts(&conn, "config", 10).unwrap();
8004        assert_eq!(f_del.len(), 2);
8005        let l_del = find_literals(&conn, "config", 10).unwrap();
8006        assert_eq!(l_del.len(), 1);
8007    }
8008
8009    fn local_variable_fixture() -> Connection {
8010        let conn = Connection::open_in_memory().unwrap();
8011        conn.execute_batch(
8012            "CREATE TABLE symbols (
8013                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
8014                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
8015                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
8016                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
8017                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
8018                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
8019                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
8020            );
8021            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
8022                                 parent_symbol_id, start_line, start_column, end_line, end_column,
8023                                 start_byte, end_byte, is_test, test_container)
8024            VALUES
8025                ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
8026                 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
8027                ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8028                 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
8029                ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
8030                 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
8031                ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8032                 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
8033                ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8034                 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
8035                ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
8036                 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
8037                ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8038                 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
8039        )
8040        .unwrap();
8041        conn
8042    }
8043
8044    fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
8045        let mut stmt = conn
8046            .prepare(
8047                "SELECT s.symbol_id FROM symbols_fts f
8048                 JOIN symbols s ON s.rowid = f.rowid
8049                 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
8050            )
8051            .unwrap();
8052        let mut ids = stmt
8053            .query_map(params![query], |row| row.get::<_, String>(0))
8054            .unwrap()
8055            .collect::<Result<Vec<_>, _>>()
8056            .unwrap();
8057        ids.sort();
8058        ids
8059    }
8060
8061    #[test]
8062    fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
8063        let conn = local_variable_fixture();
8064        conn.execute_batch(
8065            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
8066                name, signature, doc_comment,
8067                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
8068            );
8069            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
8070            SELECT rowid, name, signature, doc_comment FROM symbols;",
8071        )
8072        .unwrap();
8073
8074        ensure_fts_index(&conn).unwrap();
8075
8076        assert_eq!(
8077            matched_symbol_ids(&conn, "sqlite"),
8078            vec!["field", "func", "global", "pool"]
8079        );
8080    }
8081
8082    #[test]
8083    fn lookup_excludes_locals_and_parameters() {
8084        let conn = local_variable_fixture();
8085
8086        let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
8087            .unwrap()
8088            .into_iter()
8089            .map(|s| s.symbol_id)
8090            .collect();
8091
8092        assert!(!ids.contains(&"local".to_string()));
8093        assert!(!ids.contains(&"nested".to_string()));
8094        assert!(ids.contains(&"field".to_string()));
8095        assert!(ids.contains(&"global".to_string()));
8096    }
8097
8098    #[test]
8099    fn search_excludes_locals_and_parameters() {
8100        let conn = local_variable_fixture();
8101        ensure_fts_index(&conn).unwrap();
8102
8103        let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
8104            .unwrap()
8105            .into_iter()
8106            .map(|r| r.symbol.symbol_id)
8107            .collect();
8108
8109        assert!(!ids.contains(&"local".to_string()));
8110        assert!(ids.contains(&"func".to_string()));
8111    }
8112
8113    #[test]
8114    fn variable_kind_search_keeps_full_text_matching() {
8115        let conn = local_variable_fixture();
8116        ensure_fts_index(&conn).unwrap();
8117
8118        let ids: Vec<String> = fts_search_symbols_scoped(
8119            &conn,
8120            "sqlite connection",
8121            Some("variable"),
8122            None,
8123            false,
8124            10,
8125        )
8126        .unwrap()
8127        .into_iter()
8128        .map(|r| r.symbol.symbol_id)
8129        .collect();
8130
8131        assert!(ids.contains(&"global".to_string()));
8132        assert!(ids.contains(&"field".to_string()));
8133    }
8134
8135    #[test]
8136    fn qualified_lookup_returns_the_named_local_variable() {
8137        let conn = local_variable_fixture();
8138
8139        let ids: Vec<String> =
8140            search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
8141                .unwrap()
8142                .into_iter()
8143                .map(|s| s.symbol_id)
8144                .collect();
8145
8146        assert_eq!(ids, vec!["local".to_string()]);
8147    }
8148
8149    #[test]
8150    fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
8151        let conn = Connection::open_in_memory().unwrap();
8152        conn.execute_batch(
8153            "CREATE TABLE symbols (
8154                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
8155                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
8156                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
8157                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
8158                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
8159                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
8160                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
8161            );
8162            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
8163                                 parent_symbol_id, start_line, start_column, end_line, end_column,
8164                                 start_byte, end_byte, is_test, test_container)
8165            VALUES
8166                ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
8167                 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
8168                ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
8169                 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
8170                ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
8171                 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
8172        )
8173        .unwrap();
8174        ensure_fts_index(&conn).unwrap();
8175
8176        let rows =
8177            fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
8178                .unwrap();
8179
8180        assert_eq!(rows.len(), 1);
8181        assert_eq!(rows[0].symbol.symbol_id, "local");
8182        let explain = rows[0].explain.as_ref().unwrap();
8183        assert_eq!(explain.name_tier, "whole");
8184        assert_eq!(explain.branches, vec!["exact", "name"]);
8185        assert_eq!(explain.candidates, 2);
8186    }
8187
8188    #[test]
8189    fn variable_kind_filter_returns_locals_and_parameters() {
8190        let conn = local_variable_fixture();
8191        ensure_fts_index(&conn).unwrap();
8192
8193        let lookup_ids: Vec<String> =
8194            search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
8195                .unwrap()
8196                .into_iter()
8197                .map(|s| s.symbol_id)
8198                .collect();
8199        assert!(lookup_ids.contains(&"local".to_string()));
8200        assert!(lookup_ids.contains(&"nested".to_string()));
8201
8202        let search_ids: Vec<String> =
8203            fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
8204                .unwrap()
8205                .into_iter()
8206                .map(|r| r.symbol.symbol_id)
8207                .collect();
8208        assert!(search_ids.contains(&"local".to_string()));
8209    }
8210}