Skip to main content

code_kb_core/
queries.rs

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