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