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