Skip to main content

code_kb_core/
queries.rs

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