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