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