Skip to main content

code_kb_core/
queries.rs

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