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