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