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_calls = if has_column(conn, "symbols", "test_lifecycle") {
3012 "UNION
3013 SELECT fixture_call.target_terminal_name
3014 FROM symbols fixture
3015 JOIN pending_relationships fixture_call ON fixture_call.from_symbol_id = fixture.symbol_id
3016 WHERE fixture.name = p.target_receiver
3017 AND fixture.kind IN ('function', 'method')
3018 AND +fixture.test_lifecycle = 1
3019 AND (fixture.path = p.path
3020 OR fixture.path LIKE '%conftest.py'
3021 AND substr(p.path, 1, length(fixture.path) - 11)
3022 = substr(fixture.path, 1, length(fixture.path) - 11))"
3023 } else {
3024 ""
3025 };
3026 let fixture_receiver = if has_column(conn, "symbols", "metadata_json") {
3028 format!(
3029 "OR CASE WHEN EXISTS (
3030 SELECT 1 FROM symbols taken
3031 WHERE taken.parent_symbol_id = p.from_symbol_id
3032 AND taken.name = p.target_receiver
3033 AND taken.kind IN ('parameter', 'variable')
3034 ) THEN EXISTS (
3035 WITH RECURSIVE builder_call(name) AS (
3036 SELECT assign_call.target_terminal_name
3037 FROM symbols assigned
3038 JOIN pending_relationships assign_call
3039 ON assign_call.from_symbol_id = p.from_symbol_id
3040 AND assign_call.start_line = assigned.start_line
3041 AND +assign_call.kind = 'calls'
3042 WHERE assigned.parent_symbol_id = p.from_symbol_id
3043 AND +assigned.name = p.target_receiver
3044 AND +assigned.kind = 'variable'
3045 {fixture_calls}
3046 ),
3047 built_name(name) AS (
3048 SELECT name FROM builder_call
3049 UNION
3050 SELECT json_extract(builder.metadata_json, '$.returnType')
3051 FROM builder_call
3052 JOIN symbols builder ON builder.name = builder_call.name
3053 WHERE builder.kind IN ('function', 'method')
3054 AND json_valid(builder.metadata_json)
3055 ),
3056 built(symbol_id, depth) AS (
3057 SELECT built_class.symbol_id, 0
3058 FROM built_name
3059 CROSS JOIN symbols built_class ON built_class.name = built_name.name
3060 WHERE built_class.kind = 'class'
3061 UNION
3062 SELECT r.to_symbol_id, built.depth + 1
3063 FROM relationships r JOIN built ON r.from_symbol_id = built.symbol_id
3064 WHERE +r.kind = 'extends' AND built.depth < 8
3065 UNION
3066 SELECT c.symbol_id, built.depth + 1
3067 FROM pending_relationships pe
3068 JOIN built ON pe.from_symbol_id = built.symbol_id
3069 JOIN symbols c ON c.name = pe.target_terminal_name
3070 WHERE +pe.kind = 'extends' AND built.depth < 8
3071 AND c.kind IN ('class', 'interface', 'struct', 'trait', 'protocol')
3072 )
3073 SELECT 1 FROM built WHERE built.symbol_id = {target}.parent_symbol_id
3074 ) ELSE 0 END"
3075 )
3076 } else {
3077 String::new()
3078 };
3079 format!(
3080 "(
3081 NOT (p.kind IS 'extends' AND p.from_symbol_id = {target}.symbol_id)
3082 AND (
3083 (
3084 {target}.parent_symbol_id IS NOT NULL
3085 AND {parent}.name IS NOT NULL
3086 AND (
3087 EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
3088 OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
3089 AND s_from.parent_symbol_id = {target}.parent_symbol_id)
3090 OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver
3091 {receiver_import_elsewhere})
3092 OR ((p.target_receiver IN ('self', 'this', 'cls', 'Self', 'super')
3093 OR EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self'))
3094 AND EXISTS (
3095 WITH RECURSIVE base(symbol_id, depth) AS (
3096 SELECT s_from.parent_symbol_id, 0
3097 UNION
3098 SELECT r.to_symbol_id, base.depth + 1
3099 FROM relationships r JOIN base ON r.from_symbol_id = base.symbol_id
3100 WHERE +r.kind = 'extends' AND base.depth < 8
3101 UNION
3102 SELECT c.symbol_id, base.depth + 1
3103 FROM pending_relationships pe
3104 JOIN base ON pe.from_symbol_id = base.symbol_id
3105 JOIN symbols c ON c.name = pe.target_terminal_name
3106 WHERE +pe.kind = 'extends' AND base.depth < 8
3107 AND c.kind IN ('class', 'interface', 'struct', 'trait', 'protocol')
3108 )
3109 SELECT 1 FROM base
3110 WHERE base.symbol_id = {target}.parent_symbol_id
3111 AND (base.depth > 0 OR {target}.kind IN ('variable', 'field'))
3112 AND NOT EXISTS (
3113 SELECT 1 FROM base closer
3114 JOIN symbols nearer ON nearer.parent_symbol_id = closer.symbol_id
3115 WHERE nearer.name = {target}.name
3116 AND nearer.kind IN ('method', 'function', 'constructor', 'property')
3117 AND closer.depth < base.depth
3118 AND (closer.depth > 0 OR p.target_receiver IS NOT 'super')
3119 )
3120 ))
3121 OR EXISTS (
3122 SELECT 1 FROM symbols receiver
3123 JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
3124 WHERE receiver.name = p.target_receiver
3125 AND receiver.path = p.path
3126 AND receiver_type.resolved_type = {parent}.name
3127 AND NOT EXISTS (
3128 SELECT 1 FROM symbols shadow
3129 WHERE shadow.name = receiver.name
3130 AND shadow.path = receiver.path
3131 AND shadow.parent_symbol_id = p.from_symbol_id
3132 AND shadow.kind IN ('variable', 'parameter')
3133 AND shadow.symbol_id != receiver.symbol_id
3134 )
3135 )
3136 {fixture_receiver}
3137 )
3138 AND NOT EXISTS (
3139 SELECT 1 FROM {ns}
3140 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
3141 AND NOT EXISTS (
3142 WITH RECURSIVE ancestor(symbol_id, depth) AS (
3143 SELECT {target}.parent_symbol_id, 0
3144 UNION ALL
3145 SELECT s.parent_symbol_id, ancestor.depth + 1
3146 FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
3147 WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
3148 )
3149 SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
3150 WHERE a.name = value
3151 )
3152 AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
3153 AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
3154 )
3155 )
3156 OR (
3157 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
3158 AND (
3159 p.target_receiver IS NULL
3160 OR p.target_receiver = ''
3161 {import_alias_receiver}
3162 )
3163 AND ({target}.parent_symbol_id IS NULL
3164 OR s_from.parent_symbol_id = {target}.parent_symbol_id
3165 OR {target}.parent_symbol_id = p.from_symbol_id)
3166 AND (p.target_receiver IS NOT NULL AND p.target_receiver != '' OR {target}.path = p.path OR NOT EXISTS (
3167 SELECT 1 FROM symbols shadow
3168 WHERE shadow.name = {target}.name
3169 AND shadow.path = p.path
3170 AND shadow.symbol_id != p.from_symbol_id
3171 AND shadow.kind NOT IN ('import', 'export', 'module', 'namespace')
3172 AND (shadow.parent_symbol_id IS NULL OR shadow.parent_symbol_id = p.from_symbol_id)
3173 ))
3174 AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
3175 SELECT 1 FROM symbols closer
3176 WHERE closer.name = {target}.name
3177 AND closer.symbol_id != {target}.symbol_id
3178 AND closer.parent_symbol_id IS NULL
3179 AND closer.kind = {target}.kind
3180 AND NOT (p.kind IS 'extends' AND closer.symbol_id = p.from_symbol_id)
3181 AND {closer_rank} > {target_rank}
3182 ))
3183 )
3184 OR (
3185 {target}.parent_symbol_id IS NULL
3186 AND EXISTS (
3187 SELECT 1 FROM {ns}
3188 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
3189 AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
3190 )
3191 )
3192 )
3193 )"
3194 )
3195}
3196
3197pub(crate) const DOCUMENTATION_LANGUAGES: &[&str] = &[
3200 "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
3201];
3202
3203fn documentation_language_list() -> String {
3204 DOCUMENTATION_LANGUAGES
3205 .iter()
3206 .map(|l| format!("'{l}'"))
3207 .collect::<Vec<_>>()
3208 .join(", ")
3209}
3210
3211fn not_documentation(conn: &Connection, alias: &str) -> String {
3212 let has_content_type: bool = conn
3213 .query_row(
3214 "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
3215 [],
3216 |_| Ok(true),
3217 )
3218 .unwrap_or(false);
3219 if has_content_type {
3220 format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
3221 } else {
3222 "1 = 1".to_string()
3223 }
3224}
3225
3226pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
3227 conn.query_row(
3228 "SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2",
3229 [table, column],
3230 |_| Ok(true),
3231 )
3232 .unwrap_or(false)
3233}
3234
3235pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
3236 conn.query_row(
3237 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
3238 [name],
3239 |_| Ok(true),
3240 )
3241 .unwrap_or(false)
3242}
3243
3244fn has_pending_namespace_column(conn: &Connection) -> bool {
3245 let has_ns: bool = conn
3246 .query_row(
3247 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
3248 [],
3249 |_| Ok(true),
3250 )
3251 .unwrap_or(false);
3252 let has_display: bool = conn
3253 .query_row(
3254 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
3255 [],
3256 |_| Ok(true),
3257 )
3258 .unwrap_or(false);
3259 let has_receiver: bool = conn
3260 .query_row(
3261 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
3262 [],
3263 |_| Ok(true),
3264 )
3265 .unwrap_or(false);
3266 has_ns && has_display && has_receiver
3267}
3268
3269fn find_references_internal(
3270 conn: &Connection,
3271 symbol_name: &str,
3272 direction: &str,
3273 limit: usize,
3274 symbol_id: Option<&str>,
3275 include_external: bool,
3276) -> Result<Vec<ReferenceSite>, QueryError> {
3277 let mut sites = find_direct_references(
3278 conn,
3279 symbol_name,
3280 direction,
3281 limit,
3282 symbol_id,
3283 include_external,
3284 )?;
3285 let class = match symbol_id.filter(|_| direction == "callers") {
3286 Some(id) => constructed_class(conn, id)?,
3287 None => None,
3288 };
3289 if let Some((class_id, class_name)) = class.filter(|_| sites.len() < limit) {
3290 let mut builds = find_direct_references(
3291 conn,
3292 &class_name,
3293 "callers",
3294 limit - sites.len(),
3295 Some(&class_id),
3296 include_external,
3297 )?;
3298 builds.retain(|site| site.kind == "calls");
3299 sites.extend(builds);
3300 sites.sort_by(|a, b| {
3301 a.path
3302 .cmp(&b.path)
3303 .then(a.start_line.cmp(&b.start_line))
3304 .then(a.start_column.cmp(&b.start_column))
3305 });
3306 }
3307 Ok(sites)
3308}
3309
3310fn constructed_class(
3312 conn: &Connection,
3313 symbol_id: &str,
3314) -> Result<Option<(String, String)>, QueryError> {
3315 Ok(conn
3316 .query_row(
3317 "SELECT class.symbol_id, class.name
3318 FROM symbols ctor JOIN symbols class ON class.symbol_id = ctor.parent_symbol_id
3319 WHERE ctor.symbol_id = ?1 AND ctor.kind = 'constructor'
3320 AND class.kind IN ('class', 'struct', 'record')",
3321 params![symbol_id],
3322 |row| Ok((row.get(0)?, row.get(1)?)),
3323 )
3324 .optional()?)
3325}
3326
3327fn find_direct_references(
3328 conn: &Connection,
3329 symbol_name: &str,
3330 direction: &str,
3331 limit: usize,
3332 symbol_id: Option<&str>,
3333 include_external: bool,
3334) -> Result<Vec<ReferenceSite>, QueryError> {
3335 let mut results = Vec::new();
3336
3337 if direction == "callers" {
3338 let mut stmt = conn.prepare(
3340 "SELECT s_from.name AS from_name,
3341 r.from_symbol_id,
3342 s_to.name AS to_name,
3343 r.kind,
3344 r.path,
3345 r.start_line,
3346 r.start_column
3347 FROM relationships r
3348 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3349 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3350 WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
3351 LIMIT ?2",
3352 )?;
3353
3354 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
3355 Ok(ReferenceSite {
3356 from_symbol_name: row.get(0)?,
3357 from_symbol_id: row.get(1)?,
3358 to_symbol_name: row.get(2)?,
3359 kind: row.get(3)?,
3360 path: row.get::<_, String>(4)?.replace('\\', "/"),
3361 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3362 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3363 occurrences: None,
3364 target: None,
3365 })
3366 })?;
3367
3368 for r in rows {
3369 results.push(r?);
3370 }
3371
3372 if results.len() < limit {
3374 let remaining = limit - results.len();
3375 if has_pending_namespace_column(conn) {
3376 if let Some(sid) = symbol_id {
3377 let mut pending_stmt = conn.prepare(
3378 &format!("SELECT s_from.name AS from_name,
3379 p.from_symbol_id,
3380 p.target_terminal_name AS to_name,
3381 p.kind,
3382 p.path,
3383 p.start_line,
3384 p.start_column
3385 FROM pending_relationships p
3386 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3387 JOIN symbols s_target ON s_target.symbol_id = ?3
3388 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3389 WHERE p.target_terminal_name = ?1
3390 AND {pred}
3391 LIMIT ?2", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
3392 )?;
3393
3394 let p_rows = pending_stmt.query_map(
3395 params![symbol_name, remaining as i64, sid],
3396 |row| {
3397 Ok(ReferenceSite {
3398 from_symbol_name: row.get(0)?,
3399 from_symbol_id: row.get(1)?,
3400 to_symbol_name: row.get(2)?,
3401 kind: row.get(3)?,
3402 path: row.get::<_, String>(4)?.replace('\\', "/"),
3403 start_line: Some(row.get::<_, i64>(5)? as usize),
3404 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3405 occurrences: None,
3406 target: None,
3407 })
3408 },
3409 )?;
3410 for r in p_rows {
3411 results.push(r?);
3412 }
3413 } else {
3414 let mut pending_stmt = conn.prepare(
3415 "SELECT s_from.name AS from_name,
3416 p.from_symbol_id,
3417 p.target_terminal_name AS to_name,
3418 p.kind,
3419 p.path,
3420 p.start_line,
3421 p.start_column
3422 FROM pending_relationships p
3423 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3424 WHERE p.target_terminal_name = ?1
3425 AND (
3426 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
3427 OR EXISTS (
3428 SELECT 1 FROM symbols s_any
3429 JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
3430 WHERE s_any.name = p.target_terminal_name
3431 AND EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END) WHERE value = s_any_parent.name)
3432 )
3433 )
3434 LIMIT ?2",
3435 )?;
3436
3437 let p_rows =
3438 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
3439 Ok(ReferenceSite {
3440 from_symbol_name: row.get(0)?,
3441 from_symbol_id: row.get(1)?,
3442 to_symbol_name: row.get(2)?,
3443 kind: row.get(3)?,
3444 path: row.get::<_, String>(4)?.replace('\\', "/"),
3445 start_line: Some(row.get::<_, i64>(5)? as usize),
3446 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3447 occurrences: None,
3448 target: None,
3449 })
3450 })?;
3451 for r in p_rows {
3452 results.push(r?);
3453 }
3454 }
3455 } else {
3456 let is_nested = if let Some(sid) = symbol_id {
3457 conn.query_row(
3458 "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
3459 params![sid],
3460 |_| Ok(true),
3461 )
3462 .unwrap_or(false)
3463 } else {
3464 false
3465 };
3466
3467 if !is_nested {
3468 let mut pending_stmt = conn.prepare(
3469 "SELECT s_from.name AS from_name,
3470 p.from_symbol_id,
3471 p.target_terminal_name AS to_name,
3472 p.kind,
3473 p.path,
3474 p.start_line,
3475 p.start_column
3476 FROM pending_relationships p
3477 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3478 WHERE p.target_terminal_name = ?1
3479 LIMIT ?2",
3480 )?;
3481
3482 let p_rows =
3483 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
3484 Ok(ReferenceSite {
3485 from_symbol_name: row.get(0)?,
3486 from_symbol_id: row.get(1)?,
3487 to_symbol_name: row.get(2)?,
3488 kind: row.get(3)?,
3489 path: row.get::<_, String>(4)?.replace('\\', "/"),
3490 start_line: Some(row.get::<_, i64>(5)? as usize),
3491 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3492 occurrences: None,
3493 target: None,
3494 })
3495 })?;
3496
3497 for r in p_rows {
3498 results.push(r?);
3499 }
3500 }
3501 }
3502 }
3503
3504 if results.len() < limit && has_table(conn, "identifiers") {
3505 let remaining = limit - results.len();
3506 let mut ident_stmt = conn.prepare(
3507 "SELECT COALESCE(s.name, ''),
3508 COALESCE(i.containing_symbol_id, ''),
3509 i.name,
3510 CASE WHEN json_valid(i.metadata_json)
3511 AND json_extract(i.metadata_json, '$.role') = 'signal_handler'
3512 THEN CASE WHEN json_extract(i.metadata_json, '$.receiver') = (
3513 SELECT CASE WHEN t.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
3514 THEN t.name ELSE tp.name END
3515 FROM symbols t
3516 LEFT JOIN symbols tp ON tp.symbol_id = t.parent_symbol_id
3517 WHERE t.symbol_id = ?3)
3518 THEN 'handler' ELSE 'handler (candidate)' END
3519 ELSE i.kind END,
3520 i.path,
3521 i.start_line,
3522 i.start_column
3523 FROM identifiers i
3524 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
3525 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
3526 AND COALESCE(s.kind, '') != 'import'
3527 AND NOT EXISTS (
3528 SELECT 1 FROM relationships covered
3529 JOIN symbols covered_to ON covered.to_symbol_id = covered_to.symbol_id
3530 WHERE covered_to.name = i.name
3531 AND covered.path = i.path
3532 AND covered.start_line = i.start_line
3533 AND (?3 IS NULL OR covered.to_symbol_id = ?3)
3534 )
3535 AND NOT EXISTS (
3536 SELECT 1 FROM pending_relationships covered
3537 WHERE covered.target_terminal_name = i.name
3538 AND covered.path = i.path
3539 AND covered.start_line = i.start_line
3540 )
3541 AND (?3 IS NULL OR NOT EXISTS (
3542 SELECT 1 FROM symbols owner
3543 JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
3544 WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
3545 AND member.name = i.name
3546 AND owner.name IS NOT (SELECT parent.name FROM symbols target
3547 JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
3548 WHERE target.symbol_id = ?3)
3549 ))
3550 AND NOT (i.kind = 'member_access' AND EXISTS (
3551 SELECT 1 FROM symbols target
3552 LEFT JOIN symbols scope ON scope.symbol_id = target.parent_symbol_id
3553 WHERE target.symbol_id = ?3
3554 AND (scope.kind IN ('function', 'method', 'constructor')
3555 OR (scope.symbol_id IS NULL
3556 AND target.path != i.path
3557 AND target.language IN ('javascript', 'typescript', 'tsx')
3558 AND COALESCE(target.visibility, 'private') = 'private'))
3559 ))
3560 AND NOT (i.kind = 'member_access'
3561 AND json_valid(i.metadata_json)
3562 AND json_extract(i.metadata_json, '$.role') IS NOT 'signal_handler'
3563 AND json_extract(i.metadata_json, '$.receiver') GLOB '[A-Z]*'
3564 AND NOT EXISTS (SELECT 1 FROM symbols known
3565 WHERE known.name = json_extract(i.metadata_json, '$.receiver')
3566 AND known.kind NOT IN ('variable', 'parameter', 'method')))
3567 ORDER BY i.path, i.start_line
3568 LIMIT ?2",
3569 )?;
3570 let rows =
3571 ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
3572 Ok(ReferenceSite {
3573 from_symbol_name: row.get(0)?,
3574 from_symbol_id: row.get(1)?,
3575 to_symbol_name: row.get(2)?,
3576 kind: row.get(3)?,
3577 path: row.get::<_, String>(4)?.replace('\\', "/"),
3578 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3579 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3580 occurrences: None,
3581 target: None,
3582 })
3583 })?;
3584 for r in rows {
3585 results.push(r?);
3586 }
3587
3588 if let Some(sid) = symbol_id.filter(|_| results.len() < limit) {
3589 let remaining = limit - results.len();
3590 let mut receiver_stmt = conn.prepare(
3591 "SELECT COALESCE(s.name, ''),
3592 COALESCE(i.containing_symbol_id, ''),
3593 i.name,
3594 i.kind,
3595 i.path,
3596 MIN(i.start_line),
3597 i.start_column,
3598 COUNT(*)
3599 FROM identifiers i
3600 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
3601 JOIN symbols target ON target.symbol_id = ?2
3602 LEFT JOIN symbols target_parent ON target_parent.symbol_id = target.parent_symbol_id
3603 WHERE i.kind = 'member_access'
3604 AND i.name != target.name
3605 AND COALESCE(s.kind, '') != 'import'
3606 AND target.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
3607 AND json_valid(i.metadata_json)
3608 AND json_extract(i.metadata_json, '$.receiver') = target.name
3609 AND (json_extract(i.metadata_json, '$.receiver_qualifier') IS NULL
3610 OR json_extract(i.metadata_json, '$.receiver_qualifier') = target_parent.name)
3611 GROUP BY i.path
3612 ORDER BY i.path, i.start_line
3613 LIMIT ?1",
3614 )?;
3615 let rows = receiver_stmt.query_map(params![remaining as i64, sid], |row| {
3616 Ok(ReferenceSite {
3617 from_symbol_name: row.get(0)?,
3618 from_symbol_id: row.get(1)?,
3619 to_symbol_name: row.get(2)?,
3620 kind: row.get(3)?,
3621 path: row.get::<_, String>(4)?.replace('\\', "/"),
3622 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3623 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3624 occurrences: Some(row.get::<_, i64>(7)? as usize),
3625 target: None,
3626 })
3627 })?;
3628 for r in rows {
3629 results.push(r?);
3630 }
3631 }
3632 }
3633 } else {
3634 let mut stmt = conn.prepare(
3636 "SELECT s_from.name AS from_name,
3637 r.from_symbol_id,
3638 s_to.name AS to_name,
3639 r.kind,
3640 r.path,
3641 r.start_line,
3642 r.start_column
3643 FROM relationships r
3644 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3645 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3646 WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
3647 LIMIT ?2",
3648 )?;
3649
3650 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
3651 Ok(ReferenceSite {
3652 from_symbol_name: row.get(0)?,
3653 from_symbol_id: row.get(1)?,
3654 to_symbol_name: row.get(2)?,
3655 kind: row.get(3)?,
3656 path: row.get::<_, String>(4)?.replace('\\', "/"),
3657 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
3658 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3659 occurrences: None,
3660 target: None,
3661 })
3662 })?;
3663
3664 for r in rows {
3665 results.push(r?);
3666 }
3667
3668 if results.len() < limit {
3670 let remaining = limit - results.len();
3671 let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
3672 let sql = if include_external {
3673 String::from("SELECT DISTINCT s_from.name AS from_name,
3674 p.from_symbol_id,
3675 COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
3676 p.kind,
3677 p.path,
3678 p.start_line,
3679 p.start_column
3680 FROM pending_relationships p
3681 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3682 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3683 LIMIT ?2")
3684 } else {
3685 format!("SELECT DISTINCT s_from.name AS from_name,
3686 p.from_symbol_id,
3687 p.target_terminal_name AS to_name,
3688 p.kind,
3689 p.path,
3690 p.start_line,
3691 p.start_column
3692 FROM pending_relationships p
3693 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3694 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3695 AND EXISTS (
3696 SELECT 1 FROM symbols s_to
3697 LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
3698 WHERE s_to.name = p.target_terminal_name
3699 AND {kind}
3700 AND {pred}
3701 )
3702 LIMIT ?2", kind = callee_target_kind("s_to_parent"), pred = pending_target_predicate(conn, "s_to", "s_to_parent"))
3703 };
3704 let mut pending_stmt = conn.prepare(&sql)?;
3705 let rows = pending_stmt.query_map(
3706 params![symbol_name, remaining as i64, symbol_id],
3707 |row| {
3708 Ok(ReferenceSite {
3709 from_symbol_name: row.get(0)?,
3710 from_symbol_id: row.get(1)?,
3711 to_symbol_name: row.get(2)?,
3712 kind: row.get(3)?,
3713 path: row.get::<_, String>(4)?.replace('\\', "/"),
3714 start_line: Some(row.get::<_, i64>(5)? as usize),
3715 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3716 occurrences: None,
3717 target: None,
3718 })
3719 },
3720 )?;
3721 let mut out = Vec::new();
3722 for r in rows {
3723 out.push(r?);
3724 }
3725 out
3726 } else {
3727 let sql = if include_external {
3728 "SELECT DISTINCT s_from.name AS from_name,
3729 p.from_symbol_id,
3730 p.target_terminal_name AS to_name,
3731 p.kind,
3732 p.path,
3733 p.start_line,
3734 p.start_column
3735 FROM pending_relationships p
3736 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3737 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3738 LIMIT ?2"
3739 } else {
3740 "SELECT DISTINCT s_from.name AS from_name,
3741 p.from_symbol_id,
3742 p.target_terminal_name AS to_name,
3743 p.kind,
3744 p.path,
3745 p.start_line,
3746 p.start_column
3747 FROM pending_relationships p
3748 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3749 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
3750 AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
3751 LIMIT ?2"
3752 };
3753 let mut pending_stmt = conn.prepare(sql)?;
3754
3755 let rows = pending_stmt.query_map(
3756 params![symbol_name, remaining as i64, symbol_id],
3757 |row| {
3758 Ok(ReferenceSite {
3759 from_symbol_name: row.get(0)?,
3760 from_symbol_id: row.get(1)?,
3761 to_symbol_name: row.get(2)?,
3762 kind: row.get(3)?,
3763 path: row.get::<_, String>(4)?.replace('\\', "/"),
3764 start_line: Some(row.get::<_, i64>(5)? as usize),
3765 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
3766 occurrences: None,
3767 target: None,
3768 })
3769 },
3770 )?;
3771 let mut out = Vec::new();
3772 for r in rows {
3773 out.push(r?);
3774 }
3775 out
3776 };
3777
3778 for r in p_rows {
3779 results.push(r);
3780 }
3781 }
3782 results.sort_by(|a, b| {
3783 a.path
3784 .cmp(&b.path)
3785 .then(a.start_line.cmp(&b.start_line))
3786 .then(a.start_column.cmp(&b.start_column))
3787 });
3788 let mut sites = merge_same_site(results);
3789 describe_callee_targets(conn, &mut sites)?;
3790 return Ok(sites);
3791 }
3792
3793 Ok(merge_same_site(results))
3794}
3795
3796fn callee_target_kind(parent: &str) -> String {
3799 format!(
3800 "(s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3801 OR p.kind = 'calls' AND s_to.kind IN ('variable', 'field')
3802 AND {parent}.kind IN ('class', 'struct', 'interface', 'trait', 'record', 'object', 'protocol'))"
3803 )
3804}
3805
3806fn describe_callee_targets(
3809 conn: &Connection,
3810 sites: &mut [ReferenceSite],
3811) -> Result<(), QueryError> {
3812 let owner = "CASE WHEN par.kind IN ('class', 'struct', 'interface', 'trait', 'enum', 'record', 'object', 'protocol', 'union')
3813 THEN par.name || '.' ELSE '' END";
3814 let pending = if has_pending_namespace_column(conn) {
3815 format!(
3816 "UNION
3817 SELECT {owner} || s_to.name, replace(s_to.path, '\\', '/'), s_to.start_line
3818 FROM pending_relationships p
3819 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3820 JOIN symbols s_to ON s_to.name = p.target_terminal_name
3821 LEFT JOIN symbols par ON s_to.parent_symbol_id = par.symbol_id
3822 LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
3823 WHERE p.from_symbol_id = ?1 AND p.start_line = ?2
3824 AND (p.target_terminal_name = ?3 OR p.target_display_name = ?3)
3825 AND {kind}
3826 AND {pred}",
3827 kind = callee_target_kind("s_to_parent"),
3828 pred = pending_target_predicate(conn, "s_to", "s_to_parent")
3829 )
3830 } else {
3831 String::new()
3832 };
3833 let mut stmt = conn.prepare(&format!(
3834 "SELECT {owner} || s_to.name, replace(s_to.path, '\\', '/'), s_to.start_line
3835 FROM relationships r
3836 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3837 LEFT JOIN symbols par ON s_to.parent_symbol_id = par.symbol_id
3838 WHERE r.from_symbol_id = ?1 AND r.start_line = ?2 AND s_to.name = ?3
3839 {pending}
3840 ORDER BY 2, 3
3841 LIMIT 4"
3842 ))?;
3843 for site in sites.iter_mut() {
3844 let Some(line) = site.start_line else {
3845 continue;
3846 };
3847 let targets = stmt
3848 .query_map(
3849 params![site.from_symbol_id, line as i64, site.to_symbol_name],
3850 |row| {
3851 Ok(format!(
3852 "`{}` ({}:{})",
3853 row.get::<_, String>(0)?,
3854 row.get::<_, String>(1)?,
3855 row.get::<_, i64>(2)?
3856 ))
3857 },
3858 )?
3859 .collect::<Result<Vec<_>, _>>()?;
3860 site.target = match targets.len() {
3861 0 => None,
3862 1 => targets.into_iter().next(),
3863 2 | 3 => Some(format!("one of {}", targets.join(", "))),
3864 _ => Some(format!(
3865 "more than 3 definitions named `{}`",
3866 site.to_symbol_name
3867 )),
3868 };
3869 }
3870 Ok(())
3871}
3872
3873pub fn import_sites(
3877 conn: &Connection,
3878 name: &str,
3879 path_filter: Option<&str>,
3880) -> Result<Vec<(String, usize)>, QueryError> {
3881 if !has_column(conn, "symbols", "metadata_json") {
3882 return Ok(Vec::new());
3883 }
3884 let dotted = "replace(module_path.value, '/', '.')";
3885 let segment = format!("replace({dotted}, rtrim({dotted}, replace({dotted}, '.', '')), '')");
3886 let target_path = "('/' || replace(target.path, '\\', '/'))";
3887 let dir = |path: &str| {
3889 format!("rtrim(replace({path}, '\\', '/'), replace(replace({path}, '\\', '/'), '/', ''))")
3890 };
3891 let (import_dir, target_dir) = (dir("i.path"), dir("target.path"));
3892 let mut stmt = conn.prepare(&format!(
3893 "SELECT DISTINCT replace(i.path, '\\', '/'), i.start_line
3894 FROM symbols target
3895 JOIN symbols i ON i.name = target.name AND i.kind = 'import'
3896 CROSS JOIN json_each(json_array(json_extract(i.metadata_json, '$.source'))) module_path
3897 WHERE target.name = ?1
3898 AND target.parent_symbol_id IS NULL
3899 AND target.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3900 AND (?2 IS NULL OR replace(target.path, '\\', '/') = ?2
3901 OR replace(target.path, '\\', '/') LIKE ?3 ESCAPE '\\')
3902 AND json_valid(i.metadata_json)
3903 AND i.language NOT IN ({docs})
3904 AND (
3905 {segment} != ''
3906 AND ({target_path} GLOB '*/' || {segment} || '/*'
3907 OR {target_path} GLOB '*/' || {segment} || '.*')
3908 OR module_path.value != '' AND replace(module_path.value, '.', '') = ''
3909 AND substr({import_dir}, 1, length({target_dir})) = {target_dir}
3910 )
3911 ORDER BY 1, 2",
3912 docs = documentation_language_list()
3913 ))?;
3914 let norm = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
3915 let prefix = norm.as_ref().map(|p| format!("{}/%", escape_like(p)));
3916 let rows = stmt.query_map(params![name, norm, prefix], |row| {
3917 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
3918 })?;
3919 Ok(rows.collect::<Result<_, _>>()?)
3920}
3921
3922fn merge_same_site(sites: Vec<ReferenceSite>) -> Vec<ReferenceSite> {
3925 let mut merged: Vec<ReferenceSite> = Vec::with_capacity(sites.len());
3926 let mut index = HashMap::new();
3927 for site in sites {
3928 let key = (
3929 site.from_symbol_id.clone(),
3930 site.to_symbol_name.clone(),
3931 site.kind.clone(),
3932 site.path.clone(),
3933 site.start_line,
3934 );
3935 match index.get(&key) {
3936 Some(&at) => {
3937 let kept: &mut ReferenceSite = &mut merged[at];
3938 kept.start_column = kept.start_column.min(site.start_column);
3939 }
3940 None => {
3941 index.insert(key, merged.len());
3942 merged.push(site);
3943 }
3944 }
3945 }
3946 merged
3947}
3948
3949pub fn find_callee_signatures(
3952 conn: &Connection,
3953 symbol_name: &str,
3954 symbol_id: &str,
3955 limit: usize,
3956 include_external: bool,
3957) -> Result<Vec<String>, QueryError> {
3958 let mut stmt = conn.prepare(
3959 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
3960 FROM relationships r
3961 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3962 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
3963 WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
3964 LIMIT ?3",
3965 )?;
3966
3967 let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
3968 Ok((
3969 row.get::<_, String>(0)?,
3970 row.get::<_, Option<String>>(1)?,
3971 row.get::<_, String>(2)?.replace('\\', "/"),
3972 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
3973 row.get::<_, String>(4)?,
3974 ))
3975 })?;
3976
3977 let mut signatures = Vec::new();
3978 let mut variants = Vec::new();
3979
3980 for r in rows.flatten() {
3981 let (name, sig_opt, path, line, kind) = r;
3982 let sig = sig_opt.unwrap_or(name);
3983 let entry = format!("{sig} ({path}:{line})");
3984 if kind == "variant" {
3985 if !variants.contains(&entry) {
3986 variants.push(entry);
3987 }
3988 } else if !signatures.contains(&entry) {
3989 signatures.push(entry);
3990 }
3991 }
3992
3993 if signatures.len() < limit {
3994 let remaining = (limit - signatures.len()) * 2;
3995 let p_rows: Vec<(String, Option<String>, String, usize, String)> =
3996 if has_pending_namespace_column(conn) {
3997 let mut p_stmt = conn.prepare(
3998 &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
3999 FROM pending_relationships p
4000 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
4001 JOIN symbols s_to ON s_to.name = p.target_terminal_name
4002 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
4003 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
4004 AND {kind}
4005 AND {pred}
4006 LIMIT ?3", kind = callee_target_kind("s_parent"), pred = pending_target_predicate(conn, "s_to", "s_parent")),
4007 )?;
4008
4009 let rows =
4010 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
4011 Ok((
4012 row.get::<_, String>(0)?,
4013 row.get::<_, Option<String>>(1)?,
4014 row.get::<_, String>(2)?.replace('\\', "/"),
4015 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
4016 row.get::<_, String>(4)?,
4017 ))
4018 })?;
4019 rows.flatten().collect()
4020 } else {
4021 let mut p_stmt = conn.prepare(
4022 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
4023 FROM pending_relationships p
4024 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
4025 JOIN symbols s_to ON s_to.name = p.target_terminal_name
4026 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
4027 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
4028 LIMIT ?3",
4029 )?;
4030
4031 let rows =
4032 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
4033 Ok((
4034 row.get::<_, String>(0)?,
4035 row.get::<_, Option<String>>(1)?,
4036 row.get::<_, String>(2)?.replace('\\', "/"),
4037 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
4038 row.get::<_, String>(4)?,
4039 ))
4040 })?;
4041 rows.flatten().collect()
4042 };
4043
4044 for r in p_rows {
4045 let (name, sig_opt, path, line, kind) = r;
4046 let sig = sig_opt.unwrap_or(name);
4047 let entry = format!("{sig} ({path}:{line})");
4048 if kind == "variant" {
4049 if !variants.contains(&entry) {
4050 variants.push(entry);
4051 }
4052 } else if !signatures.contains(&entry) {
4053 signatures.push(entry);
4054 }
4055 }
4056 }
4057
4058 if include_external && signatures.len() < limit {
4059 let remaining = (limit - signatures.len()) * 2;
4060 let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
4061 let mut ext_stmt = conn.prepare(
4062 &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
4063 FROM pending_relationships p
4064 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
4065 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
4066 AND NOT EXISTS (
4067 SELECT 1 FROM symbols s_to
4068 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
4069 WHERE s_to.name = p.target_terminal_name
4070 AND {kind}
4071 AND {pred}
4072 )
4073 LIMIT ?3", kind = callee_target_kind("s_parent"), pred = pending_target_predicate(conn, "s_to", "s_parent")),
4074 )?;
4075
4076 let rows =
4077 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
4078 Ok((
4079 row.get::<_, String>(0)?,
4080 row.get::<_, String>(1)?.replace('\\', "/"),
4081 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
4082 ))
4083 })?;
4084 rows.flatten().collect()
4085 } else {
4086 let mut ext_stmt = conn.prepare(
4087 "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
4088 FROM pending_relationships p
4089 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
4090 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
4091 AND NOT EXISTS (
4092 SELECT 1 FROM symbols s_to
4093 WHERE s_to.name = p.target_terminal_name
4094 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
4095 )
4096 LIMIT ?3",
4097 )?;
4098
4099 let rows =
4100 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
4101 Ok((
4102 row.get::<_, String>(0)?,
4103 row.get::<_, String>(1)?.replace('\\', "/"),
4104 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
4105 ))
4106 })?;
4107 rows.flatten().collect()
4108 };
4109
4110 for r in ext_rows {
4111 let (name, path, line) = r;
4112 let entry = format!("{name} ({path}:{line})");
4113 if !signatures.contains(&entry) {
4114 signatures.push(entry);
4115 }
4116 }
4117 }
4118
4119 for v in variants {
4120 if signatures.len() >= limit {
4121 break;
4122 }
4123 if !signatures.contains(&v) {
4124 signatures.push(v);
4125 }
4126 }
4127
4128 signatures.truncate(limit);
4129 Ok(signatures)
4130}
4131
4132const SQL_FAMILIES: &[&str] = &["sql."];
4133
4134const ROUTE_FAMILIES: &[&str] = &[
4135 ".route.",
4136 ".attribute_route.",
4137 ".scope_route.",
4138 ".file_route.",
4139 ".route_handler.",
4140 ".route_reference.",
4141 ".route_group.",
4142 ".route_prefix.",
4143 ".resource_route.",
4144 ".router_mount.",
4145 ".include_router.",
4146 ".server_route.",
4147 ".route_definition.",
4148];
4149
4150const CONFIG_FAMILIES: &[&str] = &["toml.", "yaml.", "json.", "env."];
4151
4152const MODEL_FAMILIES: &[&str] = &[
4153 "sql.table_definition.v1",
4154 "sql.column_definition.v1",
4155 "sql.constraint.v1",
4156 "sql.foreign_key.v1",
4157 "sql.index_definition.v1",
4158 "json.schema.v1",
4159];
4160
4161const SIGNAL_FAMILIES: &[&str] = &[".signal_declaration."];
4162
4163const IMPORT_FAMILIES: &[&str] = &[".import_statement.", ".import."];
4164
4165const BINDING_FAMILIES: &[&str] = &[".binding."];
4166
4167const COMPONENT_FAMILIES: &[&str] = &[".object_instantiation.", ".object_type."];
4168
4169const MODULE_FAMILIES: &[&str] = &[".module."];
4170
4171const PRAGMA_FAMILIES: &[&str] = &[".pragma.", "javascript.qml_directive.v1"];
4172
4173const PROPERTY_FAMILIES: &[&str] = &[".property_declaration.", ".qt_property."];
4174
4175pub const CATEGORY_ALIASES: &[(&str, &[&str])] = &[
4180 ("sql", SQL_FAMILIES),
4181 ("query", SQL_FAMILIES),
4182 ("queries", SQL_FAMILIES),
4183 ("route", ROUTE_FAMILIES),
4184 ("routes", ROUTE_FAMILIES),
4185 ("config", CONFIG_FAMILIES),
4186 ("model", MODEL_FAMILIES),
4187 ("models", MODEL_FAMILIES),
4188 ("signal", SIGNAL_FAMILIES),
4189 ("signals", SIGNAL_FAMILIES),
4190 ("import", IMPORT_FAMILIES),
4191 ("imports", IMPORT_FAMILIES),
4192 ("binding", BINDING_FAMILIES),
4193 ("bindings", BINDING_FAMILIES),
4194 ("component", COMPONENT_FAMILIES),
4195 ("components", COMPONENT_FAMILIES),
4196 ("module", MODULE_FAMILIES),
4197 ("modules", MODULE_FAMILIES),
4198 ("pragma", PRAGMA_FAMILIES),
4199 ("property", PROPERTY_FAMILIES),
4200 ("properties", PROPERTY_FAMILIES),
4201];
4202
4203fn category_families(category: &str) -> Option<&'static [&'static str]> {
4204 let wanted = category.trim().to_ascii_lowercase();
4205 CATEGORY_ALIASES
4206 .iter()
4207 .find(|(alias, _)| *alias == wanted)
4208 .map(|(_, families)| *families)
4209}
4210
4211fn matches_family(pattern_id: &str, rule: &str) -> bool {
4212 if rule.starts_with('.') {
4213 pattern_id.contains(rule)
4214 } else if rule.ends_with('.') {
4215 pattern_id.starts_with(rule)
4216 } else {
4217 pattern_id == rule
4218 }
4219}
4220
4221fn family_clause(column: &str, families: &[&str]) -> String {
4222 let alternatives: Vec<String> = families
4223 .iter()
4224 .map(|rule| {
4225 let escaped = escape_like(rule);
4226 if rule.starts_with('.') {
4227 format!("{column} LIKE '%{escaped}%' ESCAPE '\\'")
4228 } else if rule.ends_with('.') {
4229 format!("{column} LIKE '{escaped}%' ESCAPE '\\'")
4230 } else {
4231 format!("{column} = '{rule}'")
4232 }
4233 })
4234 .collect();
4235 format!("({})", alternatives.join(" OR "))
4236}
4237
4238pub fn is_category_alias(category: &str) -> bool {
4240 category_families(category).is_some()
4241}
4242
4243pub fn alias_fact_counts(categories: &[(String, usize)]) -> Vec<(&'static str, usize, usize)> {
4247 let mut reported: Vec<&[&str]> = Vec::new();
4248 let mut counts = Vec::new();
4249 for (alias, families) in CATEGORY_ALIASES {
4250 if reported.contains(families) {
4251 continue;
4252 }
4253 reported.push(families);
4254 let mut patterns = 0;
4255 let mut facts = 0;
4256 for (pattern_id, count) in categories {
4257 if families.iter().any(|rule| matches_family(pattern_id, rule)) {
4258 patterns += 1;
4259 facts += count;
4260 }
4261 }
4262 if facts > 0 {
4263 counts.push((*alias, patterns, facts));
4264 }
4265 }
4266 counts
4267}
4268
4269pub fn find_structural_facts_scoped(
4271 conn: &Connection,
4272 category: &str,
4273 path_filter: Option<&str>,
4274 limit: usize,
4275) -> Result<Vec<StructuralFact>, QueryError> {
4276 validate_result_limit(limit)?;
4277 let norm_path = path_filter
4278 .map(|p| {
4279 p.replace('\\', "/")
4280 .trim_start_matches("./")
4281 .trim_matches('/')
4282 .to_string()
4283 })
4284 .filter(|p| !p.is_empty());
4285 let dir_prefix = norm_path
4286 .as_deref()
4287 .map(|p| format!("{}/%", escape_like(p)));
4288 let cat_pattern = format!("%{}%", escape_like(category));
4289
4290 let cat_clause = match category_families(category) {
4291 Some(families) => family_clause("sf.pattern_id", families),
4292 None => {
4293 "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
4294 .to_string()
4295 }
4296 };
4297
4298 let sql = format!(
4299 "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
4300 sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
4301 sf.start_line, sf.end_line, sf.confidence,
4302 COALESCE(
4303 CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
4304 THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
4305 ELSE json_extract(sf.metadata_json, '$.key_path') END,
4306 json_extract(sf.metadata_json, '$.key'),
4307 CASE WHEN json_extract(sf.metadata_json, '$.normalized_route_template') IS NOT NULL
4308 THEN COALESCE(json_extract(sf.metadata_json, '$.verb') || ' ', '')
4309 || json_extract(sf.metadata_json, '$.normalized_route_template') END,
4310 CASE WHEN sf.pattern_id = 'cpp.qt_property.v1'
4311 THEN json_extract(sf.metadata_json, '$.name') END
4312 ) AS display_key,
4313 sf.metadata_json
4314 FROM structural_facts sf
4315 LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
4316 WHERE (:cat IS NOT NULL AND {cat_clause})
4317 AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4318 ORDER BY sf.path ASC, sf.start_line ASC
4319 LIMIT :limit"
4320 );
4321
4322 let mut stmt = conn.prepare(&sql)?;
4323 let rows = stmt.query_map(
4324 rusqlite::named_params! {
4325 ":cat": cat_pattern,
4326 ":path": norm_path.as_deref(),
4327 ":dir_prefix": dir_prefix.as_deref(),
4328 ":limit": limit as i64,
4329 },
4330 |row| {
4331 Ok(StructuralFact {
4332 structural_fact_id: row.get(0)?,
4333 path: row.get::<_, String>(1)?.replace('\\', "/"),
4334 language: row.get(2)?,
4335 pattern_id: row.get(3)?,
4336 capture_name: row.get(4)?,
4337 node_kind: row.get(5)?,
4338 key: row.get(10)?,
4339 metadata: row
4340 .get::<_, Option<String>>(11)?
4341 .map(|json| serde_json::from_str(&json))
4342 .transpose()
4343 .map_err(|err| {
4344 rusqlite::Error::FromSqlConversionFailure(
4345 11,
4346 rusqlite::types::Type::Text,
4347 Box::new(err),
4348 )
4349 })?,
4350 containing_symbol_name: row.get(6)?,
4351 start_line: row.get::<_, i64>(7)? as usize,
4352 end_line: row.get::<_, i64>(8)? as usize,
4353 confidence: row.get(9)?,
4354 })
4355 },
4356 )?;
4357
4358 let mut results = Vec::new();
4359 for r in rows {
4360 let mut fact = r?;
4361 let display = fact.metadata.as_ref().and_then(|metadata| {
4362 route_display(metadata).or_else(|| mount_display(&fact.capture_name, metadata))
4363 });
4364 if let Some(display) = display {
4365 fact.key = Some(display);
4366 }
4367 results.push(fact);
4368 }
4369 Ok(results)
4370}
4371
4372fn route_display(metadata: &serde_json::Value) -> Option<String> {
4376 let text = |key: &str| metadata.get(key).and_then(|value| value.as_str());
4377 let normalized = text("normalized_route_template")?;
4378 let template = match (text("effective_route_template"), text("route_template")) {
4379 (Some(effective), _) => effective,
4380 (None, Some(raw)) if route_shape(raw) == route_shape(normalized) => raw,
4381 _ => normalized,
4382 };
4383 Some(match text("verb") {
4384 Some(verb) => format!("{verb} {template}"),
4385 None => template.to_string(),
4386 })
4387}
4388
4389fn mount_display(capture: &str, metadata: &serde_json::Value) -> Option<String> {
4392 let text = |key: &str| metadata.get(key).and_then(|value| value.as_str());
4393 if text("query_family") != Some("framework") {
4394 return None;
4395 }
4396 let target = text("mount_target")?;
4397 Some(
4398 match text("normalized_mount_path").or_else(|| text("mount_path")) {
4399 Some(path) => format!("{capture} `{target}` at {path}"),
4400 None => format!("{capture} `{target}`"),
4401 },
4402 )
4403}
4404
4405fn route_shape(template: &str) -> String {
4407 let mut shape = String::new();
4408 let mut chars = template.chars().peekable();
4409 while let Some(c) = chars.next() {
4410 match c {
4411 '<' | '{' => {
4412 let close = if c == '<' { '>' } else { '}' };
4413 for inner in chars.by_ref() {
4414 if inner == close {
4415 break;
4416 }
4417 }
4418 shape.push('*');
4419 }
4420 ':' => {
4421 while chars.peek().is_some_and(|next| *next != '/') {
4422 chars.next();
4423 }
4424 shape.push('*');
4425 }
4426 _ => shape.push(c),
4427 }
4428 }
4429 shape
4430}
4431
4432pub fn find_structural_facts(
4434 conn: &Connection,
4435 category: &str,
4436 limit: usize,
4437) -> Result<Vec<StructuralFact>, QueryError> {
4438 find_structural_facts_scoped(conn, category, None, limit)
4439}
4440
4441pub fn find_literals_scoped(
4443 conn: &Connection,
4444 category: &str,
4445 path_filter: Option<&str>,
4446 limit: usize,
4447) -> Result<Vec<LiteralFact>, QueryError> {
4448 validate_result_limit(limit)?;
4449 let norm_path = path_filter
4450 .map(|p| {
4451 p.replace('\\', "/")
4452 .trim_start_matches("./")
4453 .trim_matches('/')
4454 .to_string()
4455 })
4456 .filter(|p| !p.is_empty());
4457 let dir_prefix = norm_path
4458 .as_deref()
4459 .map(|p| format!("{}/%", escape_like(p)));
4460 let cat_pattern = format!("%{}%", escape_like(category));
4461
4462 let cat_lower = category.trim().to_ascii_lowercase();
4463 let cat_clause = match cat_lower.as_str() {
4464 "config" => {
4465 "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
4466 }
4467 "route" | "routes" => "l.kind LIKE '%route%'",
4468 "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
4469 "model" | "models" => "l.kind LIKE '%model%'",
4470 _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
4471 };
4472
4473 let sql = format!(
4474 "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
4475 l.start_line, s.name AS containing_symbol_name
4476 FROM literals l
4477 LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
4478 WHERE (:cat IS NOT NULL AND {cat_clause})
4479 AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4480 ORDER BY l.path ASC, l.start_line ASC
4481 LIMIT :limit"
4482 );
4483
4484 let mut stmt = conn.prepare(&sql)?;
4485 let rows = stmt.query_map(
4486 rusqlite::named_params! {
4487 ":cat": cat_pattern,
4488 ":path": norm_path.as_deref(),
4489 ":dir_prefix": dir_prefix.as_deref(),
4490 ":limit": limit as i64,
4491 },
4492 |row| {
4493 Ok(LiteralFact {
4494 literal_id: row.get(0)?,
4495 path: row.get::<_, String>(1)?.replace('\\', "/"),
4496 literal_text: row.get(2)?,
4497 kind: row.get(3)?,
4498 carrier: row.get(4)?,
4499 start_line: row.get::<_, i64>(5)? as usize,
4500 containing_symbol_name: row.get(6)?,
4501 })
4502 },
4503 )?;
4504
4505 let mut results = Vec::new();
4506 for r in rows {
4507 results.push(r?);
4508 }
4509 Ok(results)
4510}
4511
4512pub fn find_literals(
4514 conn: &Connection,
4515 category: &str,
4516 limit: usize,
4517) -> Result<Vec<LiteralFact>, QueryError> {
4518 find_literals_scoped(conn, category, None, limit)
4519}
4520
4521pub fn list_structural_fact_categories_scoped(
4523 conn: &Connection,
4524 path_filter: Option<&str>,
4525) -> Result<Vec<(String, usize)>, QueryError> {
4526 let norm_path = path_filter
4527 .map(|p| {
4528 p.replace('\\', "/")
4529 .trim_start_matches("./")
4530 .trim_matches('/')
4531 .to_string()
4532 })
4533 .filter(|p| !p.is_empty());
4534 let dir_prefix = norm_path
4535 .as_deref()
4536 .map(|p| format!("{}/%", escape_like(p)));
4537
4538 let mut categories = Vec::new();
4539
4540 let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
4541 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4542 GROUP BY pattern_id ORDER BY cnt DESC";
4543 let mut stmt = conn.prepare(sql)?;
4544 let rows = stmt.query_map(
4545 rusqlite::named_params! {
4546 ":path": norm_path.as_deref(),
4547 ":dir_prefix": dir_prefix.as_deref(),
4548 },
4549 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
4550 )?;
4551 for r in rows {
4552 categories.push(r?);
4553 }
4554
4555 let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
4556 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
4557 GROUP BY kind ORDER BY cnt DESC";
4558 let mut lit_stmt = conn.prepare(lit_sql)?;
4559 let lit_rows = lit_stmt.query_map(
4560 rusqlite::named_params! {
4561 ":path": norm_path.as_deref(),
4562 ":dir_prefix": dir_prefix.as_deref(),
4563 },
4564 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
4565 )?;
4566 for r in lit_rows {
4567 categories.push(r?);
4568 }
4569
4570 Ok(categories)
4571}
4572
4573pub fn list_structural_fact_categories(
4575 conn: &Connection,
4576) -> Result<Vec<(String, usize)>, QueryError> {
4577 list_structural_fact_categories_scoped(conn, None)
4578}
4579
4580pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
4582 let has_table: bool = conn
4583 .query_row(
4584 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
4585 [],
4586 |_| Ok(true),
4587 )
4588 .unwrap_or(false);
4589 if !has_table {
4590 return Ok(Vec::new());
4591 }
4592
4593 let mut stmt = conn.prepare(
4594 "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
4595 FROM type_facts
4596 WHERE symbol_id = ?1",
4597 )?;
4598
4599 let rows = stmt.query_map(params![symbol_id], |row| {
4600 Ok(TypeFact {
4601 type_fact_id: row.get(0)?,
4602 symbol_id: row.get(1)?,
4603 language: row.get(2)?,
4604 resolved_type: row.get(3)?,
4605 generic_params: row.get(4)?,
4606 })
4607 })?;
4608
4609 let mut results = Vec::new();
4610 for r in rows {
4611 results.push(r?);
4612 }
4613 Ok(results)
4614}
4615
4616pub fn find_related_types(
4620 conn: &Connection,
4621 target: &Symbol,
4622 limit: usize,
4623) -> Result<Vec<String>, QueryError> {
4624 if !has_table(conn, "type_facts") {
4625 return Ok(Vec::new());
4626 }
4627 let mut stmt = conn.prepare(
4628 "SELECT d.name, d.kind, d.signature, d.path, d.start_line
4629 FROM symbols d
4630 WHERE d.kind IN ('struct', 'enum', 'trait', 'class', 'interface', 'type', 'union')
4631 AND d.name IN (
4632 SELECT t.resolved_type FROM type_facts t
4633 JOIN symbols s ON s.symbol_id = t.symbol_id
4634 WHERE s.symbol_id = ?1 OR s.parent_symbol_id = ?1
4635 )
4636 ORDER BY d.path = ?2 DESC, length(d.path), d.path, d.start_line",
4637 )?;
4638 let rows = stmt.query_map(params![target.symbol_id, target.path], |row| {
4639 Ok((
4640 row.get::<_, String>(0)?,
4641 row.get::<_, String>(1)?,
4642 row.get::<_, Option<String>>(2)?,
4643 row.get::<_, String>(3)?,
4644 row.get::<_, i64>(4)?,
4645 ))
4646 })?;
4647
4648 let mut seen = std::collections::HashSet::new();
4649 let mut types = Vec::new();
4650 for row in rows {
4651 let (name, kind, signature, path, line) = row?;
4652 if types.len() >= limit {
4653 break;
4654 }
4655 if !seen.insert(name.clone()) {
4656 continue;
4657 }
4658 let signature = signature.unwrap_or_else(|| format!("{kind} {name}"));
4659 types.push(format!(
4660 "{} ({path}:{line})",
4661 type_declaration_line(&signature)
4662 ));
4663 }
4664 Ok(types)
4665}
4666
4667fn type_declaration_line(signature: &str) -> String {
4669 let mut declaration = signature;
4670 while declaration.starts_with("#[")
4671 && let Some((_, rest)) = declaration.split_once("] ")
4672 {
4673 declaration = rest;
4674 }
4675 let line = declaration.split_whitespace().collect::<Vec<_>>().join(" ");
4676 if line.chars().count() <= 120 {
4677 return line;
4678 }
4679 line.chars().take(119).collect::<String>() + "…"
4680}
4681
4682fn names_a_test(name: &str) -> bool {
4692 let lowered = name.to_ascii_lowercase();
4693 lowered.starts_with("test")
4694 || ["test", "tests", "spec", "specs"]
4695 .iter()
4696 .any(|suffix| lowered.ends_with(suffix))
4697}
4698
4699pub fn is_test_path(path: &str) -> bool {
4700 let p = path.replace('\\', "/");
4701 let cut = p.rfind('/').map_or(0, |i| i + 1);
4702 let directories = format!("/{}/", p[..cut].to_lowercase());
4703 let file_name = &p[cut..];
4704 let lower_name = file_name.to_lowercase();
4705 directories.contains("/test/")
4706 || directories.contains("/tests/")
4707 || directories.contains("/autotests/")
4708 || directories.contains("/__tests__/")
4709 || lower_name.starts_with("tst_")
4710 || (lower_name.starts_with("test_")
4711 && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
4712 || lower_name.contains("_test.")
4713 || lower_name.contains(".test.")
4714 || lower_name.contains(".spec.")
4715 || lower_name == "test.rs"
4716 || lower_name == "tests.rs"
4717 || file_name.ends_with("Tests.cs")
4718}
4719
4720pub(crate) fn test_path_predicate(alias: &str) -> String {
4729 let guard = format!(
4730 "(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%' OR lower({alias}.path) LIKE '%tst\\_%' ESCAPE '\\')"
4731 );
4732 let p = format!("replace({alias}.path, '\\', '/')");
4733 let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
4734 let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
4735 let lower_name = format!("lower({file_name})");
4736 let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
4737 let clauses = [
4738 like(&directories, "%/test/%"),
4739 like(&directories, "%/tests/%"),
4740 like(&directories, "%/autotests/%"),
4741 like(&directories, "%/\\_\\_tests\\_\\_/%"),
4742 like(&lower_name, "tst\\_%"),
4743 like(&lower_name, "test\\_%.py"),
4744 like(&lower_name, "test\\_%.rb"),
4745 like(&lower_name, "%\\_test.%"),
4746 like(&lower_name, "%.test.%"),
4747 like(&lower_name, "%.spec.%"),
4748 format!("{lower_name} = 'test.rs'"),
4749 format!("{lower_name} = 'tests.rs'"),
4750 format!("{file_name} GLOB '*Tests.cs'"),
4751 ]
4752 .join(" OR ");
4753 format!("({guard} AND ({clauses}))")
4754}
4755
4756pub fn compute_blast_radius_scoped(
4759 conn: &Connection,
4760 seed_symbols: &[&str],
4761 symbol_path_filter: Option<&str>,
4762 seed_paths: &[&str],
4763 max_depth: usize,
4764 limit: usize,
4765) -> Result<BlastRadiusResult, QueryError> {
4766 compute_blast_radius_scoped_with_ids(
4767 conn,
4768 seed_symbols,
4769 &[],
4770 symbol_path_filter,
4771 seed_paths,
4772 max_depth,
4773 limit,
4774 )
4775}
4776
4777pub fn compute_blast_radius_scoped_with_ids(
4779 conn: &Connection,
4780 seed_symbols: &[&str],
4781 seed_ids: &[&str],
4782 symbol_path_filter: Option<&str>,
4783 seed_paths: &[&str],
4784 max_depth: usize,
4785 limit: usize,
4786) -> Result<BlastRadiusResult, QueryError> {
4787 validate_result_limit(limit)?;
4788 let max_depth = max_depth.min(5);
4789 let mut resolved_seed_symbols = seed_symbols
4790 .iter()
4791 .map(|name| {
4792 get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
4793 let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
4794 QueryError::SymbolNotFound {
4795 name: (*name).to_string(),
4796 workspace,
4797 hint,
4798 }
4799 })
4800 })
4801 .collect::<Result<Vec<_>, _>>()?;
4802 for id in seed_ids {
4803 let symbol = get_symbol_by_id(conn, id)?.ok_or_else(|| {
4804 let workspace = workspace_name(conn);
4805 QueryError::SymbolNotFound {
4806 name: format!("symbol id {id}"),
4807 workspace,
4808 hint: "Run lookup_symbol or search_symbols again and select a current id."
4809 .to_string(),
4810 }
4811 })?;
4812 resolved_seed_symbols.push(symbol);
4813 }
4814 let mut seeds = Vec::new();
4815 let seed_type = if (!seed_symbols.is_empty() || !seed_ids.is_empty()) && !seed_paths.is_empty()
4816 {
4817 for s in seed_symbols {
4818 seeds.push(s.to_string());
4819 }
4820 for id in seed_ids {
4821 seeds.push(id.to_string());
4822 }
4823 for p in seed_paths {
4824 seeds.push(p.to_string());
4825 }
4826 "mixed".to_string()
4827 } else if !seed_symbols.is_empty() || !seed_ids.is_empty() {
4828 for s in seed_symbols {
4829 seeds.push(s.to_string());
4830 }
4831 for id in seed_ids {
4832 seeds.push(id.to_string());
4833 }
4834 "symbol".to_string()
4835 } else if !seed_paths.is_empty() {
4836 for p in seed_paths {
4837 seeds.push(p.to_string());
4838 }
4839 "file".to_string()
4840 } else {
4841 return Ok(BlastRadiusResult {
4842 seed_type: "none".to_string(),
4843 seeds: Vec::new(),
4844 likely_tests: Vec::new(),
4845 impacted_symbols: Vec::new(),
4846 likely_tests_truncated: false,
4847 impacted_symbols_truncated: false,
4848 traversal_ceiling_reached: false,
4849 test_file_ceiling_reached: false,
4850 limit_at_maximum: false,
4851 likely_tests_found: 0,
4852 impacted_symbols_found: 0,
4853 });
4854 };
4855
4856 let mut where_clauses = Vec::new();
4857 let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
4858
4859 if !resolved_seed_symbols.is_empty() {
4860 let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
4861 .map(|i| format!("?{}", i))
4862 .collect();
4863 where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
4864 for symbol in &resolved_seed_symbols {
4865 params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
4866 }
4867 }
4868
4869 if !seed_paths.is_empty() {
4870 let mut path_conds = Vec::new();
4871 for p in seed_paths.iter() {
4872 let raw = p
4873 .replace('\\', "/")
4874 .trim_start_matches("./")
4875 .trim_matches('/')
4876 .to_string();
4877 let exact_idx = params_vec.len() + 1;
4878 params_vec.push(rusqlite::types::Value::Text(raw.clone()));
4879 let dir_pattern = format!("{}/%", escape_like(&raw));
4880 let like_idx = params_vec.len() + 1;
4881 params_vec.push(rusqlite::types::Value::Text(dir_pattern));
4882 path_conds.push(format!(
4883 "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
4884 ));
4885 }
4886 where_clauses.push(format!("({})", path_conds.join(" OR ")));
4887 }
4888
4889 let seed_condition = where_clauses.join(" OR ");
4890 let max_depth_idx = params_vec.len() + 1;
4891 params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
4892
4893 let mut traversal_ceiling_reached = false;
4894 let mut test_file_ceiling_reached = false;
4895
4896 let has_relationships: bool = conn
4897 .query_row(
4898 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
4899 [],
4900 |_| Ok(true),
4901 )
4902 .unwrap_or(false);
4903
4904 let has_pending: bool = conn
4905 .query_row(
4906 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
4907 [],
4908 |_| Ok(true),
4909 )
4910 .unwrap_or(false);
4911
4912 let mut likely_tests = Vec::new();
4913 let mut impacted_symbols = Vec::new();
4914 let mut seen_test_keys = HashSet::new();
4915
4916 let mut recursive_branches = Vec::new();
4917
4918 if has_relationships {
4919 recursive_branches.push(format!(
4920 "SELECT r.from_symbol_id, iw.depth + 1, iw.via
4921 FROM relationships r
4922 JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
4923 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
4924 WHERE iw.depth < ?{max_depth_idx}
4925 AND s_from.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})"
4926 ));
4927 }
4928
4929 if has_pending {
4930 let (parent_join, ns_condition) = if conn
4931 .query_row(
4932 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
4933 [],
4934 |_| Ok(true),
4935 )
4936 .unwrap_or(false)
4937 {
4938 (
4939 "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
4940 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
4941 format!("AND {pred}", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
4942 )
4943 } else {
4944 ("", String::new())
4945 };
4946
4947 recursive_branches.push(format!(
4948 "SELECT p.from_symbol_id, iw.depth + 1, iw.via
4949 FROM pending_relationships p
4950 JOIN symbols s_target ON p.target_terminal_name = s_target.name
4951 JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
4952 {parent_join}
4953 WHERE iw.depth < ?{max_depth_idx}
4954 AND s_target.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
4955 {ns_condition}"
4956 ));
4957 }
4958
4959 if has_relationships {
4962 recursive_branches.push(format!(
4963 "SELECT r.from_symbol_id, iw.depth + 1, 1
4964 FROM impact_walk iw
4965 CROSS JOIN symbols ctor ON ctor.symbol_id = iw.symbol_id
4966 JOIN relationships r ON r.to_symbol_id = ctor.parent_symbol_id
4967 WHERE iw.depth < ?{max_depth_idx}
4968 AND ctor.kind = 'constructor'
4969 AND +r.kind = 'calls'"
4970 ));
4971 }
4972 if has_pending && has_pending_namespace_column(conn) {
4973 recursive_branches.push(format!(
4974 "SELECT p.from_symbol_id, iw.depth + 1, 1
4975 FROM impact_walk iw
4976 CROSS JOIN symbols ctor ON ctor.symbol_id = iw.symbol_id
4977 CROSS JOIN symbols s_target ON s_target.symbol_id = ctor.parent_symbol_id
4978 JOIN pending_relationships p ON p.target_terminal_name = s_target.name
4979 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
4980 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
4981 WHERE iw.depth < ?{max_depth_idx}
4982 AND ctor.kind = 'constructor'
4983 AND s_target.kind IN ('class', 'struct', 'record')
4984 AND +p.kind = 'calls'
4985 AND {pred}",
4986 pred = pending_target_predicate(conn, "s_target", "s_target_parent")
4987 ));
4988 }
4989
4990 let lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
4991 "COALESCE(s.test_lifecycle, 0) != 0"
4992 } else {
4993 "0"
4994 };
4995 let mut fixtures = Vec::new();
4996 let mut setups = Vec::new();
4997 let mut entry_classes = Vec::new();
4998 let mut walked: Vec<String> = resolved_seed_symbols
4999 .iter()
5000 .map(|symbol| symbol.symbol_id.clone())
5001 .collect();
5002 if !recursive_branches.is_empty() {
5003 let recursive_sql = recursive_branches.join("\n UNION \n");
5004 let not_documentation = not_documentation(conn, "s");
5005 let sql = format!(
5006 "WITH RECURSIVE impact_walk(symbol_id, depth, via) AS (
5007 SELECT symbol_id, 0, 0
5008 FROM symbols
5009 WHERE ({seed_condition})
5010 AND kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
5011
5012 UNION
5013
5014 {recursive_sql}
5015 )
5016 SELECT s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container, MIN(iw.depth) as min_depth,
5017 {lifecycle} AS is_fixture, s.parent_symbol_id,
5018 EXISTS (SELECT 1 FROM symbols owner
5019 WHERE owner.symbol_id = s.parent_symbol_id
5020 AND COALESCE(owner.test_container, 0) != 0) AS in_test_class
5021 FROM impact_walk iw
5022 CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
5023 WHERE s.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
5024 AND {not_documentation}
5025 GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
5026 HAVING MIN(iw.depth) > 0
5027 ORDER BY MIN(iw.via) ASC, min_depth ASC, s.path ASC, s.name ASC
5028 LIMIT 201"
5029 );
5030
5031 let mut stmt = conn.prepare(&sql)?;
5032 let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
5033 .iter()
5034 .map(|v| v as &dyn rusqlite::ToSql)
5035 .collect();
5036
5037 let rows = stmt.query_map(param_refs.as_slice(), |row| {
5038 Ok((
5039 row.get::<_, String>(0)?,
5040 row.get::<_, String>(1)?,
5041 row.get::<_, String>(2)?,
5042 row.get::<_, String>(3)?,
5043 row.get::<_, i64>(4)? as usize,
5044 row.get::<_, bool>(5)?,
5045 row.get::<_, bool>(6)?,
5046 row.get::<_, i64>(7)? as usize,
5047 row.get::<_, bool>(8)?,
5048 row.get::<_, Option<String>>(9)?,
5049 row.get::<_, bool>(10)?,
5050 ))
5051 })?;
5052
5053 for (index, r) in rows.enumerate() {
5054 if index == 200 {
5055 traversal_ceiling_reached = true;
5056 break;
5057 }
5058 let (
5059 sym_id,
5060 name,
5061 kind,
5062 raw_path,
5063 line,
5064 is_test,
5065 test_container,
5066 depth,
5067 is_fixture,
5068 parent,
5069 in_test_class,
5070 ) = r?;
5071 walked.push(sym_id);
5072 let path = raw_path.replace('\\', "/");
5073 let is_test_target = is_test
5074 || test_container
5075 || is_fixture
5076 || (is_test_path(&path) && (in_test_class || names_a_test(&name)));
5077 if name == "__call__"
5078 && let Some(parent) = &parent
5079 {
5080 entry_classes.push(parent.clone());
5081 }
5082
5083 if is_test_target {
5084 let key = format!("{}:{}", path, line);
5085 if seen_test_keys.insert(key) {
5086 let lowered = name.to_ascii_lowercase();
5087 let pytest_fixture = path.ends_with(".py")
5088 && !["setup", "teardown", "asyncsetup", "asyncteardown"]
5089 .iter()
5090 .any(|prefix| lowered.starts_with(prefix));
5091 let caller = if depth == 1 {
5092 "direct caller".to_string()
5093 } else {
5094 format!("indirect caller [depth {depth}]")
5095 };
5096 let reason = if is_fixture && pytest_fixture {
5097 fixtures.push((name.clone(), path.clone(), line));
5098 format!("fixture ({caller})")
5099 } else if is_fixture {
5100 if let Some(class_id) = parent {
5101 setups.push((name.clone(), class_id, path.clone(), line));
5102 }
5103 format!("setup ({caller})")
5104 } else {
5105 caller
5106 };
5107 likely_tests.push(TestTarget {
5108 name,
5109 path,
5110 line,
5111 reason,
5112 });
5113 }
5114 } else {
5115 impacted_symbols.push(ImpactedSymbol {
5116 name,
5117 kind,
5118 path,
5119 line,
5120 depth,
5121 });
5122 }
5123 }
5124 }
5125
5126 let mut replaced = HashSet::new();
5128 for (fixture, fixture_path, line) in &fixtures {
5129 let shown = fixture_name(conn, fixture, fixture_path);
5130 let users = fixture_users(conn, fixture, fixture_path)?;
5131 if !users.is_empty() {
5132 replaced.insert((fixture_path.clone(), *line));
5133 }
5134 for test in users {
5135 if seen_test_keys.insert(format!("{}:{}", test.path, test.line)) {
5136 likely_tests.push(TestTarget {
5137 reason: format!("uses fixture `{shown}`"),
5138 ..test
5139 });
5140 }
5141 }
5142 }
5143
5144 for (setup, class_id, setup_path, line) in &setups {
5145 let tests = tests_in_class(conn, class_id)?;
5146 if !tests.is_empty() {
5147 replaced.insert((setup_path.clone(), *line));
5148 }
5149 for test in tests {
5150 if seen_test_keys.insert(format!("{}:{}", test.path, test.line)) {
5151 likely_tests.push(TestTarget {
5152 reason: format!("setup `{setup}` runs before it"),
5153 ..test
5154 });
5155 }
5156 }
5157 }
5158 likely_tests.retain(|test| !replaced.contains(&(test.path.clone(), test.line)));
5159
5160 let mut test_name_terms = Vec::new();
5161 let mut module_terms = Vec::new();
5162 for path in seed_paths.iter().copied().chain(
5163 resolved_seed_symbols
5164 .iter()
5165 .map(|symbol| symbol.path.as_str()),
5166 ) {
5167 let mut parts = path.rsplit(['/', '\\']);
5168 let file = parts.next().unwrap_or(path);
5169 if let Some(stem) = std::path::Path::new(file)
5170 .file_stem()
5171 .and_then(|s| s.to_str())
5172 && stem.len() >= 3
5173 && !test_name_terms.iter().any(|(name, _)| name == stem)
5174 {
5175 test_name_terms.push((stem.to_string(), "stem-matched test file"));
5176 }
5177 let module = parts.next();
5178 if parts.next() == Some("src")
5179 && let Some(module) = module
5180 && module.len() >= 3
5181 && !["test", "tests", "spec", "specs", "bin", "lib"]
5182 .iter()
5183 .any(|generic| module.eq_ignore_ascii_case(generic))
5184 && !test_name_terms.iter().any(|(name, _)| name == module)
5185 && !module_terms.iter().any(|name| name == module)
5186 {
5187 module_terms.push(module.to_string());
5188 }
5189 }
5190 test_name_terms.extend(
5191 module_terms
5192 .into_iter()
5193 .map(|module| (module, "module-matched test file")),
5194 );
5195
5196 let has_files: bool = conn
5197 .query_row(
5198 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
5199 [],
5200 |_| Ok(true),
5201 )
5202 .unwrap_or(false);
5203
5204 if has_files {
5205 let doc_file = format!(
5206 "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
5207 not_documentation(conn, "d")
5208 );
5209 let slashed = "replace(path, '\\', '/')";
5210 let file_name =
5211 format!("replace({slashed}, rtrim({slashed}, replace({slashed}, '/', '')), '')");
5212 let name_tokens =
5213 format!("'_' || lower(replace(replace({file_name}, '-', '_'), '.', '_')) || '_'");
5214 let names_or_holds_tests = format!(
5215 "({name_tokens} LIKE '%\\_test\\_%' ESCAPE '\\' OR {name_tokens} LIKE '%\\_tests\\_%' ESCAPE '\\'
5216 OR {name_tokens} LIKE '%\\_spec\\_%' ESCAPE '\\' OR {name_tokens} LIKE '%\\_specs\\_%' ESCAPE '\\'
5217 OR {name_tokens} LIKE '%\\_tst\\_%' ESCAPE '\\'
5218 OR {file_name} GLOB '*Test.*' OR {file_name} GLOB '*Tests.*'
5219 OR {file_name} GLOB '*Spec.*' OR {file_name} GLOB '*Specs.*'
5220 OR EXISTS (SELECT 1 FROM symbols t WHERE t.path = files.path AND t.is_test = 1))"
5221 );
5222 let mut test_files_stmt = conn.prepare(&format!(
5223 "SELECT DISTINCT path FROM files
5224 WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND {file_name} LIKE ?1 ESCAPE '\\'
5225 AND {names_or_holds_tests}
5226 AND NOT {doc_file}
5227 ORDER BY path ASC
5228 LIMIT 201"
5229 ))?;
5230 let mut module_test_files_stmt = conn.prepare(&format!(
5231 "SELECT DISTINCT path FROM files
5232 WHERE (path LIKE '%test%' OR path LIKE '%spec%')
5233 AND (path LIKE ?1 ESCAPE '\\' OR path LIKE ?2 ESCAPE '\\')
5234 AND {names_or_holds_tests}
5235 AND NOT {doc_file}
5236 ORDER BY path ASC
5237 LIMIT 201"
5238 ))?;
5239 for (term, reason) in test_name_terms {
5240 let module_match = reason == "module-matched test file";
5241 let escaped_term = escape_like(&term);
5242 let mut rows = if module_match {
5243 module_test_files_stmt.query(rusqlite::params![
5244 format!("%/{escaped_term}%"),
5245 format!("%/test\\_{escaped_term}%")
5246 ])?
5247 } else {
5248 test_files_stmt.query([format!("%{escaped_term}%")])?
5249 };
5250 let mut candidates = 0;
5251 let mut matches = 0;
5252 while let Some(row) = rows.next()? {
5253 candidates += 1;
5254 if candidates == 201 {
5255 test_file_ceiling_reached = true;
5256 break;
5257 }
5258 let p: String = row.get(0)?;
5259 let basename = p.rsplit(['/', '\\']).next().unwrap_or_default();
5260 if !module_match && !has_word_run(&file_words(basename), &file_words(&term)) {
5261 continue;
5262 }
5263 if module_match {
5264 let basename = p
5265 .rsplit(['/', '\\'])
5266 .next()
5267 .unwrap_or_default()
5268 .to_lowercase();
5269 let term = term.to_lowercase();
5270 if !basename.starts_with(&term)
5271 && !basename.starts_with(&format!("test_{term}"))
5272 {
5273 continue;
5274 }
5275 }
5276 if matches == 10 {
5277 test_file_ceiling_reached = true;
5278 break;
5279 }
5280 matches += 1;
5281 let p = p.replace('\\', "/");
5282 let key = format!("{}:1", p);
5283 if seen_test_keys.insert(key) {
5284 likely_tests.push(TestTarget {
5285 name: p.clone(),
5286 path: p,
5287 line: 1,
5288 reason: reason.to_string(),
5289 });
5290 }
5291 }
5292 }
5293 }
5294
5295 if has_table(conn, "relationships") {
5297 let mut entry_callers = conn.prepare(
5298 "SELECT DISTINCT caller.parent_symbol_id
5299 FROM relationships r
5300 JOIN symbols caller ON caller.symbol_id = r.from_symbol_id
5301 WHERE caller.name = '__call__' AND caller.parent_symbol_id IS NOT NULL
5302 AND r.to_symbol_id IN (SELECT value FROM json_each(?1))",
5303 )?;
5304 let walked_json = serde_json::to_string(&walked).unwrap_or_else(|_| "[]".into());
5305 for class_id in entry_callers.query_map([walked_json], |row| row.get::<_, String>(0))? {
5306 let class_id = class_id?;
5307 if !entry_classes.contains(&class_id) {
5308 entry_classes.push(class_id);
5309 }
5310 }
5311 }
5312
5313 let mut seed_words: Vec<String> = Vec::new();
5314 for symbol in &resolved_seed_symbols {
5315 let callees =
5316 find_references_for_symbol(conn, &symbol.name, "callees", 50, &symbol.symbol_id)
5317 .unwrap_or_default();
5318 for name in std::iter::once(symbol.name.as_str())
5319 .chain(callees.iter().map(|site| site.to_symbol_name.as_str()))
5320 {
5321 for word in name_words(name) {
5322 if !seed_words.contains(&word) && !GENERIC_NAME_WORDS.contains(&word.as_str()) {
5323 seed_words.push(word);
5324 }
5325 }
5326 }
5327 }
5328 for class_id in entry_classes {
5329 for test in implicit_entry_tests(conn, &class_id, &seed_words)? {
5330 if seen_test_keys.insert(format!("{}:{}", test.path, test.line)) {
5331 likely_tests.push(test);
5332 }
5333 }
5334 }
5335
5336 qualify_test_methods(conn, &mut likely_tests)?;
5337 let whole_files: HashSet<String> = likely_tests
5338 .iter()
5339 .filter(|test| test.reason.ends_with("matched test file"))
5340 .map(|test| test.path.clone())
5341 .collect();
5342 likely_tests.retain(|test| {
5343 test.reason.ends_with("matched test file") || !whole_files.contains(&test.path)
5344 });
5345 likely_tests.sort_by_key(|test| {
5347 if test.reason.ends_with("matched test file") {
5348 0
5349 } else if test.reason.starts_with("possible:") {
5350 2
5351 } else {
5352 1
5353 }
5354 });
5355 let likely_tests_found = likely_tests.len();
5356 let impacted_symbols_found = impacted_symbols.len();
5357 let likely_tests_truncated = likely_tests.len() > limit;
5358 let impacted_symbols_truncated = impacted_symbols.len() > limit;
5359 if likely_tests.len() > limit {
5360 likely_tests.truncate(limit);
5361 }
5362 if impacted_symbols.len() > limit {
5363 impacted_symbols.truncate(limit);
5364 }
5365
5366 Ok(BlastRadiusResult {
5367 seed_type,
5368 seeds,
5369 likely_tests,
5370 impacted_symbols,
5371 likely_tests_truncated,
5372 impacted_symbols_truncated,
5373 traversal_ceiling_reached,
5374 test_file_ceiling_reached,
5375 limit_at_maximum: limit >= MAX_RESULT_LIMIT,
5376 likely_tests_found,
5377 impacted_symbols_found,
5378 })
5379}
5380
5381fn qualify_test_methods(conn: &Connection, tests: &mut [TestTarget]) -> Result<(), QueryError> {
5384 let mut stmt = conn.prepare(
5385 "SELECT class.name FROM symbols t
5386 JOIN symbols class ON class.symbol_id = t.parent_symbol_id
5387 WHERE replace(t.path, '\\', '/') = ?1 AND t.start_line = ?2 AND t.name = ?3
5388 AND class.kind = 'class'
5389 LIMIT 1",
5390 )?;
5391 for test in tests.iter_mut().filter(|test| test.name != test.path) {
5392 if let Some(class) = stmt
5393 .query_row(params![test.path, test.line as i64, test.name], |row| {
5394 row.get::<_, String>(0)
5395 })
5396 .optional()?
5397 {
5398 test.name = format!("{class}::{}", test.name);
5399 }
5400 }
5401 Ok(())
5402}
5403
5404fn fixture_name(conn: &Connection, function: &str, path: &str) -> String {
5407 let signature: Option<String> = conn
5408 .query_row(
5409 "SELECT signature FROM symbols
5410 WHERE name = ?1 AND replace(path, '\\', '/') = ?2 AND signature LIKE '%fixture(%'
5411 LIMIT 1",
5412 params![function, path],
5413 |row| row.get(0),
5414 )
5415 .optional()
5416 .ok()
5417 .flatten();
5418 signature
5419 .as_deref()
5420 .and_then(declared_fixture_name)
5421 .unwrap_or_else(|| function.to_string())
5422}
5423
5424fn declared_fixture_name(signature: &str) -> Option<String> {
5426 let args = &signature[signature.find("fixture(")? + "fixture(".len()..];
5427 let args = &args[..args.find(')').unwrap_or(args.len())];
5428 let value = args[args.find("name=")? + "name=".len()..].trim_start();
5429 let quote = value.chars().next().filter(|c| *c == '"' || *c == '\'')?;
5430 let end = value[1..].find(quote)?;
5431 Some(value[1..1 + end].to_string())
5432}
5433
5434fn fixture_users(
5438 conn: &Connection,
5439 name: &str,
5440 fixture_path: &str,
5441) -> Result<Vec<TestTarget>, QueryError> {
5442 let lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
5443 "COALESCE(t.test_lifecycle, 0) != 0"
5444 } else {
5445 "0"
5446 };
5447 let mut stmt = conn.prepare(&format!(
5448 "SELECT DISTINCT t.name, t.path, t.start_line, {lifecycle}
5449 FROM symbols parameter
5450 JOIN symbols t ON t.symbol_id = parameter.parent_symbol_id
5451 WHERE parameter.name = ?1
5452 AND (parameter.kind = 'parameter'
5453 OR (parameter.kind = 'variable' AND parameter.start_byte < t.body_start_byte))
5454 AND t.is_test = 1
5455 AND replace(t.path, '\\', '/') LIKE ?2 ESCAPE '\\'
5456 ORDER BY t.path, t.start_line
5457 LIMIT 2000"
5458 ))?;
5459 let mut users = Vec::new();
5462 let mut listed = HashSet::new();
5463 let mut visited = HashSet::new();
5464 let mut pending = vec![(name.to_string(), fixture_path.to_string())];
5465 while let Some((function, fixture_path)) = pending.pop() {
5466 if !visited.insert((function.clone(), fixture_path.clone())) {
5467 continue;
5468 }
5469 let name = fixture_name(conn, &function, &fixture_path);
5470 let scope = match fixture_path.rsplit_once('/') {
5471 Some((dir, "conftest.py")) => format!("{}/%", escape_like(dir)),
5472 None if fixture_path == "conftest.py" => "%".to_string(),
5473 _ => escape_like(&fixture_path),
5474 };
5475 let rows = stmt.query_map(params![name, scope], |row| {
5476 Ok((
5477 TestTarget {
5478 name: row.get(0)?,
5479 path: row.get::<_, String>(1)?.replace('\\', "/"),
5480 line: row.get::<_, i64>(2)? as usize,
5481 reason: String::new(),
5482 },
5483 row.get::<_, bool>(3)?,
5484 ))
5485 })?;
5486 for row in rows {
5487 let (user, is_fixture) = row?;
5488 if is_fixture {
5489 pending.push((user.name, user.path));
5490 } else if listed.insert((user.path.clone(), user.line)) {
5491 users.push(user);
5492 }
5493 }
5494 }
5495 Ok(users)
5496}
5497
5498fn tests_in_class(conn: &Connection, class_id: &str) -> Result<Vec<TestTarget>, QueryError> {
5500 let not_lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
5501 "COALESCE(test_lifecycle, 0) = 0"
5502 } else {
5503 "1"
5504 };
5505 let mut stmt = conn.prepare(&format!(
5506 "SELECT name, path, start_line FROM symbols
5507 WHERE parent_symbol_id = ?1 AND is_test = 1 AND {not_lifecycle}
5508 ORDER BY start_line"
5509 ))?;
5510 let rows = stmt.query_map([class_id], |row| {
5511 Ok(TestTarget {
5512 name: row.get(0)?,
5513 path: row.get::<_, String>(1)?.replace('\\', "/"),
5514 line: row.get::<_, i64>(2)? as usize,
5515 reason: String::new(),
5516 })
5517 })?;
5518 Ok(rows.collect::<Result<_, _>>()?)
5519}
5520
5521fn file_words(name: &str) -> Vec<String> {
5523 let mut words = Vec::new();
5524 let mut word = String::new();
5525 let mut previous_lower = false;
5526 for c in name.chars() {
5527 if (!c.is_alphanumeric() || (c.is_uppercase() && previous_lower)) && !word.is_empty() {
5528 words.push(std::mem::take(&mut word).to_lowercase());
5529 }
5530 if c.is_alphanumeric() {
5531 word.push(c);
5532 }
5533 previous_lower = c.is_lowercase() || c.is_ascii_digit();
5534 }
5535 if !word.is_empty() {
5536 words.push(word.to_lowercase());
5537 }
5538 words
5539}
5540
5541fn has_word_run(words: &[String], run: &[String]) -> bool {
5543 !run.is_empty() && words.windows(run.len()).any(|window| window == run)
5544}
5545
5546fn name_words(name: &str) -> Vec<String> {
5548 let mut words = Vec::new();
5549 let mut word = String::new();
5550 let mut previous_lower = false;
5551 for c in name.chars() {
5552 if !c.is_alphanumeric() || (c.is_uppercase() && previous_lower) {
5553 if word.len() >= 3 {
5554 words.push(word.to_lowercase());
5555 }
5556 word.clear();
5557 }
5558 if c.is_alphanumeric() {
5559 word.push(c);
5560 }
5561 previous_lower = c.is_lowercase();
5562 }
5563 if word.len() >= 3 {
5564 words.push(word.to_lowercase());
5565 }
5566 words
5567}
5568
5569const GENERIC_NAME_WORDS: &[&str] = &[
5571 "get", "set", "new", "the", "and", "for", "self", "init", "ensure", "sync", "find", "make",
5572 "create", "load", "call", "add",
5573];
5574
5575fn common_prefix_len(a: &str, b: &str) -> usize {
5577 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
5578}
5579
5580fn shared_words(seed_words: &[String], test: &TestTarget) -> (usize, Vec<String>) {
5585 let mut found: Vec<String> = Vec::new();
5586 let mut score = 0;
5587 let file = test.path.rsplit('/').next().unwrap_or(&test.path);
5588 for text in [test.name.as_str(), file] {
5589 let words = name_words(text);
5590 for seed in seed_words {
5591 if words.iter().any(|word| {
5592 word.starts_with(seed.as_str())
5593 || seed.starts_with(word.as_str())
5594 || common_prefix_len(word, seed) >= 5
5595 }) {
5596 score += 1;
5597 if !found.contains(seed) {
5598 found.push(seed.clone());
5599 }
5600 }
5601 }
5602 }
5603 (score, found)
5604}
5605
5606fn implicit_entry_tests(
5612 conn: &Connection,
5613 class_id: &str,
5614 seed_words: &[String],
5615) -> Result<Vec<TestTarget>, QueryError> {
5616 let Some(class) = get_symbol_by_id(conn, class_id)? else {
5617 return Ok(Vec::new());
5618 };
5619 let lifecycle = if has_column(conn, "symbols", "test_lifecycle") {
5620 "COALESCE(test_lifecycle, 0) != 0"
5621 } else {
5622 "0"
5623 };
5624 let mut classes = vec![class];
5625 let mut sites_by_class = Vec::new();
5626 let mut next = 0;
5627 while next < classes.len() && classes.len() <= 50 {
5628 let built = classes[next].clone();
5629 next += 1;
5630 let sites =
5631 find_references_for_symbol(conn, &built.name, "callers", 200, &built.symbol_id)?;
5632 for site in sites.iter().filter(|site| site.kind == "extends") {
5633 if !classes.iter().any(|c| c.symbol_id == site.from_symbol_id)
5634 && let Some(subclass) = get_symbol_by_id(conn, &site.from_symbol_id)?
5635 {
5636 classes.push(subclass);
5637 }
5638 }
5639 sites_by_class.push((built, sites));
5640 }
5641 let mut builders = Vec::new();
5642 for (built, sites) in &sites_by_class {
5643 for site in sites {
5644 if site.kind != "calls" {
5645 continue;
5646 }
5647 let Some(builder) = get_symbol_by_id(conn, &site.from_symbol_id)? else {
5648 continue;
5649 };
5650 if !(builder.is_test || is_test_path(&builder.path)) {
5651 continue;
5652 }
5653 let is_fixture: bool = conn
5654 .query_row(
5655 &format!("SELECT {lifecycle} FROM symbols WHERE symbol_id = ?1"),
5656 [&builder.symbol_id],
5657 |row| row.get(0),
5658 )
5659 .unwrap_or(false);
5660 if is_fixture {
5661 let via = format!(
5662 "builds `{}` through fixture `{}`",
5663 built.name,
5664 fixture_name(conn, &builder.name, &builder.path)
5665 );
5666 builders.extend(
5667 fixture_users(conn, &builder.name, &builder.path)?
5668 .into_iter()
5669 .map(|test| TestTarget {
5670 reason: via.clone(),
5671 ..test
5672 }),
5673 );
5674 } else if builder.is_test {
5675 builders.push(TestTarget {
5676 name: builder.name,
5677 path: builder.path,
5678 line: builder.start_line,
5679 reason: format!("builds `{}`", built.name),
5680 });
5681 }
5682 }
5683 }
5684 let mut ranked: Vec<(usize, TestTarget)> = builders
5685 .into_iter()
5686 .filter(|test| uses_a_client(conn, test))
5687 .filter_map(|test| {
5688 let (score, words) = shared_words(seed_words, &test);
5689 (score > 0).then(|| {
5690 let shared = words
5691 .iter()
5692 .map(|word| format!("`{word}`"))
5693 .collect::<Vec<_>>()
5694 .join(", ");
5695 let reason = format!(
5696 "possible: {}, and a test client calls its `__call__`, which can reach the target; shares {shared}",
5697 test.reason
5698 );
5699 (score, TestTarget { reason, ..test })
5700 })
5701 })
5702 .collect();
5703 ranked.sort_by(|(a_score, a), (b_score, b)| {
5704 b_score
5705 .cmp(a_score)
5706 .then_with(|| a.path.cmp(&b.path))
5707 .then_with(|| a.line.cmp(&b.line))
5708 });
5709 Ok(ranked.into_iter().map(|(_, test)| test).collect())
5710}
5711
5712fn uses_a_client(conn: &Connection, test: &TestTarget) -> bool {
5717 conn.query_row(
5718 "SELECT EXISTS (
5719 SELECT 1 FROM symbols t
5720 WHERE t.name = ?3 AND t.start_line = ?2 AND replace(t.path, '\\', '/') = ?1
5721 AND (EXISTS (SELECT 1 FROM symbols p
5722 WHERE p.parent_symbol_id = t.symbol_id
5723 AND p.kind IN ('parameter', 'variable')
5724 AND lower(p.name) LIKE '%client%')
5725 OR EXISTS (SELECT 1 FROM pending_relationships pr
5726 WHERE pr.from_symbol_id = t.symbol_id
5727 AND (lower(pr.target_terminal_name) LIKE '%client%'
5728 OR lower(COALESCE(pr.target_receiver, '')) LIKE '%client%'))
5729 OR EXISTS (SELECT 1 FROM relationships r
5730 JOIN symbols callee ON callee.symbol_id = r.to_symbol_id
5731 WHERE r.from_symbol_id = t.symbol_id
5732 AND lower(callee.name) LIKE '%client%')))",
5733 params![test.path, test.line as i64, test.name],
5734 |row| row.get(0),
5735 )
5736 .unwrap_or(false)
5737}
5738
5739pub fn compute_blast_radius(
5741 conn: &Connection,
5742 seed_symbols: &[&str],
5743 seed_paths: &[&str],
5744 max_depth: usize,
5745 limit: usize,
5746) -> Result<BlastRadiusResult, QueryError> {
5747 compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
5748}
5749
5750#[cfg(test)]
5751mod tests {
5752 #[test]
5753 fn a_type_declaration_drops_attributes_and_fits_on_one_short_line() {
5754 assert_eq!(
5755 super::type_declaration_line(
5756 "#[derive(Debug)] #[serde(tag = \"t\")] pub struct Workspace"
5757 ),
5758 "pub struct Workspace"
5759 );
5760 let record = format!(
5761 "public sealed record Node(\n{})",
5762 " string Id,\n".repeat(20)
5763 );
5764 let line = super::type_declaration_line(&record);
5765 assert!(
5766 line.starts_with("public sealed record Node( string Id, string Id,"),
5767 "{line}"
5768 );
5769 assert_eq!(line.chars().count(), 120);
5770 assert!(line.ends_with('…'));
5771 }
5772
5773 #[test]
5774 fn a_mount_names_what_it_mounts_and_where() {
5775 let mount = |capture: &str, json: &str| {
5776 super::mount_display(capture, &serde_json::from_str(json).unwrap())
5777 };
5778 assert_eq!(
5779 mount(
5780 "blueprint_registration",
5781 r#"{"framework":"flask","mount_target":"auth.bp","query_family":"framework"}"#
5782 )
5783 .as_deref(),
5784 Some("blueprint_registration `auth.bp`")
5785 );
5786 assert_eq!(
5787 mount(
5788 "mount",
5789 r#"{"mount_path":"/jobs","mount_target":"Sidekiq::Web","normalized_mount_path":"/admin/jobs","query_family":"framework"}"#
5790 )
5791 .as_deref(),
5792 Some("mount `Sidekiq::Web` at /admin/jobs")
5793 );
5794 assert_eq!(
5795 mount(
5796 "property",
5797 r#"{"mount_target":"x","query_family":"metadata"}"#
5798 ),
5799 None
5800 );
5801 }
5802
5803 #[test]
5804 fn a_route_shows_its_source_template_and_keeps_a_joined_prefix() {
5805 let route = |json: &str| super::route_display(&serde_json::from_str(json).unwrap());
5806 assert_eq!(
5807 route(
5808 r#"{"verb":"GET","route_template":"/<int:id>/update","normalized_route_template":"/:id/update"}"#
5809 ),
5810 Some("GET /<int:id>/update".into())
5811 );
5812 assert_eq!(
5813 route(
5814 r#"{"verb":"POST","route_template":"/register","effective_route_template":"/auth/register","normalized_route_template":"/auth/register"}"#
5815 ),
5816 Some("POST /auth/register".into())
5817 );
5818 assert_eq!(
5819 route(
5820 r#"{"verb":"GET","route_template":"/users/{id}","normalized_route_template":"/api/v1/users/:id"}"#
5821 ),
5822 Some("GET /api/v1/users/:id".into())
5823 );
5824 assert_eq!(route(r#"{"key":"x"}"#), None);
5825 }
5826
5827 #[test]
5828 fn a_fixture_declared_with_a_name_is_used_by_that_name() {
5829 assert_eq!(
5830 super::declared_fixture_name("@pytest.fixture(name=\"async_app\") def _async_app()"),
5831 Some("async_app".into())
5832 );
5833 assert_eq!(
5834 super::declared_fixture_name(
5835 "@pytest.fixture(scope='session', name='db') def make_db()"
5836 ),
5837 Some("db".into())
5838 );
5839 assert_eq!(
5840 super::declared_fixture_name("@pytest.fixture def app()"),
5841 None
5842 );
5843 assert_eq!(
5844 super::declared_fixture_name("@pytest.fixture(params=[1]) def f(name='x')"),
5845 None
5846 );
5847 }
5848
5849 #[test]
5850 fn a_stem_matches_whole_words_of_a_test_file_name() {
5851 let matches = |file: &str, stem: &str| {
5852 super::has_word_run(&super::file_words(file), &super::file_words(stem))
5853 };
5854 assert!(matches("test_app.py", "app"));
5855 assert!(matches("AppTest.java", "App"));
5856 assert!(matches("test_user_error_handler.py", "user_error_handler"));
5857 assert!(matches("widget_test_3.rs", "widget"));
5858 assert!(!matches("test_appctx.py", "app"));
5859 assert!(!matches("test_mapper.py", "app"));
5860 }
5861
5862 #[test]
5863 fn a_test_with_a_client_is_found_under_a_backslash_path() {
5864 let conn = rusqlite::Connection::open_in_memory().unwrap();
5865 conn.execute_batch(
5866 "CREATE TABLE symbols (symbol_id TEXT, name TEXT, path TEXT, start_line INTEGER,
5867 parent_symbol_id TEXT, kind TEXT);
5868 CREATE TABLE pending_relationships (from_symbol_id TEXT, target_terminal_name TEXT,
5869 target_receiver TEXT);
5870 CREATE TABLE relationships (from_symbol_id TEXT, to_symbol_id TEXT);
5871 INSERT INTO symbols VALUES ('t', 'test_user', 'tests\\test_user.py', 4, NULL, 'function');
5872 INSERT INTO symbols VALUES ('c', 'client', 'tests\\test_user.py', 4, 't', 'parameter');",
5873 )
5874 .unwrap();
5875 let test = crate::models::TestTarget {
5876 name: "test_user".into(),
5877 path: "tests/test_user.py".into(),
5878 line: 4,
5879 reason: String::new(),
5880 };
5881
5882 assert!(super::uses_a_client(&conn, &test));
5883 }
5884
5885 #[test]
5886 fn result_limit_rejects_values_above_the_shared_ceiling() {
5887 assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
5888 assert!(matches!(
5889 validate_result_limit(usize::MAX),
5890 Err(QueryError::InvalidResultLimit(usize::MAX))
5891 ));
5892 }
5893
5894 #[test]
5895 fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
5896 let conn = Connection::open_in_memory().unwrap();
5897
5898 assert!(matches!(
5899 find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
5900 Err(QueryError::InvalidResultLimit(usize::MAX))
5901 ));
5902 }
5903
5904 use super::*;
5905 use crate::db::{ensure_fts_index, open_read_write};
5906
5907 #[test]
5908 fn count_parse_diagnostics_counts_rows_for_one_file() {
5909 let dir = crate::safe_tempdir();
5910 let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
5911
5912 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
5913
5914 conn.execute_batch(
5915 "CREATE TABLE parse_diagnostics (
5916 diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
5917 );
5918 INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
5919 INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
5920 INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
5921 )
5922 .unwrap();
5923
5924 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
5925 assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
5926 assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
5927 }
5928
5929 #[test]
5930 fn test_sanitize_fts5_query() {
5931 let (and_q, or_q) = sanitize_fts5_query("parse tokens");
5932 assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
5933 assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
5934
5935 let (and_q, or_q) = sanitize_fts5_query(" Option<T> ");
5936 assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
5937 assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
5938
5939 let (and_q, or_q) = sanitize_fts5_query(" ");
5940 assert!(and_q.is_empty());
5941 assert!(or_q.is_empty());
5942 }
5943
5944 #[test]
5945 fn sanitize_splits_case_boundaries_and_drops_stop_words() {
5946 let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
5947 assert_eq!(
5948 and_q,
5949 "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
5950 );
5951 assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
5952
5953 let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
5954 assert_eq!(
5955 and_q,
5956 "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
5957 );
5958
5959 let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
5960 assert_eq!(
5961 and_q,
5962 "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
5963 );
5964 assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
5965
5966 let (and_q, _) = sanitize_fts5_query("validate_syntax");
5967 assert_eq!(
5968 and_q,
5969 "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
5970 );
5971
5972 let (and_q, _) = sanitize_fts5_query("isReady");
5973 assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
5974
5975 let (and_q, _) = sanitize_fts5_query("before");
5976 assert_eq!(and_q, "\"before\"*");
5977
5978 let (and_q, _) = sanitize_fts5_query("fooBar quux");
5979 assert_eq!(
5980 and_q,
5981 "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
5982 );
5983
5984 let (and_q, _) = sanitize_fts5_query("the for a");
5985 assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
5986 }
5987
5988 fn search_fixture(rows: &str) -> Connection {
5989 let conn = Connection::open_in_memory().unwrap();
5990 conn.execute_batch(&format!(
5991 "CREATE TABLE symbols (
5992 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5993 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5994 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5995 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5996 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5997 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5998 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5999 );
6000 INSERT INTO symbols VALUES {rows};"
6001 ))
6002 .unwrap();
6003 ensure_fts_index(&conn).unwrap();
6004 conn
6005 }
6006
6007 fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
6008 format!(
6009 "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
6010 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
6011 )
6012 }
6013
6014 fn doc_row(id: &str, name: &str, doc: &str) -> String {
6015 format!(
6016 "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
6017 3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
6018 )
6019 }
6020
6021 #[test]
6022 fn lookup_and_search_skip_documentation_links_recorded_as_imports() {
6023 let conn = search_fixture(
6024 &[
6025 "('i1', 'f_i1', 'src/app.py', 'python', 'Flask', 'import', 'from flask import Flask', NULL, NULL, NULL, 1, 0, 1, 20, 0, 20, NULL, NULL, NULL, NULL, NULL, NULL, 'h_i1', NULL, 0, 0, 'code')".to_string(),
6026 "('i2', 'f_i2', 'README.md', 'markdown', 'Flask', 'import', '[Flask](https://flask.dev)', NULL, NULL, NULL, 1, 0, 1, 20, 0, 20, NULL, NULL, NULL, NULL, NULL, NULL, 'h_i2', NULL, 0, 0, 'documentation')".to_string(),
6027 ]
6028 .join(","),
6029 );
6030
6031 let lookup: Vec<String> = search_symbols(&conn, "Flask", Some("import"), true, 10)
6032 .unwrap()
6033 .into_iter()
6034 .map(|s| s.path)
6035 .collect();
6036 assert_eq!(lookup, vec!["src/app.py"]);
6037 let search: Vec<String> =
6038 fts_search_symbols_scoped(&conn, "Flask", Some("import"), None, true, 10)
6039 .unwrap()
6040 .into_iter()
6041 .map(|r| r.symbol.path)
6042 .collect();
6043 assert_eq!(search, vec!["src/app.py"]);
6044 }
6045
6046 #[test]
6047 fn lookup_ranks_a_prefix_match_above_a_substring_match() {
6048 let conn = search_fixture(
6049 &[
6050 code_row("c1", "tests/a.rs", "rust", "test_test_client", ""),
6051 code_row("c2", "tests/b.rs", "rust", "test_client_open_environ", ""),
6052 ]
6053 .join(","),
6054 );
6055
6056 let names: Vec<String> = search_symbols(&conn, "test_cli", None, true, 10)
6057 .unwrap()
6058 .into_iter()
6059 .map(|s| s.name)
6060 .collect();
6061
6062 assert_eq!(names, vec!["test_client_open_environ", "test_test_client"]);
6063 }
6064
6065 #[test]
6066 fn lookup_ranks_a_declared_property_above_a_self_assignment() {
6067 let property = |id: &str, path: &str, signature: &str| {
6068 format!(
6069 "('{id}', 'f_{id}', '{path}', 'python', 'debug', 'property', '{signature}', NULL, NULL, NULL, 1, 0, 1, 20, 0, 20, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'code')"
6070 )
6071 };
6072 let conn = search_fixture(
6073 &[
6074 property("p1", "src/flask/app.py", "self.debug = get_debug_flag()"),
6075 property(
6076 "p2",
6077 "src/flask/sansio/app.py",
6078 "@property def debug(self) -> bool",
6079 ),
6080 ]
6081 .join(","),
6082 );
6083
6084 let paths: Vec<String> = search_symbols(&conn, "debug", None, false, 10)
6085 .unwrap()
6086 .into_iter()
6087 .map(|s| s.path)
6088 .collect();
6089
6090 assert_eq!(paths, vec!["src/flask/sansio/app.py", "src/flask/app.py"]);
6091 }
6092
6093 fn search_names(conn: &Connection, query: &str) -> Vec<String> {
6094 fts_search_symbols_scoped(conn, query, None, None, false, 10)
6095 .unwrap()
6096 .into_iter()
6097 .map(|r| r.symbol.name)
6098 .collect()
6099 }
6100
6101 const TEST_PATH_CASES: &[(&str, bool)] = &[
6102 ("tests/foo.py", true),
6103 ("tests/x.py", true),
6104 ("tests/tools/test_web.py", true),
6105 ("src/tests/x.rs", true),
6106 ("__tests__/a.ts", true),
6107 ("a/__tests__/b.ts", true),
6108 ("test/x.java", true),
6109 ("src/test/Helper.java", true),
6110 ("src/test_utils.py", true),
6111 ("lib/test_helper.rb", true),
6112 ("test_config.py", true),
6113 ("pkg/test_data/x.json", false),
6114 ("src/test_detection.rs", false),
6115 ("autotests/tst_pagerow.qml", true),
6116 ("autotests/helper.qml", true),
6117 ("src/autotests/columnview.cpp", true),
6118 ("tst_foo.qml", true),
6119 ("src/tst_columnview.qml", true),
6120 ("autotests\\tst_bar.qml", true),
6121 ("autotests_helper/x.rs", false),
6122 ("src/autotest.rs", false),
6123 ("src/tstamp.rs", false),
6124 ("src/tst.rs", false),
6125 ("crates/julie-index/src/analysis/test_quality.rs", false),
6126 ("x/foo_test.go", true),
6127 ("x/foo.test.ts", true),
6128 ("x/foo.spec.js", true),
6129 ("src/lib_test.rs", true),
6130 ("src/test.rs", true),
6131 ("tests.rs", true),
6132 ("src/tests.rs", true),
6133 ("Foo.Tests.cs", true),
6134 ("x/FooTests.cs", true),
6135 ("src/FooTests.cs", true),
6136 ("src/Foo.Tests.cs", true),
6137 ("tests/Foo.cs", true),
6138 ("x/parser.spec.ts", true),
6139 ("test_x.py", true),
6140 ("test.rs", true),
6141 ("tests\\x.py", true),
6142 ("src/protocol.spec.v1/parser.rs", false),
6143 ("pkg/test_support/runtime.py", false),
6144 ("src/Contests.cs", false),
6145 ("spec/x.rb", false),
6146 ("crates/x/src/impact/likely_tests.rs", false),
6147 ("x/foo_tests.rs", false),
6148 ("src/latest.rs", false),
6149 ("x/latest.go", false),
6150 ("x/manifest.rs", false),
6151 ("src/attest.rs", false),
6152 ("contest/x.py", false),
6153 ("src/testing.rs", false),
6154 ("src/main.rs", false),
6155 ("pkg/service.go", false),
6156 ];
6157
6158 #[test]
6159 fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
6160 let conn = Connection::open_in_memory().unwrap();
6161 let sql = format!(
6162 "SELECT {} FROM (SELECT :path AS path) s",
6163 test_path_predicate("s")
6164 );
6165 let mut stmt = conn.prepare(&sql).unwrap();
6166 for (path, expected) in TEST_PATH_CASES {
6167 assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
6168 let from_sql: bool = stmt
6169 .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
6170 .unwrap();
6171 assert_eq!(from_sql, *expected, "sql mirror: {path}");
6172 }
6173 }
6174
6175 #[test]
6176 fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
6177 let conn = search_fixture(
6178 &[
6179 code_row(
6180 "a",
6181 "src/parser.rs",
6182 "rust",
6183 "parse_sidecar",
6184 "Parse a sidecar.",
6185 ),
6186 code_row(
6187 "b",
6188 "src/tests/helpers.py",
6189 "python",
6190 "parse_sidecar_fixture",
6191 "Parse a sidecar.",
6192 ),
6193 ]
6194 .join(", "),
6195 );
6196
6197 let default_search: Vec<String> =
6198 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
6199 .unwrap()
6200 .into_iter()
6201 .map(|r| r.symbol.name)
6202 .collect();
6203 assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
6204
6205 let with_tests: Vec<String> =
6206 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
6207 .unwrap()
6208 .into_iter()
6209 .map(|r| r.symbol.name)
6210 .collect();
6211 assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
6212
6213 let default_lookup: Vec<String> =
6214 search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
6215 .unwrap()
6216 .into_iter()
6217 .map(|s| s.name)
6218 .collect();
6219 assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
6220
6221 let lookup_with_tests: Vec<String> =
6222 search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
6223 .unwrap()
6224 .into_iter()
6225 .map(|s| s.name)
6226 .collect();
6227 assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
6228 }
6229
6230 #[test]
6231 fn count_file_symbols_prefers_the_exact_case_path_like_the_loader() {
6232 let conn = search_fixture(
6233 &[
6234 code_row("a", "src/Foo.rs", "rust", "one", ""),
6235 code_row("b", "src/foo.rs", "rust", "two", ""),
6236 code_row("c", "src/foo.rs", "rust", "three", ""),
6237 ]
6238 .join(", "),
6239 );
6240 for path in ["src/Foo.rs", "src/foo.rs", "src/FOO.rs", "src\\foo.rs"] {
6241 assert_eq!(
6242 count_file_symbols(&conn, path),
6243 load_file_symbols(&conn, path).unwrap().len(),
6244 "{path}"
6245 );
6246 }
6247 assert_eq!(count_file_symbols(&conn, "src/Foo.rs"), 1);
6248 assert_eq!(count_file_symbols(&conn, "src/FOO.rs"), 3);
6249 }
6250
6251 #[test]
6252 fn lookup_statement_is_not_planned_as_a_multi_index_or() {
6253 let conn = search_fixture(&code_row("a", "src/lib.rs", "rust", "needle", ""));
6254 conn.execute_batch(
6255 "CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
6256 CREATE INDEX idx_symbols_test_container ON symbols(test_container);
6257 CREATE INDEX idx_symbols_is_test ON symbols(is_test);",
6258 )
6259 .unwrap();
6260 let sql = format!(
6261 "EXPLAIN QUERY PLAN {}",
6262 search_symbols_sql(false, false, 20)
6263 );
6264 let plan: Vec<String> = conn
6265 .prepare(&sql)
6266 .unwrap()
6267 .query_map(
6268 rusqlite::named_params! {
6269 ":query": "needle",
6270 ":pattern": "%needle%",
6271 ":kind": None::<&str>,
6272 ":path": None::<&str>,
6273 ":path_like": None::<&str>,
6274 },
6275 |row| row.get::<_, String>(3),
6276 )
6277 .unwrap()
6278 .collect::<Result<_, _>>()
6279 .unwrap();
6280 assert!(
6281 !plan.iter().any(|step| step.contains("MULTI-INDEX OR")),
6282 "{plan:?}"
6283 );
6284 }
6285
6286 #[test]
6287 fn qualified_lookup_in_a_test_file_returns_the_named_row() {
6288 let conn = search_fixture(
6289 &[
6290 "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
6291 1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
6292 "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
6293 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
6294 ]
6295 .join(", "),
6296 );
6297
6298 assert_eq!(
6299 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
6300 .unwrap()
6301 .len(),
6302 1
6303 );
6304 assert_eq!(
6305 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
6306 .unwrap()
6307 .len(),
6308 1
6309 );
6310 assert!(
6311 search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
6312 .unwrap()
6313 .is_empty()
6314 );
6315 }
6316
6317 #[test]
6318 fn concept_query_prefers_partial_code_match_over_full_doc_match() {
6319 let conn = search_fixture(
6320 &[
6321 doc_row(
6322 "d1",
6323 "Safety guarantees",
6324 "Pre-flight syntax validation runs before the edit touches disk",
6325 ),
6326 doc_row(
6327 "d2",
6328 "Audit",
6329 "The syntax validation before an edit is the invariant",
6330 ),
6331 code_row(
6332 "c1",
6333 "src/syntax.rs",
6334 "rust",
6335 "validate_syntax",
6336 "Validate the syntax of a file",
6337 ),
6338 code_row(
6339 "c2",
6340 "src/edit.rs",
6341 "rust",
6342 "replace_symbol_body",
6343 "Atomic edit with validation",
6344 ),
6345 ]
6346 .join(","),
6347 );
6348
6349 let names = search_names(&conn, "syntax validation before edit");
6350
6351 assert_eq!(names[0], "validate_syntax");
6352 assert!(names.contains(&"replace_symbol_body".to_string()));
6353 assert!(names.contains(&"Safety guarantees".to_string()));
6354 }
6355
6356 #[test]
6357 fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
6358 let conn = search_fixture(
6359 &[
6360 code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
6361 code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
6362 ]
6363 .join(","),
6364 );
6365
6366 let mut camel = search_names(&conn, "ValidateSyntax");
6367 camel.sort();
6368 assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
6369 let mut words = search_names(&conn, "validate syntax");
6370 words.sort();
6371 assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
6372 }
6373
6374 #[test]
6375 fn stop_word_prefixed_camel_case_symbol_is_still_found() {
6376 let conn = search_fixture(
6377 &[
6378 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
6379 code_row("c2", "src/hooks.rs", "rust", "before", ""),
6380 code_row(
6381 "c3",
6382 "src/x.rs",
6383 "rust",
6384 "fooBar",
6385 "has fooBar but not the other word",
6386 ),
6387 ]
6388 .join(","),
6389 );
6390
6391 assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
6392 assert_eq!(search_names(&conn, "before"), vec!["before"]);
6393 }
6394
6395 #[test]
6396 fn related_tests_use_the_name_as_typed_without_splitting() {
6397 let conn = search_fixture(
6398 &[
6399 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
6400 "('t1', 'f_t1', 'tests/ready.rs', 'rust', 'test_ready', 'function', 'fn test_ready()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t1', NULL, 1, 0, 'code')".to_string(),
6401 "('t2', 'f_t2', 'tests/state.rs', 'rust', 'isReady_reports_true', 'function', 'fn isReady_reports_true()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t2', NULL, 1, 0, 'code')".to_string(),
6402 ]
6403 .join(","),
6404 );
6405 let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
6406
6407 let names: Vec<String> = find_related_tests(&conn, &target, 5)
6408 .unwrap()
6409 .into_iter()
6410 .map(|t| t.name)
6411 .collect();
6412
6413 assert_eq!(names, vec!["isReady_reports_true"]);
6414 }
6415
6416 #[test]
6417 fn related_tests_match_underscores_literally_and_skip_dunder_names() {
6418 let conn = search_fixture(
6419 &[
6420 code_row("c1", "src/app.py", "python", "wsgi_app", ""),
6421 code_row("c2", "src/app.py", "python", "__init__", ""),
6422 "('t1', 'f_t1', 'tests/test_app.py', 'python', 'test_wsgi_app', 'function', 'def test_wsgi_app()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t1', NULL, 1, 0, 'code')".to_string(),
6423 "('t2', 'f_t2', 'tests/test_app.py', 'python', 'test_wsgiXapp', 'function', 'def test_wsgiXapp()', NULL, NULL, NULL, 6, 0, 9, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t2', NULL, 1, 0, 'code')".to_string(),
6424 "('t3', 'f_t3', 'tests/test_views.py', 'python', 'test_init_once', 'function', 'def test_init_once()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_t3', NULL, 1, 0, 'code')".to_string(),
6425 ]
6426 .join(","),
6427 );
6428 let names = |name: &str| -> Vec<String> {
6429 let target = get_symbol_by_name(&conn, name, None).unwrap().unwrap();
6430 find_related_tests(&conn, &target, 5)
6431 .unwrap()
6432 .into_iter()
6433 .map(|t| t.name)
6434 .collect()
6435 };
6436
6437 assert_eq!(names("wsgi_app"), vec!["test_wsgi_app"]);
6438 assert!(names("__init__").is_empty());
6439 }
6440
6441 #[test]
6442 fn related_tests_admit_recognized_unflagged_paths_without_low_signal_rows() {
6443 let conn = search_fixture(
6444 "('target', 'f_target', 'src/core.rs', 'rust', 'calculate', 'function', 'fn calculate()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_target', NULL, 0, 0, 'code'),
6445 ('direct', 'f_direct', 'tests/direct.rs', 'rust', 'direct_case', 'function', 'fn direct_case()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_direct', NULL, 0, 0, 'code'),
6446 ('pending', 'f_pending', 'autotests/tst_pending.qml', 'qml', 'pending_case', 'function', 'function pending_case() {}', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_pending', NULL, 0, 0, 'code'),
6447 ('name', 'f_name', 'tests/name.rs', 'rust', 'calculate_named_case', 'function', 'fn calculate_named_case()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_name', NULL, 0, 0, 'code'),
6448 ('fts', 'f_fts', 'tests/fts.rs', 'rust', 'fts_case', 'function', 'fn fts_case()', 'calculate behavior', NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_fts', NULL, 0, 0, 'code'),
6449 ('local', 'f_local', 'tests/name.rs', 'rust', 'calculate_local', 'variable', 'let calculate_local = 1;', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_local', NULL, 0, 0, 'code'),
6450 ('import', 'f_import', 'tests/name.rs', 'rust', 'calculate_import', 'import', 'use calculate_import;', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_import', NULL, 0, 0, 'code'),
6451 ('production', 'f_production', 'src/testing.rs', 'rust', 'calculate_production', 'function', 'fn calculate_production()', NULL, NULL, NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, 'h_production', NULL, 0, 0, 'code')",
6452 );
6453 conn.execute_batch(
6454 "CREATE TABLE relationships (from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT, start_line INTEGER, start_column INTEGER);
6455 CREATE TABLE pending_relationships (from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT, start_line INTEGER, start_column INTEGER, target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT);
6456 CREATE TABLE type_facts (type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT);
6457 INSERT INTO relationships VALUES ('direct', 'target', 'calls', 'tests/direct.rs', 1, 0);
6458 INSERT INTO pending_relationships VALUES ('pending', 'calculate', 'calls', 'autotests/tst_pending.qml', 1, 0, NULL, '[]', 'calculate');",
6459 )
6460 .unwrap();
6461 let target = get_symbol_by_name(&conn, "calculate", None)
6462 .unwrap()
6463 .unwrap();
6464
6465 let names: Vec<String> = find_related_tests(&conn, &target, 5)
6466 .unwrap()
6467 .into_iter()
6468 .map(|test| test.name)
6469 .collect();
6470
6471 assert_eq!(
6472 names,
6473 vec![
6474 "direct_case",
6475 "pending_case",
6476 "calculate_named_case",
6477 "fts_case"
6478 ]
6479 );
6480 }
6481
6482 #[test]
6483 fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
6484 let conn = search_fixture(
6485 &[
6486 code_row(
6487 "c1",
6488 "src/queries.rs",
6489 "rust",
6490 "fts_search_symbols_scoped",
6491 "search symbols scoped with fts",
6492 ),
6493 code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
6494 ]
6495 .join(","),
6496 );
6497
6498 assert_eq!(
6499 search_names(&conn, "search_symbols_scoped")[0],
6500 "search_symbols_scoped"
6501 );
6502 }
6503
6504 fn sidecar_fixture() -> Connection {
6505 search_fixture(
6506 &[
6507 code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
6508 code_row(
6509 "c2",
6510 "src/sidecar.rs",
6511 "rust",
6512 "parse_sidecar_file",
6513 "parse the sha256 sidecar file",
6514 ),
6515 ]
6516 .join(","),
6517 )
6518 }
6519
6520 fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
6521 candidates
6522 .iter()
6523 .find(|c| c.result.symbol.name == name)
6524 .unwrap_or_else(|| panic!("{name} is not a candidate"))
6525 }
6526
6527 #[test]
6528 fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
6529 let conn = sidecar_fixture();
6530
6531 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
6532
6533 let target = candidate(&candidates, "parseSha256Sidecar");
6534 assert!(target.name_match);
6535 assert!(!target.word_match);
6536 assert!(!target.exact_name);
6537 assert!(target.name_terms.contains(&"sha256".to_string()));
6538 }
6539
6540 #[test]
6541 fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
6542 let conn = search_fixture(
6543 &[
6544 code_row(
6545 "c1",
6546 "examples/demo.rs",
6547 "rust",
6548 "demo",
6549 "restore offline state",
6550 ),
6551 code_row(
6552 "c2",
6553 "src/replay.rs",
6554 "rust",
6555 "replay",
6556 "restore offline records",
6557 ),
6558 ]
6559 .join(","),
6560 );
6561
6562 let candidates =
6563 collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
6564 .unwrap();
6565
6566 assert!(candidate(&candidates, "demo").word_match);
6567 assert!(candidate(&candidates, "replay").word_match);
6568 assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
6569 }
6570
6571 #[test]
6572 fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
6573 let mut rows: Vec<String> = (1..=170)
6574 .map(|i| {
6575 code_row(
6576 &format!("h{i:03}"),
6577 "src/sidecar.rs",
6578 "rust",
6579 &format!("sidecar_helper_{i:03}"),
6580 "parse sidecar file",
6581 )
6582 })
6583 .collect();
6584 rows.push(code_row(
6585 "c1",
6586 "src/sidecar.rs",
6587 "rust",
6588 "parseSha256Sidecar",
6589 "",
6590 ));
6591 let conn = search_fixture(&rows.join(","));
6592
6593 let candidates = collect_search_candidates(
6594 &conn,
6595 "parse the sha256 sidecar file",
6596 None,
6597 None,
6598 false,
6599 40,
6600 )
6601 .unwrap();
6602
6603 assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
6604 assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
6605 }
6606
6607 #[test]
6608 fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
6609 let mut rows: Vec<String> = (1..=20)
6610 .map(|i| {
6611 code_row(
6612 &format!("a{i:02}"),
6613 "src/a.rs",
6614 "rust",
6615 &format!("both_{i:02}"),
6616 "restore offline",
6617 )
6618 })
6619 .collect();
6620 rows.extend((1..=50).map(|i| {
6621 code_row(
6622 &format!("p{i:02}"),
6623 "src/p.rs",
6624 "rust",
6625 &format!("partial_{i:02}"),
6626 "restore records",
6627 )
6628 }));
6629 let conn = search_fixture(&rows.join(","));
6630
6631 let candidates =
6632 collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
6633
6634 let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
6635 assert_eq!(word_rows.len(), 40);
6636 assert_eq!(
6637 word_rows
6638 .iter()
6639 .filter(|c| c.result.symbol.name.starts_with("both_"))
6640 .count(),
6641 20
6642 );
6643 }
6644
6645 #[test]
6646 fn exact_name_is_admitted_regardless_of_case() {
6647 let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
6648
6649 let candidates =
6650 collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
6651 assert!(candidate(&candidates, "xyzzy_q").exact_name);
6652
6653 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
6654 let candidates =
6655 collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
6656 assert!(candidate(&candidates, "xyzzy_q").exact_name);
6657 }
6658
6659 #[test]
6660 fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
6661 let conn = search_fixture(&code_row(
6662 "c1",
6663 "src/say.js",
6664 "javascript",
6665 "say \"hi\"",
6666 "",
6667 ));
6668
6669 let candidates =
6670 collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
6671
6672 let target = candidate(&candidates, "say \"hi\"");
6673 assert!(target.exact_name && target.name_match);
6674 }
6675
6676 #[test]
6677 fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
6678 let conn = search_fixture(
6679 &[
6680 code_row("c1", "src/a.rs", "rust", "sidecar", ""),
6681 code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
6682 ]
6683 .join(","),
6684 );
6685
6686 let candidates =
6687 collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
6688
6689 assert_eq!(candidates.len(), 2);
6690 let target = candidate(&candidates, "sidecar");
6691 assert!(target.exact_name && target.word_match && target.name_match);
6692 assert!(target.bm25.is_some());
6693 let helper = candidate(&candidates, "sidecar_helper");
6694 assert!(!helper.exact_name && helper.word_match && helper.name_match);
6695 }
6696
6697 #[test]
6698 fn an_index_without_the_trigram_table_returns_word_rows_only() {
6699 let conn = sidecar_fixture();
6700 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
6701
6702 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
6703
6704 let names: Vec<&str> = candidates
6705 .iter()
6706 .map(|c| c.result.symbol.name.as_str())
6707 .collect();
6708 assert_eq!(names, vec!["parse_sidecar_file"]);
6709 assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
6710 assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
6711 }
6712
6713 #[test]
6714 fn words_under_three_characters_skip_the_name_branch() {
6715 let conn = search_fixture(
6716 &[
6717 code_row("c1", "src/a.rs", "rust", "ab", ""),
6718 code_row("c2", "src/b.rs", "rust", "cab", ""),
6719 ]
6720 .join(","),
6721 );
6722
6723 let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
6724
6725 assert!(candidates.iter().all(|c| !c.name_match));
6726 assert!(candidate(&candidates, "ab").exact_name);
6727 }
6728
6729 #[test]
6730 fn trigram_terms_include_the_identifier_parts_of_each_word() {
6731 assert_eq!(
6732 trigram_name_terms("collapse_name"),
6733 vec!["collapse_name", "collapse", "name"]
6734 );
6735 assert_eq!(
6736 trigram_name_terms("parse the sha256 sidecar"),
6737 vec!["parse", "sha256", "sha", "256", "sidecar"]
6738 );
6739 assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
6740 assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
6741 assert!(trigram_name_terms("ab").is_empty());
6742 }
6743
6744 #[test]
6745 fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
6746 let conn = search_fixture(
6747 &[
6748 code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
6749 code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
6750 ]
6751 .join(","),
6752 );
6753
6754 let candidates =
6755 collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
6756
6757 let target = candidate(&candidates, "CollapseName");
6758 assert!(target.name_match);
6759 assert_eq!(target.name_terms, vec!["collapse", "name"]);
6760 assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
6761 }
6762
6763 fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
6764 Candidate {
6765 result: SymbolSearchResult {
6766 symbol: Symbol {
6767 symbol_id: format!("{path}:{name}"),
6768 file_id: "f".into(),
6769 path: path.into(),
6770 language: "rust".into(),
6771 name: name.into(),
6772 kind: kind.into(),
6773 signature: None,
6774 doc_comment: None,
6775 visibility: None,
6776 parent_symbol_id: None,
6777 start_line: 1,
6778 start_column: 0,
6779 end_line: 1,
6780 end_column: 0,
6781 start_byte: 0,
6782 end_byte: 0,
6783 body_start_line: None,
6784 body_start_column: None,
6785 body_end_line: None,
6786 body_end_column: None,
6787 body_start_byte: None,
6788 body_end_byte: None,
6789 body_hash: None,
6790 semantic_group: None,
6791 is_test: false,
6792 test_container: false,
6793 },
6794 score: 0.0,
6795 snippet: None,
6796 explain: None,
6797 },
6798 bm25: None,
6799 exact_name: false,
6800 word_match: false,
6801 name_match: false,
6802 owner_match: false,
6803 name_terms: Vec::new(),
6804 documentation: false,
6805 nested: false,
6806 owner: None,
6807 }
6808 }
6809
6810 fn function(name: &str) -> Candidate {
6811 plain_candidate(name, "function", "src/lib.rs")
6812 }
6813
6814 fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
6815 rerank_with(candidates, query, false, None)
6816 }
6817
6818 fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
6819 ranked(candidates, query)
6820 .into_iter()
6821 .map(|(r, _)| r.symbol.name)
6822 .collect()
6823 }
6824
6825 fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
6826 let mut candidate = function(name);
6827 candidate.result.symbol.signature = signature.map(str::to_string);
6828 candidate.result.symbol.doc_comment = Some(doc.into());
6829 candidate
6830 }
6831
6832 fn strip_ansi_case() -> Vec<Candidate> {
6833 vec![
6834 documented("strip_ansi", None, "Remove ANSI escape sequences"),
6835 documented(
6836 "_strip_code_fences",
6837 Some("def _strip_code_fences(text: str)"),
6838 "The first fenced code block's body, or the stripped text",
6839 ),
6840 ]
6841 }
6842
6843 #[test]
6844 fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
6845 assert_eq!(
6846 rerank_words("parse the sha256 sidecar file"),
6847 vec!["parse", "sha", "256", "sidecar", "file"]
6848 );
6849 assert_eq!(
6850 rerank_words("parse_sha256_sidecar"),
6851 vec!["parse", "sha", "256", "sidecar"]
6852 );
6853 assert_eq!(
6854 rerank_words("ParseHTTPResponse"),
6855 vec!["parse", "http", "response"]
6856 );
6857 assert_eq!(rerank_words("is_ok"), vec!["ok"]);
6858 assert_eq!(rerank_words("the before"), vec!["the", "before"]);
6859 }
6860
6861 #[test]
6862 fn stop_words_cover_english_function_words_but_not_identifier_directions() {
6863 for word in ["was", "whether", "another"] {
6864 assert!(is_stop_word(word), "{word} must be a stop word");
6865 }
6866 for word in ["down", "into", "run"] {
6867 assert!(!is_stop_word(word), "{word} must stay a content word");
6868 }
6869 assert_eq!(rerank_words("what was the file"), vec!["file"]);
6870 }
6871
6872 #[test]
6873 fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
6874 let mut private = function("_create_skill");
6875 private.bm25 = Some(-9.0);
6876 let mut public = function("create_skill");
6877 public.bm25 = Some(-1.0);
6878
6879 let rows = ranked(vec![private, public], "create skill");
6880
6881 assert_eq!(rows[0].0.score, rows[1].0.score);
6882 assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
6883 assert_eq!(
6884 rows.iter()
6885 .map(|(r, _)| r.symbol.name.as_str())
6886 .collect::<Vec<_>>(),
6887 vec!["create_skill", "_create_skill"]
6888 );
6889 }
6890
6891 #[test]
6892 fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
6893 let rows = ranked(
6894 vec![
6895 plain_candidate("Glob", "constant", "src/glob.rs"),
6896 function("matches_glob_pattern"),
6897 ],
6898 "glob",
6899 );
6900
6901 assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
6902 assert_eq!(rows[1].1.name_tier, "whole");
6903 assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
6904 }
6905
6906 #[test]
6907 fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
6908 let rows = ranked(
6909 vec![
6910 function("validate_everything"),
6911 function("validate_syntax_now"),
6912 function("validate_syntax"),
6913 function("unrelated"),
6914 ],
6915 "validate syntax",
6916 );
6917 let tiers: Vec<(&str, &str, f64)> = rows
6918 .iter()
6919 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
6920 .collect();
6921
6922 assert_eq!(
6923 tiers,
6924 vec![
6925 ("validate_syntax", "whole", W_NAME_WHOLE),
6926 ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
6927 ("validate_everything", "partial", 0.0),
6928 ("unrelated", "none", 0.0),
6929 ]
6930 );
6931 assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
6932 assert_eq!(
6933 rows[1].0.score,
6934 W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
6935 );
6936 assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
6937 assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
6938 }
6939
6940 #[test]
6941 fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
6942 assert_eq!(
6943 ranked_names(strip_ansi_case(), "strip ansi escape codes"),
6944 vec!["strip_ansi", "_strip_code_fences"]
6945 );
6946 }
6947
6948 #[test]
6949 fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
6950 let candidates = vec![
6951 function("execute_julie_extract"),
6952 documented("slice_bytes", None, "cut a byte range"),
6953 ];
6954
6955 let rows = ranked(candidates, "cut");
6956 let bonuses: Vec<(&str, &str, f64)> = rows
6957 .iter()
6958 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
6959 .collect();
6960
6961 assert_eq!(
6962 bonuses,
6963 vec![
6964 ("execute_julie_extract", "partial", 0.0),
6965 ("slice_bytes", "none", 0.0),
6966 ]
6967 );
6968 assert_eq!(rows[0].0.score, rows[1].0.score);
6969 }
6970
6971 #[test]
6972 fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
6973 let candidates = vec![
6974 function("validate_everything"),
6975 function("validate_syntax_now"),
6976 function("validate_syntax"),
6977 function("unrelated"),
6978 ];
6979
6980 assert_eq!(
6981 ranked_names(candidates, "validate syntax"),
6982 vec![
6983 "validate_syntax",
6984 "validate_syntax_now",
6985 "validate_everything",
6986 "unrelated"
6987 ]
6988 );
6989 }
6990
6991 #[test]
6992 fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
6993 let mut rows: Vec<String> = (0..10)
6994 .map(|i| {
6995 code_row(
6996 &format!("s{i}"),
6997 &format!("src/f{i}.rs"),
6998 "rust",
6999 &format!("search_{i}"),
7000 "searches the index",
7001 )
7002 })
7003 .collect();
7004 rows.push(code_row(
7005 "rare",
7006 "src/rare.rs",
7007 "rust",
7008 "sanitize_input",
7009 "sanitize the input",
7010 ));
7011 let conn = search_fixture(&rows.join(", "));
7012 let terms = vec!["sanitize".to_string(), "search".to_string()];
7013
7014 let weights = idf_weights(&conn, &terms);
7015 assert!(weights[0] > weights[1]);
7016 }
7017
7018 #[test]
7019 fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
7020 let conn = search_fixture(&code_row(
7021 "n",
7022 "src/news.rs",
7023 "rust",
7024 "fetch_news",
7025 "fetch the news feed",
7026 ));
7027 let terms = vec!["news".to_string(), "unseen".to_string()];
7028
7029 let weights = idf_weights(&conn, &terms);
7030
7031 assert!(weights[0] < weights[1]);
7032 }
7033
7034 #[test]
7035 fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
7036 let crediting_field = |padding: usize| {
7037 let mut row = function("handler");
7038 row.result.symbol.signature = Some(format!(
7039 "fn handler({}sidecar: u8)",
7040 "a: u8, ".repeat(padding)
7041 ));
7042 ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
7043 };
7044
7045 assert_eq!(crediting_field(4), "signature");
7046 assert_eq!(crediting_field(80), "none");
7047 }
7048
7049 #[test]
7050 fn explain_terms_name_the_crediting_field_of_every_query_term() {
7051 let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
7052 let terms: Vec<(&str, &str, f64)> = rows[0]
7053 .1
7054 .terms
7055 .iter()
7056 .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
7057 .collect();
7058
7059 assert_eq!(rows[0].0.symbol.name, "strip_ansi");
7060 assert_eq!(
7061 terms,
7062 vec![
7063 ("strip", "name", 3.0),
7064 ("ansi", "name", 3.0),
7065 ("escape", "doc", TEXT_CREDIT),
7066 ("codes", "none", 0.0),
7067 ]
7068 );
7069 }
7070
7071 #[test]
7072 fn name_coverage_accepts_token_runs_substrings_and_stems() {
7073 let strengths = |name: &str, query: &str| {
7074 let stemmer = Stemmer::create(Algorithm::English);
7075 let words: Vec<QueryWord> = rerank_words(query)
7076 .into_iter()
7077 .map(|word| QueryWord {
7078 stem: stemmer.stem(&word).into_owned(),
7079 word,
7080 })
7081 .collect();
7082 name_hits(name, &words, &stemmer)
7083 };
7084
7085 assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
7086 assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
7087 assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
7088 assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
7089 assert_eq!(strengths("is_ok", "ok"), vec![3]);
7090 assert_eq!(strengths("isReady", "is"), vec![3]);
7091 assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
7092 assert_eq!(
7093 strengths("parseSha256Sidecar", "sidecar checksum"),
7094 vec![3, 0]
7095 );
7096 assert_eq!(
7097 strengths("parseSha256Sidecar", "checksum digest"),
7098 vec![0, 0]
7099 );
7100 }
7101
7102 #[test]
7103 fn a_rarer_term_moves_the_score_more_than_a_common_one() {
7104 let weights = [4.0, 1.0];
7105 let rows = rerank_with(
7106 vec![function("rare_helper"), function("common_helper")],
7107 "rare common",
7108 false,
7109 Some(&weights),
7110 );
7111
7112 assert_eq!(
7113 rows[0].1.word_weights,
7114 vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
7115 );
7116 assert_eq!(rows[0].0.symbol.name, "rare_helper");
7117 assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
7118 assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
7119 }
7120
7121 #[test]
7122 fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
7123 let rows = ranked(
7124 vec![
7125 function("render_mode"),
7126 plain_candidate("retry_count", "constant", "src/scan.rs"),
7127 ],
7128 "how many times a failed download is tried again retry limit",
7129 );
7130
7131 assert_eq!(rows[0].0.symbol.name, "retry_count");
7132 assert_eq!(rows[0].1.name_tier, "partial");
7133 assert!(rows[0].0.score > rows[1].0.score);
7134 assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
7135 }
7136
7137 #[test]
7138 fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
7139 let mut row = function("load");
7140 row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
7141 row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
7142 let (result, explain) = ranked(vec![row], "config settings").remove(0);
7143
7144 assert_eq!(
7145 explain.terms,
7146 vec![
7147 ("config".into(), "signature".into(), TEXT_CREDIT),
7148 ("settings".into(), "none".into(), 0.0),
7149 ]
7150 );
7151 assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
7152 }
7153
7154 #[test]
7155 fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
7156 let crediting_field =
7157 |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
7158 let doc_field = |doc: &str, query: &str| {
7159 let mut row = function("row");
7160 row.result.symbol.doc_comment = Some(doc.into());
7161 crediting_field(row, query)
7162 };
7163 let sig_field = |signature: &str, query: &str| {
7164 let mut row = function("row");
7165 row.result.symbol.signature = Some(signature.into());
7166 crediting_field(row, query)
7167 };
7168
7169 assert_eq!(doc_field("The system runs.", "stemming"), "none");
7170 assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
7171 assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
7172 assert_eq!(doc_field("An important port.", "porter"), "none");
7173 assert_eq!(
7174 sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
7175 "signature"
7176 );
7177 assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
7178 assert_eq!(sig_field("fn okay()", "ok"), "none");
7179 assert_eq!(
7180 sig_field("fn parseSha256Sidecar(text)", "sidecar"),
7181 "signature"
7182 );
7183 }
7184
7185 #[test]
7186 fn text_tokens_split_like_query_words_then_identifiers() {
7187 fn two_pass(text: &str) -> Vec<&str> {
7188 query_words(text)
7189 .into_iter()
7190 .flat_map(split_identifier)
7191 .collect()
7192 }
7193 fn one_pass(text: &str) -> Vec<&str> {
7194 let mut out = Vec::new();
7195 text_tokens_into(text, &mut out);
7196 out
7197 }
7198 let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
7199 let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
7200
7201 assert_eq!(one_pass(ascii), two_pass(ascii));
7202 assert_eq!(
7203 one_pass(ascii),
7204 vec![
7205 "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
7206 "sha", "256", "sum",
7207 ]
7208 );
7209 assert_eq!(one_pass(unicode), two_pass(unicode));
7210 assert!(one_pass("").is_empty());
7211 assert!(one_pass("_ __ ...").is_empty());
7212 }
7213
7214 #[test]
7215 fn a_doc_credits_a_term_by_its_stem() {
7216 let mut row = function("check");
7217 row.result.symbol.doc_comment = Some("Validates the input.".into());
7218 let explain = ranked(vec![row], "validation").remove(0).1;
7219
7220 assert_eq!(
7221 explain.terms,
7222 vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
7223 );
7224 }
7225
7226 #[test]
7227 fn kind_prior_orders_definitions_over_members_over_imports() {
7228 let rows = ranked(
7229 vec![
7230 plain_candidate("Scan", "import", "src/a.rs"),
7231 plain_candidate("Scan", "enum_member", "src/b.rs"),
7232 plain_candidate("Scan", "function", "src/c.rs"),
7233 ],
7234 "scan",
7235 );
7236 let order: Vec<(&str, f64)> = rows
7237 .iter()
7238 .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
7239 .collect();
7240
7241 assert_eq!(
7242 order,
7243 vec![
7244 ("src/c.rs", W_KIND_DEFINITION),
7245 ("src/b.rs", W_KIND_MEMBER),
7246 ("src/a.rs", W_KIND_IMPORT),
7247 ]
7248 );
7249 }
7250
7251 #[test]
7252 fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
7253 let names = ranked_names(
7254 vec![
7255 function("RenderMode"),
7256 plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
7257 ],
7258 "retry download limit timeout",
7259 );
7260
7261 assert_eq!(names[0], "MaxRetryCount");
7262 }
7263
7264 #[test]
7265 fn path_role_demotes_role_directories_unless_the_query_names_them() {
7266 let rows = |query: &str| {
7267 ranked(
7268 vec![
7269 plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
7270 plain_candidate("verify_checksum", "function", "src/archive.rs"),
7271 ],
7272 query,
7273 )
7274 };
7275
7276 let plain = rows("verify checksum");
7277 assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
7278 assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
7279
7280 let named = rows("launcher script verify checksum");
7281 assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
7282
7283 let only_launcher = rows("launcher verify checksum");
7284 assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
7285 assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
7286
7287 let windows = ranked(
7288 vec![plain_candidate(
7289 "verifyChecksum",
7290 "function",
7291 "scripts\\launcher.ts",
7292 )],
7293 "verify checksum",
7294 );
7295 assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
7296 }
7297
7298 #[test]
7299 fn documentation_rows_sort_after_every_code_row() {
7300 let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
7301 heading.documentation = true;
7302 heading.result.symbol.language = "markdown".into();
7303 heading.result.symbol.signature = Some("Verify checksum".into());
7304 heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
7305 let rows = ranked(
7306 vec![
7307 heading,
7308 plain_candidate("unrelated", "variable", "src/a.rs"),
7309 ],
7310 "verify checksum",
7311 );
7312
7313 assert_eq!(rows[0].0.symbol.name, "unrelated");
7314 assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
7315 assert_eq!(rows[1].1.name_tier, "whole");
7316 assert!(rows[1].0.score < 0.0);
7317 }
7318
7319 #[test]
7320 fn a_helper_nested_in_a_function_ranks_below_an_equal_module_function() {
7321 let mut helper = plain_candidate("run_test", "function", "tests/test_basic.py");
7322 helper.nested = true;
7323 let module_function = plain_candidate("run_dotenv", "function", "tests/test_basic.py");
7324
7325 let rows = rerank_with(vec![helper, module_function], "run", true, None);
7326
7327 assert_eq!(rows[0].0.symbol.name, "run_dotenv");
7328 assert_eq!(rows[1].1.nested, W_NESTED);
7329 }
7330
7331 #[test]
7332 fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
7333 let rows = |query: &str, include_tests: bool| {
7334 let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
7335 test_row.result.symbol.is_test = true;
7336 let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
7337 rerank_with(vec![plain_row, test_row], query, include_tests, None)
7338 };
7339
7340 let boosted = rows("payment flow tests", true);
7341 assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
7342 assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
7343 assert_eq!(boosted[1].1.test_intent, 0.0);
7344
7345 assert!(
7346 rows("payment flow tests", false)
7347 .iter()
7348 .all(|(_, e)| e.test_intent == 0.0)
7349 );
7350 assert!(
7351 rows("payment flow", true)
7352 .iter()
7353 .all(|(_, e)| e.test_intent == 0.0)
7354 );
7355 }
7356
7357 #[test]
7358 fn ties_break_by_bm25_then_name_length_then_path() {
7359 let mut word_row = plain_candidate("payment", "function", "src/z.rs");
7360 word_row.word_match = true;
7361 word_row.bm25 = Some(-4.0);
7362 let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
7363 weaker_word_row.word_match = true;
7364 weaker_word_row.bm25 = Some(-2.0);
7365 let mut name_only = plain_candidate("payment", "function", "src/b.rs");
7366 name_only.name_match = true;
7367 let rows = ranked(
7368 vec![
7369 plain_candidate("payment", "function", "src/y.rs"),
7370 name_only,
7371 weaker_word_row,
7372 word_row,
7373 ],
7374 "payment",
7375 );
7376 let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
7377
7378 assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
7379
7380 let by_length = ranked_names(
7381 vec![
7382 function("payment_gateway_client"),
7383 function("payment_gateway"),
7384 ],
7385 "gateway",
7386 );
7387 assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
7388 }
7389
7390 #[test]
7391 fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
7392 let mut token = function("csr");
7393 token.bm25 = Some(-1.0);
7394 let mut substring = function("action_csrf_token");
7395 substring.bm25 = Some(-5.0);
7396
7397 let rows = ranked(vec![substring, token], "csr adjacency");
7398
7399 assert!(rows[0].0.score > rows[1].0.score);
7400 assert_eq!(rows[0].0.symbol.name, "csr");
7401 }
7402
7403 #[test]
7404 fn an_acronym_token_outranks_a_name_that_only_contains_it() {
7405 let mut token = function("http_client");
7406 token.bm25 = Some(-1.0);
7407 let mut substring = function("shttpd_config");
7408 substring.bm25 = Some(-5.0);
7409
7410 let rows = ranked(vec![substring, token], "http");
7411
7412 assert!(rows[0].0.score > rows[1].0.score);
7413 assert_eq!(rows[0].0.symbol.name, "http_client");
7414 }
7415
7416 #[test]
7417 fn explain_reports_the_sum_of_the_name_strengths() {
7418 let rows = ranked(vec![function("action_csrf_token")], "csr token");
7419
7420 assert_eq!(rows[0].1.name_strength, 4);
7421 }
7422
7423 #[test]
7424 fn snippets_follow_the_admitting_branch() {
7425 let mut word_row = function("parse_sidecar_file");
7426 word_row.word_match = true;
7427 word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
7428 let mut name_row = function("parseSha256Sidecar");
7429 name_row.name_match = true;
7430 name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
7431 let mut exact_row = function("sha256");
7432 exact_row.exact_name = true;
7433 let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
7434 let snippets: Vec<(&str, &str)> = rows
7435 .iter()
7436 .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
7437 .collect();
7438
7439 assert_eq!(
7440 snippets,
7441 vec![
7442 ("sha256", "sha256"),
7443 ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
7444 ("parse_sidecar_file", "parse the [sha256] sidecar file"),
7445 ]
7446 );
7447 assert_eq!(rows[1].1.branches, vec!["name"]);
7448 assert_eq!(rows[0].1.branches, vec!["exact"]);
7449 }
7450
7451 #[test]
7452 fn explain_is_attached_only_when_requested() {
7453 let conn = sidecar_fixture();
7454 let query = "sha256";
7455
7456 let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
7457 assert!(silent.iter().all(|r| r.explain.is_none()));
7458 assert!(silent[0].score > 0.0);
7459 assert_eq!(
7460 serde_json::to_value(&silent[0]).unwrap().get("explain"),
7461 None
7462 );
7463
7464 let explained =
7465 fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
7466 let by_name = |name: &str| {
7467 explained
7468 .iter()
7469 .find(|r| r.symbol.name == name)
7470 .and_then(|r| r.explain.as_ref())
7471 .unwrap()
7472 };
7473 let name_only = by_name("parseSha256Sidecar");
7474 assert_eq!(name_only.candidates, 2);
7475 assert_eq!(name_only.branches, vec!["name"]);
7476 assert_eq!(name_only.bm25, None);
7477 let word_row = by_name("parse_sidecar_file");
7478 assert!(word_row.bm25.unwrap() < 0.0);
7479 assert_eq!(word_row.candidates, 2);
7480 assert!(
7481 serde_json::to_value(&explained[0])
7482 .unwrap()
7483 .get("explain")
7484 .is_some()
7485 );
7486 }
7487
7488 #[test]
7489 fn search_symbols_treats_like_wildcards_as_literals() {
7490 let dir = crate::safe_tempdir();
7491 let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
7492 let conn = open_read_write(&db_path).unwrap();
7493 conn.execute_batch(
7494 "CREATE TABLE symbols (
7495 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
7496 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
7497 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7498 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7499 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7500 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7501 semantic_group TEXT, is_test INTEGER, test_container INTEGER
7502 );
7503 INSERT INTO symbols VALUES (
7504 's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
7505 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
7506 );
7507 INSERT INTO symbols VALUES (
7508 'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
7509 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
7510 );
7511 INSERT INTO symbols VALUES (
7512 'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
7513 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
7514 );
7515 CREATE TABLE files (
7516 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
7517 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
7518 );
7519 INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
7520 INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
7521 );",
7522 )
7523 .unwrap();
7524
7525 assert_eq!(
7526 search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
7527 "literal%name"
7528 );
7529 assert_eq!(
7530 search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
7531 "literal_name"
7532 );
7533 assert_eq!(
7534 load_scoped_files(&conn, Some("src/literal_path"))
7535 .unwrap()
7536 .len(),
7537 1
7538 );
7539 }
7540
7541 #[test]
7542 fn find_references_for_symbol_limits_callees_by_symbol_id() {
7543 let dir = crate::safe_tempdir();
7544 let db_path = dir.path().join("find_references_for_symbol.db");
7545 let conn = open_read_write(&db_path).unwrap();
7546 conn.execute_batch(
7547 "CREATE TABLE symbols (
7548 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
7549 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
7550 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7551 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7552 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7553 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7554 semantic_group TEXT, is_test INTEGER, test_container INTEGER
7555 );
7556 CREATE TABLE relationships (
7557 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
7558 start_line INTEGER, start_column INTEGER
7559 );
7560 CREATE TABLE pending_relationships (
7561 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
7562 start_line INTEGER, start_column INTEGER
7563 );
7564 INSERT INTO symbols VALUES
7565 ('wanted', 'f', 'a.rs', 'rust', 'new', 'method', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
7566 ('other', 'f', 'b.rs', 'rust', 'new', 'method', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
7567 ('wanted-callee', 'f', 'a.rs', 'rust', 'wanted_dep', 'function', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
7568 ('other-callee', 'f', 'b.rs', 'rust', 'other_dep', 'function', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0);
7569 INSERT INTO relationships VALUES
7570 ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
7571 ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
7572 )
7573 .unwrap();
7574
7575 let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
7576 assert_eq!(references.len(), 1);
7577 assert_eq!(references[0].to_symbol_name, "wanted_dep");
7578 }
7579
7580 #[test]
7581 fn test_fts_search_symbols_and_porter_stemming() {
7582 let dir = crate::safe_tempdir();
7583 let db_path = dir.path().join("fts_search_symbols.db");
7584 let conn = open_read_write(&db_path).unwrap();
7585
7586 conn.execute_batch(
7587 "CREATE TABLE symbols (
7588 symbol_id TEXT PRIMARY KEY,
7589 file_id TEXT,
7590 path TEXT,
7591 language TEXT,
7592 name TEXT,
7593 kind TEXT,
7594 signature TEXT,
7595 doc_comment TEXT,
7596 visibility TEXT,
7597 parent_symbol_id TEXT,
7598 start_line INTEGER,
7599 start_column INTEGER,
7600 end_line INTEGER,
7601 end_column INTEGER,
7602 start_byte INTEGER,
7603 end_byte INTEGER,
7604 body_start_line INTEGER,
7605 body_start_column INTEGER,
7606 body_end_line INTEGER,
7607 body_end_column INTEGER,
7608 body_start_byte INTEGER,
7609 body_end_byte INTEGER,
7610 body_hash TEXT,
7611 semantic_group TEXT,
7612 is_test INTEGER,
7613 test_container INTEGER
7614 );
7615 INSERT INTO symbols VALUES (
7616 's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
7617 'pub trait PaymentGateway', 'Core payment provider interface for transactions',
7618 'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
7619 );
7620 INSERT INTO symbols VALUES (
7621 's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
7622 'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
7623 'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
7624 );
7625 INSERT INTO symbols VALUES (
7626 's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
7627 'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
7628 'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
7629 );
7630 INSERT INTO symbols VALUES (
7631 's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
7632 'fn test_payment_flow()', 'Tests payment charge workflow',
7633 NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
7634 );",
7635 )
7636 .unwrap();
7637
7638 ensure_fts_index(&conn).unwrap();
7639
7640 let results =
7642 fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
7643 assert_eq!(results.len(), 1);
7644 assert_eq!(results[0].symbol.name, "parse_tokens");
7645 assert!(results[0].snippet.is_some());
7646
7647 let results =
7649 fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
7650 assert_eq!(results.len(), 1);
7651 assert_eq!(results[0].symbol.name, "PaymentGateway");
7652
7653 let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
7655 assert_eq!(results.len(), 2);
7656 assert!(results.iter().all(|r| !r.symbol.is_test));
7657
7658 let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
7660 assert_eq!(results.len(), 3);
7661
7662 let results =
7664 fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
7665 assert_eq!(results.len(), 1);
7666 assert_eq!(results[0].symbol.name, "StripeClient");
7667 }
7668
7669 #[test]
7670 fn find_related_tests_returns_each_test_once_under_the_limit() {
7671 let dir = crate::safe_tempdir();
7672 let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
7673 conn.execute_batch(
7674 "CREATE TABLE symbols (
7675 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
7676 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
7677 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
7678 start_column INTEGER, end_line INTEGER, end_column INTEGER,
7679 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7680 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7681 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7682 semantic_group TEXT, is_test INTEGER, test_container INTEGER
7683 );
7684 CREATE TABLE relationships (
7685 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
7686 start_line INTEGER, start_column INTEGER
7687 );
7688 CREATE TABLE pending_relationships (
7689 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
7690 start_line INTEGER, start_column INTEGER,
7691 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
7692 );
7693 CREATE TABLE type_facts (
7694 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
7695 );
7696 INSERT INTO symbols VALUES
7697 ('s_target', 'f1', 'src/lib.rs', 'rust', 'compute', 'function', 'pub fn compute()', NULL, 'pub', NULL, 1, 0, 5, 1, 0, 50, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
7698 ('t_a', 'f2', 'tests/a.rs', 'rust', 'first_case', 'function', 'fn first_case()', NULL, NULL, NULL, 1, 0, 20, 1, 0, 300, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 0),
7699 ('t_b', 'f3', 'tests/b.rs', 'rust', 'second_case', 'function', 'fn second_case()', NULL, NULL, NULL, 1, 0, 10, 1, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 0);
7700 INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES
7701 ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
7702 ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
7703 ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
7704 ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
7705 ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
7706 ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
7707 )
7708 .unwrap();
7709 let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
7710
7711 let tests = find_related_tests(&conn, &target, 5).unwrap();
7712
7713 let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
7714 names.sort();
7715 assert_eq!(names, vec!["first_case", "second_case"]);
7716 }
7717
7718 #[test]
7719 fn documentation_rows_rank_after_code_in_search() {
7720 let dir = crate::safe_tempdir();
7721 let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
7722 conn.execute_batch(
7723 "CREATE TABLE symbols (
7724 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
7725 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
7726 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
7727 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7728 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7729 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7730 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
7731 );
7732 INSERT INTO symbols VALUES
7733 ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
7734 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
7735 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
7736 ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
7737 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
7738 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
7739 )
7740 .unwrap();
7741 ensure_fts_index(&conn).unwrap();
7742
7743 let results =
7744 fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
7745 .unwrap();
7746
7747 assert_eq!(results.len(), 2);
7748 assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
7749 assert_eq!(results[1].symbol.name, "Reconcile offline edits");
7750 }
7751
7752 #[test]
7753 fn test_queries_nocase_and_path_normalization() {
7754 let conn = Connection::open_in_memory().unwrap();
7755 conn.execute_batch(
7756 "CREATE TABLE files (
7757 file_id TEXT PRIMARY KEY,
7758 path TEXT NOT NULL,
7759 language TEXT,
7760 content_hash TEXT,
7761 content_bytes INTEGER,
7762 line_count INTEGER,
7763 indexed_at INTEGER
7764 );
7765 CREATE TABLE symbols (
7766 symbol_id TEXT PRIMARY KEY,
7767 file_id TEXT,
7768 path TEXT NOT NULL,
7769 language TEXT,
7770 name TEXT,
7771 kind TEXT,
7772 signature TEXT,
7773 doc_comment TEXT,
7774 visibility TEXT,
7775 parent_symbol_id TEXT,
7776 start_line INTEGER,
7777 start_column INTEGER,
7778 end_line INTEGER,
7779 end_column INTEGER,
7780 start_byte INTEGER,
7781 end_byte INTEGER,
7782 body_start_line INTEGER,
7783 body_start_column INTEGER,
7784 body_end_line INTEGER,
7785 body_end_column INTEGER,
7786 body_start_byte INTEGER,
7787 body_end_byte INTEGER,
7788 body_hash TEXT,
7789 semantic_group TEXT,
7790 is_test INTEGER,
7791 test_container INTEGER
7792 );
7793 -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
7794 INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
7795 INSERT INTO symbols VALUES (
7796 's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
7797 'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
7798 2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
7799 );",
7800 )
7801 .unwrap();
7802
7803 let file = get_file(&conn, "SRC/PAYMENT.RS")
7805 .unwrap()
7806 .expect("File should be found");
7807 assert_eq!(
7808 file.path, "src/Payment.rs",
7809 "Path should be normalized to forward slashes"
7810 );
7811
7812 let file2 = get_file(&conn, "src/payment.rs")
7813 .unwrap()
7814 .expect("File should be found");
7815 assert_eq!(file2.path, "src/Payment.rs");
7816
7817 let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
7819 assert_eq!(syms.len(), 1);
7820 assert_eq!(
7821 syms[0].path, "src/Payment.rs",
7822 "Symbol path should be normalized to forward slashes"
7823 );
7824
7825 let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
7827 .unwrap()
7828 .expect("Symbol should be found with case-insensitive path filter");
7829 assert_eq!(sym.path, "src/Payment.rs");
7830 }
7831
7832 #[test]
7833 fn test_exact_case_prioritized_over_nocase() {
7834 let conn = Connection::open_in_memory().unwrap();
7835 conn.execute_batch(
7836 "CREATE TABLE files (
7837 file_id TEXT PRIMARY KEY,
7838 path TEXT NOT NULL,
7839 language TEXT,
7840 content_hash TEXT,
7841 content_bytes INTEGER,
7842 line_count INTEGER,
7843 indexed_at TEXT
7844 );
7845 CREATE TABLE symbols (
7846 symbol_id TEXT PRIMARY KEY,
7847 file_id TEXT,
7848 path TEXT NOT NULL,
7849 language TEXT,
7850 name TEXT NOT NULL,
7851 kind TEXT NOT NULL,
7852 signature TEXT,
7853 doc_comment TEXT,
7854 visibility TEXT,
7855 parent_symbol_id TEXT,
7856 start_line INTEGER,
7857 start_column INTEGER,
7858 end_line INTEGER,
7859 end_column INTEGER,
7860 start_byte INTEGER,
7861 end_byte INTEGER,
7862 body_start_line INTEGER,
7863 body_start_column INTEGER,
7864 body_end_line INTEGER,
7865 body_end_column INTEGER,
7866 body_start_byte INTEGER,
7867 body_end_byte INTEGER,
7868 body_hash TEXT,
7869 semantic_group TEXT,
7870 is_test INTEGER,
7871 test_container INTEGER
7872 );
7873 INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
7874 INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
7875 INSERT INTO symbols VALUES (
7876 's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
7877 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
7878 2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
7879 );
7880 INSERT INTO symbols VALUES (
7881 's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
7882 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
7883 2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
7884 );",
7885 )
7886 .unwrap();
7887
7888 let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
7890 assert_eq!(f_lower.path, "src/payment.rs");
7891 assert_eq!(f_lower.file_id, "f2");
7892
7893 let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
7894 assert_eq!(f_upper.path, "src/Payment.rs");
7895 assert_eq!(f_upper.file_id, "f1");
7896
7897 let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
7898 assert_eq!(syms_lower.len(), 1);
7899 assert_eq!(syms_lower[0].file_id, "f2");
7900
7901 let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
7902 assert_eq!(syms_upper.len(), 1);
7903 assert_eq!(syms_upper[0].file_id, "f1");
7904 }
7905
7906 #[test]
7907 fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
7908 let dir = crate::safe_tempdir();
7909 let db_path = dir.path().join("conservative_resolution.db");
7910 let conn = open_read_write(&db_path).unwrap();
7911
7912 conn.execute_batch(
7913 "CREATE TABLE symbols (
7914 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
7915 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
7916 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
7917 start_column INTEGER, end_line INTEGER, end_column INTEGER,
7918 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
7919 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
7920 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
7921 semantic_group TEXT, is_test INTEGER, test_container INTEGER
7922 );
7923 CREATE TABLE relationships (
7924 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
7925 start_line INTEGER, start_column INTEGER
7926 );
7927 CREATE TABLE pending_relationships (
7928 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
7929 start_line INTEGER, start_column INTEGER,
7930 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
7931 );
7932 CREATE TABLE type_facts (
7933 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
7934 );
7935 -- Workspace struct Workspace and method Workspace::new
7936 INSERT INTO symbols VALUES
7937 ('s_ws', 'f1', 'src/workspace.rs', 'rust', 'Workspace', 'struct', 'pub struct Workspace', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'struct', 0, 0),
7938 ('s_ws_new', 'f1', 'src/workspace.rs', 'rust', 'new', 'method', 'pub fn new() -> Workspace', NULL, 'pub', 's_ws', 2, 4, 4, 5, 20, 50, 2, 4, 4, 5, 20, 50, 'h1', 'method', 0, 0),
7939 ('s_caller', 'f2', 'src/caller.rs', 'rust', 'my_func', 'function', 'pub fn my_func()', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'function', 0, 0);
7940
7941 -- my_func calls Vec::new() (external namespace 'Vec')
7942 INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES
7943 ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
7944 )
7945 .unwrap();
7946
7947 let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
7949 assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
7950
7951 let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
7952 assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
7953
7954 let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
7956 assert!(
7957 callers.is_empty(),
7958 "Expected 0 callers for Workspace::new, got: {:?}",
7959 callers
7960 );
7961
7962 let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
7964 assert!(
7965 !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
7966 "my_func should not be impacted before calling Workspace::new: {:?}",
7967 blast.impacted_symbols
7968 );
7969
7970 conn.execute(
7972 "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_caller', 'new', 'calls', 'src/caller.rs', 5, 8, NULL, '[\"Workspace\"]', 'Workspace::new')",
7973 [],
7974 )
7975 .unwrap();
7976
7977 let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
7978 assert_eq!(
7979 sigs2.len(),
7980 1,
7981 "Expected 1 signature for Workspace::new, got: {:?}",
7982 sigs2
7983 );
7984 assert!(sigs2[0].contains("pub fn new() -> Workspace"));
7985
7986 let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
7988 assert!(
7989 blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
7990 "my_func should be impacted after calling Workspace::new: {:?}",
7991 blast2.impacted_symbols
7992 );
7993
7994 conn.execute(
7996 "INSERT INTO symbols VALUES
7997 ('s_other', 'f3', 'src/other.rs', 'rust', 'other_func', 'function', 'pub fn other_func()', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'function', 0, 0);",
7998 [],
7999 )
8000 .unwrap();
8001 conn.execute(
8002 "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_other', 'new', 'calls', 'src/other.rs', 2, 8, NULL, NULL, 'new')",
8003 [],
8004 )
8005 .unwrap();
8006
8007 let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
8009 assert!(
8010 sigs_other.is_empty(),
8011 "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
8012 sigs_other
8013 );
8014
8015 conn.execute(
8017 "INSERT INTO symbols VALUES
8018 ('s_ws_helper', 'f1', 'src/workspace.rs', 'rust', 'helper', 'method', 'pub fn helper()', NULL, 'pub', 's_ws', 5, 4, 7, 5, 60, 90, 5, 4, 7, 5, 60, 90, 'h2', 'method', 0, 0);",
8019 [],
8020 )
8021 .unwrap();
8022 conn.execute(
8023 "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_ws_helper', 'new', 'calls', 'src/workspace.rs', 6, 8, NULL, NULL, 'new')",
8024 [],
8025 )
8026 .unwrap();
8027
8028 let sigs_sibling =
8029 find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
8030 assert_eq!(
8031 sigs_sibling.len(),
8032 1,
8033 "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
8034 sigs_sibling
8035 );
8036
8037 let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
8039 assert!(
8040 ext_sigs.iter().any(|s| s.contains("Vec")),
8041 "include_external: true should include external Vec::new: {:?}",
8042 ext_sigs
8043 );
8044 }
8045
8046 #[test]
8047 fn test_find_structural_facts_and_literals_scoped() {
8048 let dir = crate::safe_tempdir();
8049 let db_path = dir.path().join("facts_test.db");
8050 let conn = open_read_write(&db_path).unwrap();
8051 conn.execute_batch(
8052 "CREATE TABLE symbols (
8053 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
8054 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
8055 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
8056 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
8057 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
8058 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
8059 semantic_group TEXT, is_test INTEGER, test_container INTEGER
8060 );
8061 CREATE TABLE structural_facts (
8062 structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
8063 pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
8064 start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
8065 );
8066 CREATE TABLE literals (
8067 literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
8068 kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
8069 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
8070 start_byte INTEGER, end_byte INTEGER
8071 );
8072 INSERT INTO structural_facts VALUES
8073 ('sf_toml', 'f1', 'Cargo.toml', 'toml', 'toml.key_value.v1', 'key_value', 'table', NULL, 1, 2, 1.0, '{\"key\":\"command\",\"key_path\":\"mcp_servers.code-kb.command\"}'),
8074 ('sf_yaml', 'f6', '.github/workflows/ci.yml', 'yaml', 'yaml.key_value.v1', 'key_value', 'block_mapping_pair', NULL, 3, 3, 1.0, '{\"key\":\"name\",\"key_path\":\"$.on.name\"}'),
8075 ('sf_route', 'f2', 'src/routes/api.rs', 'rust', 'axum.route.v1', 'get_users', 'function', NULL, 10, 20, 1.0, '{\"verb\":\"GET\",\"route_template\":\"/api/v1/users/{id}\",\"normalized_route_template\":\"/api/v1/users/:id\"}'),
8076 ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
8077 ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
8078 ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
8079 ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
8080 INSERT INTO literals VALUES
8081 ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
8082 ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
8083 ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
8084 ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
8085 )
8086 .unwrap();
8087
8088 let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
8090 assert_eq!(facts_config.len(), 2);
8091 assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
8092 assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
8093 assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
8094 assert_eq!(
8095 facts_config[1].key.as_deref(),
8096 Some("mcp_servers.code-kb.command")
8097 );
8098 let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
8099 assert_eq!(lits_config.len(), 1);
8100 assert_eq!(lits_config[0].kind, "toml_key");
8101
8102 let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
8104 assert_eq!(facts_route.len(), 1);
8105 assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
8106 assert_eq!(
8107 facts_route[0].key.as_deref(),
8108 Some("GET /api/v1/users/{id}")
8109 );
8110 let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
8111 assert_eq!(facts_routes.len(), 1);
8112 let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
8113 assert_eq!(lits_route.len(), 1);
8114 assert_eq!(lits_route[0].kind, "http_route");
8115
8116 for q in &["query", "queries", "sql"] {
8118 let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
8119 assert_eq!(facts.len(), 2, "Failed for {}", q);
8120 assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
8121 let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
8122 assert_eq!(lits.len(), 1, "Failed for {}", q);
8123 assert_eq!(lits[0].kind, "sql_query");
8124 }
8125
8126 for m in &["model", "models"] {
8128 let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
8129 assert_eq!(facts.len(), 1, "Failed for {}", m);
8130 assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
8131 let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
8132 assert_eq!(lits.len(), 1, "Failed for {}", m);
8133 assert_eq!(lits[0].kind, "model_table");
8134 }
8135
8136 let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
8138 assert_eq!(facts_custom.len(), 1);
8139 assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
8140 assert_eq!(facts_custom[0].key, None);
8141
8142 let facts_exact =
8144 find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
8145 assert_eq!(facts_exact.len(), 1);
8146 let facts_miss =
8147 find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
8148 assert_eq!(facts_miss.len(), 0);
8149
8150 let facts_dir =
8152 find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
8153 assert_eq!(facts_dir.len(), 1);
8154 let facts_dir_miss =
8155 find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
8156 assert_eq!(facts_dir_miss.len(), 0);
8157
8158 let f_del = find_structural_facts(&conn, "config", 10).unwrap();
8160 assert_eq!(f_del.len(), 2);
8161 let l_del = find_literals(&conn, "config", 10).unwrap();
8162 assert_eq!(l_del.len(), 1);
8163 }
8164
8165 fn local_variable_fixture() -> Connection {
8166 let conn = Connection::open_in_memory().unwrap();
8167 conn.execute_batch(
8168 "CREATE TABLE symbols (
8169 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
8170 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
8171 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
8172 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
8173 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
8174 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
8175 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
8176 );
8177 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
8178 parent_symbol_id, start_line, start_column, end_line, end_column,
8179 start_byte, end_byte, is_test, test_container)
8180 VALUES
8181 ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
8182 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
8183 ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8184 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
8185 ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
8186 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
8187 ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8188 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
8189 ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8190 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
8191 ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
8192 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
8193 ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
8194 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
8195 )
8196 .unwrap();
8197 conn
8198 }
8199
8200 fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
8201 let mut stmt = conn
8202 .prepare(
8203 "SELECT s.symbol_id FROM symbols_fts f
8204 JOIN symbols s ON s.rowid = f.rowid
8205 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
8206 )
8207 .unwrap();
8208 let mut ids = stmt
8209 .query_map(params![query], |row| row.get::<_, String>(0))
8210 .unwrap()
8211 .collect::<Result<Vec<_>, _>>()
8212 .unwrap();
8213 ids.sort();
8214 ids
8215 }
8216
8217 #[test]
8218 fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
8219 let conn = local_variable_fixture();
8220 conn.execute_batch(
8221 "CREATE VIRTUAL TABLE symbols_fts USING fts5(
8222 name, signature, doc_comment,
8223 content='symbols', content_rowid='rowid', tokenize='porter unicode61'
8224 );
8225 INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
8226 SELECT rowid, name, signature, doc_comment FROM symbols;",
8227 )
8228 .unwrap();
8229
8230 ensure_fts_index(&conn).unwrap();
8231
8232 assert_eq!(
8233 matched_symbol_ids(&conn, "sqlite"),
8234 vec!["field", "func", "global", "pool"]
8235 );
8236 }
8237
8238 #[test]
8239 fn lookup_excludes_locals_and_parameters() {
8240 let conn = local_variable_fixture();
8241
8242 let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
8243 .unwrap()
8244 .into_iter()
8245 .map(|s| s.symbol_id)
8246 .collect();
8247
8248 assert!(!ids.contains(&"local".to_string()));
8249 assert!(!ids.contains(&"nested".to_string()));
8250 assert!(ids.contains(&"field".to_string()));
8251 assert!(ids.contains(&"global".to_string()));
8252 }
8253
8254 #[test]
8255 fn search_excludes_locals_and_parameters() {
8256 let conn = local_variable_fixture();
8257 ensure_fts_index(&conn).unwrap();
8258
8259 let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
8260 .unwrap()
8261 .into_iter()
8262 .map(|r| r.symbol.symbol_id)
8263 .collect();
8264
8265 assert!(!ids.contains(&"local".to_string()));
8266 assert!(ids.contains(&"func".to_string()));
8267 }
8268
8269 #[test]
8270 fn variable_kind_search_keeps_full_text_matching() {
8271 let conn = local_variable_fixture();
8272 ensure_fts_index(&conn).unwrap();
8273
8274 let ids: Vec<String> = fts_search_symbols_scoped(
8275 &conn,
8276 "sqlite connection",
8277 Some("variable"),
8278 None,
8279 false,
8280 10,
8281 )
8282 .unwrap()
8283 .into_iter()
8284 .map(|r| r.symbol.symbol_id)
8285 .collect();
8286
8287 assert!(ids.contains(&"global".to_string()));
8288 assert!(ids.contains(&"field".to_string()));
8289 }
8290
8291 #[test]
8292 fn qualified_lookup_returns_the_named_local_variable() {
8293 let conn = local_variable_fixture();
8294
8295 let ids: Vec<String> =
8296 search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
8297 .unwrap()
8298 .into_iter()
8299 .map(|s| s.symbol_id)
8300 .collect();
8301
8302 assert_eq!(ids, vec!["local".to_string()]);
8303 }
8304
8305 #[test]
8306 fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
8307 let conn = Connection::open_in_memory().unwrap();
8308 conn.execute_batch(
8309 "CREATE TABLE symbols (
8310 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
8311 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
8312 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
8313 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
8314 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
8315 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
8316 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
8317 );
8318 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
8319 parent_symbol_id, start_line, start_column, end_line, end_column,
8320 start_byte, end_byte, is_test, test_container)
8321 VALUES
8322 ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
8323 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
8324 ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
8325 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
8326 ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
8327 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
8328 )
8329 .unwrap();
8330 ensure_fts_index(&conn).unwrap();
8331
8332 let rows =
8333 fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
8334 .unwrap();
8335
8336 assert_eq!(rows.len(), 1);
8337 assert_eq!(rows[0].symbol.symbol_id, "local");
8338 let explain = rows[0].explain.as_ref().unwrap();
8339 assert_eq!(explain.name_tier, "whole");
8340 assert_eq!(explain.branches, vec!["exact", "name"]);
8341 assert_eq!(explain.candidates, 2);
8342 }
8343
8344 #[test]
8345 fn variable_kind_filter_returns_locals_and_parameters() {
8346 let conn = local_variable_fixture();
8347 ensure_fts_index(&conn).unwrap();
8348
8349 let lookup_ids: Vec<String> =
8350 search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
8351 .unwrap()
8352 .into_iter()
8353 .map(|s| s.symbol_id)
8354 .collect();
8355 assert!(lookup_ids.contains(&"local".to_string()));
8356 assert!(lookup_ids.contains(&"nested".to_string()));
8357
8358 let search_ids: Vec<String> =
8359 fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
8360 .unwrap()
8361 .into_iter()
8362 .map(|r| r.symbol.symbol_id)
8363 .collect();
8364 assert!(search_ids.contains(&"local".to_string()));
8365 }
8366}