1use rusqlite::{Connection, 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 '{0}' not found")]
17 SymbolNotFound(String),
18 #[error("Symbol '{0}' not found. Did you mean one of:\n{1}")]
19 SymbolNotFoundWithSuggestions(String, String),
20 #[error(
21 "Ambiguous symbol '{0}': found {1} matching candidates. Specify file_path or qualified name to disambiguate:\n{2}"
22 )]
23 AmbiguousSymbol(String, usize, String),
24 #[error("Invalid direction '{0}': must be 'callers' or 'callees'")]
25 InvalidDirection(String),
26 #[error("Result limit must be between 0 and {MAX_RESULT_LIMIT}, got {0}")]
27 InvalidResultLimit(usize),
28}
29
30pub const MAX_RESULT_LIMIT: usize = 200;
31
32pub fn validate_result_limit(limit: usize) -> Result<(), QueryError> {
33 if limit > MAX_RESULT_LIMIT {
34 return Err(QueryError::InvalidResultLimit(limit));
35 }
36 Ok(())
37}
38
39fn map_symbol(row: &Row) -> rusqlite::Result<Symbol> {
40 Ok(Symbol {
41 symbol_id: row.get("symbol_id")?,
42 file_id: row.get("file_id")?,
43 path: row.get::<_, String>("path")?.replace('\\', "/"),
44 language: row.get("language")?,
45 name: row.get("name")?,
46 kind: row.get("kind")?,
47 signature: row.get("signature")?,
48 doc_comment: row.get("doc_comment")?,
49 visibility: row.get("visibility")?,
50 parent_symbol_id: row.get("parent_symbol_id")?,
51 start_line: row.get::<_, i64>("start_line")? as usize,
52 start_column: row.get::<_, i64>("start_column")? as usize,
53 end_line: row.get::<_, i64>("end_line")? as usize,
54 end_column: row.get::<_, i64>("end_column")? as usize,
55 start_byte: row.get::<_, i64>("start_byte")? as usize,
56 end_byte: row.get::<_, i64>("end_byte")? as usize,
57 body_start_line: row
58 .get::<_, Option<i64>>("body_start_line")?
59 .map(|v| v as usize),
60 body_start_column: row
61 .get::<_, Option<i64>>("body_start_column")?
62 .map(|v| v as usize),
63 body_end_line: row
64 .get::<_, Option<i64>>("body_end_line")?
65 .map(|v| v as usize),
66 body_end_column: row
67 .get::<_, Option<i64>>("body_end_column")?
68 .map(|v| v as usize),
69 body_start_byte: row
70 .get::<_, Option<i64>>("body_start_byte")?
71 .map(|v| v as usize),
72 body_end_byte: row
73 .get::<_, Option<i64>>("body_end_byte")?
74 .map(|v| v as usize),
75 body_hash: row.get("body_hash")?,
76 semantic_group: row.get("semantic_group")?,
77 is_test: row.get::<_, i64>("is_test")? != 0,
78 test_container: row.get::<_, i64>("test_container")? != 0,
79 })
80}
81
82pub(crate) fn escape_like(value: &str) -> String {
83 value
84 .replace('\\', "\\\\")
85 .replace('%', "\\%")
86 .replace('_', "\\_")
87}
88
89pub fn load_scoped_files(
91 conn: &Connection,
92 path_filter: Option<&str>,
93) -> Result<Vec<FileFact>, QueryError> {
94 let norm = path_filter
95 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
96 .filter(|p| !p.is_empty());
97 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
98 let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
99 let prefix_bs = norm_bs
100 .as_ref()
101 .map(|path| format!("{}\\\\%", escape_like(path)));
102
103 let sql = "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
104 FROM files
105 WHERE (:path IS NULL
106 OR path = :path COLLATE NOCASE
107 OR path = :path_bs COLLATE NOCASE
108 OR path LIKE :path_prefix ESCAPE '\\'
109 OR path LIKE :path_prefix_bs ESCAPE '\\')
110 ORDER BY (:path IS NOT NULL AND (path = :path OR path = :path_bs)) DESC, path ASC";
111
112 let mut stmt = conn.prepare(sql)?;
113 let files = stmt
114 .query_map(
115 rusqlite::named_params! {
116 ":path": norm.as_deref(),
117 ":path_bs": norm_bs.as_deref(),
118 ":path_prefix": prefix.as_deref(),
119 ":path_prefix_bs": prefix_bs.as_deref(),
120 },
121 |row| {
122 Ok(FileFact {
123 file_id: row.get(0)?,
124 path: row.get::<_, String>(1)?.replace('\\', "/"),
125 language: row.get(2)?,
126 content_hash: row.get(3)?,
127 content_bytes: row.get(4)?,
128 line_count: row.get(5)?,
129 indexed_at: row.get(6)?,
130 })
131 },
132 )?
133 .collect::<Result<Vec<_>, _>>()?;
134
135 Ok(files)
136}
137
138pub fn load_scoped_outline_symbols(
141 conn: &Connection,
142 path_filter: Option<&str>,
143 depth: usize,
144 limit_per_file: usize,
145) -> Result<HashMap<String, Vec<Symbol>>, QueryError> {
146 let norm = path_filter
147 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
148 .filter(|p| !p.is_empty());
149 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
150 let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
151 let prefix_bs = norm_bs
152 .as_ref()
153 .map(|path| format!("{}\\\\%", escape_like(path)));
154
155 let max_slashes = match &norm {
156 None => {
157 if depth > 0 {
158 (depth - 1) as i64
159 } else {
160 0
161 }
162 }
163 Some(f) => {
164 let filter_slashes = f.chars().filter(|&c| c == '/').count();
165 (filter_slashes + depth) as i64
166 }
167 };
168
169 let sql = "
170 WITH bounded_files AS (
171 SELECT path FROM files
172 WHERE (:path IS NULL
173 OR path = :path COLLATE NOCASE
174 OR path = :path_bs COLLATE NOCASE
175 OR path LIKE :path_prefix ESCAPE '\\'
176 OR path LIKE :path_prefix_bs ESCAPE '\\')
177 ORDER BY path ASC
178 LIMIT 1000
179 ),
180 ranked AS (
181 SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
182 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
183 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
184 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
185 s.is_test, s.test_container,
186 ROW_NUMBER() OVER (PARTITION BY s.path ORDER BY s.start_line ASC) as rn
187 FROM symbols s
188 JOIN bounded_files bf ON (s.path = bf.path COLLATE NOCASE OR replace(s.path, '\\', '/') = replace(bf.path, '\\', '/') COLLATE NOCASE)
189 WHERE (length(s.path) - length(replace(replace(s.path, '/', ''), '\\', '')) <= :max_slashes)
190 AND s.kind IN ('function', 'method', 'struct', 'enum', 'trait', 'class', 'interface', 'type')
191 AND s.parent_symbol_id IS NULL
192 )
193 SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
194 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
195 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
196 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
197 is_test, test_container
198 FROM ranked
199 WHERE rn <= :limit
200 ORDER BY path ASC, start_line ASC
201 ";
202
203 let mut stmt = conn.prepare(sql)?;
204 let mut rows = stmt.query(rusqlite::named_params! {
205 ":path": norm.as_deref(),
206 ":path_bs": norm_bs.as_deref(),
207 ":path_prefix": prefix.as_deref(),
208 ":path_prefix_bs": prefix_bs.as_deref(),
209 ":max_slashes": max_slashes,
210 ":limit": limit_per_file as i64,
211 })?;
212
213 let mut symbols_by_file: HashMap<String, Vec<Symbol>> = HashMap::new();
214 while let Some(row) = rows.next()? {
215 let sym = map_symbol(row)?;
216 symbols_by_file
217 .entry(sym.path.clone())
218 .or_default()
219 .push(sym);
220 }
221
222 Ok(symbols_by_file)
223}
224
225pub fn get_file(conn: &Connection, path: &str) -> Result<Option<FileFact>, QueryError> {
227 let normalized = path.replace('\\', "/");
228 let backslash = path.replace('/', "\\");
229
230 let mut stmt = conn.prepare(
232 "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
233 FROM files
234 WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
235 ORDER BY (path = ?1 OR path = ?2) DESC
236 LIMIT 1",
237 )?;
238
239 let mut rows = stmt.query(params![normalized, backslash])?;
240 if let Some(row) = rows.next()? {
241 Ok(Some(FileFact {
242 file_id: row.get(0)?,
243 path: row.get::<_, String>(1)?.replace('\\', "/"),
244 language: row.get(2)?,
245 content_hash: row.get(3)?,
246 content_bytes: row.get(4)?,
247 line_count: row.get(5)?,
248 indexed_at: row.get(6)?,
249 }))
250 } else {
251 Ok(None)
252 }
253}
254
255pub fn count_parse_diagnostics(conn: &Connection, path: &str) -> usize {
257 conn.query_row(
258 "SELECT COUNT(*) FROM parse_diagnostics
259 WHERE path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE",
260 params![path.replace('\\', "/"), path.replace('/', "\\")],
261 |row| row.get::<_, i64>(0),
262 )
263 .map(|count| count as usize)
264 .unwrap_or(0)
265}
266
267pub fn count_unsupported_files(conn: &Connection, path_filter: Option<&str>) -> usize {
269 let norm = path_filter
270 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
271 .filter(|p| !p.is_empty());
272 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
273 let prefix = norm.as_ref().map(|p| format!("{}/%", escape_like(p)));
274 let prefix_bs = norm_bs.as_ref().map(|p| format!("{}\\\\%", escape_like(p)));
275
276 conn.query_row(
277 "SELECT COUNT(*) FROM files
278 WHERE status = 'unsupported'
279 AND (:path IS NULL
280 OR path = :path COLLATE NOCASE
281 OR path = :path_bs COLLATE NOCASE
282 OR path LIKE :path_prefix ESCAPE '\\'
283 OR path LIKE :path_prefix_bs ESCAPE '\\')",
284 rusqlite::named_params! {
285 ":path": norm.as_deref(),
286 ":path_bs": norm_bs.as_deref(),
287 ":path_prefix": prefix.as_deref(),
288 ":path_prefix_bs": prefix_bs.as_deref(),
289 },
290 |row| row.get::<_, i64>(0),
291 )
292 .map(|count| count as usize)
293 .unwrap_or(0)
294}
295
296pub fn load_file_symbols(conn: &Connection, file_path: &str) -> Result<Vec<Symbol>, QueryError> {
298 let normalized = file_path.replace('\\', "/");
300 let backslash = file_path.replace('/', "\\");
301
302 let mut stmt = conn.prepare(
304 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
305 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
306 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
307 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
308 is_test, test_container
309 FROM symbols
310 WHERE (path = ?1 OR path = ?2)
311 ORDER BY start_line ASC, start_column ASC",
312 )?;
313
314 let rows = stmt
315 .query_map(params![&normalized, &backslash], map_symbol)?
316 .collect::<Result<Vec<_>, _>>()?;
317
318 if !rows.is_empty() {
319 return Ok(rows);
320 }
321
322 let mut stmt = conn.prepare(
324 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
325 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
326 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
327 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
328 is_test, test_container
329 FROM symbols
330 WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
331 ORDER BY start_line ASC, start_column ASC",
332 )?;
333
334 let rows = stmt
335 .query_map(params![normalized, backslash], map_symbol)?
336 .collect::<Result<Vec<_>, _>>()?;
337
338 Ok(rows)
339}
340
341pub fn normalize_kind(kind: &str) -> String {
343 let lower = kind.trim().to_lowercase();
344 match lower.as_str() {
345 "fn" | "func" | "function" => "function".to_string(),
346 "method" => "method".to_string(),
347 "struct" => "struct".to_string(),
348 "class" => "class".to_string(),
349 "enum" => "enum".to_string(),
350 "trait" => "trait".to_string(),
351 "interface" => "interface".to_string(),
352 "type" | "typedef" => "type".to_string(),
353 "mod" | "module" => "module".to_string(),
354 "const" | "constant" => "constant".to_string(),
355 "var" | "variable" => "variable".to_string(),
356 _ => lower,
357 }
358}
359
360pub fn search_symbols(
362 conn: &Connection,
363 query: &str,
364 kind_filter: Option<&str>,
365 include_tests: bool,
366 limit: usize,
367) -> Result<Vec<Symbol>, QueryError> {
368 search_symbols_scoped(conn, query, kind_filter, None, include_tests, limit)
369}
370
371pub fn search_symbols_scoped(
376 conn: &Connection,
377 query: &str,
378 kind_filter: Option<&str>,
379 path_filter: Option<&str>,
380 include_tests: bool,
381 limit: usize,
382) -> Result<Vec<Symbol>, QueryError> {
383 validate_result_limit(limit)?;
384 if limit == 0 {
385 return Ok(Vec::new());
386 }
387 let norm_kind = kind_filter.map(normalize_kind);
388 if (query.contains("::") || query.contains('.'))
389 && let Some(sym) = get_symbol_by_name(conn, query, path_filter)?
390 {
391 let kind_matches = norm_kind.as_deref().is_none_or(|kind| sym.kind == kind);
392 let test_matches =
393 include_tests || (!sym.is_test && !sym.test_container && !is_test_path(&sym.path));
394 return Ok(if kind_matches && test_matches {
395 vec![sym]
396 } else {
397 Vec::new()
398 });
399 }
400
401 let pattern = format!("%{}%", escape_like(query));
402 let normalized_path = path_filter.map(|p| {
403 p.replace('\\', "/")
404 .trim_start_matches("./")
405 .trim_matches('/')
406 .to_string()
407 });
408 let escaped_path = normalized_path.as_deref().map(escape_like);
409
410 let mut sql = String::from(
411 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
412 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
413 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
414 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
415 is_test, test_container
416 FROM symbols s
417 WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
418 AND (:kind IS NULL OR kind = :kind)
419 AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
420 );
421
422 if norm_kind.as_deref() != Some("variable") {
423 sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
424 }
425
426 if !include_tests {
427 sql.push_str(" AND is_test = 0 AND test_container = 0");
428 sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
429 }
430
431 sql.push_str(
432 " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
433 );
434 sql.push_str(&limit.to_string());
435
436 let mut stmt = conn.prepare(&sql)?;
437
438 let path_val = normalized_path.as_deref();
439 let path_like = escaped_path.as_deref();
440 let kind_val = norm_kind.as_deref();
441 let rows = stmt
442 .query_map(
443 rusqlite::named_params! {
444 ":query": query,
445 ":pattern": pattern,
446 ":kind": kind_val,
447 ":path": path_val,
448 ":path_like": path_like,
449 },
450 map_symbol,
451 )?
452 .collect::<Result<Vec<_>, _>>()?;
453
454 Ok(rows)
455}
456
457pub fn sanitize_fts5_query(query: &str) -> (String, String) {
466 let raw_words = query_words(query);
467 let split: Vec<(Vec<&str>, Option<&str>)> = raw_words
468 .iter()
469 .map(|raw| {
470 let parts = split_identifier(raw);
471 let whole = (parts.len() > 1).then_some(*raw);
472 (parts, whole)
473 })
474 .collect();
475 let any_content = split
476 .iter()
477 .any(|(parts, _)| parts.iter().any(|p| !is_stop_word(p)));
478
479 let mut and_groups: Vec<String> = Vec::new();
480 let mut or_terms: Vec<String> = Vec::new();
481 for (parts, whole) in split {
482 let parts: Vec<String> = parts
483 .into_iter()
484 .filter(|p| !any_content || !is_stop_word(p))
485 .map(fts5_term)
486 .collect();
487 let whole = whole.map(fts5_term);
488 let group = match (parts.is_empty(), whole.as_deref()) {
489 (true, None) => continue,
490 (true, Some(w)) => w.to_string(),
491 (false, None) => parts.join(" "),
492 (false, Some(w)) => format!("(({}) OR {w})", parts.join(" ")),
493 };
494 and_groups.push(group);
495 or_terms.extend(parts);
496 or_terms.extend(whole);
497 }
498
499 if and_groups.is_empty() {
500 return (String::new(), String::new());
501 }
502
503 let mut and_query = and_groups.join(" AND ");
504 if (2..=3).contains(&raw_words.len()) {
505 let all: String = raw_words.concat();
506 if all.len() <= 64 {
507 let all = fts5_term(&all);
508 and_query = format!("({and_query}) OR {all}");
509 or_terms.push(all);
510 }
511 }
512 (and_query, or_terms.join(" OR "))
513}
514
515const STOP_WORDS: &[&str] = &[
516 "a", "about", "after", "again", "all", "already", "also", "always", "an", "and", "another",
517 "any", "are", "as", "at", "be", "because", "been", "before", "being", "between", "both", "but",
518 "by", "can", "could", "did", "do", "does", "each", "either", "else", "ever", "every", "for",
519 "from", "had", "has", "have", "here", "how", "if", "in", "instead", "is", "it", "its",
520 "itself", "just", "many", "may", "might", "more", "most", "much", "must", "neither", "never",
521 "no", "nor", "not", "of", "on", "once", "one", "only", "or", "other", "our", "per", "rather",
522 "same", "should", "since", "so", "some", "still", "such", "than", "that", "the", "their",
523 "them", "then", "there", "these", "they", "this", "those", "through", "to", "too", "two",
524 "until", "very", "via", "was", "we", "were", "what", "when", "where", "whether", "which",
525 "while", "who", "whom", "why", "will", "with", "within", "without", "would", "yet", "you",
526 "your",
527];
528
529fn is_stop_word(word: &str) -> bool {
530 STOP_WORDS.contains(&word.to_ascii_lowercase().as_str())
531}
532
533fn query_words(query: &str) -> Vec<&str> {
535 query
536 .split(|c: char| !c.is_alphanumeric() && c != '_')
537 .filter(|s| !s.is_empty())
538 .collect()
539}
540
541fn trigram_name_terms(query: &str) -> Vec<String> {
546 let mut terms: Vec<String> = Vec::new();
547 for word in query_words(query) {
548 for term in std::iter::once(word).chain(split_identifier(word)) {
549 let lower = term.to_lowercase();
550 if lower.chars().count() >= 3 && !terms.contains(&lower) {
551 terms.push(lower);
552 }
553 }
554 }
555 let any_content = terms.iter().any(|t| !is_stop_word(t));
556 terms.retain(|t| !any_content || !is_stop_word(t));
557 terms
558}
559
560fn fts5_term(token: &str) -> String {
562 if token.chars().count() < 3 {
563 format!("\"{token}\"")
564 } else {
565 format!("\"{token}\"*")
566 }
567}
568
569fn name_prefix_query(name: &str) -> String {
572 name.split(|c: char| !c.is_alphanumeric() && c != '_')
573 .filter(|s| !s.is_empty())
574 .map(|s| format!("\"{s}\"*"))
575 .collect::<Vec<_>>()
576 .join(" ")
577}
578
579fn split_identifier(word: &str) -> Vec<&str> {
582 let mut out = Vec::new();
583 split_identifier_into(word, &mut out);
584 out
585}
586
587fn split_identifier_into<'a>(word: &'a str, out: &mut Vec<&'a str>) {
590 let mut chars = word.char_indices().peekable();
591 let Some((_, first)) = chars.next() else {
592 return;
593 };
594 let mut prev = char_class(first);
595 let mut start = 0;
596 while let Some((idx, c)) = chars.next() {
597 let cur = char_class(c);
598 let next = chars.peek().map_or(OTHER, |(_, n)| char_class(*n));
599 if identifier_boundary(prev, cur, next) {
600 push_piece(out, &word[start..idx]);
601 start = idx;
602 }
603 prev = cur;
604 }
605 push_piece(out, &word[start..]);
606}
607
608const OTHER: u8 = 0;
609const UNDERSCORE: u8 = 1;
610const UPPER: u8 = 2;
611const LOWER: u8 = 3;
612const DIGIT: u8 = 4;
613
614fn char_class(c: char) -> u8 {
615 if c == '_' {
616 UNDERSCORE
617 } else if c.is_uppercase() {
618 UPPER
619 } else if c.is_lowercase() {
620 LOWER
621 } else if c.is_ascii_digit() {
622 DIGIT
623 } else {
624 OTHER
625 }
626}
627
628fn byte_class(b: u8) -> u8 {
629 match b {
630 b'_' => UNDERSCORE,
631 b'A'..=b'Z' => UPPER,
632 b'a'..=b'z' => LOWER,
633 b'0'..=b'9' => DIGIT,
634 _ => OTHER,
635 }
636}
637
638fn identifier_boundary(prev: u8, cur: u8, next: u8) -> bool {
641 cur == UNDERSCORE
642 || prev == UNDERSCORE
643 || (cur == UPPER && (prev == LOWER || prev == DIGIT))
644 || (cur == UPPER && prev == UPPER && next == LOWER)
645 || ((cur == DIGIT) != (prev == DIGIT))
646}
647
648fn push_piece<'a>(out: &mut Vec<&'a str>, piece: &'a str) {
649 if !piece.is_empty() && piece != "_" {
650 out.push(piece);
651 }
652}
653
654fn text_tokens_into<'a>(text: &'a str, out: &mut Vec<&'a str>) {
658 if !text.is_ascii() {
659 for word in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
660 split_identifier_into(word, out);
661 }
662 return;
663 }
664 let bytes = text.as_bytes();
665 let mut start: Option<usize> = None;
666 let mut prev = OTHER;
667 for (i, &b) in bytes.iter().enumerate() {
668 let cur = byte_class(b);
669 if cur == OTHER {
670 if let Some(s) = start.take() {
671 push_piece(out, &text[s..i]);
672 }
673 continue;
674 }
675 match start {
676 None => start = Some(i),
677 Some(s) => {
678 let next = bytes.get(i + 1).map_or(OTHER, |n| byte_class(*n));
679 if identifier_boundary(prev, cur, next) {
680 push_piece(out, &text[s..i]);
681 start = Some(i);
682 }
683 }
684 }
685 prev = cur;
686 }
687 if let Some(s) = start {
688 push_piece(out, &text[s..]);
689 }
690}
691
692pub(crate) struct Candidate {
696 pub result: SymbolSearchResult,
697 pub bm25: Option<f64>,
698 pub exact_name: bool,
699 pub word_match: bool,
700 pub name_match: bool,
701 pub name_terms: Vec<String>,
702 pub documentation: bool,
703}
704
705fn candidate_columns(conn: &Connection) -> String {
706 format!(
707 "s.rowid AS row_id, s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind,
708 s.signature, s.doc_comment, s.visibility, s.parent_symbol_id, s.start_line,
709 s.start_column, s.end_line, s.end_column, s.start_byte, s.end_byte,
710 s.body_start_line, s.body_start_column, s.body_end_line, s.body_end_column,
711 s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group, s.is_test,
712 s.test_container,
713 (s.language IN ({doc_langs}) OR NOT ({not_doc})) AS documentation",
714 doc_langs = documentation_language_list(),
715 not_doc = not_documentation(conn, "s")
716 )
717}
718
719fn candidate_filters(searching_variables: bool, include_tests: bool) -> String {
720 let mut sql = String::from(
721 " AND (:kind IS NULL OR s.kind = :kind)
722 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 '\\')",
723 );
724 if !searching_variables {
725 sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
726 }
727 if !include_tests {
728 sql.push_str(" AND +s.is_test = 0 AND +s.test_container = 0");
731 sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
732 }
733 sql
734}
735
736pub(crate) fn collect_search_candidates(
743 conn: &Connection,
744 query: &str,
745 kind_filter: Option<&str>,
746 path_filter: Option<&str>,
747 include_tests: bool,
748 limit: usize,
749) -> Result<Vec<Candidate>, QueryError> {
750 let (and_q, or_q) = sanitize_fts5_query(query);
751 let terms = trigram_name_terms(query);
752 let normalized_path = path_filter.map(|p| {
753 p.replace('\\', "/")
754 .trim_start_matches("./")
755 .trim_matches('/')
756 .to_string()
757 });
758 let escaped_path = normalized_path.as_deref().map(escape_like);
759 let norm_kind = kind_filter.map(normalize_kind);
760 let searching_variables = norm_kind.as_deref() == Some("variable");
761 let path_val = normalized_path.as_deref();
762 let path_like = escaped_path.as_deref();
763 let kind_val = norm_kind.as_deref();
764 let columns = candidate_columns(conn);
765 let filters = candidate_filters(searching_variables, include_tests);
766 let word_cap = (limit * 4).clamp(40, 160);
767 let name_cap = (limit * 2).clamp(20, 40);
768
769 let new_candidate = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
770 let symbol = map_symbol(row)?;
771 let lower_name = symbol.name.to_lowercase();
772 let name_terms = terms
773 .iter()
774 .filter(|t| lower_name.contains(t.as_str()))
775 .cloned()
776 .collect();
777 let candidate = Candidate {
778 result: SymbolSearchResult {
779 symbol,
780 score: 0.0,
781 snippet: None,
782 explain: None,
783 },
784 bm25: None,
785 exact_name: false,
786 word_match: false,
787 name_match: false,
788 name_terms,
789 documentation: row.get::<_, Option<i64>>("documentation")? == Some(1),
790 };
791 Ok((row.get("row_id")?, candidate))
792 };
793
794 let mut candidates: Vec<Candidate> = Vec::new();
795 let mut by_rowid: HashMap<i64, usize> = HashMap::new();
796 let mut admit = |rowid: i64, incoming: Candidate| match by_rowid.get(&rowid).copied() {
797 Some(i) => {
798 let existing = &mut candidates[i];
799 existing.exact_name |= incoming.exact_name;
800 existing.word_match |= incoming.word_match;
801 existing.name_match |= incoming.name_match;
802 if incoming.bm25.is_some() && existing.bm25.is_none() {
803 existing.bm25 = incoming.bm25;
804 existing.result = incoming.result;
805 }
806 }
807 None => {
808 by_rowid.insert(rowid, candidates.len());
809 candidates.push(incoming);
810 }
811 };
812
813 let has_trigram = has_table(conn, "symbol_names_tri");
814 let exact_query = query.trim();
815 let exact_phrase = format!("\"{}\"", exact_query.replace('"', "\"\""));
816 let exact_via_trigram = has_trigram && exact_query.chars().count() >= 3;
817 let exact_sql = if exact_via_trigram {
818 format!(
819 "SELECT {columns} FROM symbol_names_tri
820 CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
821 WHERE symbol_names_tri MATCH :exact AND length(s.name) = length(:query) {filters}
822 ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
823 )
824 } else {
825 format!(
826 "SELECT {columns} FROM symbols s WHERE s.name = :query {filters}
827 ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
828 )
829 };
830 let mut exact_params: Vec<(&str, &dyn ToSql)> = vec![
831 (":query", &exact_query),
832 (":kind", &kind_val),
833 (":path", &path_val),
834 (":path_like", &path_like),
835 ];
836 if exact_via_trigram {
837 exact_params.push((":exact", &exact_phrase));
838 }
839 let exact_rows = conn
840 .prepare(&exact_sql)?
841 .query_map(exact_params.as_slice(), new_candidate)?
842 .collect::<Result<Vec<_>, _>>()?;
843 for (rowid, mut candidate) in exact_rows {
844 candidate.exact_name = true;
845 admit(rowid, candidate);
846 }
847
848 if searching_variables {
849 let pattern = format!("%{}%", escape_like(exact_query));
850 let local_sql = format!(
851 "SELECT {columns} FROM symbols s
852 WHERE {local} AND (s.name = :query OR s.name LIKE :pattern ESCAPE '\\') {filters}
853 ORDER BY (s.name = :query) DESC, length(s.name) ASC, s.path ASC LIMIT {limit}",
854 local = local_variable_predicate("s")
855 );
856 let local_rows = conn
857 .prepare(&local_sql)?
858 .query_map(
859 rusqlite::named_params! {
860 ":query": exact_query,
861 ":pattern": pattern,
862 ":kind": kind_val,
863 ":path": path_val,
864 ":path_like": path_like,
865 },
866 new_candidate,
867 )?
868 .collect::<Result<Vec<_>, _>>()?;
869 for (rowid, mut candidate) in local_rows {
870 candidate.exact_name = candidate.result.symbol.name == exact_query;
871 candidate.name_match = true;
872 admit(rowid, candidate);
873 }
874 }
875
876 let word_sql = format!(
877 "SELECT {columns},
878 bm25(symbols_fts, 10.0, 5.0, 1.0) AS rank_score,
879 snippet(symbols_fts, 2, '[', ']', '...', 12) AS doc_snippet,
880 snippet(symbols_fts, 1, '[', ']', '...', 12) AS sig_snippet,
881 snippet(symbols_fts, 0, '[', ']', '...', 12) AS name_snippet
882 FROM symbols_fts
883 CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
884 WHERE symbols_fts MATCH :match {filters}
885 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}",
886 doc_langs = documentation_language_list(),
887 not_doc = not_documentation(conn, "s")
888 );
889 let word_rows = |match_clause: &str| -> Result<Vec<(i64, Candidate)>, QueryError> {
890 let map_fn = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
891 let (rowid, mut candidate) = new_candidate(row)?;
892 let score: f64 = row.get("rank_score")?;
893 let doc_snip: Option<String> = row.get("doc_snippet").ok();
894 let sig_snip: Option<String> = row.get("sig_snippet").ok();
895 let name_snip: Option<String> = row.get("name_snippet").ok();
896 let highlighted = |s: &Option<String>| s.as_ref().is_some_and(|s| s.contains('['));
897 candidate.result.snippet = if highlighted(&doc_snip) {
898 doc_snip
899 } else if highlighted(&sig_snip) {
900 sig_snip
901 } else if highlighted(&name_snip) {
902 name_snip
903 } else {
904 doc_snip.or(sig_snip).or(name_snip)
905 };
906 candidate.result.score = score;
907 candidate.bm25 = Some(score);
908 candidate.word_match = true;
909 Ok((rowid, candidate))
910 };
911 Ok(conn
912 .prepare(&word_sql)?
913 .query_map(
914 rusqlite::named_params! {
915 ":match": match_clause,
916 ":query": query.trim(),
917 ":kind": kind_val,
918 ":path": path_val,
919 ":path_like": path_like,
920 },
921 map_fn,
922 )?
923 .collect::<Result<Vec<_>, _>>()?)
924 };
925 if !and_q.is_empty() {
926 let and_rows = word_rows(&and_q)?;
927 let mut word_admitted: HashSet<i64> = and_rows.iter().map(|(rowid, _)| *rowid).collect();
928 for (rowid, candidate) in and_rows {
929 admit(rowid, candidate);
930 }
931 if and_q != or_q {
932 for (rowid, candidate) in word_rows(&or_q)? {
933 if word_admitted.len() >= word_cap && !word_admitted.contains(&rowid) {
934 break;
935 }
936 word_admitted.insert(rowid);
937 admit(rowid, candidate);
938 }
939 }
940 }
941
942 if !terms.is_empty() && has_trigram {
943 let match_clause = terms
944 .iter()
945 .map(|t| format!("\"{t}\""))
946 .collect::<Vec<_>>()
947 .join(" OR ");
948 let name_sql = format!(
949 "SELECT {columns} FROM symbol_names_tri
950 CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
951 WHERE symbol_names_tri MATCH :match {filters}
952 ORDER BY bm25(symbol_names_tri) ASC, length(s.name) ASC, s.path ASC LIMIT {name_cap}"
953 );
954 let name_rows = conn
955 .prepare(&name_sql)?
956 .query_map(
957 rusqlite::named_params! {
958 ":match": match_clause,
959 ":kind": kind_val,
960 ":path": path_val,
961 ":path_like": path_like,
962 },
963 new_candidate,
964 )?
965 .collect::<Result<Vec<_>, _>>()?;
966 for (rowid, mut candidate) in name_rows {
967 candidate.name_match = true;
968 admit(rowid, candidate);
969 }
970 }
971
972 Ok(candidates)
973}
974
975pub fn fts_search_symbols_scoped(
977 conn: &Connection,
978 query: &str,
979 kind_filter: Option<&str>,
980 path_filter: Option<&str>,
981 include_tests: bool,
982 limit: usize,
983) -> Result<Vec<SymbolSearchResult>, QueryError> {
984 fts_search_symbols_explained(
985 conn,
986 query,
987 kind_filter,
988 path_filter,
989 include_tests,
990 limit,
991 false,
992 )
993}
994
995pub fn fts_search_symbols_explained(
998 conn: &Connection,
999 query: &str,
1000 kind_filter: Option<&str>,
1001 path_filter: Option<&str>,
1002 include_tests: bool,
1003 limit: usize,
1004 explain: bool,
1005) -> Result<Vec<SymbolSearchResult>, QueryError> {
1006 validate_result_limit(limit)?;
1007 if limit == 0 {
1008 return Ok(Vec::new());
1009 }
1010 let (and_q, _) = sanitize_fts5_query(query);
1011 if and_q.is_empty() {
1012 return Ok(Vec::new());
1013 }
1014
1015 let normalized_path = path_filter.map(|p| {
1016 p.replace('\\', "/")
1017 .trim_start_matches("./")
1018 .trim_matches('/')
1019 .to_string()
1020 });
1021 let norm_kind = kind_filter.map(normalize_kind);
1022 let escaped_path = normalized_path.as_deref().map(escape_like);
1023 let searching_variables = norm_kind.as_deref() == Some("variable");
1024
1025 let name_search = |local_clause: &str| -> Result<Vec<SymbolSearchResult>, QueryError> {
1026 let pattern = format!("%{}%", escape_like(query));
1027 let mut sql = String::from(
1028 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
1029 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
1030 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
1031 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
1032 is_test, test_container
1033 FROM symbols s
1034 WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
1035 AND (:kind IS NULL OR kind = :kind)
1036 AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
1037 );
1038 sql.push_str(local_clause);
1039 if !include_tests {
1040 sql.push_str(" AND is_test = 0 AND test_container = 0");
1041 sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
1042 }
1043 sql.push_str(
1044 " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
1045 );
1046 sql.push_str(&limit.to_string());
1047
1048 let mut stmt = conn.prepare(&sql)?;
1049 let path_val = normalized_path.as_deref();
1050 let path_like = escaped_path.as_deref();
1051 let kind_val = norm_kind.as_deref();
1052 let rows = stmt
1053 .query_map(
1054 rusqlite::named_params! {
1055 ":query": query,
1056 ":pattern": pattern,
1057 ":kind": kind_val,
1058 ":path": path_val,
1059 ":path_like": path_like,
1060 },
1061 map_symbol,
1062 )?
1063 .collect::<Result<Vec<_>, _>>()?;
1064
1065 Ok(rows
1066 .into_iter()
1067 .map(|s| SymbolSearchResult {
1068 symbol: s,
1069 score: 0.0,
1070 snippet: None,
1071 explain: None,
1072 })
1073 .collect())
1074 };
1075
1076 if !has_table(conn, "symbols_fts") {
1077 let local_clause = if searching_variables {
1078 String::new()
1079 } else {
1080 format!(" AND NOT {}", local_variable_predicate("s"))
1081 };
1082 return name_search(&local_clause);
1083 }
1084
1085 let candidates =
1086 collect_search_candidates(conn, query, kind_filter, path_filter, include_tests, limit)?;
1087 let candidate_count = candidates.len();
1088 let started = std::time::Instant::now();
1089 let idf = idf_weights(conn, &rerank_words(query));
1090 let ranked = rerank_with(candidates, query, include_tests, Some(&idf));
1091 let rerank_us = started.elapsed().as_micros();
1092 Ok(ranked
1093 .into_iter()
1094 .take(limit)
1095 .map(|(mut result, mut breakdown)| {
1096 if explain {
1097 breakdown.candidates = candidate_count;
1098 breakdown.rerank_us = rerank_us;
1099 result.explain = Some(breakdown);
1100 }
1101 result
1102 })
1103 .collect())
1104}
1105
1106const W_NAME_WHOLE: f64 = 100.0;
1107const W_NAME_ALL_WORDS: f64 = 60.0;
1108const W_KIND_DEFINITION: f64 = 4.0;
1109const W_KIND_MEMBER: f64 = 0.0;
1110const W_KIND_IMPORT: f64 = -50.0;
1111const W_PATH_ROLE: f64 = -10.0;
1112const W_DOCUMENTATION_ROW: f64 = -200.0;
1113const W_TEST_INTENT: f64 = 5.0;
1114const W_TERMS: f64 = 52.0;
1115const MAX_TERM_CREDIT: f64 = 3.0;
1116const TEXT_CREDIT: f64 = 1.0;
1117const TEXT_HEAD_BYTES: usize = 400;
1118
1119const DEFINITION_KINDS: &[&str] = &[
1120 "function",
1121 "method",
1122 "class",
1123 "struct",
1124 "trait",
1125 "interface",
1126 "enum",
1127 "type",
1128];
1129const MEMBER_KINDS: &[&str] = &["enum_member", "field", "property", "constant", "variable"];
1130const DEMOTED_PATH_SEGMENTS: &[&str] = &["scripts", "examples", "benchmarks", "fixtures", "vendor"];
1131const TEST_INTENT_WORDS: &[&str] = &["test", "tests", "spec", "specs"];
1132
1133struct QueryWord {
1134 word: String,
1135 stem: String,
1136}
1137
1138struct Hits {
1141 name: Vec<u8>,
1142 signature: Vec<bool>,
1143 doc: Vec<bool>,
1144}
1145
1146const DF_CAP: usize = 20_000;
1149
1150fn idf_weights(conn: &Connection, words: &[String]) -> Vec<f64> {
1155 let n = conn
1156 .query_row("SELECT max(rowid) FROM symbols", [], |r| {
1157 r.get::<_, Option<i64>>(0)
1158 })
1159 .ok()
1160 .flatten()
1161 .unwrap_or(0) as f64;
1162 let mut count = conn
1163 .prepare(&format!(
1164 "SELECT count(*) FROM (SELECT rowid FROM symbols_fts WHERE symbols_fts MATCH ?1 LIMIT {DF_CAP})"
1165 ))
1166 .ok();
1167 words
1168 .iter()
1169 .map(|word| {
1170 let df = count
1171 .as_mut()
1172 .and_then(|stmt| document_frequency(stmt, &format!("\"{word}\"")))
1173 .unwrap_or(0);
1174 (1.0 + n / (df as f64 + 1.0)).ln()
1175 })
1176 .collect()
1177}
1178
1179fn document_frequency(stmt: &mut rusqlite::Statement<'_>, term: &str) -> Option<i64> {
1180 stmt.query_row([term], |r| r.get(0)).ok()
1181}
1182
1183fn term_credits(hits: &Hits, words: &[QueryWord]) -> Vec<(String, String, f64)> {
1186 words
1187 .iter()
1188 .enumerate()
1189 .map(|(i, w)| {
1190 let (field, credit) = match hits.name[i] {
1191 3 => ("name", 3.0),
1192 2 => ("name", 2.0),
1193 _ if hits.signature[i] => ("signature", TEXT_CREDIT),
1194 _ if hits.doc[i] => ("doc", TEXT_CREDIT),
1195 1 => ("name", 1.0),
1196 _ => ("none", 0.0),
1197 };
1198 (w.word.clone(), field.to_string(), credit)
1199 })
1200 .collect()
1201}
1202
1203fn term_score(terms: &[(String, String, f64)], weights: &[f64]) -> f64 {
1204 let total: f64 = weights.iter().sum();
1205 if total == 0.0 {
1206 return 0.0;
1207 }
1208 let credited: f64 = terms
1209 .iter()
1210 .zip(weights)
1211 .map(|((_, _, credit), weight)| credit * weight)
1212 .sum();
1213 W_TERMS * credited / (MAX_TERM_CREDIT * total)
1214}
1215
1216fn rerank_words(query: &str) -> Vec<String> {
1219 let words: Vec<String> = query_words(query)
1220 .into_iter()
1221 .flat_map(split_identifier)
1222 .map(str::to_lowercase)
1223 .collect();
1224 let any_content = words.iter().any(|w| !is_stop_word(w));
1225 words
1226 .into_iter()
1227 .filter(|w| !any_content || !is_stop_word(w))
1228 .collect()
1229}
1230
1231fn collapse(text: &str) -> String {
1232 text.chars()
1233 .filter(|c| c.is_alphanumeric())
1234 .flat_map(char::to_lowercase)
1235 .collect()
1236}
1237
1238fn head_bytes(text: &str, bytes: usize) -> &str {
1239 let mut end = bytes.min(text.len());
1240 while !text.is_char_boundary(end) {
1241 end -= 1;
1242 }
1243 &text[..end]
1244}
1245
1246fn token_run_equals(tokens: &[String], word: &str) -> bool {
1247 (0..tokens.len()).any(|start| {
1248 let mut joined = String::new();
1249 for token in &tokens[start..] {
1250 joined.push_str(token);
1251 if joined.len() >= word.len() {
1252 return joined == word;
1253 }
1254 }
1255 false
1256 })
1257}
1258
1259fn name_hits(name: &str, words: &[QueryWord], stemmer: &Stemmer) -> Vec<u8> {
1263 let tokens: Vec<String> = split_identifier(name)
1264 .into_iter()
1265 .map(str::to_lowercase)
1266 .collect();
1267 let stems: Vec<String> = tokens
1268 .iter()
1269 .map(|t| stemmer.stem(t).into_owned())
1270 .collect();
1271 let collapsed = collapse(name);
1272 words
1273 .iter()
1274 .map(|w| {
1275 if token_run_equals(&tokens, &w.word) {
1276 3
1277 } else if stems.contains(&w.stem) {
1278 2
1279 } else if w.word.chars().count() >= 3 && collapsed.contains(&w.word) {
1280 1
1281 } else {
1282 0
1283 }
1284 })
1285 .collect()
1286}
1287
1288fn lowercase_prefix_match(token: &str, prefix: &str, exact: bool) -> bool {
1291 if token.is_ascii() && prefix.is_ascii() {
1292 let Some(head) = token.as_bytes().get(..prefix.len()) else {
1293 return false;
1294 };
1295 return head.eq_ignore_ascii_case(prefix.as_bytes())
1296 && (!exact || token.len() == prefix.len());
1297 }
1298 let mut lower = token.chars().flat_map(char::to_lowercase);
1299 for expected in prefix.chars() {
1300 if lower.next() != Some(expected) {
1301 return false;
1302 }
1303 }
1304 !exact || lower.next().is_none()
1305}
1306
1307fn text_hits<'a>(
1311 text: Option<&'a str>,
1312 words: &[QueryWord],
1313 tokens: &mut Vec<&'a str>,
1314) -> Vec<bool> {
1315 tokens.clear();
1316 text_tokens_into(text.unwrap_or(""), tokens);
1317 words
1318 .iter()
1319 .map(|w| {
1320 let stem_prefix = w.stem.chars().count() >= 3;
1321 let exact_word = w.word.chars().count() < 3;
1322 tokens.iter().any(|t| {
1323 lowercase_prefix_match(t, &w.word, exact_word)
1324 || (stem_prefix && lowercase_prefix_match(t, &w.stem, false))
1325 })
1326 })
1327 .collect()
1328}
1329
1330fn kind_prior(kind: &str) -> f64 {
1331 let kind = normalize_kind(kind);
1332 match kind.as_str() {
1333 "import" => W_KIND_IMPORT,
1334 k if DEFINITION_KINDS.contains(&k) => W_KIND_DEFINITION,
1335 k if MEMBER_KINDS.contains(&k) => W_KIND_MEMBER,
1336 _ => 0.0,
1337 }
1338}
1339
1340fn path_role(path: &str, words: &[QueryWord], stemmer: &Stemmer) -> f64 {
1341 let Some(segment) = path.split(['/', '\\']).find(|seg| {
1342 DEMOTED_PATH_SEGMENTS
1343 .iter()
1344 .any(|d| d.eq_ignore_ascii_case(seg))
1345 }) else {
1346 return 0.0;
1347 };
1348 let segment = segment.to_lowercase();
1349 let segment_stem = stemmer.stem(&segment);
1350 let named = words.iter().any(|w| {
1351 w.word == segment || w.word == segment_stem || w.stem == segment || w.stem == segment_stem
1352 });
1353 if named { 0.0 } else { W_PATH_ROLE }
1354}
1355
1356fn bracket_longest_term(name: &str, terms: &[String]) -> String {
1357 let lower = name.to_lowercase();
1358 if lower.len() != name.len() {
1359 return name.to_string();
1360 }
1361 let mut best: Option<(usize, usize)> = None;
1362 for term in terms {
1363 if let Some(start) = lower.find(term.as_str()) {
1364 let end = start + term.len();
1365 let longer = best.is_none_or(|(s, e)| end - start > e - s);
1366 if longer && name.is_char_boundary(start) && name.is_char_boundary(end) {
1367 best = Some((start, end));
1368 }
1369 }
1370 }
1371 match best {
1372 Some((start, end)) => {
1373 format!("{}[{}]{}", &name[..start], &name[start..end], &name[end..])
1374 }
1375 None => name.to_string(),
1376 }
1377}
1378
1379fn branch_snippet(candidate: &Candidate) -> Option<String> {
1380 let name = &candidate.result.symbol.name;
1381 if candidate.word_match {
1382 candidate.result.snippet.clone()
1383 } else if candidate.exact_name {
1384 Some(name.clone())
1385 } else {
1386 Some(bracket_longest_term(name, &candidate.name_terms))
1387 }
1388}
1389
1390fn rerank_with(
1395 candidates: Vec<Candidate>,
1396 query: &str,
1397 include_tests: bool,
1398 idf: Option<&[f64]>,
1399) -> Vec<(SymbolSearchResult, SearchExplain)> {
1400 let stemmer = Stemmer::create(Algorithm::English);
1401 let words: Vec<QueryWord> = rerank_words(query)
1402 .into_iter()
1403 .map(|word| QueryWord {
1404 stem: stemmer.stem(&word).into_owned(),
1405 word,
1406 })
1407 .collect();
1408 let collapsed_query = collapse(query);
1409 let test_intent = include_tests
1410 && words
1411 .iter()
1412 .any(|w| TEST_INTENT_WORDS.contains(&w.word.as_str()));
1413
1414 let mut tokens: Vec<&str> = Vec::new();
1415 let hits: Vec<Hits> = candidates
1416 .iter()
1417 .map(|candidate| {
1418 let symbol = &candidate.result.symbol;
1419 Hits {
1420 name: name_hits(&symbol.name, &words, &stemmer),
1421 signature: text_hits(
1422 symbol
1423 .signature
1424 .as_deref()
1425 .map(|signature| head_bytes(signature, TEXT_HEAD_BYTES)),
1426 &words,
1427 &mut tokens,
1428 ),
1429 doc: text_hits(
1430 symbol
1431 .doc_comment
1432 .as_deref()
1433 .map(|doc| head_bytes(doc, TEXT_HEAD_BYTES)),
1434 &words,
1435 &mut tokens,
1436 ),
1437 }
1438 })
1439 .collect();
1440 let term_weights: Vec<f64> = match idf {
1441 Some(weights) if weights.len() == words.len() => weights.to_vec(),
1442 _ => vec![1.0; words.len()],
1443 };
1444 let word_weights: Vec<(String, f64)> = words
1445 .iter()
1446 .zip(&term_weights)
1447 .map(|(w, weight)| (w.word.clone(), *weight))
1448 .collect();
1449
1450 let mut scored: Vec<(SymbolSearchResult, SearchExplain)> = candidates
1451 .into_iter()
1452 .zip(hits)
1453 .map(|(candidate, hits)| {
1454 let symbol = &candidate.result.symbol;
1455 let name_strength: u32 = hits.name.iter().map(|s| u32::from(*s)).sum();
1456 let tier = if !collapsed_query.is_empty() && collapse(&symbol.name) == collapsed_query {
1457 "whole"
1458 } else if !hits.name.is_empty() && hits.name.iter().all(|s| *s >= 2) {
1459 "all"
1460 } else if hits.name.iter().any(|s| *s > 0) {
1461 "partial"
1462 } else {
1463 "none"
1464 };
1465 let terms = term_credits(&hits, &words);
1466 let explain = SearchExplain {
1467 bm25: candidate.bm25,
1468 branches: [
1469 (candidate.exact_name, "exact"),
1470 (candidate.word_match, "word"),
1471 (candidate.name_match, "name"),
1472 ]
1473 .into_iter()
1474 .filter(|(hit, _)| *hit)
1475 .map(|(_, branch)| branch.to_string())
1476 .collect(),
1477 name_tier: tier.to_string(),
1478 name_strength,
1479 term_score: term_score(&terms, &term_weights),
1480 name_bonus: match tier {
1481 "whole"
1482 if DEFINITION_KINDS.contains(&normalize_kind(&symbol.kind).as_str()) =>
1483 {
1484 W_NAME_WHOLE
1485 }
1486 "whole" | "all" => W_NAME_ALL_WORDS,
1487 _ => 0.0,
1488 },
1489 kind_prior: kind_prior(&symbol.kind),
1490 path_role: path_role(&symbol.path, &words, &stemmer),
1491 documentation: if candidate.documentation {
1492 W_DOCUMENTATION_ROW
1493 } else {
1494 0.0
1495 },
1496 test_intent: if test_intent && (symbol.is_test || symbol.test_container) {
1497 W_TEST_INTENT
1498 } else {
1499 0.0
1500 },
1501 terms,
1502 word_weights: word_weights.clone(),
1503 candidates: 0,
1504 rerank_us: 0,
1505 };
1506 let score = explain.term_score
1507 + explain.name_bonus
1508 + explain.kind_prior
1509 + explain.path_role
1510 + explain.documentation
1511 + explain.test_intent;
1512 let snippet = branch_snippet(&candidate);
1513 let mut result = candidate.result;
1514 result.score = score;
1515 result.snippet = snippet;
1516 (result, explain)
1517 })
1518 .collect();
1519
1520 scored.sort_by(|(a, ea), (b, eb)| {
1521 b.score
1522 .total_cmp(&a.score)
1523 .then_with(|| eb.name_strength.cmp(&ea.name_strength))
1524 .then_with(|| {
1525 a.symbol
1526 .name
1527 .starts_with('_')
1528 .cmp(&b.symbol.name.starts_with('_'))
1529 })
1530 .then_with(|| ea.bm25.is_none().cmp(&eb.bm25.is_none()))
1531 .then_with(|| ea.bm25.unwrap_or(0.0).total_cmp(&eb.bm25.unwrap_or(0.0)))
1532 .then_with(|| a.symbol.name.len().cmp(&b.symbol.name.len()))
1533 .then_with(|| a.symbol.path.cmp(&b.symbol.path))
1534 .then_with(|| a.symbol.name.cmp(&b.symbol.name))
1535 });
1536 scored
1537}
1538
1539pub fn find_related_tests(
1541 conn: &Connection,
1542 target_symbol: &Symbol,
1543 limit: usize,
1544) -> Result<Vec<Symbol>, QueryError> {
1545 if limit == 0 {
1546 return Ok(Vec::new());
1547 }
1548
1549 const COLUMNS: &str = "s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1550 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1551 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1552 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1553 s.is_test, s.test_container";
1554 const IS_TEST: &str = "(s.is_test = 1 OR s.test_container = 1)";
1555 let not_documentation = not_documentation(conn, "s");
1556
1557 let mut tests = Vec::new();
1558 let mut seen_ids = std::collections::HashSet::new();
1559
1560 let callers_sql = format!(
1561 "SELECT {COLUMNS}
1562 FROM symbols s
1563 JOIN relationships r ON r.from_symbol_id = s.symbol_id
1564 WHERE r.to_symbol_id = ?1 AND {IS_TEST} AND {not_documentation}
1565 LIMIT ?2"
1566 );
1567
1568 if let Ok(mut stmt) = conn.prepare(&callers_sql)
1569 && let Ok(rows) = stmt.query_map(params![target_symbol.symbol_id, limit as i64], map_symbol)
1570 {
1571 for row in rows.flatten() {
1572 if seen_ids.insert(row.symbol_id.clone()) {
1573 tests.push(row);
1574 if tests.len() >= limit {
1575 return Ok(tests);
1576 }
1577 }
1578 }
1579 }
1580
1581 let remaining = limit - tests.len();
1583 if remaining > 0 && has_pending_namespace_column(conn) {
1584 let pending_sql = format!(
1585 "SELECT DISTINCT {COLUMNS}
1586 FROM pending_relationships p
1587 JOIN symbols s ON p.from_symbol_id = s.symbol_id
1588 JOIN symbols s_from ON s_from.symbol_id = s.symbol_id
1589 JOIN symbols s_target ON s_target.symbol_id = ?1
1590 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1591 WHERE p.target_terminal_name = s_target.name
1592 AND {IS_TEST}
1593 AND {not_documentation}
1594 AND {pred}
1595 LIMIT ?2",
1596 pred = pending_target_predicate("s_target", "s_target_parent")
1597 );
1598
1599 if let Ok(mut stmt) = conn.prepare(&pending_sql)
1600 && let Ok(rows) = stmt.query_map(
1601 params![target_symbol.symbol_id, remaining as i64],
1602 map_symbol,
1603 )
1604 {
1605 for row in rows.flatten() {
1606 if seen_ids.insert(row.symbol_id.clone()) {
1607 tests.push(row);
1608 if tests.len() >= limit {
1609 return Ok(tests);
1610 }
1611 }
1612 }
1613 }
1614 }
1615
1616 let remaining = limit - tests.len();
1617 let name_sql = format!(
1618 "SELECT {COLUMNS}
1619 FROM symbols s
1620 WHERE {IS_TEST}
1621 AND {not_documentation}
1622 AND (s.name LIKE '%' || ?1 || '%' OR s.signature LIKE '%' || ?1 || '%')
1623 ORDER BY (s.name LIKE '%' || ?1 || '%') DESC
1624 LIMIT ?2"
1625 );
1626
1627 if let Ok(mut stmt) = conn.prepare(&name_sql)
1628 && let Ok(rows) = stmt.query_map(
1629 params![target_symbol.name, (remaining * 2) as i64],
1630 map_symbol,
1631 )
1632 {
1633 for row in rows.flatten() {
1634 if seen_ids.insert(row.symbol_id.clone()) {
1635 tests.push(row);
1636 if tests.len() >= limit {
1637 return Ok(tests);
1638 }
1639 }
1640 }
1641 }
1642
1643 let remaining = limit - tests.len();
1644 let fts_exists: bool = conn
1645 .query_row(
1646 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
1647 [],
1648 |_| Ok(true),
1649 )
1650 .unwrap_or(false);
1651
1652 if remaining > 0 && fts_exists {
1653 let fts_sql = format!(
1654 "SELECT {COLUMNS}
1655 FROM symbols_fts
1656 CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
1657 WHERE symbols_fts MATCH ?1 AND {IS_TEST} AND {not_documentation}
1658 LIMIT ?2"
1659 );
1660
1661 let and_q = name_prefix_query(&target_symbol.name);
1662 if !and_q.is_empty()
1663 && let Ok(mut stmt) = conn.prepare(&fts_sql)
1664 && let Ok(rows) = stmt.query_map(params![and_q, (remaining * 2) as i64], map_symbol)
1665 {
1666 for row in rows.flatten() {
1667 if seen_ids.insert(row.symbol_id.clone()) {
1668 tests.push(row);
1669 if tests.len() >= limit {
1670 break;
1671 }
1672 }
1673 }
1674 }
1675 }
1676
1677 Ok(tests)
1678}
1679
1680pub fn get_symbol_by_name(
1682 conn: &Connection,
1683 name: &str,
1684 path_filter: Option<&str>,
1685) -> Result<Option<Symbol>, QueryError> {
1686 get_symbol_by_name_internal(conn, name, path_filter, false)
1687}
1688
1689pub fn get_symbol_by_name_exact(
1691 conn: &Connection,
1692 name: &str,
1693 exact_path: &str,
1694) -> Result<Option<Symbol>, QueryError> {
1695 get_symbol_by_name_internal(conn, name, Some(exact_path), true)
1696}
1697
1698fn get_symbol_by_name_internal(
1699 conn: &Connection,
1700 name: &str,
1701 path_filter: Option<&str>,
1702 exact_path: bool,
1703) -> Result<Option<Symbol>, QueryError> {
1704 let (parent_name, terminal_name) = if let Some(idx) = name.rfind("::") {
1706 let parent = &name[..idx];
1707 let term = &name[idx + 2..];
1708 let immediate_parent = if let Some(p_idx) = parent.rfind("::") {
1709 &parent[p_idx + 2..]
1710 } else {
1711 parent
1712 };
1713 (Some(immediate_parent), term)
1714 } else if let Some(idx) = name.rfind('.') {
1715 let parent = &name[..idx];
1716 let term = &name[idx + 1..];
1717 let immediate_parent = if let Some(p_idx) = parent.rfind('.') {
1718 &parent[p_idx + 1..]
1719 } else {
1720 parent
1721 };
1722 (Some(immediate_parent), term)
1723 } else {
1724 (None, name)
1725 };
1726
1727 let sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1728 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1729 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1730 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1731 s.is_test, s.test_container
1732 FROM symbols s
1733 LEFT JOIN symbols p ON s.parent_symbol_id = p.symbol_id
1734 WHERE (s.name = :name OR (s.name = :term AND (:parent IS NULL OR p.name = :parent)))
1735 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 '\\')))
1736 ORDER BY (s.kind != 'import') DESC,
1737 (s.kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC,
1738 (s.name = :name) DESC,
1739 (:path IS NOT NULL AND (s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE)) DESC,
1740 s.is_test ASC
1741 LIMIT 25";
1742
1743 let mut stmt = conn.prepare(sql)?;
1744 let normalized_path = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
1745 let backslash_path = normalized_path.as_deref().map(|p| p.replace('/', "\\"));
1746 let path_like = normalized_path.as_deref().map(escape_like);
1747 let path_like_bs = backslash_path.as_deref().map(escape_like);
1748
1749 let mut rows = stmt.query(rusqlite::named_params! {
1750 ":name": name,
1751 ":term": terminal_name,
1752 ":parent": parent_name,
1753 ":path": normalized_path.as_deref(),
1754 ":path_bs": backslash_path.as_deref(),
1755 ":path_like": path_like.as_deref(),
1756 ":path_like_bs": path_like_bs.as_deref(),
1757 ":exact": if exact_path { 1 } else { 0 },
1758 })?;
1759
1760 let mut matches: Vec<Symbol> = Vec::new();
1761 while let Some(row) = rows.next()? {
1762 matches.push(map_symbol(row)?);
1763 }
1764
1765 if matches.is_empty() {
1766 return Ok(None);
1767 }
1768
1769 if matches.len() == 1 {
1770 return Ok(Some(matches.remove(0)));
1771 }
1772
1773 let candidates: Vec<Symbol> = if matches.iter().any(|s| s.kind != "import") {
1775 matches.into_iter().filter(|s| s.kind != "import").collect()
1776 } else {
1777 matches
1778 };
1779
1780 if candidates.len() == 1 {
1781 return Ok(Some(candidates.into_iter().next().unwrap()));
1782 }
1783
1784 let exact_name_matches: Vec<_> = candidates
1786 .iter()
1787 .filter(|s| s.name == name)
1788 .cloned()
1789 .collect();
1790 if exact_name_matches.len() == 1 {
1791 return Ok(Some(exact_name_matches.into_iter().next().unwrap()));
1792 }
1793
1794 let definition_candidates = if exact_name_matches.is_empty() {
1795 &candidates
1796 } else {
1797 &exact_name_matches
1798 };
1799 let def_matches: Vec<_> = definition_candidates
1800 .iter()
1801 .filter(|s| {
1802 matches!(
1803 s.kind.as_str(),
1804 "function"
1805 | "struct"
1806 | "class"
1807 | "trait"
1808 | "method"
1809 | "enum"
1810 | "interface"
1811 | "type"
1812 )
1813 })
1814 .cloned()
1815 .collect();
1816 if def_matches.len() == 1 {
1817 return Ok(Some(def_matches.into_iter().next().unwrap()));
1818 }
1819
1820 let active_pool = if !def_matches.is_empty() {
1821 def_matches
1822 } else if !exact_name_matches.is_empty() {
1823 exact_name_matches
1824 } else {
1825 candidates
1826 };
1827
1828 if let Some(ref p) = normalized_path {
1830 let exact_path_matches: Vec<_> = active_pool
1831 .iter()
1832 .filter(|s| s.path == *p)
1833 .cloned()
1834 .collect();
1835 if exact_path_matches.len() == 1 {
1836 return Ok(Some(exact_path_matches.into_iter().next().unwrap()));
1837 }
1838 }
1839
1840 if active_pool.len() == 1 {
1841 return Ok(Some(active_pool.into_iter().next().unwrap()));
1842 }
1843
1844 let mut candidate_list = String::new();
1846 for s in &active_pool {
1847 candidate_list.push_str(&format!(
1848 "- {} `{}` in {}:{}\n",
1849 s.kind, s.name, s.path, s.start_line
1850 ));
1851 }
1852
1853 Err(QueryError::AmbiguousSymbol(
1854 name.to_string(),
1855 active_pool.len(),
1856 candidate_list,
1857 ))
1858}
1859
1860pub fn find_references(
1862 conn: &Connection,
1863 symbol_name: &str,
1864 direction: &str,
1865 limit: usize,
1866) -> Result<Vec<ReferenceSite>, QueryError> {
1867 find_references_ext(conn, symbol_name, direction, limit, false)
1868}
1869
1870pub fn find_references_ext(
1872 conn: &Connection,
1873 symbol_name: &str,
1874 direction: &str,
1875 limit: usize,
1876 include_external: bool,
1877) -> Result<Vec<ReferenceSite>, QueryError> {
1878 find_references_scoped(conn, symbol_name, direction, limit, include_external, None)
1879}
1880
1881pub fn find_references_scoped(
1883 conn: &Connection,
1884 symbol_name: &str,
1885 direction: &str,
1886 limit: usize,
1887 include_external: bool,
1888 path_filter: Option<&str>,
1889) -> Result<Vec<ReferenceSite>, QueryError> {
1890 validate_result_limit(limit)?;
1891 if direction != "callers" && direction != "callees" {
1892 return Err(QueryError::InvalidDirection(direction.to_string()));
1893 }
1894
1895 match get_symbol_by_name(conn, symbol_name, path_filter)? {
1896 Some(target) => find_references_internal(
1897 conn,
1898 &target.name,
1899 direction,
1900 limit,
1901 Some(&target.symbol_id),
1902 include_external,
1903 ),
1904 None => {
1905 let suggestions = search_symbols_scoped(conn, symbol_name, None, path_filter, false, 3)
1906 .unwrap_or_default();
1907 if suggestions.is_empty() {
1908 Err(QueryError::SymbolNotFound(symbol_name.to_string()))
1909 } else {
1910 let list = suggestions
1911 .into_iter()
1912 .map(|s| format!(" - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
1913 .collect::<Vec<_>>()
1914 .join("\n");
1915 Err(QueryError::SymbolNotFoundWithSuggestions(
1916 symbol_name.to_string(),
1917 list,
1918 ))
1919 }
1920 }
1921 }
1922}
1923
1924pub fn find_references_for_symbol(
1925 conn: &Connection,
1926 symbol_name: &str,
1927 direction: &str,
1928 limit: usize,
1929 symbol_id: &str,
1930) -> Result<Vec<ReferenceSite>, QueryError> {
1931 find_references_internal(conn, symbol_name, direction, limit, Some(symbol_id), false)
1932}
1933
1934fn call_site_proximity(candidate_path: &str) -> String {
1937 let normalized = format!("replace({candidate_path}, '\\', '/')");
1938 let call_site = "replace(p.path, '\\', '/')";
1939 format!(
1940 "CASE WHEN {normalized} = {call_site} THEN 2
1941 WHEN rtrim({normalized}, replace({normalized}, '/', '')) = rtrim({call_site}, replace({call_site}, '/', '')) THEN 1
1942 ELSE 0 END"
1943 )
1944}
1945
1946fn pending_target_predicate(target: &str, parent: &str) -> String {
1949 let ns = "json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END)";
1950 let target_path = format!("('/' || replace({target}.path, '\\', '/'))");
1951 let like_value = "replace(replace(replace(value, '\\', '\\\\'), '%', '\\%'), '_', '\\_')";
1952 let closer_rank = call_site_proximity("closer.path");
1953 let target_rank = call_site_proximity(&format!("{target}.path"));
1954 format!(
1955 "(
1956 (
1957 {target}.parent_symbol_id IS NOT NULL
1958 AND {parent}.name IS NOT NULL
1959 AND (
1960 EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
1961 OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
1962 AND s_from.parent_symbol_id = {target}.parent_symbol_id)
1963 OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver)
1964 OR EXISTS (
1965 SELECT 1 FROM symbols receiver
1966 JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
1967 WHERE receiver.name = p.target_receiver
1968 AND receiver.path = p.path
1969 AND receiver_type.resolved_type = {parent}.name
1970 )
1971 )
1972 AND NOT EXISTS (
1973 SELECT 1 FROM {ns}
1974 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
1975 AND NOT EXISTS (
1976 WITH RECURSIVE ancestor(symbol_id, depth) AS (
1977 SELECT {target}.parent_symbol_id, 0
1978 UNION ALL
1979 SELECT s.parent_symbol_id, ancestor.depth + 1
1980 FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
1981 WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
1982 )
1983 SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
1984 WHERE a.name = value
1985 )
1986 AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1987 AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
1988 )
1989 )
1990 OR (
1991 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1992 AND (p.target_receiver IS NULL OR p.target_receiver = '')
1993 AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
1994 AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
1995 SELECT 1 FROM symbols closer
1996 WHERE closer.name = {target}.name
1997 AND closer.symbol_id != {target}.symbol_id
1998 AND closer.parent_symbol_id IS NULL
1999 AND closer.kind = {target}.kind
2000 AND {closer_rank} > {target_rank}
2001 ))
2002 )
2003 OR (
2004 {target}.parent_symbol_id IS NULL
2005 AND EXISTS (
2006 SELECT 1 FROM {ns}
2007 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
2008 AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2009 )
2010 )
2011 )"
2012 )
2013}
2014
2015const DOCUMENTATION_LANGUAGES: &[&str] = &[
2018 "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
2019];
2020
2021fn documentation_language_list() -> String {
2022 DOCUMENTATION_LANGUAGES
2023 .iter()
2024 .map(|l| format!("'{l}'"))
2025 .collect::<Vec<_>>()
2026 .join(", ")
2027}
2028
2029fn not_documentation(conn: &Connection, alias: &str) -> String {
2030 let has_content_type: bool = conn
2031 .query_row(
2032 "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
2033 [],
2034 |_| Ok(true),
2035 )
2036 .unwrap_or(false);
2037 if has_content_type {
2038 format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
2039 } else {
2040 "1 = 1".to_string()
2041 }
2042}
2043
2044pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
2045 conn.query_row(
2046 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
2047 [name],
2048 |_| Ok(true),
2049 )
2050 .unwrap_or(false)
2051}
2052
2053fn has_pending_namespace_column(conn: &Connection) -> bool {
2054 let has_ns: bool = conn
2055 .query_row(
2056 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
2057 [],
2058 |_| Ok(true),
2059 )
2060 .unwrap_or(false);
2061 let has_display: bool = conn
2062 .query_row(
2063 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
2064 [],
2065 |_| Ok(true),
2066 )
2067 .unwrap_or(false);
2068 let has_receiver: bool = conn
2069 .query_row(
2070 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
2071 [],
2072 |_| Ok(true),
2073 )
2074 .unwrap_or(false);
2075 has_ns && has_display && has_receiver
2076}
2077
2078fn find_references_internal(
2079 conn: &Connection,
2080 symbol_name: &str,
2081 direction: &str,
2082 limit: usize,
2083 symbol_id: Option<&str>,
2084 include_external: bool,
2085) -> Result<Vec<ReferenceSite>, QueryError> {
2086 let mut results = Vec::new();
2087
2088 if direction == "callers" {
2089 let mut stmt = conn.prepare(
2091 "SELECT s_from.name AS from_name,
2092 r.from_symbol_id,
2093 s_to.name AS to_name,
2094 r.kind,
2095 r.path,
2096 r.start_line,
2097 r.start_column
2098 FROM relationships r
2099 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2100 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2101 WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
2102 LIMIT ?2",
2103 )?;
2104
2105 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2106 Ok(ReferenceSite {
2107 from_symbol_name: row.get(0)?,
2108 from_symbol_id: row.get(1)?,
2109 to_symbol_name: row.get(2)?,
2110 kind: row.get(3)?,
2111 path: row.get::<_, String>(4)?.replace('\\', "/"),
2112 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2113 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2114 })
2115 })?;
2116
2117 for r in rows {
2118 results.push(r?);
2119 }
2120
2121 if results.len() < limit {
2123 let remaining = limit - results.len();
2124 if has_pending_namespace_column(conn) {
2125 if let Some(sid) = symbol_id {
2126 let mut pending_stmt = conn.prepare(
2127 &format!("SELECT s_from.name AS from_name,
2128 p.from_symbol_id,
2129 p.target_terminal_name AS to_name,
2130 p.kind,
2131 p.path,
2132 p.start_line,
2133 p.start_column
2134 FROM pending_relationships p
2135 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2136 JOIN symbols s_target ON s_target.symbol_id = ?3
2137 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2138 WHERE p.target_terminal_name = ?1
2139 AND {pred}
2140 LIMIT ?2", pred = pending_target_predicate("s_target", "s_target_parent")),
2141 )?;
2142
2143 let p_rows = pending_stmt.query_map(
2144 params![symbol_name, remaining as i64, sid],
2145 |row| {
2146 Ok(ReferenceSite {
2147 from_symbol_name: row.get(0)?,
2148 from_symbol_id: row.get(1)?,
2149 to_symbol_name: row.get(2)?,
2150 kind: row.get(3)?,
2151 path: row.get::<_, String>(4)?.replace('\\', "/"),
2152 start_line: Some(row.get::<_, i64>(5)? as usize),
2153 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2154 })
2155 },
2156 )?;
2157 for r in p_rows {
2158 results.push(r?);
2159 }
2160 } else {
2161 let mut pending_stmt = conn.prepare(
2162 "SELECT s_from.name AS from_name,
2163 p.from_symbol_id,
2164 p.target_terminal_name AS to_name,
2165 p.kind,
2166 p.path,
2167 p.start_line,
2168 p.start_column
2169 FROM pending_relationships p
2170 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2171 WHERE p.target_terminal_name = ?1
2172 AND (
2173 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2174 OR EXISTS (
2175 SELECT 1 FROM symbols s_any
2176 JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
2177 WHERE s_any.name = p.target_terminal_name
2178 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)
2179 )
2180 )
2181 LIMIT ?2",
2182 )?;
2183
2184 let p_rows =
2185 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2186 Ok(ReferenceSite {
2187 from_symbol_name: row.get(0)?,
2188 from_symbol_id: row.get(1)?,
2189 to_symbol_name: row.get(2)?,
2190 kind: row.get(3)?,
2191 path: row.get::<_, String>(4)?.replace('\\', "/"),
2192 start_line: Some(row.get::<_, i64>(5)? as usize),
2193 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2194 })
2195 })?;
2196 for r in p_rows {
2197 results.push(r?);
2198 }
2199 }
2200 } else {
2201 let is_nested = if let Some(sid) = symbol_id {
2202 conn.query_row(
2203 "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
2204 params![sid],
2205 |_| Ok(true),
2206 )
2207 .unwrap_or(false)
2208 } else {
2209 false
2210 };
2211
2212 if !is_nested {
2213 let mut pending_stmt = conn.prepare(
2214 "SELECT s_from.name AS from_name,
2215 p.from_symbol_id,
2216 p.target_terminal_name AS to_name,
2217 p.kind,
2218 p.path,
2219 p.start_line,
2220 p.start_column
2221 FROM pending_relationships p
2222 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2223 WHERE p.target_terminal_name = ?1
2224 LIMIT ?2",
2225 )?;
2226
2227 let p_rows =
2228 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2229 Ok(ReferenceSite {
2230 from_symbol_name: row.get(0)?,
2231 from_symbol_id: row.get(1)?,
2232 to_symbol_name: row.get(2)?,
2233 kind: row.get(3)?,
2234 path: row.get::<_, String>(4)?.replace('\\', "/"),
2235 start_line: Some(row.get::<_, i64>(5)? as usize),
2236 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2237 })
2238 })?;
2239
2240 for r in p_rows {
2241 results.push(r?);
2242 }
2243 }
2244 }
2245 }
2246
2247 if results.len() < limit && has_table(conn, "identifiers") {
2248 let remaining = limit - results.len();
2249 let mut ident_stmt = conn.prepare(
2250 "SELECT COALESCE(s.name, ''),
2251 COALESCE(i.containing_symbol_id, ''),
2252 i.name,
2253 i.kind,
2254 i.path,
2255 i.start_line,
2256 i.start_column
2257 FROM identifiers i
2258 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2259 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
2260 AND COALESCE(s.kind, '') != 'import'
2261 AND (?3 IS NULL OR NOT EXISTS (
2262 SELECT 1 FROM symbols owner
2263 JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
2264 WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
2265 AND member.name = i.name
2266 AND owner.name IS NOT (SELECT parent.name FROM symbols target
2267 JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
2268 WHERE target.symbol_id = ?3)
2269 ))
2270 ORDER BY i.path, i.start_line
2271 LIMIT ?2",
2272 )?;
2273 let rows =
2274 ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
2275 Ok(ReferenceSite {
2276 from_symbol_name: row.get(0)?,
2277 from_symbol_id: row.get(1)?,
2278 to_symbol_name: row.get(2)?,
2279 kind: row.get(3)?,
2280 path: row.get::<_, String>(4)?.replace('\\', "/"),
2281 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2282 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2283 })
2284 })?;
2285 for r in rows {
2286 results.push(r?);
2287 }
2288 }
2289 } else {
2290 let mut stmt = conn.prepare(
2292 "SELECT s_from.name AS from_name,
2293 r.from_symbol_id,
2294 s_to.name AS to_name,
2295 r.kind,
2296 r.path,
2297 r.start_line,
2298 r.start_column
2299 FROM relationships r
2300 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2301 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2302 WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
2303 LIMIT ?2",
2304 )?;
2305
2306 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2307 Ok(ReferenceSite {
2308 from_symbol_name: row.get(0)?,
2309 from_symbol_id: row.get(1)?,
2310 to_symbol_name: row.get(2)?,
2311 kind: row.get(3)?,
2312 path: row.get::<_, String>(4)?.replace('\\', "/"),
2313 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2314 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2315 })
2316 })?;
2317
2318 for r in rows {
2319 results.push(r?);
2320 }
2321
2322 if results.len() < limit {
2324 let remaining = limit - results.len();
2325 let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
2326 let sql = if include_external {
2327 String::from("SELECT DISTINCT s_from.name AS from_name,
2328 p.from_symbol_id,
2329 COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
2330 p.kind,
2331 p.path,
2332 p.start_line,
2333 p.start_column
2334 FROM pending_relationships p
2335 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2336 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2337 LIMIT ?2")
2338 } else {
2339 format!("SELECT DISTINCT s_from.name AS from_name,
2340 p.from_symbol_id,
2341 p.target_terminal_name AS to_name,
2342 p.kind,
2343 p.path,
2344 p.start_line,
2345 p.start_column
2346 FROM pending_relationships p
2347 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2348 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2349 AND EXISTS (
2350 SELECT 1 FROM symbols s_to
2351 LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
2352 WHERE s_to.name = p.target_terminal_name
2353 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2354 AND {pred}
2355 )
2356 LIMIT ?2", pred = pending_target_predicate("s_to", "s_to_parent"))
2357 };
2358 let mut pending_stmt = conn.prepare(&sql)?;
2359 let rows = pending_stmt.query_map(
2360 params![symbol_name, remaining as i64, symbol_id],
2361 |row| {
2362 Ok(ReferenceSite {
2363 from_symbol_name: row.get(0)?,
2364 from_symbol_id: row.get(1)?,
2365 to_symbol_name: row.get(2)?,
2366 kind: row.get(3)?,
2367 path: row.get::<_, String>(4)?.replace('\\', "/"),
2368 start_line: Some(row.get::<_, i64>(5)? as usize),
2369 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2370 })
2371 },
2372 )?;
2373 let mut out = Vec::new();
2374 for r in rows {
2375 out.push(r?);
2376 }
2377 out
2378 } else {
2379 let sql = if include_external {
2380 "SELECT DISTINCT s_from.name AS from_name,
2381 p.from_symbol_id,
2382 p.target_terminal_name AS to_name,
2383 p.kind,
2384 p.path,
2385 p.start_line,
2386 p.start_column
2387 FROM pending_relationships p
2388 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2389 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2390 LIMIT ?2"
2391 } else {
2392 "SELECT DISTINCT s_from.name AS from_name,
2393 p.from_symbol_id,
2394 p.target_terminal_name AS to_name,
2395 p.kind,
2396 p.path,
2397 p.start_line,
2398 p.start_column
2399 FROM pending_relationships p
2400 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2401 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2402 AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
2403 LIMIT ?2"
2404 };
2405 let mut pending_stmt = conn.prepare(sql)?;
2406
2407 let rows = pending_stmt.query_map(
2408 params![symbol_name, remaining as i64, symbol_id],
2409 |row| {
2410 Ok(ReferenceSite {
2411 from_symbol_name: row.get(0)?,
2412 from_symbol_id: row.get(1)?,
2413 to_symbol_name: row.get(2)?,
2414 kind: row.get(3)?,
2415 path: row.get::<_, String>(4)?.replace('\\', "/"),
2416 start_line: Some(row.get::<_, i64>(5)? as usize),
2417 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2418 })
2419 },
2420 )?;
2421 let mut out = Vec::new();
2422 for r in rows {
2423 out.push(r?);
2424 }
2425 out
2426 };
2427
2428 for r in p_rows {
2429 results.push(r);
2430 }
2431 }
2432 }
2433
2434 Ok(results)
2435}
2436
2437pub fn find_callee_signatures(
2440 conn: &Connection,
2441 symbol_name: &str,
2442 symbol_id: &str,
2443 limit: usize,
2444 include_external: bool,
2445) -> Result<Vec<String>, QueryError> {
2446 let mut stmt = conn.prepare(
2447 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2448 FROM relationships r
2449 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2450 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2451 WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
2452 LIMIT ?3",
2453 )?;
2454
2455 let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
2456 Ok((
2457 row.get::<_, String>(0)?,
2458 row.get::<_, Option<String>>(1)?,
2459 row.get::<_, String>(2)?.replace('\\', "/"),
2460 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2461 row.get::<_, String>(4)?,
2462 ))
2463 })?;
2464
2465 let mut signatures = Vec::new();
2466 let mut variants = Vec::new();
2467
2468 for r in rows.flatten() {
2469 let (name, sig_opt, path, line, kind) = r;
2470 let sig = sig_opt.unwrap_or(name);
2471 let entry = format!("{sig} ({path}:{line})");
2472 if kind == "variant" {
2473 if !variants.contains(&entry) {
2474 variants.push(entry);
2475 }
2476 } else if !signatures.contains(&entry) {
2477 signatures.push(entry);
2478 }
2479 }
2480
2481 if signatures.len() < limit {
2482 let remaining = (limit - signatures.len()) * 2;
2483 let p_rows: Vec<(String, Option<String>, String, usize, String)> =
2484 if has_pending_namespace_column(conn) {
2485 let mut p_stmt = conn.prepare(
2486 &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2487 FROM pending_relationships p
2488 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2489 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2490 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2491 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2492 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2493 AND {pred}
2494 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
2495 )?;
2496
2497 let rows =
2498 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2499 Ok((
2500 row.get::<_, String>(0)?,
2501 row.get::<_, Option<String>>(1)?,
2502 row.get::<_, String>(2)?.replace('\\', "/"),
2503 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2504 row.get::<_, String>(4)?,
2505 ))
2506 })?;
2507 rows.flatten().collect()
2508 } else {
2509 let mut p_stmt = conn.prepare(
2510 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2511 FROM pending_relationships p
2512 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2513 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2514 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2515 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2516 LIMIT ?3",
2517 )?;
2518
2519 let rows =
2520 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2521 Ok((
2522 row.get::<_, String>(0)?,
2523 row.get::<_, Option<String>>(1)?,
2524 row.get::<_, String>(2)?.replace('\\', "/"),
2525 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2526 row.get::<_, String>(4)?,
2527 ))
2528 })?;
2529 rows.flatten().collect()
2530 };
2531
2532 for r in p_rows {
2533 let (name, sig_opt, path, line, kind) = r;
2534 let sig = sig_opt.unwrap_or(name);
2535 let entry = format!("{sig} ({path}:{line})");
2536 if kind == "variant" {
2537 if !variants.contains(&entry) {
2538 variants.push(entry);
2539 }
2540 } else if !signatures.contains(&entry) {
2541 signatures.push(entry);
2542 }
2543 }
2544 }
2545
2546 if include_external && signatures.len() < limit {
2547 let remaining = (limit - signatures.len()) * 2;
2548 let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
2549 let mut ext_stmt = conn.prepare(
2550 &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
2551 FROM pending_relationships p
2552 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2553 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2554 AND NOT EXISTS (
2555 SELECT 1 FROM symbols s_to
2556 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2557 WHERE s_to.name = p.target_terminal_name
2558 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2559 AND {pred}
2560 )
2561 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
2562 )?;
2563
2564 let rows =
2565 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2566 Ok((
2567 row.get::<_, String>(0)?,
2568 row.get::<_, String>(1)?.replace('\\', "/"),
2569 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
2570 ))
2571 })?;
2572 rows.flatten().collect()
2573 } else {
2574 let mut ext_stmt = conn.prepare(
2575 "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
2576 FROM pending_relationships p
2577 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2578 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2579 AND NOT EXISTS (
2580 SELECT 1 FROM symbols s_to
2581 WHERE s_to.name = p.target_terminal_name
2582 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2583 )
2584 LIMIT ?3",
2585 )?;
2586
2587 let rows =
2588 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2589 Ok((
2590 row.get::<_, String>(0)?,
2591 row.get::<_, String>(1)?.replace('\\', "/"),
2592 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
2593 ))
2594 })?;
2595 rows.flatten().collect()
2596 };
2597
2598 for r in ext_rows {
2599 let (name, path, line) = r;
2600 let entry = format!("{name} ({path}:{line})");
2601 if !signatures.contains(&entry) {
2602 signatures.push(entry);
2603 }
2604 }
2605 }
2606
2607 for v in variants {
2608 if signatures.len() >= limit {
2609 break;
2610 }
2611 if !signatures.contains(&v) {
2612 signatures.push(v);
2613 }
2614 }
2615
2616 signatures.truncate(limit);
2617 Ok(signatures)
2618}
2619
2620pub fn find_structural_facts_scoped(
2622 conn: &Connection,
2623 category: &str,
2624 path_filter: Option<&str>,
2625 limit: usize,
2626) -> Result<Vec<StructuralFact>, QueryError> {
2627 validate_result_limit(limit)?;
2628 let norm_path = path_filter
2629 .map(|p| {
2630 p.replace('\\', "/")
2631 .trim_start_matches("./")
2632 .trim_matches('/')
2633 .to_string()
2634 })
2635 .filter(|p| !p.is_empty());
2636 let dir_prefix = norm_path
2637 .as_deref()
2638 .map(|p| format!("{}/%", escape_like(p)));
2639 let cat_pattern = format!("%{}%", escape_like(category));
2640
2641 let cat_lower = category.trim().to_ascii_lowercase();
2642 let cat_clause = match cat_lower.as_str() {
2643 "config" => {
2644 "(sf.pattern_id LIKE '%.key_value.%' OR sf.pattern_id LIKE '%config%' OR sf.capture_name LIKE '%config%' OR sf.node_kind LIKE '%config%')"
2645 }
2646 "route" | "routes" => {
2647 "(sf.pattern_id LIKE '%.route%' OR sf.pattern_id LIKE '%route%' OR sf.capture_name LIKE '%route%')"
2648 }
2649 "query" | "queries" | "sql" => {
2650 "(sf.pattern_id LIKE '%.sql.%' OR sf.pattern_id LIKE '%query%')"
2651 }
2652 "model" | "models" => "sf.pattern_id LIKE '%.model%'",
2653 _ => {
2654 "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
2655 }
2656 };
2657
2658 let sql = format!(
2659 "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
2660 sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
2661 sf.start_line, sf.end_line, sf.confidence,
2662 COALESCE(
2663 CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
2664 THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
2665 ELSE json_extract(sf.metadata_json, '$.key_path') END,
2666 json_extract(sf.metadata_json, '$.key'),
2667 json_extract(sf.metadata_json, '$.normalized_route_template')
2668 ) AS display_key
2669 FROM structural_facts sf
2670 LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
2671 WHERE (:cat IS NOT NULL AND {cat_clause})
2672 AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2673 ORDER BY sf.path ASC, sf.start_line ASC
2674 LIMIT :limit"
2675 );
2676
2677 let mut stmt = conn.prepare(&sql)?;
2678 let rows = stmt.query_map(
2679 rusqlite::named_params! {
2680 ":cat": cat_pattern,
2681 ":path": norm_path.as_deref(),
2682 ":dir_prefix": dir_prefix.as_deref(),
2683 ":limit": limit as i64,
2684 },
2685 |row| {
2686 Ok(StructuralFact {
2687 structural_fact_id: row.get(0)?,
2688 path: row.get::<_, String>(1)?.replace('\\', "/"),
2689 language: row.get(2)?,
2690 pattern_id: row.get(3)?,
2691 capture_name: row.get(4)?,
2692 node_kind: row.get(5)?,
2693 key: row.get(10)?,
2694 containing_symbol_name: row.get(6)?,
2695 start_line: row.get::<_, i64>(7)? as usize,
2696 end_line: row.get::<_, i64>(8)? as usize,
2697 confidence: row.get(9)?,
2698 })
2699 },
2700 )?;
2701
2702 let mut results = Vec::new();
2703 for r in rows {
2704 results.push(r?);
2705 }
2706 Ok(results)
2707}
2708
2709pub fn find_structural_facts(
2711 conn: &Connection,
2712 category: &str,
2713 limit: usize,
2714) -> Result<Vec<StructuralFact>, QueryError> {
2715 find_structural_facts_scoped(conn, category, None, limit)
2716}
2717
2718pub fn find_literals_scoped(
2720 conn: &Connection,
2721 category: &str,
2722 path_filter: Option<&str>,
2723 limit: usize,
2724) -> Result<Vec<LiteralFact>, QueryError> {
2725 validate_result_limit(limit)?;
2726 let norm_path = path_filter
2727 .map(|p| {
2728 p.replace('\\', "/")
2729 .trim_start_matches("./")
2730 .trim_matches('/')
2731 .to_string()
2732 })
2733 .filter(|p| !p.is_empty());
2734 let dir_prefix = norm_path
2735 .as_deref()
2736 .map(|p| format!("{}/%", escape_like(p)));
2737 let cat_pattern = format!("%{}%", escape_like(category));
2738
2739 let cat_lower = category.trim().to_ascii_lowercase();
2740 let cat_clause = match cat_lower.as_str() {
2741 "config" => {
2742 "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
2743 }
2744 "route" | "routes" => "l.kind LIKE '%route%'",
2745 "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
2746 "model" | "models" => "l.kind LIKE '%model%'",
2747 _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
2748 };
2749
2750 let sql = format!(
2751 "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
2752 l.start_line, s.name AS containing_symbol_name
2753 FROM literals l
2754 LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
2755 WHERE (:cat IS NOT NULL AND {cat_clause})
2756 AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2757 ORDER BY l.path ASC, l.start_line ASC
2758 LIMIT :limit"
2759 );
2760
2761 let mut stmt = conn.prepare(&sql)?;
2762 let rows = stmt.query_map(
2763 rusqlite::named_params! {
2764 ":cat": cat_pattern,
2765 ":path": norm_path.as_deref(),
2766 ":dir_prefix": dir_prefix.as_deref(),
2767 ":limit": limit as i64,
2768 },
2769 |row| {
2770 Ok(LiteralFact {
2771 literal_id: row.get(0)?,
2772 path: row.get::<_, String>(1)?.replace('\\', "/"),
2773 literal_text: row.get(2)?,
2774 kind: row.get(3)?,
2775 carrier: row.get(4)?,
2776 start_line: row.get::<_, i64>(5)? as usize,
2777 containing_symbol_name: row.get(6)?,
2778 })
2779 },
2780 )?;
2781
2782 let mut results = Vec::new();
2783 for r in rows {
2784 results.push(r?);
2785 }
2786 Ok(results)
2787}
2788
2789pub fn find_literals(
2791 conn: &Connection,
2792 category: &str,
2793 limit: usize,
2794) -> Result<Vec<LiteralFact>, QueryError> {
2795 find_literals_scoped(conn, category, None, limit)
2796}
2797
2798pub fn list_structural_fact_categories_scoped(
2800 conn: &Connection,
2801 path_filter: Option<&str>,
2802) -> Result<Vec<(String, usize)>, QueryError> {
2803 let norm_path = path_filter
2804 .map(|p| {
2805 p.replace('\\', "/")
2806 .trim_start_matches("./")
2807 .trim_matches('/')
2808 .to_string()
2809 })
2810 .filter(|p| !p.is_empty());
2811 let dir_prefix = norm_path
2812 .as_deref()
2813 .map(|p| format!("{}/%", escape_like(p)));
2814
2815 let mut categories = Vec::new();
2816
2817 let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
2818 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2819 GROUP BY pattern_id ORDER BY cnt DESC";
2820 let mut stmt = conn.prepare(sql)?;
2821 let rows = stmt.query_map(
2822 rusqlite::named_params! {
2823 ":path": norm_path.as_deref(),
2824 ":dir_prefix": dir_prefix.as_deref(),
2825 },
2826 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
2827 )?;
2828 for r in rows {
2829 categories.push(r?);
2830 }
2831
2832 let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
2833 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2834 GROUP BY kind ORDER BY cnt DESC";
2835 let mut lit_stmt = conn.prepare(lit_sql)?;
2836 let lit_rows = lit_stmt.query_map(
2837 rusqlite::named_params! {
2838 ":path": norm_path.as_deref(),
2839 ":dir_prefix": dir_prefix.as_deref(),
2840 },
2841 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
2842 )?;
2843 for r in lit_rows {
2844 categories.push(r?);
2845 }
2846
2847 Ok(categories)
2848}
2849
2850pub fn list_structural_fact_categories(
2852 conn: &Connection,
2853) -> Result<Vec<(String, usize)>, QueryError> {
2854 list_structural_fact_categories_scoped(conn, None)
2855}
2856
2857pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
2859 let has_table: bool = conn
2860 .query_row(
2861 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
2862 [],
2863 |_| Ok(true),
2864 )
2865 .unwrap_or(false);
2866 if !has_table {
2867 return Ok(Vec::new());
2868 }
2869
2870 let mut stmt = conn.prepare(
2871 "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
2872 FROM type_facts
2873 WHERE symbol_id = ?1",
2874 )?;
2875
2876 let rows = stmt.query_map(params![symbol_id], |row| {
2877 Ok(TypeFact {
2878 type_fact_id: row.get(0)?,
2879 symbol_id: row.get(1)?,
2880 language: row.get(2)?,
2881 resolved_type: row.get(3)?,
2882 generic_params: row.get(4)?,
2883 })
2884 })?;
2885
2886 let mut results = Vec::new();
2887 for r in rows {
2888 results.push(r?);
2889 }
2890 Ok(results)
2891}
2892
2893pub fn is_test_path(path: &str) -> bool {
2899 let p = path.replace('\\', "/");
2900 let cut = p.rfind('/').map_or(0, |i| i + 1);
2901 let directories = format!("/{}/", p[..cut].to_lowercase());
2902 let file_name = &p[cut..];
2903 let lower_name = file_name.to_lowercase();
2904 directories.contains("/test/")
2905 || directories.contains("/tests/")
2906 || directories.contains("/__tests__/")
2907 || (lower_name.starts_with("test_")
2908 && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
2909 || lower_name.contains("_test.")
2910 || lower_name.contains(".test.")
2911 || lower_name.contains(".spec.")
2912 || lower_name == "test.rs"
2913 || lower_name == "tests.rs"
2914 || file_name.ends_with("Tests.cs")
2915}
2916
2917pub(crate) fn test_path_predicate(alias: &str) -> String {
2926 let guard = format!("(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%')");
2927 let p = format!("replace({alias}.path, '\\', '/')");
2928 let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
2929 let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
2930 let lower_name = format!("lower({file_name})");
2931 let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
2932 let clauses = [
2933 like(&directories, "%/test/%"),
2934 like(&directories, "%/tests/%"),
2935 like(&directories, "%/\\_\\_tests\\_\\_/%"),
2936 like(&lower_name, "test\\_%.py"),
2937 like(&lower_name, "test\\_%.rb"),
2938 like(&lower_name, "%\\_test.%"),
2939 like(&lower_name, "%.test.%"),
2940 like(&lower_name, "%.spec.%"),
2941 format!("{lower_name} = 'test.rs'"),
2942 format!("{lower_name} = 'tests.rs'"),
2943 format!("{file_name} GLOB '*Tests.cs'"),
2944 ]
2945 .join(" OR ");
2946 format!("({guard} AND ({clauses}))")
2947}
2948
2949pub fn compute_blast_radius_scoped(
2952 conn: &Connection,
2953 seed_symbols: &[&str],
2954 symbol_path_filter: Option<&str>,
2955 seed_paths: &[&str],
2956 max_depth: usize,
2957 limit: usize,
2958) -> Result<BlastRadiusResult, QueryError> {
2959 validate_result_limit(limit)?;
2960 let max_depth = max_depth.min(5);
2961 let resolved_seed_symbols = seed_symbols
2962 .iter()
2963 .map(|name| {
2964 get_symbol_by_name(conn, name, symbol_path_filter)?
2965 .ok_or_else(|| QueryError::SymbolNotFound((*name).to_string()))
2966 })
2967 .collect::<Result<Vec<_>, _>>()?;
2968 let mut seeds = Vec::new();
2969 let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
2970 for s in seed_symbols {
2971 seeds.push(s.to_string());
2972 }
2973 for p in seed_paths {
2974 seeds.push(p.to_string());
2975 }
2976 "mixed".to_string()
2977 } else if !seed_symbols.is_empty() {
2978 for s in seed_symbols {
2979 seeds.push(s.to_string());
2980 }
2981 "symbol".to_string()
2982 } else if !seed_paths.is_empty() {
2983 for p in seed_paths {
2984 seeds.push(p.to_string());
2985 }
2986 "file".to_string()
2987 } else {
2988 return Ok(BlastRadiusResult {
2989 seed_type: "none".to_string(),
2990 seeds: Vec::new(),
2991 likely_tests: Vec::new(),
2992 impacted_symbols: Vec::new(),
2993 traversal_ceiling_reached: false,
2994 });
2995 };
2996
2997 let mut where_clauses = Vec::new();
2998 let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
2999
3000 if !resolved_seed_symbols.is_empty() {
3001 let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3002 .map(|i| format!("?{}", i))
3003 .collect();
3004 where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3005 for symbol in &resolved_seed_symbols {
3006 params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3007 }
3008 }
3009
3010 if !seed_paths.is_empty() {
3011 let mut path_conds = Vec::new();
3012 for p in seed_paths.iter() {
3013 let raw = p
3014 .replace('\\', "/")
3015 .trim_start_matches("./")
3016 .trim_matches('/')
3017 .to_string();
3018 let exact_idx = params_vec.len() + 1;
3019 params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3020 let dir_pattern = format!("{}/%", escape_like(&raw));
3021 let like_idx = params_vec.len() + 1;
3022 params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3023 path_conds.push(format!(
3024 "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3025 ));
3026 }
3027 where_clauses.push(format!("({})", path_conds.join(" OR ")));
3028 }
3029
3030 let seed_condition = where_clauses.join(" OR ");
3031 let max_depth_idx = params_vec.len() + 1;
3032 params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3033
3034 let mut traversal_ceiling_reached = false;
3035
3036 let has_relationships: bool = conn
3037 .query_row(
3038 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3039 [],
3040 |_| Ok(true),
3041 )
3042 .unwrap_or(false);
3043
3044 let has_pending: bool = conn
3045 .query_row(
3046 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3047 [],
3048 |_| Ok(true),
3049 )
3050 .unwrap_or(false);
3051
3052 let mut likely_tests = Vec::new();
3053 let mut impacted_symbols = Vec::new();
3054 let mut seen_test_keys = HashSet::new();
3055
3056 let mut recursive_branches = Vec::new();
3057
3058 if has_relationships {
3059 recursive_branches.push(format!(
3060 "SELECT r.from_symbol_id, iw.depth + 1
3061 FROM relationships r
3062 JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3063 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3064 WHERE iw.depth < ?{max_depth_idx}
3065 AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
3066 ));
3067 }
3068
3069 if has_pending {
3070 let (parent_join, ns_condition) = if conn
3071 .query_row(
3072 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3073 [],
3074 |_| Ok(true),
3075 )
3076 .unwrap_or(false)
3077 {
3078 (
3079 "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3080 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3081 format!("AND {pred}", pred = pending_target_predicate("s_target", "s_target_parent")),
3082 )
3083 } else {
3084 ("", String::new())
3085 };
3086
3087 recursive_branches.push(format!(
3088 "SELECT p.from_symbol_id, iw.depth + 1
3089 FROM pending_relationships p
3090 JOIN symbols s_target ON p.target_terminal_name = s_target.name
3091 JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3092 {parent_join}
3093 WHERE iw.depth < ?{max_depth_idx}
3094 AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3095 {ns_condition}"
3096 ));
3097 }
3098
3099 if !recursive_branches.is_empty() {
3100 let recursive_sql = recursive_branches.join("\n UNION \n");
3101 let not_documentation = not_documentation(conn, "s");
3102 let sql = format!(
3103 "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3104 SELECT symbol_id, 0
3105 FROM symbols
3106 WHERE ({seed_condition})
3107 AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3108
3109 UNION
3110
3111 {recursive_sql}
3112 )
3113 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
3114 FROM impact_walk iw
3115 CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3116 WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3117 AND {not_documentation}
3118 GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3119 HAVING MIN(iw.depth) > 0
3120 ORDER BY min_depth ASC, s.path ASC, s.name ASC
3121 LIMIT 200"
3122 );
3123
3124 let mut stmt = conn.prepare(&sql)?;
3125 let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3126 .iter()
3127 .map(|v| v as &dyn rusqlite::ToSql)
3128 .collect();
3129
3130 let rows = stmt.query_map(param_refs.as_slice(), |row| {
3131 Ok((
3132 row.get::<_, String>(0)?,
3133 row.get::<_, String>(1)?,
3134 row.get::<_, String>(2)?,
3135 row.get::<_, String>(3)?,
3136 row.get::<_, i64>(4)? as usize,
3137 row.get::<_, bool>(5)?,
3138 row.get::<_, bool>(6)?,
3139 row.get::<_, i64>(7)? as usize,
3140 ))
3141 })?;
3142
3143 let mut row_count = 0;
3144 for r in rows {
3145 row_count += 1;
3146 let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3147 let path = raw_path.replace('\\', "/");
3148 let is_test_target = is_test || test_container || is_test_path(&path);
3149
3150 if is_test_target {
3151 let key = format!("{}:{}", path, line);
3152 if seen_test_keys.insert(key) {
3153 likely_tests.push(TestTarget {
3154 name,
3155 path,
3156 line,
3157 reason: format!("transitive caller [depth {depth}]"),
3158 });
3159 }
3160 } else {
3161 impacted_symbols.push(ImpactedSymbol {
3162 name,
3163 kind,
3164 path,
3165 line,
3166 depth,
3167 });
3168 }
3169 }
3170 traversal_ceiling_reached = row_count >= 200;
3171 }
3172
3173 let mut file_stems = Vec::new();
3175 for p in seed_paths {
3176 if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
3177 && stem.len() >= 3
3178 && !file_stems.contains(&stem.to_string())
3179 {
3180 file_stems.push(stem.to_string());
3181 }
3182 }
3183 for symbol in &resolved_seed_symbols {
3184 if let Some(stem) = std::path::Path::new(&symbol.path)
3185 .file_stem()
3186 .and_then(|s| s.to_str())
3187 && stem.len() >= 3
3188 && !file_stems.contains(&stem.to_string())
3189 {
3190 file_stems.push(stem.to_string());
3191 }
3192 }
3193
3194 let has_files: bool = conn
3195 .query_row(
3196 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3197 [],
3198 |_| Ok(true),
3199 )
3200 .unwrap_or(false);
3201
3202 if has_files {
3203 let doc_file = format!(
3204 "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3205 not_documentation(conn, "d")
3206 );
3207 let mut test_files_stmt = conn.prepare(&format!(
3208 "SELECT DISTINCT path FROM files
3209 WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3210 AND NOT {doc_file}
3211 LIMIT 10"
3212 ))?;
3213 for stem in file_stems {
3214 let stem_pattern = format!("%{}%", escape_like(&stem));
3215 let t_rows =
3216 test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
3217 for p in t_rows.flatten() {
3218 let p = p.replace('\\', "/");
3219 let key = format!("{}:1", p);
3220 if seen_test_keys.insert(key) {
3221 likely_tests.push(TestTarget {
3222 name: p.clone(),
3223 path: p,
3224 line: 1,
3225 reason: "stem-matched test file".to_string(),
3226 });
3227 }
3228 }
3229 }
3230 }
3231
3232 if likely_tests.len() > limit {
3234 likely_tests.truncate(limit);
3235 }
3236 if impacted_symbols.len() > limit {
3237 impacted_symbols.truncate(limit);
3238 }
3239
3240 Ok(BlastRadiusResult {
3241 seed_type,
3242 seeds,
3243 likely_tests,
3244 impacted_symbols,
3245 traversal_ceiling_reached,
3246 })
3247}
3248
3249pub fn compute_blast_radius(
3251 conn: &Connection,
3252 seed_symbols: &[&str],
3253 seed_paths: &[&str],
3254 max_depth: usize,
3255 limit: usize,
3256) -> Result<BlastRadiusResult, QueryError> {
3257 compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3258}
3259
3260#[cfg(test)]
3261mod tests {
3262 #[test]
3263 fn result_limit_rejects_values_above_the_shared_ceiling() {
3264 assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3265 assert!(matches!(
3266 validate_result_limit(usize::MAX),
3267 Err(QueryError::InvalidResultLimit(usize::MAX))
3268 ));
3269 }
3270
3271 #[test]
3272 fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
3273 let conn = Connection::open_in_memory().unwrap();
3274
3275 assert!(matches!(
3276 find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
3277 Err(QueryError::InvalidResultLimit(usize::MAX))
3278 ));
3279 }
3280
3281 use super::*;
3282 use crate::db::{ensure_fts_index, open_read_write};
3283
3284 #[test]
3285 fn count_parse_diagnostics_counts_rows_for_one_file() {
3286 let dir = crate::safe_tempdir();
3287 let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
3288
3289 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
3290
3291 conn.execute_batch(
3292 "CREATE TABLE parse_diagnostics (
3293 diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
3294 );
3295 INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
3296 INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
3297 INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
3298 )
3299 .unwrap();
3300
3301 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
3302 assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
3303 assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
3304 }
3305
3306 #[test]
3307 fn test_sanitize_fts5_query() {
3308 let (and_q, or_q) = sanitize_fts5_query("parse tokens");
3309 assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
3310 assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
3311
3312 let (and_q, or_q) = sanitize_fts5_query(" Option<T> ");
3313 assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
3314 assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
3315
3316 let (and_q, or_q) = sanitize_fts5_query(" ");
3317 assert!(and_q.is_empty());
3318 assert!(or_q.is_empty());
3319 }
3320
3321 #[test]
3322 fn sanitize_splits_case_boundaries_and_drops_stop_words() {
3323 let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
3324 assert_eq!(
3325 and_q,
3326 "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
3327 );
3328 assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
3329
3330 let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
3331 assert_eq!(
3332 and_q,
3333 "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
3334 );
3335
3336 let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
3337 assert_eq!(
3338 and_q,
3339 "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
3340 );
3341 assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
3342
3343 let (and_q, _) = sanitize_fts5_query("validate_syntax");
3344 assert_eq!(
3345 and_q,
3346 "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
3347 );
3348
3349 let (and_q, _) = sanitize_fts5_query("isReady");
3350 assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
3351
3352 let (and_q, _) = sanitize_fts5_query("before");
3353 assert_eq!(and_q, "\"before\"*");
3354
3355 let (and_q, _) = sanitize_fts5_query("fooBar quux");
3356 assert_eq!(
3357 and_q,
3358 "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
3359 );
3360
3361 let (and_q, _) = sanitize_fts5_query("the for a");
3362 assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
3363 }
3364
3365 fn search_fixture(rows: &str) -> Connection {
3366 let conn = Connection::open_in_memory().unwrap();
3367 conn.execute_batch(&format!(
3368 "CREATE TABLE symbols (
3369 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3370 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
3371 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3372 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3373 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3374 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3375 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
3376 );
3377 INSERT INTO symbols VALUES {rows};"
3378 ))
3379 .unwrap();
3380 ensure_fts_index(&conn).unwrap();
3381 conn
3382 }
3383
3384 fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
3385 format!(
3386 "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
3387 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
3388 )
3389 }
3390
3391 fn doc_row(id: &str, name: &str, doc: &str) -> String {
3392 format!(
3393 "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
3394 3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
3395 )
3396 }
3397
3398 fn search_names(conn: &Connection, query: &str) -> Vec<String> {
3399 fts_search_symbols_scoped(conn, query, None, None, false, 10)
3400 .unwrap()
3401 .into_iter()
3402 .map(|r| r.symbol.name)
3403 .collect()
3404 }
3405
3406 const TEST_PATH_CASES: &[(&str, bool)] = &[
3407 ("tests/foo.py", true),
3408 ("tests/x.py", true),
3409 ("tests/tools/test_web.py", true),
3410 ("src/tests/x.rs", true),
3411 ("__tests__/a.ts", true),
3412 ("a/__tests__/b.ts", true),
3413 ("test/x.java", true),
3414 ("src/test/Helper.java", true),
3415 ("src/test_utils.py", true),
3416 ("lib/test_helper.rb", true),
3417 ("test_config.py", true),
3418 ("pkg/test_data/x.json", false),
3419 ("src/test_detection.rs", false),
3420 ("crates/julie-index/src/analysis/test_quality.rs", false),
3421 ("x/foo_test.go", true),
3422 ("x/foo.test.ts", true),
3423 ("x/foo.spec.js", true),
3424 ("src/lib_test.rs", true),
3425 ("src/test.rs", true),
3426 ("tests.rs", true),
3427 ("src/tests.rs", true),
3428 ("Foo.Tests.cs", true),
3429 ("x/FooTests.cs", true),
3430 ("src/FooTests.cs", true),
3431 ("src/Foo.Tests.cs", true),
3432 ("tests/Foo.cs", true),
3433 ("x/parser.spec.ts", true),
3434 ("test_x.py", true),
3435 ("test.rs", true),
3436 ("tests\\x.py", true),
3437 ("src/protocol.spec.v1/parser.rs", false),
3438 ("pkg/test_support/runtime.py", false),
3439 ("src/Contests.cs", false),
3440 ("spec/x.rb", false),
3441 ("crates/x/src/impact/likely_tests.rs", false),
3442 ("x/foo_tests.rs", false),
3443 ("src/latest.rs", false),
3444 ("x/latest.go", false),
3445 ("x/manifest.rs", false),
3446 ("src/attest.rs", false),
3447 ("contest/x.py", false),
3448 ("src/testing.rs", false),
3449 ("src/main.rs", false),
3450 ("pkg/service.go", false),
3451 ];
3452
3453 #[test]
3454 fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
3455 let conn = Connection::open_in_memory().unwrap();
3456 let sql = format!(
3457 "SELECT {} FROM (SELECT :path AS path) s",
3458 test_path_predicate("s")
3459 );
3460 let mut stmt = conn.prepare(&sql).unwrap();
3461 for (path, expected) in TEST_PATH_CASES {
3462 assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
3463 let from_sql: bool = stmt
3464 .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
3465 .unwrap();
3466 assert_eq!(from_sql, *expected, "sql mirror: {path}");
3467 }
3468 }
3469
3470 #[test]
3471 fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
3472 let conn = search_fixture(
3473 &[
3474 code_row(
3475 "a",
3476 "src/parser.rs",
3477 "rust",
3478 "parse_sidecar",
3479 "Parse a sidecar.",
3480 ),
3481 code_row(
3482 "b",
3483 "src/tests/helpers.py",
3484 "python",
3485 "parse_sidecar_fixture",
3486 "Parse a sidecar.",
3487 ),
3488 ]
3489 .join(", "),
3490 );
3491
3492 let default_search: Vec<String> =
3493 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
3494 .unwrap()
3495 .into_iter()
3496 .map(|r| r.symbol.name)
3497 .collect();
3498 assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
3499
3500 let with_tests: Vec<String> =
3501 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
3502 .unwrap()
3503 .into_iter()
3504 .map(|r| r.symbol.name)
3505 .collect();
3506 assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
3507
3508 let default_lookup: Vec<String> =
3509 search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
3510 .unwrap()
3511 .into_iter()
3512 .map(|s| s.name)
3513 .collect();
3514 assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
3515
3516 let lookup_with_tests: Vec<String> =
3517 search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
3518 .unwrap()
3519 .into_iter()
3520 .map(|s| s.name)
3521 .collect();
3522 assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
3523 }
3524
3525 #[test]
3526 fn qualified_lookup_in_a_test_file_needs_include_tests() {
3527 let conn = search_fixture(
3528 &[
3529 "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
3530 1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
3531 "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
3532 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
3533 ]
3534 .join(", "),
3535 );
3536
3537 assert!(
3538 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
3539 .unwrap()
3540 .is_empty()
3541 );
3542 assert_eq!(
3543 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
3544 .unwrap()
3545 .len(),
3546 1
3547 );
3548 }
3549
3550 #[test]
3551 fn concept_query_prefers_partial_code_match_over_full_doc_match() {
3552 let conn = search_fixture(
3553 &[
3554 doc_row(
3555 "d1",
3556 "Safety guarantees",
3557 "Pre-flight syntax validation runs before the edit touches disk",
3558 ),
3559 doc_row(
3560 "d2",
3561 "Audit",
3562 "The syntax validation before an edit is the invariant",
3563 ),
3564 code_row(
3565 "c1",
3566 "src/syntax.rs",
3567 "rust",
3568 "validate_syntax",
3569 "Validate the syntax of a file",
3570 ),
3571 code_row(
3572 "c2",
3573 "src/edit.rs",
3574 "rust",
3575 "replace_symbol_body",
3576 "Atomic edit with validation",
3577 ),
3578 ]
3579 .join(","),
3580 );
3581
3582 let names = search_names(&conn, "syntax validation before edit");
3583
3584 assert_eq!(names[0], "validate_syntax");
3585 assert!(names.contains(&"replace_symbol_body".to_string()));
3586 assert!(names.contains(&"Safety guarantees".to_string()));
3587 }
3588
3589 #[test]
3590 fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
3591 let conn = search_fixture(
3592 &[
3593 code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
3594 code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
3595 ]
3596 .join(","),
3597 );
3598
3599 let mut camel = search_names(&conn, "ValidateSyntax");
3600 camel.sort();
3601 assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
3602 let mut words = search_names(&conn, "validate syntax");
3603 words.sort();
3604 assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
3605 }
3606
3607 #[test]
3608 fn stop_word_prefixed_camel_case_symbol_is_still_found() {
3609 let conn = search_fixture(
3610 &[
3611 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
3612 code_row("c2", "src/hooks.rs", "rust", "before", ""),
3613 code_row(
3614 "c3",
3615 "src/x.rs",
3616 "rust",
3617 "fooBar",
3618 "has fooBar but not the other word",
3619 ),
3620 ]
3621 .join(","),
3622 );
3623
3624 assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
3625 assert_eq!(search_names(&conn, "before"), vec!["before"]);
3626 }
3627
3628 #[test]
3629 fn related_tests_use_the_name_as_typed_without_splitting() {
3630 let conn = search_fixture(
3631 &[
3632 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
3633 "('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(),
3634 "('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(),
3635 ]
3636 .join(","),
3637 );
3638 let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
3639
3640 let names: Vec<String> = find_related_tests(&conn, &target, 5)
3641 .unwrap()
3642 .into_iter()
3643 .map(|t| t.name)
3644 .collect();
3645
3646 assert_eq!(names, vec!["isReady_reports_true"]);
3647 }
3648
3649 #[test]
3650 fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
3651 let conn = search_fixture(
3652 &[
3653 code_row(
3654 "c1",
3655 "src/queries.rs",
3656 "rust",
3657 "fts_search_symbols_scoped",
3658 "search symbols scoped with fts",
3659 ),
3660 code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
3661 ]
3662 .join(","),
3663 );
3664
3665 assert_eq!(
3666 search_names(&conn, "search_symbols_scoped")[0],
3667 "search_symbols_scoped"
3668 );
3669 }
3670
3671 fn sidecar_fixture() -> Connection {
3672 search_fixture(
3673 &[
3674 code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
3675 code_row(
3676 "c2",
3677 "src/sidecar.rs",
3678 "rust",
3679 "parse_sidecar_file",
3680 "parse the sha256 sidecar file",
3681 ),
3682 ]
3683 .join(","),
3684 )
3685 }
3686
3687 fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
3688 candidates
3689 .iter()
3690 .find(|c| c.result.symbol.name == name)
3691 .unwrap_or_else(|| panic!("{name} is not a candidate"))
3692 }
3693
3694 #[test]
3695 fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
3696 let conn = sidecar_fixture();
3697
3698 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
3699
3700 let target = candidate(&candidates, "parseSha256Sidecar");
3701 assert!(target.name_match);
3702 assert!(!target.word_match);
3703 assert!(!target.exact_name);
3704 assert!(target.name_terms.contains(&"sha256".to_string()));
3705 }
3706
3707 #[test]
3708 fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
3709 let conn = search_fixture(
3710 &[
3711 code_row(
3712 "c1",
3713 "examples/demo.rs",
3714 "rust",
3715 "demo",
3716 "restore offline state",
3717 ),
3718 code_row(
3719 "c2",
3720 "src/replay.rs",
3721 "rust",
3722 "replay",
3723 "restore offline records",
3724 ),
3725 ]
3726 .join(","),
3727 );
3728
3729 let candidates =
3730 collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
3731 .unwrap();
3732
3733 assert!(candidate(&candidates, "demo").word_match);
3734 assert!(candidate(&candidates, "replay").word_match);
3735 assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
3736 }
3737
3738 #[test]
3739 fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
3740 let mut rows: Vec<String> = (1..=170)
3741 .map(|i| {
3742 code_row(
3743 &format!("h{i:03}"),
3744 "src/sidecar.rs",
3745 "rust",
3746 &format!("sidecar_helper_{i:03}"),
3747 "parse sidecar file",
3748 )
3749 })
3750 .collect();
3751 rows.push(code_row(
3752 "c1",
3753 "src/sidecar.rs",
3754 "rust",
3755 "parseSha256Sidecar",
3756 "",
3757 ));
3758 let conn = search_fixture(&rows.join(","));
3759
3760 let candidates = collect_search_candidates(
3761 &conn,
3762 "parse the sha256 sidecar file",
3763 None,
3764 None,
3765 false,
3766 40,
3767 )
3768 .unwrap();
3769
3770 assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
3771 assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
3772 }
3773
3774 #[test]
3775 fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
3776 let mut rows: Vec<String> = (1..=20)
3777 .map(|i| {
3778 code_row(
3779 &format!("a{i:02}"),
3780 "src/a.rs",
3781 "rust",
3782 &format!("both_{i:02}"),
3783 "restore offline",
3784 )
3785 })
3786 .collect();
3787 rows.extend((1..=50).map(|i| {
3788 code_row(
3789 &format!("p{i:02}"),
3790 "src/p.rs",
3791 "rust",
3792 &format!("partial_{i:02}"),
3793 "restore records",
3794 )
3795 }));
3796 let conn = search_fixture(&rows.join(","));
3797
3798 let candidates =
3799 collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
3800
3801 let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
3802 assert_eq!(word_rows.len(), 40);
3803 assert_eq!(
3804 word_rows
3805 .iter()
3806 .filter(|c| c.result.symbol.name.starts_with("both_"))
3807 .count(),
3808 20
3809 );
3810 }
3811
3812 #[test]
3813 fn exact_name_is_admitted_regardless_of_case() {
3814 let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
3815
3816 let candidates =
3817 collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
3818 assert!(candidate(&candidates, "xyzzy_q").exact_name);
3819
3820 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
3821 let candidates =
3822 collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
3823 assert!(candidate(&candidates, "xyzzy_q").exact_name);
3824 }
3825
3826 #[test]
3827 fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
3828 let conn = search_fixture(&code_row(
3829 "c1",
3830 "src/say.js",
3831 "javascript",
3832 "say \"hi\"",
3833 "",
3834 ));
3835
3836 let candidates =
3837 collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
3838
3839 let target = candidate(&candidates, "say \"hi\"");
3840 assert!(target.exact_name && target.name_match);
3841 }
3842
3843 #[test]
3844 fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
3845 let conn = search_fixture(
3846 &[
3847 code_row("c1", "src/a.rs", "rust", "sidecar", ""),
3848 code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
3849 ]
3850 .join(","),
3851 );
3852
3853 let candidates =
3854 collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
3855
3856 assert_eq!(candidates.len(), 2);
3857 let target = candidate(&candidates, "sidecar");
3858 assert!(target.exact_name && target.word_match && target.name_match);
3859 assert!(target.bm25.is_some());
3860 let helper = candidate(&candidates, "sidecar_helper");
3861 assert!(!helper.exact_name && helper.word_match && helper.name_match);
3862 }
3863
3864 #[test]
3865 fn an_index_without_the_trigram_table_returns_word_rows_only() {
3866 let conn = sidecar_fixture();
3867 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
3868
3869 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
3870
3871 let names: Vec<&str> = candidates
3872 .iter()
3873 .map(|c| c.result.symbol.name.as_str())
3874 .collect();
3875 assert_eq!(names, vec!["parse_sidecar_file"]);
3876 assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
3877 assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
3878 }
3879
3880 #[test]
3881 fn words_under_three_characters_skip_the_name_branch() {
3882 let conn = search_fixture(
3883 &[
3884 code_row("c1", "src/a.rs", "rust", "ab", ""),
3885 code_row("c2", "src/b.rs", "rust", "cab", ""),
3886 ]
3887 .join(","),
3888 );
3889
3890 let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
3891
3892 assert!(candidates.iter().all(|c| !c.name_match));
3893 assert!(candidate(&candidates, "ab").exact_name);
3894 }
3895
3896 #[test]
3897 fn trigram_terms_include_the_identifier_parts_of_each_word() {
3898 assert_eq!(
3899 trigram_name_terms("collapse_name"),
3900 vec!["collapse_name", "collapse", "name"]
3901 );
3902 assert_eq!(
3903 trigram_name_terms("parse the sha256 sidecar"),
3904 vec!["parse", "sha256", "sha", "256", "sidecar"]
3905 );
3906 assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
3907 assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
3908 assert!(trigram_name_terms("ab").is_empty());
3909 }
3910
3911 #[test]
3912 fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
3913 let conn = search_fixture(
3914 &[
3915 code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
3916 code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
3917 ]
3918 .join(","),
3919 );
3920
3921 let candidates =
3922 collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
3923
3924 let target = candidate(&candidates, "CollapseName");
3925 assert!(target.name_match);
3926 assert_eq!(target.name_terms, vec!["collapse", "name"]);
3927 assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
3928 }
3929
3930 fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
3931 Candidate {
3932 result: SymbolSearchResult {
3933 symbol: Symbol {
3934 symbol_id: format!("{path}:{name}"),
3935 file_id: "f".into(),
3936 path: path.into(),
3937 language: "rust".into(),
3938 name: name.into(),
3939 kind: kind.into(),
3940 signature: None,
3941 doc_comment: None,
3942 visibility: None,
3943 parent_symbol_id: None,
3944 start_line: 1,
3945 start_column: 0,
3946 end_line: 1,
3947 end_column: 0,
3948 start_byte: 0,
3949 end_byte: 0,
3950 body_start_line: None,
3951 body_start_column: None,
3952 body_end_line: None,
3953 body_end_column: None,
3954 body_start_byte: None,
3955 body_end_byte: None,
3956 body_hash: None,
3957 semantic_group: None,
3958 is_test: false,
3959 test_container: false,
3960 },
3961 score: 0.0,
3962 snippet: None,
3963 explain: None,
3964 },
3965 bm25: None,
3966 exact_name: false,
3967 word_match: false,
3968 name_match: false,
3969 name_terms: Vec::new(),
3970 documentation: false,
3971 }
3972 }
3973
3974 fn function(name: &str) -> Candidate {
3975 plain_candidate(name, "function", "src/lib.rs")
3976 }
3977
3978 fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
3979 rerank_with(candidates, query, false, None)
3980 }
3981
3982 fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
3983 ranked(candidates, query)
3984 .into_iter()
3985 .map(|(r, _)| r.symbol.name)
3986 .collect()
3987 }
3988
3989 fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
3990 let mut candidate = function(name);
3991 candidate.result.symbol.signature = signature.map(str::to_string);
3992 candidate.result.symbol.doc_comment = Some(doc.into());
3993 candidate
3994 }
3995
3996 fn strip_ansi_case() -> Vec<Candidate> {
3997 vec![
3998 documented("strip_ansi", None, "Remove ANSI escape sequences"),
3999 documented(
4000 "_strip_code_fences",
4001 Some("def _strip_code_fences(text: str)"),
4002 "The first fenced code block's body, or the stripped text",
4003 ),
4004 ]
4005 }
4006
4007 #[test]
4008 fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4009 assert_eq!(
4010 rerank_words("parse the sha256 sidecar file"),
4011 vec!["parse", "sha", "256", "sidecar", "file"]
4012 );
4013 assert_eq!(
4014 rerank_words("parse_sha256_sidecar"),
4015 vec!["parse", "sha", "256", "sidecar"]
4016 );
4017 assert_eq!(
4018 rerank_words("ParseHTTPResponse"),
4019 vec!["parse", "http", "response"]
4020 );
4021 assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4022 assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4023 }
4024
4025 #[test]
4026 fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4027 for word in ["was", "whether", "another"] {
4028 assert!(is_stop_word(word), "{word} must be a stop word");
4029 }
4030 for word in ["down", "into", "run"] {
4031 assert!(!is_stop_word(word), "{word} must stay a content word");
4032 }
4033 assert_eq!(rerank_words("what was the file"), vec!["file"]);
4034 }
4035
4036 #[test]
4037 fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4038 let mut private = function("_create_skill");
4039 private.bm25 = Some(-9.0);
4040 let mut public = function("create_skill");
4041 public.bm25 = Some(-1.0);
4042
4043 let rows = ranked(vec![private, public], "create skill");
4044
4045 assert_eq!(rows[0].0.score, rows[1].0.score);
4046 assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4047 assert_eq!(
4048 rows.iter()
4049 .map(|(r, _)| r.symbol.name.as_str())
4050 .collect::<Vec<_>>(),
4051 vec!["create_skill", "_create_skill"]
4052 );
4053 }
4054
4055 #[test]
4056 fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4057 let rows = ranked(
4058 vec![
4059 plain_candidate("Glob", "constant", "src/glob.rs"),
4060 function("matches_glob_pattern"),
4061 ],
4062 "glob",
4063 );
4064
4065 assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4066 assert_eq!(rows[1].1.name_tier, "whole");
4067 assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4068 }
4069
4070 #[test]
4071 fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4072 let rows = ranked(
4073 vec![
4074 function("validate_everything"),
4075 function("validate_syntax_now"),
4076 function("validate_syntax"),
4077 function("unrelated"),
4078 ],
4079 "validate syntax",
4080 );
4081 let tiers: Vec<(&str, &str, f64)> = rows
4082 .iter()
4083 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4084 .collect();
4085
4086 assert_eq!(
4087 tiers,
4088 vec![
4089 ("validate_syntax", "whole", W_NAME_WHOLE),
4090 ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4091 ("validate_everything", "partial", 0.0),
4092 ("unrelated", "none", 0.0),
4093 ]
4094 );
4095 assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4096 assert_eq!(
4097 rows[1].0.score,
4098 W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4099 );
4100 assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4101 assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4102 }
4103
4104 #[test]
4105 fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4106 assert_eq!(
4107 ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4108 vec!["strip_ansi", "_strip_code_fences"]
4109 );
4110 }
4111
4112 #[test]
4113 fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4114 let candidates = vec![
4115 function("execute_julie_extract"),
4116 documented("slice_bytes", None, "cut a byte range"),
4117 ];
4118
4119 let rows = ranked(candidates, "cut");
4120 let bonuses: Vec<(&str, &str, f64)> = rows
4121 .iter()
4122 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4123 .collect();
4124
4125 assert_eq!(
4126 bonuses,
4127 vec![
4128 ("execute_julie_extract", "partial", 0.0),
4129 ("slice_bytes", "none", 0.0),
4130 ]
4131 );
4132 assert_eq!(rows[0].0.score, rows[1].0.score);
4133 }
4134
4135 #[test]
4136 fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4137 let candidates = vec![
4138 function("validate_everything"),
4139 function("validate_syntax_now"),
4140 function("validate_syntax"),
4141 function("unrelated"),
4142 ];
4143
4144 assert_eq!(
4145 ranked_names(candidates, "validate syntax"),
4146 vec![
4147 "validate_syntax",
4148 "validate_syntax_now",
4149 "validate_everything",
4150 "unrelated"
4151 ]
4152 );
4153 }
4154
4155 #[test]
4156 fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4157 let mut rows: Vec<String> = (0..10)
4158 .map(|i| {
4159 code_row(
4160 &format!("s{i}"),
4161 &format!("src/f{i}.rs"),
4162 "rust",
4163 &format!("search_{i}"),
4164 "searches the index",
4165 )
4166 })
4167 .collect();
4168 rows.push(code_row(
4169 "rare",
4170 "src/rare.rs",
4171 "rust",
4172 "sanitize_input",
4173 "sanitize the input",
4174 ));
4175 let conn = search_fixture(&rows.join(", "));
4176 let terms = vec!["sanitize".to_string(), "search".to_string()];
4177
4178 let weights = idf_weights(&conn, &terms);
4179 assert!(weights[0] > weights[1]);
4180 }
4181
4182 #[test]
4183 fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
4184 let conn = search_fixture(&code_row(
4185 "n",
4186 "src/news.rs",
4187 "rust",
4188 "fetch_news",
4189 "fetch the news feed",
4190 ));
4191 let terms = vec!["news".to_string(), "unseen".to_string()];
4192
4193 let weights = idf_weights(&conn, &terms);
4194
4195 assert!(weights[0] < weights[1]);
4196 }
4197
4198 #[test]
4199 fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4200 let crediting_field = |padding: usize| {
4201 let mut row = function("handler");
4202 row.result.symbol.signature = Some(format!(
4203 "fn handler({}sidecar: u8)",
4204 "a: u8, ".repeat(padding)
4205 ));
4206 ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
4207 };
4208
4209 assert_eq!(crediting_field(4), "signature");
4210 assert_eq!(crediting_field(80), "none");
4211 }
4212
4213 #[test]
4214 fn explain_terms_name_the_crediting_field_of_every_query_term() {
4215 let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
4216 let terms: Vec<(&str, &str, f64)> = rows[0]
4217 .1
4218 .terms
4219 .iter()
4220 .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
4221 .collect();
4222
4223 assert_eq!(rows[0].0.symbol.name, "strip_ansi");
4224 assert_eq!(
4225 terms,
4226 vec![
4227 ("strip", "name", 3.0),
4228 ("ansi", "name", 3.0),
4229 ("escape", "doc", TEXT_CREDIT),
4230 ("codes", "none", 0.0),
4231 ]
4232 );
4233 }
4234
4235 #[test]
4236 fn name_coverage_accepts_token_runs_substrings_and_stems() {
4237 let strengths = |name: &str, query: &str| {
4238 let stemmer = Stemmer::create(Algorithm::English);
4239 let words: Vec<QueryWord> = rerank_words(query)
4240 .into_iter()
4241 .map(|word| QueryWord {
4242 stem: stemmer.stem(&word).into_owned(),
4243 word,
4244 })
4245 .collect();
4246 name_hits(name, &words, &stemmer)
4247 };
4248
4249 assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
4250 assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
4251 assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
4252 assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
4253 assert_eq!(strengths("is_ok", "ok"), vec![3]);
4254 assert_eq!(strengths("isReady", "is"), vec![3]);
4255 assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
4256 assert_eq!(
4257 strengths("parseSha256Sidecar", "sidecar checksum"),
4258 vec![3, 0]
4259 );
4260 assert_eq!(
4261 strengths("parseSha256Sidecar", "checksum digest"),
4262 vec![0, 0]
4263 );
4264 }
4265
4266 #[test]
4267 fn a_rarer_term_moves_the_score_more_than_a_common_one() {
4268 let weights = [4.0, 1.0];
4269 let rows = rerank_with(
4270 vec![function("rare_helper"), function("common_helper")],
4271 "rare common",
4272 false,
4273 Some(&weights),
4274 );
4275
4276 assert_eq!(
4277 rows[0].1.word_weights,
4278 vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
4279 );
4280 assert_eq!(rows[0].0.symbol.name, "rare_helper");
4281 assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
4282 assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
4283 }
4284
4285 #[test]
4286 fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
4287 let rows = ranked(
4288 vec![
4289 function("render_mode"),
4290 plain_candidate("retry_count", "constant", "src/scan.rs"),
4291 ],
4292 "how many times a failed download is tried again retry limit",
4293 );
4294
4295 assert_eq!(rows[0].0.symbol.name, "retry_count");
4296 assert_eq!(rows[0].1.name_tier, "partial");
4297 assert!(rows[0].0.score > rows[1].0.score);
4298 assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
4299 }
4300
4301 #[test]
4302 fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4303 let mut row = function("load");
4304 row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
4305 row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
4306 let (result, explain) = ranked(vec![row], "config settings").remove(0);
4307
4308 assert_eq!(
4309 explain.terms,
4310 vec![
4311 ("config".into(), "signature".into(), TEXT_CREDIT),
4312 ("settings".into(), "none".into(), 0.0),
4313 ]
4314 );
4315 assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
4316 }
4317
4318 #[test]
4319 fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
4320 let crediting_field =
4321 |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
4322 let doc_field = |doc: &str, query: &str| {
4323 let mut row = function("row");
4324 row.result.symbol.doc_comment = Some(doc.into());
4325 crediting_field(row, query)
4326 };
4327 let sig_field = |signature: &str, query: &str| {
4328 let mut row = function("row");
4329 row.result.symbol.signature = Some(signature.into());
4330 crediting_field(row, query)
4331 };
4332
4333 assert_eq!(doc_field("The system runs.", "stemming"), "none");
4334 assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
4335 assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
4336 assert_eq!(doc_field("An important port.", "porter"), "none");
4337 assert_eq!(
4338 sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
4339 "signature"
4340 );
4341 assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
4342 assert_eq!(sig_field("fn okay()", "ok"), "none");
4343 assert_eq!(
4344 sig_field("fn parseSha256Sidecar(text)", "sidecar"),
4345 "signature"
4346 );
4347 }
4348
4349 #[test]
4350 fn text_tokens_split_like_query_words_then_identifiers() {
4351 fn two_pass(text: &str) -> Vec<&str> {
4352 query_words(text)
4353 .into_iter()
4354 .flat_map(split_identifier)
4355 .collect()
4356 }
4357 fn one_pass(text: &str) -> Vec<&str> {
4358 let mut out = Vec::new();
4359 text_tokens_into(text, &mut out);
4360 out
4361 }
4362 let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
4363 let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
4364
4365 assert_eq!(one_pass(ascii), two_pass(ascii));
4366 assert_eq!(
4367 one_pass(ascii),
4368 vec![
4369 "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
4370 "sha", "256", "sum",
4371 ]
4372 );
4373 assert_eq!(one_pass(unicode), two_pass(unicode));
4374 assert!(one_pass("").is_empty());
4375 assert!(one_pass("_ __ ...").is_empty());
4376 }
4377
4378 #[test]
4379 fn a_doc_credits_a_term_by_its_stem() {
4380 let mut row = function("check");
4381 row.result.symbol.doc_comment = Some("Validates the input.".into());
4382 let explain = ranked(vec![row], "validation").remove(0).1;
4383
4384 assert_eq!(
4385 explain.terms,
4386 vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
4387 );
4388 }
4389
4390 #[test]
4391 fn kind_prior_orders_definitions_over_members_over_imports() {
4392 let rows = ranked(
4393 vec![
4394 plain_candidate("Scan", "import", "src/a.rs"),
4395 plain_candidate("Scan", "enum_member", "src/b.rs"),
4396 plain_candidate("Scan", "function", "src/c.rs"),
4397 ],
4398 "scan",
4399 );
4400 let order: Vec<(&str, f64)> = rows
4401 .iter()
4402 .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
4403 .collect();
4404
4405 assert_eq!(
4406 order,
4407 vec![
4408 ("src/c.rs", W_KIND_DEFINITION),
4409 ("src/b.rs", W_KIND_MEMBER),
4410 ("src/a.rs", W_KIND_IMPORT),
4411 ]
4412 );
4413 }
4414
4415 #[test]
4416 fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
4417 let names = ranked_names(
4418 vec![
4419 function("RenderMode"),
4420 plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
4421 ],
4422 "retry download limit timeout",
4423 );
4424
4425 assert_eq!(names[0], "MaxRetryCount");
4426 }
4427
4428 #[test]
4429 fn path_role_demotes_role_directories_unless_the_query_names_them() {
4430 let rows = |query: &str| {
4431 ranked(
4432 vec![
4433 plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
4434 plain_candidate("verify_checksum", "function", "src/archive.rs"),
4435 ],
4436 query,
4437 )
4438 };
4439
4440 let plain = rows("verify checksum");
4441 assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
4442 assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
4443
4444 let named = rows("launcher script verify checksum");
4445 assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
4446
4447 let only_launcher = rows("launcher verify checksum");
4448 assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
4449 assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
4450
4451 let windows = ranked(
4452 vec![plain_candidate(
4453 "verifyChecksum",
4454 "function",
4455 "scripts\\launcher.ts",
4456 )],
4457 "verify checksum",
4458 );
4459 assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
4460 }
4461
4462 #[test]
4463 fn documentation_rows_sort_after_every_code_row() {
4464 let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
4465 heading.documentation = true;
4466 heading.result.symbol.language = "markdown".into();
4467 heading.result.symbol.signature = Some("Verify checksum".into());
4468 heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
4469 let rows = ranked(
4470 vec![
4471 heading,
4472 plain_candidate("unrelated", "variable", "src/a.rs"),
4473 ],
4474 "verify checksum",
4475 );
4476
4477 assert_eq!(rows[0].0.symbol.name, "unrelated");
4478 assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
4479 assert_eq!(rows[1].1.name_tier, "whole");
4480 assert!(rows[1].0.score < 0.0);
4481 }
4482
4483 #[test]
4484 fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
4485 let rows = |query: &str, include_tests: bool| {
4486 let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
4487 test_row.result.symbol.is_test = true;
4488 let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
4489 rerank_with(vec![plain_row, test_row], query, include_tests, None)
4490 };
4491
4492 let boosted = rows("payment flow tests", true);
4493 assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
4494 assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
4495 assert_eq!(boosted[1].1.test_intent, 0.0);
4496
4497 assert!(
4498 rows("payment flow tests", false)
4499 .iter()
4500 .all(|(_, e)| e.test_intent == 0.0)
4501 );
4502 assert!(
4503 rows("payment flow", true)
4504 .iter()
4505 .all(|(_, e)| e.test_intent == 0.0)
4506 );
4507 }
4508
4509 #[test]
4510 fn ties_break_by_bm25_then_name_length_then_path() {
4511 let mut word_row = plain_candidate("payment", "function", "src/z.rs");
4512 word_row.word_match = true;
4513 word_row.bm25 = Some(-4.0);
4514 let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
4515 weaker_word_row.word_match = true;
4516 weaker_word_row.bm25 = Some(-2.0);
4517 let mut name_only = plain_candidate("payment", "function", "src/b.rs");
4518 name_only.name_match = true;
4519 let rows = ranked(
4520 vec![
4521 plain_candidate("payment", "function", "src/y.rs"),
4522 name_only,
4523 weaker_word_row,
4524 word_row,
4525 ],
4526 "payment",
4527 );
4528 let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
4529
4530 assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
4531
4532 let by_length = ranked_names(
4533 vec![
4534 function("payment_gateway_client"),
4535 function("payment_gateway"),
4536 ],
4537 "gateway",
4538 );
4539 assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
4540 }
4541
4542 #[test]
4543 fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
4544 let mut token = function("csr");
4545 token.bm25 = Some(-1.0);
4546 let mut substring = function("action_csrf_token");
4547 substring.bm25 = Some(-5.0);
4548
4549 let rows = ranked(vec![substring, token], "csr adjacency");
4550
4551 assert!(rows[0].0.score > rows[1].0.score);
4552 assert_eq!(rows[0].0.symbol.name, "csr");
4553 }
4554
4555 #[test]
4556 fn an_acronym_token_outranks_a_name_that_only_contains_it() {
4557 let mut token = function("http_client");
4558 token.bm25 = Some(-1.0);
4559 let mut substring = function("shttpd_config");
4560 substring.bm25 = Some(-5.0);
4561
4562 let rows = ranked(vec![substring, token], "http");
4563
4564 assert!(rows[0].0.score > rows[1].0.score);
4565 assert_eq!(rows[0].0.symbol.name, "http_client");
4566 }
4567
4568 #[test]
4569 fn explain_reports_the_sum_of_the_name_strengths() {
4570 let rows = ranked(vec![function("action_csrf_token")], "csr token");
4571
4572 assert_eq!(rows[0].1.name_strength, 4);
4573 }
4574
4575 #[test]
4576 fn snippets_follow_the_admitting_branch() {
4577 let mut word_row = function("parse_sidecar_file");
4578 word_row.word_match = true;
4579 word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
4580 let mut name_row = function("parseSha256Sidecar");
4581 name_row.name_match = true;
4582 name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
4583 let mut exact_row = function("sha256");
4584 exact_row.exact_name = true;
4585 let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
4586 let snippets: Vec<(&str, &str)> = rows
4587 .iter()
4588 .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
4589 .collect();
4590
4591 assert_eq!(
4592 snippets,
4593 vec![
4594 ("sha256", "sha256"),
4595 ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
4596 ("parse_sidecar_file", "parse the [sha256] sidecar file"),
4597 ]
4598 );
4599 assert_eq!(rows[1].1.branches, vec!["name"]);
4600 assert_eq!(rows[0].1.branches, vec!["exact"]);
4601 }
4602
4603 #[test]
4604 fn explain_is_attached_only_when_requested() {
4605 let conn = sidecar_fixture();
4606 let query = "sha256";
4607
4608 let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
4609 assert!(silent.iter().all(|r| r.explain.is_none()));
4610 assert!(silent[0].score > 0.0);
4611 assert_eq!(
4612 serde_json::to_value(&silent[0]).unwrap().get("explain"),
4613 None
4614 );
4615
4616 let explained =
4617 fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
4618 let by_name = |name: &str| {
4619 explained
4620 .iter()
4621 .find(|r| r.symbol.name == name)
4622 .and_then(|r| r.explain.as_ref())
4623 .unwrap()
4624 };
4625 let name_only = by_name("parseSha256Sidecar");
4626 assert_eq!(name_only.candidates, 2);
4627 assert_eq!(name_only.branches, vec!["name"]);
4628 assert_eq!(name_only.bm25, None);
4629 let word_row = by_name("parse_sidecar_file");
4630 assert!(word_row.bm25.unwrap() < 0.0);
4631 assert_eq!(word_row.candidates, 2);
4632 assert!(
4633 serde_json::to_value(&explained[0])
4634 .unwrap()
4635 .get("explain")
4636 .is_some()
4637 );
4638 }
4639
4640 #[test]
4641 fn search_symbols_treats_like_wildcards_as_literals() {
4642 let dir = crate::safe_tempdir();
4643 let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
4644 let conn = open_read_write(&db_path).unwrap();
4645 conn.execute_batch(
4646 "CREATE TABLE symbols (
4647 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
4648 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4649 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4650 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4651 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4652 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4653 semantic_group TEXT, is_test INTEGER, test_container INTEGER
4654 );
4655 INSERT INTO symbols VALUES (
4656 's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
4657 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
4658 );
4659 INSERT INTO symbols VALUES (
4660 'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
4661 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
4662 );
4663 INSERT INTO symbols VALUES (
4664 'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
4665 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
4666 );
4667 CREATE TABLE files (
4668 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
4669 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
4670 );
4671 INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
4672 INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
4673 );",
4674 )
4675 .unwrap();
4676
4677 assert_eq!(
4678 search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
4679 "literal%name"
4680 );
4681 assert_eq!(
4682 search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
4683 "literal_name"
4684 );
4685 assert_eq!(
4686 load_scoped_files(&conn, Some("src/literal_path"))
4687 .unwrap()
4688 .len(),
4689 1
4690 );
4691 }
4692
4693 #[test]
4694 fn find_references_for_symbol_limits_callees_by_symbol_id() {
4695 let dir = crate::safe_tempdir();
4696 let db_path = dir.path().join("find_references_for_symbol.db");
4697 let conn = open_read_write(&db_path).unwrap();
4698 conn.execute_batch(
4699 "CREATE TABLE symbols (
4700 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
4701 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4702 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4703 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4704 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4705 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4706 semantic_group TEXT, is_test INTEGER, test_container INTEGER
4707 );
4708 CREATE TABLE relationships (
4709 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
4710 start_line INTEGER, start_column INTEGER
4711 );
4712 CREATE TABLE pending_relationships (
4713 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
4714 start_line INTEGER, start_column INTEGER
4715 );
4716 INSERT INTO symbols VALUES
4717 ('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),
4718 ('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),
4719 ('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),
4720 ('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);
4721 INSERT INTO relationships VALUES
4722 ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
4723 ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
4724 )
4725 .unwrap();
4726
4727 let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
4728 assert_eq!(references.len(), 1);
4729 assert_eq!(references[0].to_symbol_name, "wanted_dep");
4730 }
4731
4732 #[test]
4733 fn test_fts_search_symbols_and_porter_stemming() {
4734 let dir = crate::safe_tempdir();
4735 let db_path = dir.path().join("fts_search_symbols.db");
4736 let conn = open_read_write(&db_path).unwrap();
4737
4738 conn.execute_batch(
4739 "CREATE TABLE symbols (
4740 symbol_id TEXT PRIMARY KEY,
4741 file_id TEXT,
4742 path TEXT,
4743 language TEXT,
4744 name TEXT,
4745 kind TEXT,
4746 signature TEXT,
4747 doc_comment TEXT,
4748 visibility TEXT,
4749 parent_symbol_id TEXT,
4750 start_line INTEGER,
4751 start_column INTEGER,
4752 end_line INTEGER,
4753 end_column INTEGER,
4754 start_byte INTEGER,
4755 end_byte INTEGER,
4756 body_start_line INTEGER,
4757 body_start_column INTEGER,
4758 body_end_line INTEGER,
4759 body_end_column INTEGER,
4760 body_start_byte INTEGER,
4761 body_end_byte INTEGER,
4762 body_hash TEXT,
4763 semantic_group TEXT,
4764 is_test INTEGER,
4765 test_container INTEGER
4766 );
4767 INSERT INTO symbols VALUES (
4768 's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
4769 'pub trait PaymentGateway', 'Core payment provider interface for transactions',
4770 'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
4771 );
4772 INSERT INTO symbols VALUES (
4773 's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
4774 'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
4775 'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
4776 );
4777 INSERT INTO symbols VALUES (
4778 's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
4779 'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
4780 'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
4781 );
4782 INSERT INTO symbols VALUES (
4783 's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
4784 'fn test_payment_flow()', 'Tests payment charge workflow',
4785 NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
4786 );",
4787 )
4788 .unwrap();
4789
4790 ensure_fts_index(&conn).unwrap();
4791
4792 let results =
4794 fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
4795 assert_eq!(results.len(), 1);
4796 assert_eq!(results[0].symbol.name, "parse_tokens");
4797 assert!(results[0].snippet.is_some());
4798
4799 let results =
4801 fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
4802 assert_eq!(results.len(), 1);
4803 assert_eq!(results[0].symbol.name, "PaymentGateway");
4804
4805 let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
4807 assert_eq!(results.len(), 2);
4808 assert!(results.iter().all(|r| !r.symbol.is_test));
4809
4810 let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
4812 assert_eq!(results.len(), 3);
4813
4814 let results =
4816 fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
4817 assert_eq!(results.len(), 1);
4818 assert_eq!(results[0].symbol.name, "StripeClient");
4819 }
4820
4821 #[test]
4822 fn find_related_tests_returns_each_test_once_under_the_limit() {
4823 let dir = crate::safe_tempdir();
4824 let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
4825 conn.execute_batch(
4826 "CREATE TABLE symbols (
4827 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
4828 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
4829 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
4830 start_column INTEGER, end_line INTEGER, end_column INTEGER,
4831 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4832 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4833 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4834 semantic_group TEXT, is_test INTEGER, test_container INTEGER
4835 );
4836 CREATE TABLE relationships (
4837 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
4838 start_line INTEGER, start_column INTEGER
4839 );
4840 CREATE TABLE pending_relationships (
4841 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
4842 start_line INTEGER, start_column INTEGER,
4843 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
4844 );
4845 CREATE TABLE type_facts (
4846 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
4847 );
4848 INSERT INTO symbols VALUES
4849 ('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),
4850 ('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),
4851 ('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);
4852 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
4853 ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
4854 ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
4855 ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
4856 ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
4857 ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
4858 ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
4859 )
4860 .unwrap();
4861 let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
4862
4863 let tests = find_related_tests(&conn, &target, 5).unwrap();
4864
4865 let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
4866 names.sort();
4867 assert_eq!(names, vec!["first_case", "second_case"]);
4868 }
4869
4870 #[test]
4871 fn documentation_rows_rank_after_code_in_search() {
4872 let dir = crate::safe_tempdir();
4873 let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
4874 conn.execute_batch(
4875 "CREATE TABLE symbols (
4876 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
4877 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4878 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4879 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4880 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4881 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4882 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
4883 );
4884 INSERT INTO symbols VALUES
4885 ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
4886 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
4887 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
4888 ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
4889 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
4890 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
4891 )
4892 .unwrap();
4893 ensure_fts_index(&conn).unwrap();
4894
4895 let results =
4896 fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
4897 .unwrap();
4898
4899 assert_eq!(results.len(), 2);
4900 assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
4901 assert_eq!(results[1].symbol.name, "Reconcile offline edits");
4902 }
4903
4904 #[test]
4905 fn test_queries_nocase_and_path_normalization() {
4906 let conn = Connection::open_in_memory().unwrap();
4907 conn.execute_batch(
4908 "CREATE TABLE files (
4909 file_id TEXT PRIMARY KEY,
4910 path TEXT NOT NULL,
4911 language TEXT,
4912 content_hash TEXT,
4913 content_bytes INTEGER,
4914 line_count INTEGER,
4915 indexed_at INTEGER
4916 );
4917 CREATE TABLE symbols (
4918 symbol_id TEXT PRIMARY KEY,
4919 file_id TEXT,
4920 path TEXT NOT NULL,
4921 language TEXT,
4922 name TEXT,
4923 kind TEXT,
4924 signature TEXT,
4925 doc_comment TEXT,
4926 visibility TEXT,
4927 parent_symbol_id TEXT,
4928 start_line INTEGER,
4929 start_column INTEGER,
4930 end_line INTEGER,
4931 end_column INTEGER,
4932 start_byte INTEGER,
4933 end_byte INTEGER,
4934 body_start_line INTEGER,
4935 body_start_column INTEGER,
4936 body_end_line INTEGER,
4937 body_end_column INTEGER,
4938 body_start_byte INTEGER,
4939 body_end_byte INTEGER,
4940 body_hash TEXT,
4941 semantic_group TEXT,
4942 is_test INTEGER,
4943 test_container INTEGER
4944 );
4945 -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
4946 INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
4947 INSERT INTO symbols VALUES (
4948 's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
4949 'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
4950 2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
4951 );",
4952 )
4953 .unwrap();
4954
4955 let file = get_file(&conn, "SRC/PAYMENT.RS")
4957 .unwrap()
4958 .expect("File should be found");
4959 assert_eq!(
4960 file.path, "src/Payment.rs",
4961 "Path should be normalized to forward slashes"
4962 );
4963
4964 let file2 = get_file(&conn, "src/payment.rs")
4965 .unwrap()
4966 .expect("File should be found");
4967 assert_eq!(file2.path, "src/Payment.rs");
4968
4969 let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
4971 assert_eq!(syms.len(), 1);
4972 assert_eq!(
4973 syms[0].path, "src/Payment.rs",
4974 "Symbol path should be normalized to forward slashes"
4975 );
4976
4977 let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
4979 .unwrap()
4980 .expect("Symbol should be found with case-insensitive path filter");
4981 assert_eq!(sym.path, "src/Payment.rs");
4982 }
4983
4984 #[test]
4985 fn test_exact_case_prioritized_over_nocase() {
4986 let conn = Connection::open_in_memory().unwrap();
4987 conn.execute_batch(
4988 "CREATE TABLE files (
4989 file_id TEXT PRIMARY KEY,
4990 path TEXT NOT NULL,
4991 language TEXT,
4992 content_hash TEXT,
4993 content_bytes INTEGER,
4994 line_count INTEGER,
4995 indexed_at TEXT
4996 );
4997 CREATE TABLE symbols (
4998 symbol_id TEXT PRIMARY KEY,
4999 file_id TEXT,
5000 path TEXT NOT NULL,
5001 language TEXT,
5002 name TEXT NOT NULL,
5003 kind TEXT NOT NULL,
5004 signature TEXT,
5005 doc_comment TEXT,
5006 visibility TEXT,
5007 parent_symbol_id TEXT,
5008 start_line INTEGER,
5009 start_column INTEGER,
5010 end_line INTEGER,
5011 end_column INTEGER,
5012 start_byte INTEGER,
5013 end_byte INTEGER,
5014 body_start_line INTEGER,
5015 body_start_column INTEGER,
5016 body_end_line INTEGER,
5017 body_end_column INTEGER,
5018 body_start_byte INTEGER,
5019 body_end_byte INTEGER,
5020 body_hash TEXT,
5021 semantic_group TEXT,
5022 is_test INTEGER,
5023 test_container INTEGER
5024 );
5025 INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5026 INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5027 INSERT INTO symbols VALUES (
5028 's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5029 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5030 2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5031 );
5032 INSERT INTO symbols VALUES (
5033 's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5034 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5035 2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5036 );",
5037 )
5038 .unwrap();
5039
5040 let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5042 assert_eq!(f_lower.path, "src/payment.rs");
5043 assert_eq!(f_lower.file_id, "f2");
5044
5045 let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5046 assert_eq!(f_upper.path, "src/Payment.rs");
5047 assert_eq!(f_upper.file_id, "f1");
5048
5049 let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5050 assert_eq!(syms_lower.len(), 1);
5051 assert_eq!(syms_lower[0].file_id, "f2");
5052
5053 let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5054 assert_eq!(syms_upper.len(), 1);
5055 assert_eq!(syms_upper[0].file_id, "f1");
5056 }
5057
5058 #[test]
5059 fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5060 let dir = crate::safe_tempdir();
5061 let db_path = dir.path().join("conservative_resolution.db");
5062 let conn = open_read_write(&db_path).unwrap();
5063
5064 conn.execute_batch(
5065 "CREATE TABLE symbols (
5066 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5067 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5068 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5069 start_column INTEGER, end_line INTEGER, end_column INTEGER,
5070 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5071 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5072 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5073 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5074 );
5075 CREATE TABLE relationships (
5076 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5077 start_line INTEGER, start_column INTEGER
5078 );
5079 CREATE TABLE pending_relationships (
5080 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5081 start_line INTEGER, start_column INTEGER,
5082 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5083 );
5084 CREATE TABLE type_facts (
5085 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5086 );
5087 -- Workspace struct Workspace and method Workspace::new
5088 INSERT INTO symbols VALUES
5089 ('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),
5090 ('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),
5091 ('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);
5092
5093 -- my_func calls Vec::new() (external namespace 'Vec')
5094 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
5095 ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5096 )
5097 .unwrap();
5098
5099 let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5101 assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5102
5103 let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5104 assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5105
5106 let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5108 assert!(
5109 callers.is_empty(),
5110 "Expected 0 callers for Workspace::new, got: {:?}",
5111 callers
5112 );
5113
5114 let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5116 assert!(
5117 !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5118 "my_func should not be impacted before calling Workspace::new: {:?}",
5119 blast.impacted_symbols
5120 );
5121
5122 conn.execute(
5124 "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')",
5125 [],
5126 )
5127 .unwrap();
5128
5129 let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5130 assert_eq!(
5131 sigs2.len(),
5132 1,
5133 "Expected 1 signature for Workspace::new, got: {:?}",
5134 sigs2
5135 );
5136 assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5137
5138 let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5140 assert!(
5141 blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5142 "my_func should be impacted after calling Workspace::new: {:?}",
5143 blast2.impacted_symbols
5144 );
5145
5146 conn.execute(
5148 "INSERT INTO symbols VALUES
5149 ('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);",
5150 [],
5151 )
5152 .unwrap();
5153 conn.execute(
5154 "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')",
5155 [],
5156 )
5157 .unwrap();
5158
5159 let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
5161 assert!(
5162 sigs_other.is_empty(),
5163 "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
5164 sigs_other
5165 );
5166
5167 conn.execute(
5169 "INSERT INTO symbols VALUES
5170 ('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);",
5171 [],
5172 )
5173 .unwrap();
5174 conn.execute(
5175 "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')",
5176 [],
5177 )
5178 .unwrap();
5179
5180 let sigs_sibling =
5181 find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
5182 assert_eq!(
5183 sigs_sibling.len(),
5184 1,
5185 "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
5186 sigs_sibling
5187 );
5188
5189 let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
5191 assert!(
5192 ext_sigs.iter().any(|s| s.contains("Vec")),
5193 "include_external: true should include external Vec::new: {:?}",
5194 ext_sigs
5195 );
5196 }
5197
5198 #[test]
5199 fn test_find_structural_facts_and_literals_scoped() {
5200 let dir = crate::safe_tempdir();
5201 let db_path = dir.path().join("facts_test.db");
5202 let conn = open_read_write(&db_path).unwrap();
5203 conn.execute_batch(
5204 "CREATE TABLE symbols (
5205 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5206 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5207 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5208 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5209 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5210 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5211 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5212 );
5213 CREATE TABLE structural_facts (
5214 structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5215 pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
5216 start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
5217 );
5218 CREATE TABLE literals (
5219 literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5220 kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
5221 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5222 start_byte INTEGER, end_byte INTEGER
5223 );
5224 INSERT INTO structural_facts VALUES
5225 ('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\"}'),
5226 ('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\"}'),
5227 ('sf_route', 'f2', 'src/routes/api.rs', 'rust', 'http.route.v1', 'get_users', 'function', NULL, 10, 20, 1.0, '{\"verb\":\"GET\",\"normalized_route_template\":\"/api/v1/users/:id\"}'),
5228 ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'db.sql.select', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
5229 ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'orm.model.entity', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
5230 ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
5231 INSERT INTO literals VALUES
5232 ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
5233 ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
5234 ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
5235 ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
5236 )
5237 .unwrap();
5238
5239 let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
5241 assert_eq!(facts_config.len(), 2);
5242 assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
5243 assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
5244 assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
5245 assert_eq!(
5246 facts_config[1].key.as_deref(),
5247 Some("mcp_servers.code-kb.command")
5248 );
5249 let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
5250 assert_eq!(lits_config.len(), 1);
5251 assert_eq!(lits_config[0].kind, "toml_key");
5252
5253 let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
5255 assert_eq!(facts_route.len(), 1);
5256 assert_eq!(facts_route[0].pattern_id, "http.route.v1");
5257 assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
5258 let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
5259 assert_eq!(facts_routes.len(), 1);
5260 let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
5261 assert_eq!(lits_route.len(), 1);
5262 assert_eq!(lits_route[0].kind, "http_route");
5263
5264 for q in &["query", "queries", "sql"] {
5266 let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
5267 assert_eq!(facts.len(), 1, "Failed for {}", q);
5268 assert_eq!(facts[0].pattern_id, "db.sql.select");
5269 let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
5270 assert_eq!(lits.len(), 1, "Failed for {}", q);
5271 assert_eq!(lits[0].kind, "sql_query");
5272 }
5273
5274 for m in &["model", "models"] {
5276 let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
5277 assert_eq!(facts.len(), 1, "Failed for {}", m);
5278 assert_eq!(facts[0].pattern_id, "orm.model.entity");
5279 let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
5280 assert_eq!(lits.len(), 1, "Failed for {}", m);
5281 assert_eq!(lits[0].kind, "model_table");
5282 }
5283
5284 let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
5286 assert_eq!(facts_custom.len(), 1);
5287 assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
5288 assert_eq!(facts_custom[0].key, None);
5289
5290 let facts_exact =
5292 find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
5293 assert_eq!(facts_exact.len(), 1);
5294 let facts_miss =
5295 find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
5296 assert_eq!(facts_miss.len(), 0);
5297
5298 let facts_dir =
5300 find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
5301 assert_eq!(facts_dir.len(), 1);
5302 let facts_dir_miss =
5303 find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
5304 assert_eq!(facts_dir_miss.len(), 0);
5305
5306 let f_del = find_structural_facts(&conn, "config", 10).unwrap();
5308 assert_eq!(f_del.len(), 2);
5309 let l_del = find_literals(&conn, "config", 10).unwrap();
5310 assert_eq!(l_del.len(), 1);
5311 }
5312
5313 fn local_variable_fixture() -> Connection {
5314 let conn = Connection::open_in_memory().unwrap();
5315 conn.execute_batch(
5316 "CREATE TABLE symbols (
5317 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5318 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5319 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5320 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5321 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5322 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5323 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5324 );
5325 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5326 parent_symbol_id, start_line, start_column, end_line, end_column,
5327 start_byte, end_byte, is_test, test_container)
5328 VALUES
5329 ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
5330 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5331 ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5332 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5333 ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
5334 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
5335 ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5336 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
5337 ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5338 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
5339 ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
5340 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
5341 ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5342 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
5343 )
5344 .unwrap();
5345 conn
5346 }
5347
5348 fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
5349 let mut stmt = conn
5350 .prepare(
5351 "SELECT s.symbol_id FROM symbols_fts f
5352 JOIN symbols s ON s.rowid = f.rowid
5353 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
5354 )
5355 .unwrap();
5356 let mut ids = stmt
5357 .query_map(params![query], |row| row.get::<_, String>(0))
5358 .unwrap()
5359 .collect::<Result<Vec<_>, _>>()
5360 .unwrap();
5361 ids.sort();
5362 ids
5363 }
5364
5365 #[test]
5366 fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
5367 let conn = local_variable_fixture();
5368 conn.execute_batch(
5369 "CREATE VIRTUAL TABLE symbols_fts USING fts5(
5370 name, signature, doc_comment,
5371 content='symbols', content_rowid='rowid', tokenize='porter unicode61'
5372 );
5373 INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
5374 SELECT rowid, name, signature, doc_comment FROM symbols;",
5375 )
5376 .unwrap();
5377
5378 ensure_fts_index(&conn).unwrap();
5379
5380 assert_eq!(
5381 matched_symbol_ids(&conn, "sqlite"),
5382 vec!["field", "func", "global", "pool"]
5383 );
5384 }
5385
5386 #[test]
5387 fn lookup_excludes_locals_and_parameters() {
5388 let conn = local_variable_fixture();
5389
5390 let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
5391 .unwrap()
5392 .into_iter()
5393 .map(|s| s.symbol_id)
5394 .collect();
5395
5396 assert!(!ids.contains(&"local".to_string()));
5397 assert!(!ids.contains(&"nested".to_string()));
5398 assert!(ids.contains(&"field".to_string()));
5399 assert!(ids.contains(&"global".to_string()));
5400 }
5401
5402 #[test]
5403 fn search_excludes_locals_and_parameters() {
5404 let conn = local_variable_fixture();
5405 ensure_fts_index(&conn).unwrap();
5406
5407 let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
5408 .unwrap()
5409 .into_iter()
5410 .map(|r| r.symbol.symbol_id)
5411 .collect();
5412
5413 assert!(!ids.contains(&"local".to_string()));
5414 assert!(ids.contains(&"func".to_string()));
5415 }
5416
5417 #[test]
5418 fn variable_kind_search_keeps_full_text_matching() {
5419 let conn = local_variable_fixture();
5420 ensure_fts_index(&conn).unwrap();
5421
5422 let ids: Vec<String> = fts_search_symbols_scoped(
5423 &conn,
5424 "sqlite connection",
5425 Some("variable"),
5426 None,
5427 false,
5428 10,
5429 )
5430 .unwrap()
5431 .into_iter()
5432 .map(|r| r.symbol.symbol_id)
5433 .collect();
5434
5435 assert!(ids.contains(&"global".to_string()));
5436 assert!(ids.contains(&"field".to_string()));
5437 }
5438
5439 #[test]
5440 fn qualified_lookup_returns_the_named_local_variable() {
5441 let conn = local_variable_fixture();
5442
5443 let ids: Vec<String> =
5444 search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
5445 .unwrap()
5446 .into_iter()
5447 .map(|s| s.symbol_id)
5448 .collect();
5449
5450 assert_eq!(ids, vec!["local".to_string()]);
5451 }
5452
5453 #[test]
5454 fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
5455 let conn = Connection::open_in_memory().unwrap();
5456 conn.execute_batch(
5457 "CREATE TABLE symbols (
5458 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5459 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5460 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5461 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5462 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5463 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5464 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5465 );
5466 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5467 parent_symbol_id, start_line, start_column, end_line, end_column,
5468 start_byte, end_byte, is_test, test_container)
5469 VALUES
5470 ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
5471 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5472 ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
5473 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5474 ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
5475 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
5476 )
5477 .unwrap();
5478 ensure_fts_index(&conn).unwrap();
5479
5480 let rows =
5481 fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
5482 .unwrap();
5483
5484 assert_eq!(rows.len(), 1);
5485 assert_eq!(rows[0].symbol.symbol_id, "local");
5486 let explain = rows[0].explain.as_ref().unwrap();
5487 assert_eq!(explain.name_tier, "whole");
5488 assert_eq!(explain.branches, vec!["exact", "name"]);
5489 assert_eq!(explain.candidates, 2);
5490 }
5491
5492 #[test]
5493 fn variable_kind_filter_returns_locals_and_parameters() {
5494 let conn = local_variable_fixture();
5495 ensure_fts_index(&conn).unwrap();
5496
5497 let lookup_ids: Vec<String> =
5498 search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
5499 .unwrap()
5500 .into_iter()
5501 .map(|s| s.symbol_id)
5502 .collect();
5503 assert!(lookup_ids.contains(&"local".to_string()));
5504 assert!(lookup_ids.contains(&"nested".to_string()));
5505
5506 let search_ids: Vec<String> =
5507 fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
5508 .unwrap()
5509 .into_iter()
5510 .map(|r| r.symbol.symbol_id)
5511 .collect();
5512 assert!(search_ids.contains(&"local".to_string()));
5513 }
5514}