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