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