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