Skip to main content

code_kb_core/
queries.rs

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