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