Skip to main content

code_kb_core/
queries.rs

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