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 )
2341 )
2342 AND NOT EXISTS (
2343 SELECT 1 FROM {ns}
2344 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
2345 AND NOT EXISTS (
2346 WITH RECURSIVE ancestor(symbol_id, depth) AS (
2347 SELECT {target}.parent_symbol_id, 0
2348 UNION ALL
2349 SELECT s.parent_symbol_id, ancestor.depth + 1
2350 FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
2351 WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
2352 )
2353 SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
2354 WHERE a.name = value
2355 )
2356 AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2357 AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
2358 )
2359 )
2360 OR (
2361 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2362 AND (
2363 p.target_receiver IS NULL
2364 OR p.target_receiver = ''
2365 {import_alias_receiver}
2366 )
2367 AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
2368 AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
2369 SELECT 1 FROM symbols closer
2370 WHERE closer.name = {target}.name
2371 AND closer.symbol_id != {target}.symbol_id
2372 AND closer.parent_symbol_id IS NULL
2373 AND closer.kind = {target}.kind
2374 AND NOT (p.kind IS 'extends' AND closer.symbol_id = p.from_symbol_id)
2375 AND {closer_rank} > {target_rank}
2376 ))
2377 )
2378 OR (
2379 {target}.parent_symbol_id IS NULL
2380 AND EXISTS (
2381 SELECT 1 FROM {ns}
2382 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
2383 AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2384 )
2385 )
2386 )
2387 )"
2388 )
2389}
2390
2391const DOCUMENTATION_LANGUAGES: &[&str] = &[
2394 "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
2395];
2396
2397fn documentation_language_list() -> String {
2398 DOCUMENTATION_LANGUAGES
2399 .iter()
2400 .map(|l| format!("'{l}'"))
2401 .collect::<Vec<_>>()
2402 .join(", ")
2403}
2404
2405fn not_documentation(conn: &Connection, alias: &str) -> String {
2406 let has_content_type: bool = conn
2407 .query_row(
2408 "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
2409 [],
2410 |_| Ok(true),
2411 )
2412 .unwrap_or(false);
2413 if has_content_type {
2414 format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
2415 } else {
2416 "1 = 1".to_string()
2417 }
2418}
2419
2420fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
2421 conn.query_row(
2422 "SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2",
2423 [table, column],
2424 |_| Ok(true),
2425 )
2426 .unwrap_or(false)
2427}
2428
2429pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
2430 conn.query_row(
2431 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
2432 [name],
2433 |_| Ok(true),
2434 )
2435 .unwrap_or(false)
2436}
2437
2438fn has_pending_namespace_column(conn: &Connection) -> bool {
2439 let has_ns: bool = conn
2440 .query_row(
2441 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
2442 [],
2443 |_| Ok(true),
2444 )
2445 .unwrap_or(false);
2446 let has_display: bool = conn
2447 .query_row(
2448 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
2449 [],
2450 |_| Ok(true),
2451 )
2452 .unwrap_or(false);
2453 let has_receiver: bool = conn
2454 .query_row(
2455 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
2456 [],
2457 |_| Ok(true),
2458 )
2459 .unwrap_or(false);
2460 has_ns && has_display && has_receiver
2461}
2462
2463fn find_references_internal(
2464 conn: &Connection,
2465 symbol_name: &str,
2466 direction: &str,
2467 limit: usize,
2468 symbol_id: Option<&str>,
2469 include_external: bool,
2470) -> Result<Vec<ReferenceSite>, QueryError> {
2471 let mut results = Vec::new();
2472
2473 if direction == "callers" {
2474 let mut stmt = conn.prepare(
2476 "SELECT s_from.name AS from_name,
2477 r.from_symbol_id,
2478 s_to.name AS to_name,
2479 r.kind,
2480 r.path,
2481 r.start_line,
2482 r.start_column
2483 FROM relationships r
2484 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2485 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2486 WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
2487 LIMIT ?2",
2488 )?;
2489
2490 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2491 Ok(ReferenceSite {
2492 from_symbol_name: row.get(0)?,
2493 from_symbol_id: row.get(1)?,
2494 to_symbol_name: row.get(2)?,
2495 kind: row.get(3)?,
2496 path: row.get::<_, String>(4)?.replace('\\', "/"),
2497 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2498 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2499 occurrences: None,
2500 })
2501 })?;
2502
2503 for r in rows {
2504 results.push(r?);
2505 }
2506
2507 if results.len() < limit {
2509 let remaining = limit - results.len();
2510 if has_pending_namespace_column(conn) {
2511 if let Some(sid) = symbol_id {
2512 let mut pending_stmt = conn.prepare(
2513 &format!("SELECT s_from.name AS from_name,
2514 p.from_symbol_id,
2515 p.target_terminal_name AS to_name,
2516 p.kind,
2517 p.path,
2518 p.start_line,
2519 p.start_column
2520 FROM pending_relationships p
2521 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2522 JOIN symbols s_target ON s_target.symbol_id = ?3
2523 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2524 WHERE p.target_terminal_name = ?1
2525 AND {pred}
2526 LIMIT ?2", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
2527 )?;
2528
2529 let p_rows = pending_stmt.query_map(
2530 params![symbol_name, remaining as i64, sid],
2531 |row| {
2532 Ok(ReferenceSite {
2533 from_symbol_name: row.get(0)?,
2534 from_symbol_id: row.get(1)?,
2535 to_symbol_name: row.get(2)?,
2536 kind: row.get(3)?,
2537 path: row.get::<_, String>(4)?.replace('\\', "/"),
2538 start_line: Some(row.get::<_, i64>(5)? as usize),
2539 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2540 occurrences: None,
2541 })
2542 },
2543 )?;
2544 for r in p_rows {
2545 results.push(r?);
2546 }
2547 } else {
2548 let mut pending_stmt = conn.prepare(
2549 "SELECT s_from.name AS from_name,
2550 p.from_symbol_id,
2551 p.target_terminal_name AS to_name,
2552 p.kind,
2553 p.path,
2554 p.start_line,
2555 p.start_column
2556 FROM pending_relationships p
2557 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2558 WHERE p.target_terminal_name = ?1
2559 AND (
2560 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2561 OR EXISTS (
2562 SELECT 1 FROM symbols s_any
2563 JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
2564 WHERE s_any.name = p.target_terminal_name
2565 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)
2566 )
2567 )
2568 LIMIT ?2",
2569 )?;
2570
2571 let p_rows =
2572 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2573 Ok(ReferenceSite {
2574 from_symbol_name: row.get(0)?,
2575 from_symbol_id: row.get(1)?,
2576 to_symbol_name: row.get(2)?,
2577 kind: row.get(3)?,
2578 path: row.get::<_, String>(4)?.replace('\\', "/"),
2579 start_line: Some(row.get::<_, i64>(5)? as usize),
2580 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2581 occurrences: None,
2582 })
2583 })?;
2584 for r in p_rows {
2585 results.push(r?);
2586 }
2587 }
2588 } else {
2589 let is_nested = if let Some(sid) = symbol_id {
2590 conn.query_row(
2591 "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
2592 params![sid],
2593 |_| Ok(true),
2594 )
2595 .unwrap_or(false)
2596 } else {
2597 false
2598 };
2599
2600 if !is_nested {
2601 let mut pending_stmt = conn.prepare(
2602 "SELECT s_from.name AS from_name,
2603 p.from_symbol_id,
2604 p.target_terminal_name AS to_name,
2605 p.kind,
2606 p.path,
2607 p.start_line,
2608 p.start_column
2609 FROM pending_relationships p
2610 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2611 WHERE p.target_terminal_name = ?1
2612 LIMIT ?2",
2613 )?;
2614
2615 let p_rows =
2616 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2617 Ok(ReferenceSite {
2618 from_symbol_name: row.get(0)?,
2619 from_symbol_id: row.get(1)?,
2620 to_symbol_name: row.get(2)?,
2621 kind: row.get(3)?,
2622 path: row.get::<_, String>(4)?.replace('\\', "/"),
2623 start_line: Some(row.get::<_, i64>(5)? as usize),
2624 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2625 occurrences: None,
2626 })
2627 })?;
2628
2629 for r in p_rows {
2630 results.push(r?);
2631 }
2632 }
2633 }
2634 }
2635
2636 if results.len() < limit && has_table(conn, "identifiers") {
2637 let remaining = limit - results.len();
2638 let mut ident_stmt = conn.prepare(
2639 "SELECT COALESCE(s.name, ''),
2640 COALESCE(i.containing_symbol_id, ''),
2641 i.name,
2642 CASE WHEN json_valid(i.metadata_json)
2643 AND json_extract(i.metadata_json, '$.role') = 'signal_handler'
2644 THEN CASE WHEN json_extract(i.metadata_json, '$.receiver') = (
2645 SELECT CASE WHEN t.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
2646 THEN t.name ELSE tp.name END
2647 FROM symbols t
2648 LEFT JOIN symbols tp ON tp.symbol_id = t.parent_symbol_id
2649 WHERE t.symbol_id = ?3)
2650 THEN 'handler' ELSE 'handler (candidate)' END
2651 ELSE i.kind END,
2652 i.path,
2653 i.start_line,
2654 i.start_column
2655 FROM identifiers i
2656 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2657 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
2658 AND COALESCE(s.kind, '') != 'import'
2659 AND NOT EXISTS (
2660 SELECT 1 FROM relationships covered
2661 JOIN symbols covered_to ON covered.to_symbol_id = covered_to.symbol_id
2662 WHERE covered_to.name = i.name
2663 AND covered.path = i.path
2664 AND covered.start_line = i.start_line
2665 AND (?3 IS NULL OR covered.to_symbol_id = ?3)
2666 )
2667 AND NOT EXISTS (
2668 SELECT 1 FROM pending_relationships covered
2669 WHERE covered.target_terminal_name = i.name
2670 AND covered.path = i.path
2671 AND covered.start_line = i.start_line
2672 )
2673 AND (?3 IS NULL OR NOT EXISTS (
2674 SELECT 1 FROM symbols owner
2675 JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
2676 WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
2677 AND member.name = i.name
2678 AND owner.name IS NOT (SELECT parent.name FROM symbols target
2679 JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
2680 WHERE target.symbol_id = ?3)
2681 ))
2682 ORDER BY i.path, i.start_line
2683 LIMIT ?2",
2684 )?;
2685 let rows =
2686 ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
2687 Ok(ReferenceSite {
2688 from_symbol_name: row.get(0)?,
2689 from_symbol_id: row.get(1)?,
2690 to_symbol_name: row.get(2)?,
2691 kind: row.get(3)?,
2692 path: row.get::<_, String>(4)?.replace('\\', "/"),
2693 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2694 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2695 occurrences: None,
2696 })
2697 })?;
2698 for r in rows {
2699 results.push(r?);
2700 }
2701
2702 if let Some(sid) = symbol_id.filter(|_| results.len() < limit) {
2703 let remaining = limit - results.len();
2704 let mut receiver_stmt = conn.prepare(
2705 "SELECT COALESCE(s.name, ''),
2706 COALESCE(i.containing_symbol_id, ''),
2707 i.name,
2708 i.kind,
2709 i.path,
2710 MIN(i.start_line),
2711 i.start_column,
2712 COUNT(*)
2713 FROM identifiers i
2714 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2715 JOIN symbols target ON target.symbol_id = ?2
2716 LEFT JOIN symbols target_parent ON target_parent.symbol_id = target.parent_symbol_id
2717 WHERE i.kind = 'member_access'
2718 AND i.name != target.name
2719 AND COALESCE(s.kind, '') != 'import'
2720 AND target.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
2721 AND json_valid(i.metadata_json)
2722 AND json_extract(i.metadata_json, '$.receiver') = target.name
2723 AND (json_extract(i.metadata_json, '$.receiver_qualifier') IS NULL
2724 OR json_extract(i.metadata_json, '$.receiver_qualifier') = target_parent.name)
2725 GROUP BY i.path
2726 ORDER BY i.path, i.start_line
2727 LIMIT ?1",
2728 )?;
2729 let rows = receiver_stmt.query_map(params![remaining as i64, sid], |row| {
2730 Ok(ReferenceSite {
2731 from_symbol_name: row.get(0)?,
2732 from_symbol_id: row.get(1)?,
2733 to_symbol_name: row.get(2)?,
2734 kind: row.get(3)?,
2735 path: row.get::<_, String>(4)?.replace('\\', "/"),
2736 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2737 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2738 occurrences: Some(row.get::<_, i64>(7)? as usize),
2739 })
2740 })?;
2741 for r in rows {
2742 results.push(r?);
2743 }
2744 }
2745 }
2746 } else {
2747 let mut stmt = conn.prepare(
2749 "SELECT s_from.name AS from_name,
2750 r.from_symbol_id,
2751 s_to.name AS to_name,
2752 r.kind,
2753 r.path,
2754 r.start_line,
2755 r.start_column
2756 FROM relationships r
2757 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2758 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2759 WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
2760 LIMIT ?2",
2761 )?;
2762
2763 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2764 Ok(ReferenceSite {
2765 from_symbol_name: row.get(0)?,
2766 from_symbol_id: row.get(1)?,
2767 to_symbol_name: row.get(2)?,
2768 kind: row.get(3)?,
2769 path: row.get::<_, String>(4)?.replace('\\', "/"),
2770 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2771 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2772 occurrences: None,
2773 })
2774 })?;
2775
2776 for r in rows {
2777 results.push(r?);
2778 }
2779
2780 if results.len() < limit {
2782 let remaining = limit - results.len();
2783 let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
2784 let sql = if include_external {
2785 String::from("SELECT DISTINCT s_from.name AS from_name,
2786 p.from_symbol_id,
2787 COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
2788 p.kind,
2789 p.path,
2790 p.start_line,
2791 p.start_column
2792 FROM pending_relationships p
2793 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2794 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2795 LIMIT ?2")
2796 } else {
2797 format!("SELECT DISTINCT s_from.name AS from_name,
2798 p.from_symbol_id,
2799 p.target_terminal_name AS to_name,
2800 p.kind,
2801 p.path,
2802 p.start_line,
2803 p.start_column
2804 FROM pending_relationships p
2805 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2806 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2807 AND EXISTS (
2808 SELECT 1 FROM symbols s_to
2809 LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
2810 WHERE s_to.name = p.target_terminal_name
2811 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2812 AND {pred}
2813 )
2814 LIMIT ?2", pred = pending_target_predicate(conn, "s_to", "s_to_parent"))
2815 };
2816 let mut pending_stmt = conn.prepare(&sql)?;
2817 let rows = pending_stmt.query_map(
2818 params![symbol_name, remaining as i64, symbol_id],
2819 |row| {
2820 Ok(ReferenceSite {
2821 from_symbol_name: row.get(0)?,
2822 from_symbol_id: row.get(1)?,
2823 to_symbol_name: row.get(2)?,
2824 kind: row.get(3)?,
2825 path: row.get::<_, String>(4)?.replace('\\', "/"),
2826 start_line: Some(row.get::<_, i64>(5)? as usize),
2827 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2828 occurrences: None,
2829 })
2830 },
2831 )?;
2832 let mut out = Vec::new();
2833 for r in rows {
2834 out.push(r?);
2835 }
2836 out
2837 } else {
2838 let sql = if include_external {
2839 "SELECT DISTINCT s_from.name AS from_name,
2840 p.from_symbol_id,
2841 p.target_terminal_name AS to_name,
2842 p.kind,
2843 p.path,
2844 p.start_line,
2845 p.start_column
2846 FROM pending_relationships p
2847 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2848 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2849 LIMIT ?2"
2850 } else {
2851 "SELECT DISTINCT s_from.name AS from_name,
2852 p.from_symbol_id,
2853 p.target_terminal_name AS to_name,
2854 p.kind,
2855 p.path,
2856 p.start_line,
2857 p.start_column
2858 FROM pending_relationships p
2859 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2860 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2861 AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
2862 LIMIT ?2"
2863 };
2864 let mut pending_stmt = conn.prepare(sql)?;
2865
2866 let rows = pending_stmt.query_map(
2867 params![symbol_name, remaining as i64, symbol_id],
2868 |row| {
2869 Ok(ReferenceSite {
2870 from_symbol_name: row.get(0)?,
2871 from_symbol_id: row.get(1)?,
2872 to_symbol_name: row.get(2)?,
2873 kind: row.get(3)?,
2874 path: row.get::<_, String>(4)?.replace('\\', "/"),
2875 start_line: Some(row.get::<_, i64>(5)? as usize),
2876 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2877 occurrences: None,
2878 })
2879 },
2880 )?;
2881 let mut out = Vec::new();
2882 for r in rows {
2883 out.push(r?);
2884 }
2885 out
2886 };
2887
2888 for r in p_rows {
2889 results.push(r);
2890 }
2891 }
2892 }
2893
2894 Ok(results)
2895}
2896
2897pub fn find_callee_signatures(
2900 conn: &Connection,
2901 symbol_name: &str,
2902 symbol_id: &str,
2903 limit: usize,
2904 include_external: bool,
2905) -> Result<Vec<String>, QueryError> {
2906 let mut stmt = conn.prepare(
2907 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2908 FROM relationships r
2909 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2910 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2911 WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
2912 LIMIT ?3",
2913 )?;
2914
2915 let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
2916 Ok((
2917 row.get::<_, String>(0)?,
2918 row.get::<_, Option<String>>(1)?,
2919 row.get::<_, String>(2)?.replace('\\', "/"),
2920 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2921 row.get::<_, String>(4)?,
2922 ))
2923 })?;
2924
2925 let mut signatures = Vec::new();
2926 let mut variants = Vec::new();
2927
2928 for r in rows.flatten() {
2929 let (name, sig_opt, path, line, kind) = r;
2930 let sig = sig_opt.unwrap_or(name);
2931 let entry = format!("{sig} ({path}:{line})");
2932 if kind == "variant" {
2933 if !variants.contains(&entry) {
2934 variants.push(entry);
2935 }
2936 } else if !signatures.contains(&entry) {
2937 signatures.push(entry);
2938 }
2939 }
2940
2941 if signatures.len() < limit {
2942 let remaining = (limit - signatures.len()) * 2;
2943 let p_rows: Vec<(String, Option<String>, String, usize, String)> =
2944 if has_pending_namespace_column(conn) {
2945 let mut p_stmt = conn.prepare(
2946 &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2947 FROM pending_relationships p
2948 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2949 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2950 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2951 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2952 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2953 AND {pred}
2954 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
2955 )?;
2956
2957 let rows =
2958 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2959 Ok((
2960 row.get::<_, String>(0)?,
2961 row.get::<_, Option<String>>(1)?,
2962 row.get::<_, String>(2)?.replace('\\', "/"),
2963 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2964 row.get::<_, String>(4)?,
2965 ))
2966 })?;
2967 rows.flatten().collect()
2968 } else {
2969 let mut p_stmt = conn.prepare(
2970 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2971 FROM pending_relationships p
2972 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2973 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2974 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2975 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2976 LIMIT ?3",
2977 )?;
2978
2979 let rows =
2980 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2981 Ok((
2982 row.get::<_, String>(0)?,
2983 row.get::<_, Option<String>>(1)?,
2984 row.get::<_, String>(2)?.replace('\\', "/"),
2985 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2986 row.get::<_, String>(4)?,
2987 ))
2988 })?;
2989 rows.flatten().collect()
2990 };
2991
2992 for r in p_rows {
2993 let (name, sig_opt, path, line, kind) = r;
2994 let sig = sig_opt.unwrap_or(name);
2995 let entry = format!("{sig} ({path}:{line})");
2996 if kind == "variant" {
2997 if !variants.contains(&entry) {
2998 variants.push(entry);
2999 }
3000 } else if !signatures.contains(&entry) {
3001 signatures.push(entry);
3002 }
3003 }
3004 }
3005
3006 if include_external && signatures.len() < limit {
3007 let remaining = (limit - signatures.len()) * 2;
3008 let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
3009 let mut ext_stmt = conn.prepare(
3010 &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
3011 FROM pending_relationships p
3012 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3013 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3014 AND NOT EXISTS (
3015 SELECT 1 FROM symbols s_to
3016 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
3017 WHERE s_to.name = p.target_terminal_name
3018 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3019 AND {pred}
3020 )
3021 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
3022 )?;
3023
3024 let rows =
3025 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3026 Ok((
3027 row.get::<_, String>(0)?,
3028 row.get::<_, String>(1)?.replace('\\', "/"),
3029 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
3030 ))
3031 })?;
3032 rows.flatten().collect()
3033 } else {
3034 let mut ext_stmt = conn.prepare(
3035 "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
3036 FROM pending_relationships p
3037 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
3038 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
3039 AND NOT EXISTS (
3040 SELECT 1 FROM symbols s_to
3041 WHERE s_to.name = p.target_terminal_name
3042 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3043 )
3044 LIMIT ?3",
3045 )?;
3046
3047 let rows =
3048 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3049 Ok((
3050 row.get::<_, String>(0)?,
3051 row.get::<_, String>(1)?.replace('\\', "/"),
3052 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
3053 ))
3054 })?;
3055 rows.flatten().collect()
3056 };
3057
3058 for r in ext_rows {
3059 let (name, path, line) = r;
3060 let entry = format!("{name} ({path}:{line})");
3061 if !signatures.contains(&entry) {
3062 signatures.push(entry);
3063 }
3064 }
3065 }
3066
3067 for v in variants {
3068 if signatures.len() >= limit {
3069 break;
3070 }
3071 if !signatures.contains(&v) {
3072 signatures.push(v);
3073 }
3074 }
3075
3076 signatures.truncate(limit);
3077 Ok(signatures)
3078}
3079
3080const SQL_FAMILIES: &[&str] = &["sql."];
3081
3082const ROUTE_FAMILIES: &[&str] = &[
3083 ".route.",
3084 ".attribute_route.",
3085 ".scope_route.",
3086 ".file_route.",
3087 ".route_handler.",
3088 ".route_reference.",
3089 ".route_group.",
3090 ".route_prefix.",
3091 ".resource_route.",
3092 ".router_mount.",
3093 ".include_router.",
3094 ".server_route.",
3095 ".route_definition.",
3096];
3097
3098const CONFIG_FAMILIES: &[&str] = &["toml.", "yaml.", "json.", "env."];
3099
3100const MODEL_FAMILIES: &[&str] = &[
3101 "sql.table_definition.v1",
3102 "sql.column_definition.v1",
3103 "sql.constraint.v1",
3104 "sql.foreign_key.v1",
3105 "sql.index_definition.v1",
3106 "json.schema.v1",
3107];
3108
3109const SIGNAL_FAMILIES: &[&str] = &[".signal_declaration."];
3110
3111const IMPORT_FAMILIES: &[&str] = &[".import_statement.", ".import."];
3112
3113const BINDING_FAMILIES: &[&str] = &[".binding."];
3114
3115const COMPONENT_FAMILIES: &[&str] = &[".object_instantiation.", ".object_type."];
3116
3117const MODULE_FAMILIES: &[&str] = &[".module."];
3118
3119const PRAGMA_FAMILIES: &[&str] = &[".pragma.", "javascript.qml_directive.v1"];
3120
3121const PROPERTY_FAMILIES: &[&str] = &[".property_declaration.", ".qt_property."];
3122
3123pub const CATEGORY_ALIASES: &[(&str, &[&str])] = &[
3128 ("sql", SQL_FAMILIES),
3129 ("query", SQL_FAMILIES),
3130 ("queries", SQL_FAMILIES),
3131 ("route", ROUTE_FAMILIES),
3132 ("routes", ROUTE_FAMILIES),
3133 ("config", CONFIG_FAMILIES),
3134 ("model", MODEL_FAMILIES),
3135 ("models", MODEL_FAMILIES),
3136 ("signal", SIGNAL_FAMILIES),
3137 ("signals", SIGNAL_FAMILIES),
3138 ("import", IMPORT_FAMILIES),
3139 ("imports", IMPORT_FAMILIES),
3140 ("binding", BINDING_FAMILIES),
3141 ("bindings", BINDING_FAMILIES),
3142 ("component", COMPONENT_FAMILIES),
3143 ("components", COMPONENT_FAMILIES),
3144 ("module", MODULE_FAMILIES),
3145 ("modules", MODULE_FAMILIES),
3146 ("pragma", PRAGMA_FAMILIES),
3147 ("property", PROPERTY_FAMILIES),
3148 ("properties", PROPERTY_FAMILIES),
3149];
3150
3151fn category_families(category: &str) -> Option<&'static [&'static str]> {
3152 let wanted = category.trim().to_ascii_lowercase();
3153 CATEGORY_ALIASES
3154 .iter()
3155 .find(|(alias, _)| *alias == wanted)
3156 .map(|(_, families)| *families)
3157}
3158
3159fn matches_family(pattern_id: &str, rule: &str) -> bool {
3160 if rule.starts_with('.') {
3161 pattern_id.contains(rule)
3162 } else if rule.ends_with('.') {
3163 pattern_id.starts_with(rule)
3164 } else {
3165 pattern_id == rule
3166 }
3167}
3168
3169fn family_clause(column: &str, families: &[&str]) -> String {
3170 let alternatives: Vec<String> = families
3171 .iter()
3172 .map(|rule| {
3173 let escaped = escape_like(rule);
3174 if rule.starts_with('.') {
3175 format!("{column} LIKE '%{escaped}%' ESCAPE '\\'")
3176 } else if rule.ends_with('.') {
3177 format!("{column} LIKE '{escaped}%' ESCAPE '\\'")
3178 } else {
3179 format!("{column} = '{rule}'")
3180 }
3181 })
3182 .collect();
3183 format!("({})", alternatives.join(" OR "))
3184}
3185
3186pub fn is_category_alias(category: &str) -> bool {
3188 category_families(category).is_some()
3189}
3190
3191pub fn alias_fact_counts(categories: &[(String, usize)]) -> Vec<(&'static str, usize, usize)> {
3195 let mut reported: Vec<&[&str]> = Vec::new();
3196 let mut counts = Vec::new();
3197 for (alias, families) in CATEGORY_ALIASES {
3198 if reported.contains(families) {
3199 continue;
3200 }
3201 reported.push(families);
3202 let mut patterns = 0;
3203 let mut facts = 0;
3204 for (pattern_id, count) in categories {
3205 if families.iter().any(|rule| matches_family(pattern_id, rule)) {
3206 patterns += 1;
3207 facts += count;
3208 }
3209 }
3210 if facts > 0 {
3211 counts.push((*alias, patterns, facts));
3212 }
3213 }
3214 counts
3215}
3216
3217pub fn find_structural_facts_scoped(
3219 conn: &Connection,
3220 category: &str,
3221 path_filter: Option<&str>,
3222 limit: usize,
3223) -> Result<Vec<StructuralFact>, QueryError> {
3224 validate_result_limit(limit)?;
3225 let norm_path = path_filter
3226 .map(|p| {
3227 p.replace('\\', "/")
3228 .trim_start_matches("./")
3229 .trim_matches('/')
3230 .to_string()
3231 })
3232 .filter(|p| !p.is_empty());
3233 let dir_prefix = norm_path
3234 .as_deref()
3235 .map(|p| format!("{}/%", escape_like(p)));
3236 let cat_pattern = format!("%{}%", escape_like(category));
3237
3238 let cat_clause = match category_families(category) {
3239 Some(families) => family_clause("sf.pattern_id", families),
3240 None => {
3241 "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
3242 .to_string()
3243 }
3244 };
3245
3246 let sql = format!(
3247 "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
3248 sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
3249 sf.start_line, sf.end_line, sf.confidence,
3250 COALESCE(
3251 CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
3252 THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
3253 ELSE json_extract(sf.metadata_json, '$.key_path') END,
3254 json_extract(sf.metadata_json, '$.key'),
3255 json_extract(sf.metadata_json, '$.normalized_route_template'),
3256 CASE WHEN sf.pattern_id = 'cpp.qt_property.v1'
3257 THEN json_extract(sf.metadata_json, '$.name') END
3258 ) AS display_key,
3259 sf.metadata_json
3260 FROM structural_facts sf
3261 LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
3262 WHERE (:cat IS NOT NULL AND {cat_clause})
3263 AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3264 ORDER BY sf.path ASC, sf.start_line ASC
3265 LIMIT :limit"
3266 );
3267
3268 let mut stmt = conn.prepare(&sql)?;
3269 let rows = stmt.query_map(
3270 rusqlite::named_params! {
3271 ":cat": cat_pattern,
3272 ":path": norm_path.as_deref(),
3273 ":dir_prefix": dir_prefix.as_deref(),
3274 ":limit": limit as i64,
3275 },
3276 |row| {
3277 Ok(StructuralFact {
3278 structural_fact_id: row.get(0)?,
3279 path: row.get::<_, String>(1)?.replace('\\', "/"),
3280 language: row.get(2)?,
3281 pattern_id: row.get(3)?,
3282 capture_name: row.get(4)?,
3283 node_kind: row.get(5)?,
3284 key: row.get(10)?,
3285 metadata: row
3286 .get::<_, Option<String>>(11)?
3287 .map(|json| serde_json::from_str(&json))
3288 .transpose()
3289 .map_err(|err| {
3290 rusqlite::Error::FromSqlConversionFailure(
3291 11,
3292 rusqlite::types::Type::Text,
3293 Box::new(err),
3294 )
3295 })?,
3296 containing_symbol_name: row.get(6)?,
3297 start_line: row.get::<_, i64>(7)? as usize,
3298 end_line: row.get::<_, i64>(8)? as usize,
3299 confidence: row.get(9)?,
3300 })
3301 },
3302 )?;
3303
3304 let mut results = Vec::new();
3305 for r in rows {
3306 results.push(r?);
3307 }
3308 Ok(results)
3309}
3310
3311pub fn find_structural_facts(
3313 conn: &Connection,
3314 category: &str,
3315 limit: usize,
3316) -> Result<Vec<StructuralFact>, QueryError> {
3317 find_structural_facts_scoped(conn, category, None, limit)
3318}
3319
3320pub fn find_literals_scoped(
3322 conn: &Connection,
3323 category: &str,
3324 path_filter: Option<&str>,
3325 limit: usize,
3326) -> Result<Vec<LiteralFact>, QueryError> {
3327 validate_result_limit(limit)?;
3328 let norm_path = path_filter
3329 .map(|p| {
3330 p.replace('\\', "/")
3331 .trim_start_matches("./")
3332 .trim_matches('/')
3333 .to_string()
3334 })
3335 .filter(|p| !p.is_empty());
3336 let dir_prefix = norm_path
3337 .as_deref()
3338 .map(|p| format!("{}/%", escape_like(p)));
3339 let cat_pattern = format!("%{}%", escape_like(category));
3340
3341 let cat_lower = category.trim().to_ascii_lowercase();
3342 let cat_clause = match cat_lower.as_str() {
3343 "config" => {
3344 "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
3345 }
3346 "route" | "routes" => "l.kind LIKE '%route%'",
3347 "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
3348 "model" | "models" => "l.kind LIKE '%model%'",
3349 _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
3350 };
3351
3352 let sql = format!(
3353 "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
3354 l.start_line, s.name AS containing_symbol_name
3355 FROM literals l
3356 LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
3357 WHERE (:cat IS NOT NULL AND {cat_clause})
3358 AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3359 ORDER BY l.path ASC, l.start_line ASC
3360 LIMIT :limit"
3361 );
3362
3363 let mut stmt = conn.prepare(&sql)?;
3364 let rows = stmt.query_map(
3365 rusqlite::named_params! {
3366 ":cat": cat_pattern,
3367 ":path": norm_path.as_deref(),
3368 ":dir_prefix": dir_prefix.as_deref(),
3369 ":limit": limit as i64,
3370 },
3371 |row| {
3372 Ok(LiteralFact {
3373 literal_id: row.get(0)?,
3374 path: row.get::<_, String>(1)?.replace('\\', "/"),
3375 literal_text: row.get(2)?,
3376 kind: row.get(3)?,
3377 carrier: row.get(4)?,
3378 start_line: row.get::<_, i64>(5)? as usize,
3379 containing_symbol_name: row.get(6)?,
3380 })
3381 },
3382 )?;
3383
3384 let mut results = Vec::new();
3385 for r in rows {
3386 results.push(r?);
3387 }
3388 Ok(results)
3389}
3390
3391pub fn find_literals(
3393 conn: &Connection,
3394 category: &str,
3395 limit: usize,
3396) -> Result<Vec<LiteralFact>, QueryError> {
3397 find_literals_scoped(conn, category, None, limit)
3398}
3399
3400pub fn list_structural_fact_categories_scoped(
3402 conn: &Connection,
3403 path_filter: Option<&str>,
3404) -> Result<Vec<(String, usize)>, QueryError> {
3405 let norm_path = path_filter
3406 .map(|p| {
3407 p.replace('\\', "/")
3408 .trim_start_matches("./")
3409 .trim_matches('/')
3410 .to_string()
3411 })
3412 .filter(|p| !p.is_empty());
3413 let dir_prefix = norm_path
3414 .as_deref()
3415 .map(|p| format!("{}/%", escape_like(p)));
3416
3417 let mut categories = Vec::new();
3418
3419 let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
3420 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3421 GROUP BY pattern_id ORDER BY cnt DESC";
3422 let mut stmt = conn.prepare(sql)?;
3423 let rows = stmt.query_map(
3424 rusqlite::named_params! {
3425 ":path": norm_path.as_deref(),
3426 ":dir_prefix": dir_prefix.as_deref(),
3427 },
3428 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3429 )?;
3430 for r in rows {
3431 categories.push(r?);
3432 }
3433
3434 let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
3435 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3436 GROUP BY kind ORDER BY cnt DESC";
3437 let mut lit_stmt = conn.prepare(lit_sql)?;
3438 let lit_rows = lit_stmt.query_map(
3439 rusqlite::named_params! {
3440 ":path": norm_path.as_deref(),
3441 ":dir_prefix": dir_prefix.as_deref(),
3442 },
3443 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3444 )?;
3445 for r in lit_rows {
3446 categories.push(r?);
3447 }
3448
3449 Ok(categories)
3450}
3451
3452pub fn list_structural_fact_categories(
3454 conn: &Connection,
3455) -> Result<Vec<(String, usize)>, QueryError> {
3456 list_structural_fact_categories_scoped(conn, None)
3457}
3458
3459pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
3461 let has_table: bool = conn
3462 .query_row(
3463 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
3464 [],
3465 |_| Ok(true),
3466 )
3467 .unwrap_or(false);
3468 if !has_table {
3469 return Ok(Vec::new());
3470 }
3471
3472 let mut stmt = conn.prepare(
3473 "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
3474 FROM type_facts
3475 WHERE symbol_id = ?1",
3476 )?;
3477
3478 let rows = stmt.query_map(params![symbol_id], |row| {
3479 Ok(TypeFact {
3480 type_fact_id: row.get(0)?,
3481 symbol_id: row.get(1)?,
3482 language: row.get(2)?,
3483 resolved_type: row.get(3)?,
3484 generic_params: row.get(4)?,
3485 })
3486 })?;
3487
3488 let mut results = Vec::new();
3489 for r in rows {
3490 results.push(r?);
3491 }
3492 Ok(results)
3493}
3494
3495pub fn is_test_path(path: &str) -> bool {
3502 let p = path.replace('\\', "/");
3503 let cut = p.rfind('/').map_or(0, |i| i + 1);
3504 let directories = format!("/{}/", p[..cut].to_lowercase());
3505 let file_name = &p[cut..];
3506 let lower_name = file_name.to_lowercase();
3507 directories.contains("/test/")
3508 || directories.contains("/tests/")
3509 || directories.contains("/autotests/")
3510 || directories.contains("/__tests__/")
3511 || lower_name.starts_with("tst_")
3512 || (lower_name.starts_with("test_")
3513 && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
3514 || lower_name.contains("_test.")
3515 || lower_name.contains(".test.")
3516 || lower_name.contains(".spec.")
3517 || lower_name == "test.rs"
3518 || lower_name == "tests.rs"
3519 || file_name.ends_with("Tests.cs")
3520}
3521
3522pub(crate) fn test_path_predicate(alias: &str) -> String {
3531 let guard = format!(
3532 "(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%' OR lower({alias}.path) LIKE '%tst\\_%' ESCAPE '\\')"
3533 );
3534 let p = format!("replace({alias}.path, '\\', '/')");
3535 let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
3536 let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
3537 let lower_name = format!("lower({file_name})");
3538 let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
3539 let clauses = [
3540 like(&directories, "%/test/%"),
3541 like(&directories, "%/tests/%"),
3542 like(&directories, "%/autotests/%"),
3543 like(&directories, "%/\\_\\_tests\\_\\_/%"),
3544 like(&lower_name, "tst\\_%"),
3545 like(&lower_name, "test\\_%.py"),
3546 like(&lower_name, "test\\_%.rb"),
3547 like(&lower_name, "%\\_test.%"),
3548 like(&lower_name, "%.test.%"),
3549 like(&lower_name, "%.spec.%"),
3550 format!("{lower_name} = 'test.rs'"),
3551 format!("{lower_name} = 'tests.rs'"),
3552 format!("{file_name} GLOB '*Tests.cs'"),
3553 ]
3554 .join(" OR ");
3555 format!("({guard} AND ({clauses}))")
3556}
3557
3558pub fn compute_blast_radius_scoped(
3561 conn: &Connection,
3562 seed_symbols: &[&str],
3563 symbol_path_filter: Option<&str>,
3564 seed_paths: &[&str],
3565 max_depth: usize,
3566 limit: usize,
3567) -> Result<BlastRadiusResult, QueryError> {
3568 compute_blast_radius_scoped_with_ids(
3569 conn,
3570 seed_symbols,
3571 &[],
3572 symbol_path_filter,
3573 seed_paths,
3574 max_depth,
3575 limit,
3576 )
3577}
3578
3579pub fn compute_blast_radius_scoped_with_ids(
3581 conn: &Connection,
3582 seed_symbols: &[&str],
3583 seed_ids: &[&str],
3584 symbol_path_filter: Option<&str>,
3585 seed_paths: &[&str],
3586 max_depth: usize,
3587 limit: usize,
3588) -> Result<BlastRadiusResult, QueryError> {
3589 validate_result_limit(limit)?;
3590 let max_depth = max_depth.min(5);
3591 let mut resolved_seed_symbols = seed_symbols
3592 .iter()
3593 .map(|name| {
3594 get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
3595 let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
3596 QueryError::SymbolNotFound {
3597 name: (*name).to_string(),
3598 workspace,
3599 hint,
3600 }
3601 })
3602 })
3603 .collect::<Result<Vec<_>, _>>()?;
3604 for id in seed_ids {
3605 let symbol = get_symbol_by_id(conn, id)?.ok_or_else(|| {
3606 let workspace = workspace_name(conn);
3607 QueryError::SymbolNotFound {
3608 name: format!("symbol id {id}"),
3609 workspace,
3610 hint: "Run lookup_symbol or search_symbols again and select a current id."
3611 .to_string(),
3612 }
3613 })?;
3614 resolved_seed_symbols.push(symbol);
3615 }
3616 let mut seeds = Vec::new();
3617 let seed_type = if (!seed_symbols.is_empty() || !seed_ids.is_empty()) && !seed_paths.is_empty()
3618 {
3619 for s in seed_symbols {
3620 seeds.push(s.to_string());
3621 }
3622 for id in seed_ids {
3623 seeds.push(id.to_string());
3624 }
3625 for p in seed_paths {
3626 seeds.push(p.to_string());
3627 }
3628 "mixed".to_string()
3629 } else if !seed_symbols.is_empty() || !seed_ids.is_empty() {
3630 for s in seed_symbols {
3631 seeds.push(s.to_string());
3632 }
3633 for id in seed_ids {
3634 seeds.push(id.to_string());
3635 }
3636 "symbol".to_string()
3637 } else if !seed_paths.is_empty() {
3638 for p in seed_paths {
3639 seeds.push(p.to_string());
3640 }
3641 "file".to_string()
3642 } else {
3643 return Ok(BlastRadiusResult {
3644 seed_type: "none".to_string(),
3645 seeds: Vec::new(),
3646 likely_tests: Vec::new(),
3647 impacted_symbols: Vec::new(),
3648 likely_tests_truncated: false,
3649 impacted_symbols_truncated: false,
3650 traversal_ceiling_reached: false,
3651 test_file_ceiling_reached: false,
3652 });
3653 };
3654
3655 let mut where_clauses = Vec::new();
3656 let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
3657
3658 if !resolved_seed_symbols.is_empty() {
3659 let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3660 .map(|i| format!("?{}", i))
3661 .collect();
3662 where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3663 for symbol in &resolved_seed_symbols {
3664 params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3665 }
3666 }
3667
3668 if !seed_paths.is_empty() {
3669 let mut path_conds = Vec::new();
3670 for p in seed_paths.iter() {
3671 let raw = p
3672 .replace('\\', "/")
3673 .trim_start_matches("./")
3674 .trim_matches('/')
3675 .to_string();
3676 let exact_idx = params_vec.len() + 1;
3677 params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3678 let dir_pattern = format!("{}/%", escape_like(&raw));
3679 let like_idx = params_vec.len() + 1;
3680 params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3681 path_conds.push(format!(
3682 "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3683 ));
3684 }
3685 where_clauses.push(format!("({})", path_conds.join(" OR ")));
3686 }
3687
3688 let seed_condition = where_clauses.join(" OR ");
3689 let max_depth_idx = params_vec.len() + 1;
3690 params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3691
3692 let mut traversal_ceiling_reached = false;
3693 let mut test_file_ceiling_reached = false;
3694
3695 let has_relationships: bool = conn
3696 .query_row(
3697 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3698 [],
3699 |_| Ok(true),
3700 )
3701 .unwrap_or(false);
3702
3703 let has_pending: bool = conn
3704 .query_row(
3705 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3706 [],
3707 |_| Ok(true),
3708 )
3709 .unwrap_or(false);
3710
3711 let mut likely_tests = Vec::new();
3712 let mut impacted_symbols = Vec::new();
3713 let mut seen_test_keys = HashSet::new();
3714
3715 let mut recursive_branches = Vec::new();
3716
3717 if has_relationships {
3718 recursive_branches.push(format!(
3719 "SELECT r.from_symbol_id, iw.depth + 1
3720 FROM relationships r
3721 JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3722 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3723 WHERE iw.depth < ?{max_depth_idx}
3724 AND s_from.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})"
3725 ));
3726 }
3727
3728 if has_pending {
3729 let (parent_join, ns_condition) = if conn
3730 .query_row(
3731 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3732 [],
3733 |_| Ok(true),
3734 )
3735 .unwrap_or(false)
3736 {
3737 (
3738 "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3739 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3740 format!("AND {pred}", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
3741 )
3742 } else {
3743 ("", String::new())
3744 };
3745
3746 recursive_branches.push(format!(
3747 "SELECT p.from_symbol_id, iw.depth + 1
3748 FROM pending_relationships p
3749 JOIN symbols s_target ON p.target_terminal_name = s_target.name
3750 JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3751 {parent_join}
3752 WHERE iw.depth < ?{max_depth_idx}
3753 AND s_target.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3754 {ns_condition}"
3755 ));
3756 }
3757
3758 if !recursive_branches.is_empty() {
3759 let recursive_sql = recursive_branches.join("\n UNION \n");
3760 let not_documentation = not_documentation(conn, "s");
3761 let sql = format!(
3762 "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3763 SELECT symbol_id, 0
3764 FROM symbols
3765 WHERE ({seed_condition})
3766 AND kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3767
3768 UNION
3769
3770 {recursive_sql}
3771 )
3772 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
3773 FROM impact_walk iw
3774 CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3775 WHERE s.kind NOT IN ({LOW_SIGNAL_KINDS_SQL})
3776 AND {not_documentation}
3777 GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3778 HAVING MIN(iw.depth) > 0
3779 ORDER BY min_depth ASC, s.path ASC, s.name ASC
3780 LIMIT 201"
3781 );
3782
3783 let mut stmt = conn.prepare(&sql)?;
3784 let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3785 .iter()
3786 .map(|v| v as &dyn rusqlite::ToSql)
3787 .collect();
3788
3789 let rows = stmt.query_map(param_refs.as_slice(), |row| {
3790 Ok((
3791 row.get::<_, String>(0)?,
3792 row.get::<_, String>(1)?,
3793 row.get::<_, String>(2)?,
3794 row.get::<_, String>(3)?,
3795 row.get::<_, i64>(4)? as usize,
3796 row.get::<_, bool>(5)?,
3797 row.get::<_, bool>(6)?,
3798 row.get::<_, i64>(7)? as usize,
3799 ))
3800 })?;
3801
3802 for (index, r) in rows.enumerate() {
3803 if index == 200 {
3804 traversal_ceiling_reached = true;
3805 break;
3806 }
3807 let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3808 let path = raw_path.replace('\\', "/");
3809 let is_test_target = is_test || test_container || is_test_path(&path);
3810
3811 if is_test_target {
3812 let key = format!("{}:{}", path, line);
3813 if seen_test_keys.insert(key) {
3814 likely_tests.push(TestTarget {
3815 name,
3816 path,
3817 line,
3818 reason: format!("transitive caller [depth {depth}]"),
3819 });
3820 }
3821 } else {
3822 impacted_symbols.push(ImpactedSymbol {
3823 name,
3824 kind,
3825 path,
3826 line,
3827 depth,
3828 });
3829 }
3830 }
3831 }
3832
3833 let mut test_name_terms = Vec::new();
3834 let mut module_terms = Vec::new();
3835 for path in seed_paths.iter().copied().chain(
3836 resolved_seed_symbols
3837 .iter()
3838 .map(|symbol| symbol.path.as_str()),
3839 ) {
3840 let mut parts = path.rsplit(['/', '\\']);
3841 let file = parts.next().unwrap_or(path);
3842 if let Some(stem) = std::path::Path::new(file)
3843 .file_stem()
3844 .and_then(|s| s.to_str())
3845 && stem.len() >= 3
3846 && !test_name_terms.iter().any(|(name, _)| name == stem)
3847 {
3848 test_name_terms.push((stem.to_string(), "stem-matched test file"));
3849 }
3850 let module = parts.next();
3851 if parts.next() == Some("src")
3852 && let Some(module) = module
3853 && module.len() >= 3
3854 && !["test", "tests", "spec", "specs", "bin", "lib"]
3855 .iter()
3856 .any(|generic| module.eq_ignore_ascii_case(generic))
3857 && !test_name_terms.iter().any(|(name, _)| name == module)
3858 && !module_terms.iter().any(|name| name == module)
3859 {
3860 module_terms.push(module.to_string());
3861 }
3862 }
3863 test_name_terms.extend(
3864 module_terms
3865 .into_iter()
3866 .map(|module| (module, "module-matched test file")),
3867 );
3868
3869 let has_files: bool = conn
3870 .query_row(
3871 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3872 [],
3873 |_| Ok(true),
3874 )
3875 .unwrap_or(false);
3876
3877 if has_files {
3878 let doc_file = format!(
3879 "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3880 not_documentation(conn, "d")
3881 );
3882 let mut test_files_stmt = conn.prepare(&format!(
3883 "SELECT DISTINCT path FROM files
3884 WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3885 AND NOT {doc_file}
3886 ORDER BY path ASC
3887 LIMIT 11"
3888 ))?;
3889 let mut module_test_files_stmt = conn.prepare(&format!(
3890 "SELECT DISTINCT path FROM files
3891 WHERE (path LIKE '%test%' OR path LIKE '%spec%')
3892 AND (path LIKE ?1 ESCAPE '\\' OR path LIKE ?2 ESCAPE '\\')
3893 AND NOT {doc_file}
3894 ORDER BY path ASC
3895 LIMIT 201"
3896 ))?;
3897 for (term, reason) in test_name_terms {
3898 let module_match = reason == "module-matched test file";
3899 let escaped_term = escape_like(&term);
3900 let mut rows = if module_match {
3901 module_test_files_stmt.query(rusqlite::params![
3902 format!("%/{escaped_term}%"),
3903 format!("%/test\\_{escaped_term}%")
3904 ])?
3905 } else {
3906 test_files_stmt.query([format!("%{escaped_term}%")])?
3907 };
3908 let mut candidates = 0;
3909 let mut matches = 0;
3910 while let Some(row) = rows.next()? {
3911 candidates += 1;
3912 if candidates == 201 {
3913 test_file_ceiling_reached = true;
3914 break;
3915 }
3916 let p: String = row.get(0)?;
3917 if module_match {
3918 let basename = p
3919 .rsplit(['/', '\\'])
3920 .next()
3921 .unwrap_or_default()
3922 .to_lowercase();
3923 let term = term.to_lowercase();
3924 if !basename.starts_with(&term)
3925 && !basename.starts_with(&format!("test_{term}"))
3926 {
3927 continue;
3928 }
3929 }
3930 if matches == 10 {
3931 test_file_ceiling_reached = true;
3932 break;
3933 }
3934 matches += 1;
3935 let p = p.replace('\\', "/");
3936 let key = format!("{}:1", p);
3937 if seen_test_keys.insert(key) {
3938 likely_tests.push(TestTarget {
3939 name: p.clone(),
3940 path: p,
3941 line: 1,
3942 reason: reason.to_string(),
3943 });
3944 }
3945 }
3946 }
3947 }
3948
3949 let likely_tests_truncated = likely_tests.len() > limit;
3950 let impacted_symbols_truncated = impacted_symbols.len() > limit;
3951 if likely_tests.len() > limit {
3952 likely_tests.truncate(limit);
3953 }
3954 if impacted_symbols.len() > limit {
3955 impacted_symbols.truncate(limit);
3956 }
3957
3958 Ok(BlastRadiusResult {
3959 seed_type,
3960 seeds,
3961 likely_tests,
3962 impacted_symbols,
3963 likely_tests_truncated,
3964 impacted_symbols_truncated,
3965 traversal_ceiling_reached,
3966 test_file_ceiling_reached,
3967 })
3968}
3969
3970pub fn compute_blast_radius(
3972 conn: &Connection,
3973 seed_symbols: &[&str],
3974 seed_paths: &[&str],
3975 max_depth: usize,
3976 limit: usize,
3977) -> Result<BlastRadiusResult, QueryError> {
3978 compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3979}
3980
3981#[cfg(test)]
3982mod tests {
3983 #[test]
3984 fn result_limit_rejects_values_above_the_shared_ceiling() {
3985 assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3986 assert!(matches!(
3987 validate_result_limit(usize::MAX),
3988 Err(QueryError::InvalidResultLimit(usize::MAX))
3989 ));
3990 }
3991
3992 #[test]
3993 fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
3994 let conn = Connection::open_in_memory().unwrap();
3995
3996 assert!(matches!(
3997 find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
3998 Err(QueryError::InvalidResultLimit(usize::MAX))
3999 ));
4000 }
4001
4002 use super::*;
4003 use crate::db::{ensure_fts_index, open_read_write};
4004
4005 #[test]
4006 fn count_parse_diagnostics_counts_rows_for_one_file() {
4007 let dir = crate::safe_tempdir();
4008 let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
4009
4010 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
4011
4012 conn.execute_batch(
4013 "CREATE TABLE parse_diagnostics (
4014 diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
4015 );
4016 INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
4017 INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
4018 INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
4019 )
4020 .unwrap();
4021
4022 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
4023 assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
4024 assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
4025 }
4026
4027 #[test]
4028 fn test_sanitize_fts5_query() {
4029 let (and_q, or_q) = sanitize_fts5_query("parse tokens");
4030 assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
4031 assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
4032
4033 let (and_q, or_q) = sanitize_fts5_query(" Option<T> ");
4034 assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
4035 assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
4036
4037 let (and_q, or_q) = sanitize_fts5_query(" ");
4038 assert!(and_q.is_empty());
4039 assert!(or_q.is_empty());
4040 }
4041
4042 #[test]
4043 fn sanitize_splits_case_boundaries_and_drops_stop_words() {
4044 let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
4045 assert_eq!(
4046 and_q,
4047 "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
4048 );
4049 assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
4050
4051 let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
4052 assert_eq!(
4053 and_q,
4054 "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
4055 );
4056
4057 let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
4058 assert_eq!(
4059 and_q,
4060 "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
4061 );
4062 assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
4063
4064 let (and_q, _) = sanitize_fts5_query("validate_syntax");
4065 assert_eq!(
4066 and_q,
4067 "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
4068 );
4069
4070 let (and_q, _) = sanitize_fts5_query("isReady");
4071 assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
4072
4073 let (and_q, _) = sanitize_fts5_query("before");
4074 assert_eq!(and_q, "\"before\"*");
4075
4076 let (and_q, _) = sanitize_fts5_query("fooBar quux");
4077 assert_eq!(
4078 and_q,
4079 "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
4080 );
4081
4082 let (and_q, _) = sanitize_fts5_query("the for a");
4083 assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
4084 }
4085
4086 fn search_fixture(rows: &str) -> Connection {
4087 let conn = Connection::open_in_memory().unwrap();
4088 conn.execute_batch(&format!(
4089 "CREATE TABLE symbols (
4090 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
4091 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
4092 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
4093 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
4094 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
4095 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
4096 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
4097 );
4098 INSERT INTO symbols VALUES {rows};"
4099 ))
4100 .unwrap();
4101 ensure_fts_index(&conn).unwrap();
4102 conn
4103 }
4104
4105 fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
4106 format!(
4107 "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
4108 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
4109 )
4110 }
4111
4112 fn doc_row(id: &str, name: &str, doc: &str) -> String {
4113 format!(
4114 "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
4115 3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
4116 )
4117 }
4118
4119 fn search_names(conn: &Connection, query: &str) -> Vec<String> {
4120 fts_search_symbols_scoped(conn, query, None, None, false, 10)
4121 .unwrap()
4122 .into_iter()
4123 .map(|r| r.symbol.name)
4124 .collect()
4125 }
4126
4127 const TEST_PATH_CASES: &[(&str, bool)] = &[
4128 ("tests/foo.py", true),
4129 ("tests/x.py", true),
4130 ("tests/tools/test_web.py", true),
4131 ("src/tests/x.rs", true),
4132 ("__tests__/a.ts", true),
4133 ("a/__tests__/b.ts", true),
4134 ("test/x.java", true),
4135 ("src/test/Helper.java", true),
4136 ("src/test_utils.py", true),
4137 ("lib/test_helper.rb", true),
4138 ("test_config.py", true),
4139 ("pkg/test_data/x.json", false),
4140 ("src/test_detection.rs", false),
4141 ("autotests/tst_pagerow.qml", true),
4142 ("autotests/helper.qml", true),
4143 ("src/autotests/columnview.cpp", true),
4144 ("tst_foo.qml", true),
4145 ("src/tst_columnview.qml", true),
4146 ("autotests\\tst_bar.qml", true),
4147 ("autotests_helper/x.rs", false),
4148 ("src/autotest.rs", false),
4149 ("src/tstamp.rs", false),
4150 ("src/tst.rs", false),
4151 ("crates/julie-index/src/analysis/test_quality.rs", false),
4152 ("x/foo_test.go", true),
4153 ("x/foo.test.ts", true),
4154 ("x/foo.spec.js", true),
4155 ("src/lib_test.rs", true),
4156 ("src/test.rs", true),
4157 ("tests.rs", true),
4158 ("src/tests.rs", true),
4159 ("Foo.Tests.cs", true),
4160 ("x/FooTests.cs", true),
4161 ("src/FooTests.cs", true),
4162 ("src/Foo.Tests.cs", true),
4163 ("tests/Foo.cs", true),
4164 ("x/parser.spec.ts", true),
4165 ("test_x.py", true),
4166 ("test.rs", true),
4167 ("tests\\x.py", true),
4168 ("src/protocol.spec.v1/parser.rs", false),
4169 ("pkg/test_support/runtime.py", false),
4170 ("src/Contests.cs", false),
4171 ("spec/x.rb", false),
4172 ("crates/x/src/impact/likely_tests.rs", false),
4173 ("x/foo_tests.rs", false),
4174 ("src/latest.rs", false),
4175 ("x/latest.go", false),
4176 ("x/manifest.rs", false),
4177 ("src/attest.rs", false),
4178 ("contest/x.py", false),
4179 ("src/testing.rs", false),
4180 ("src/main.rs", false),
4181 ("pkg/service.go", false),
4182 ];
4183
4184 #[test]
4185 fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
4186 let conn = Connection::open_in_memory().unwrap();
4187 let sql = format!(
4188 "SELECT {} FROM (SELECT :path AS path) s",
4189 test_path_predicate("s")
4190 );
4191 let mut stmt = conn.prepare(&sql).unwrap();
4192 for (path, expected) in TEST_PATH_CASES {
4193 assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
4194 let from_sql: bool = stmt
4195 .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
4196 .unwrap();
4197 assert_eq!(from_sql, *expected, "sql mirror: {path}");
4198 }
4199 }
4200
4201 #[test]
4202 fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
4203 let conn = search_fixture(
4204 &[
4205 code_row(
4206 "a",
4207 "src/parser.rs",
4208 "rust",
4209 "parse_sidecar",
4210 "Parse a sidecar.",
4211 ),
4212 code_row(
4213 "b",
4214 "src/tests/helpers.py",
4215 "python",
4216 "parse_sidecar_fixture",
4217 "Parse a sidecar.",
4218 ),
4219 ]
4220 .join(", "),
4221 );
4222
4223 let default_search: Vec<String> =
4224 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
4225 .unwrap()
4226 .into_iter()
4227 .map(|r| r.symbol.name)
4228 .collect();
4229 assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
4230
4231 let with_tests: Vec<String> =
4232 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
4233 .unwrap()
4234 .into_iter()
4235 .map(|r| r.symbol.name)
4236 .collect();
4237 assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
4238
4239 let default_lookup: Vec<String> =
4240 search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
4241 .unwrap()
4242 .into_iter()
4243 .map(|s| s.name)
4244 .collect();
4245 assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
4246
4247 let lookup_with_tests: Vec<String> =
4248 search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
4249 .unwrap()
4250 .into_iter()
4251 .map(|s| s.name)
4252 .collect();
4253 assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
4254 }
4255
4256 #[test]
4257 fn count_file_symbols_prefers_the_exact_case_path_like_the_loader() {
4258 let conn = search_fixture(
4259 &[
4260 code_row("a", "src/Foo.rs", "rust", "one", ""),
4261 code_row("b", "src/foo.rs", "rust", "two", ""),
4262 code_row("c", "src/foo.rs", "rust", "three", ""),
4263 ]
4264 .join(", "),
4265 );
4266 for path in ["src/Foo.rs", "src/foo.rs", "src/FOO.rs", "src\\foo.rs"] {
4267 assert_eq!(
4268 count_file_symbols(&conn, path),
4269 load_file_symbols(&conn, path).unwrap().len(),
4270 "{path}"
4271 );
4272 }
4273 assert_eq!(count_file_symbols(&conn, "src/Foo.rs"), 1);
4274 assert_eq!(count_file_symbols(&conn, "src/FOO.rs"), 3);
4275 }
4276
4277 #[test]
4278 fn lookup_statement_is_not_planned_as_a_multi_index_or() {
4279 let conn = search_fixture(&code_row("a", "src/lib.rs", "rust", "needle", ""));
4280 conn.execute_batch(
4281 "CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
4282 CREATE INDEX idx_symbols_test_container ON symbols(test_container);
4283 CREATE INDEX idx_symbols_is_test ON symbols(is_test);",
4284 )
4285 .unwrap();
4286 let sql = format!(
4287 "EXPLAIN QUERY PLAN {}",
4288 search_symbols_sql(false, false, 20)
4289 );
4290 let plan: Vec<String> = conn
4291 .prepare(&sql)
4292 .unwrap()
4293 .query_map(
4294 rusqlite::named_params! {
4295 ":query": "needle",
4296 ":pattern": "%needle%",
4297 ":kind": None::<&str>,
4298 ":path": None::<&str>,
4299 ":path_like": None::<&str>,
4300 },
4301 |row| row.get::<_, String>(3),
4302 )
4303 .unwrap()
4304 .collect::<Result<_, _>>()
4305 .unwrap();
4306 assert!(
4307 !plan.iter().any(|step| step.contains("MULTI-INDEX OR")),
4308 "{plan:?}"
4309 );
4310 }
4311
4312 #[test]
4313 fn qualified_lookup_in_a_test_file_returns_the_named_row() {
4314 let conn = search_fixture(
4315 &[
4316 "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
4317 1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
4318 "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
4319 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
4320 ]
4321 .join(", "),
4322 );
4323
4324 assert_eq!(
4325 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
4326 .unwrap()
4327 .len(),
4328 1
4329 );
4330 assert_eq!(
4331 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
4332 .unwrap()
4333 .len(),
4334 1
4335 );
4336 assert!(
4337 search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
4338 .unwrap()
4339 .is_empty()
4340 );
4341 }
4342
4343 #[test]
4344 fn concept_query_prefers_partial_code_match_over_full_doc_match() {
4345 let conn = search_fixture(
4346 &[
4347 doc_row(
4348 "d1",
4349 "Safety guarantees",
4350 "Pre-flight syntax validation runs before the edit touches disk",
4351 ),
4352 doc_row(
4353 "d2",
4354 "Audit",
4355 "The syntax validation before an edit is the invariant",
4356 ),
4357 code_row(
4358 "c1",
4359 "src/syntax.rs",
4360 "rust",
4361 "validate_syntax",
4362 "Validate the syntax of a file",
4363 ),
4364 code_row(
4365 "c2",
4366 "src/edit.rs",
4367 "rust",
4368 "replace_symbol_body",
4369 "Atomic edit with validation",
4370 ),
4371 ]
4372 .join(","),
4373 );
4374
4375 let names = search_names(&conn, "syntax validation before edit");
4376
4377 assert_eq!(names[0], "validate_syntax");
4378 assert!(names.contains(&"replace_symbol_body".to_string()));
4379 assert!(names.contains(&"Safety guarantees".to_string()));
4380 }
4381
4382 #[test]
4383 fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
4384 let conn = search_fixture(
4385 &[
4386 code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
4387 code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
4388 ]
4389 .join(","),
4390 );
4391
4392 let mut camel = search_names(&conn, "ValidateSyntax");
4393 camel.sort();
4394 assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
4395 let mut words = search_names(&conn, "validate syntax");
4396 words.sort();
4397 assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
4398 }
4399
4400 #[test]
4401 fn stop_word_prefixed_camel_case_symbol_is_still_found() {
4402 let conn = search_fixture(
4403 &[
4404 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4405 code_row("c2", "src/hooks.rs", "rust", "before", ""),
4406 code_row(
4407 "c3",
4408 "src/x.rs",
4409 "rust",
4410 "fooBar",
4411 "has fooBar but not the other word",
4412 ),
4413 ]
4414 .join(","),
4415 );
4416
4417 assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
4418 assert_eq!(search_names(&conn, "before"), vec!["before"]);
4419 }
4420
4421 #[test]
4422 fn related_tests_use_the_name_as_typed_without_splitting() {
4423 let conn = search_fixture(
4424 &[
4425 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4426 "('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(),
4427 "('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(),
4428 ]
4429 .join(","),
4430 );
4431 let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
4432
4433 let names: Vec<String> = find_related_tests(&conn, &target, 5)
4434 .unwrap()
4435 .into_iter()
4436 .map(|t| t.name)
4437 .collect();
4438
4439 assert_eq!(names, vec!["isReady_reports_true"]);
4440 }
4441
4442 #[test]
4443 fn related_tests_admit_recognized_unflagged_paths_without_low_signal_rows() {
4444 let conn = search_fixture(
4445 "('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'),
4446 ('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'),
4447 ('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'),
4448 ('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'),
4449 ('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'),
4450 ('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'),
4451 ('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'),
4452 ('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')",
4453 );
4454 conn.execute_batch(
4455 "CREATE TABLE relationships (from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT, start_line INTEGER, start_column INTEGER);
4456 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);
4457 CREATE TABLE type_facts (type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT);
4458 INSERT INTO relationships VALUES ('direct', 'target', 'calls', 'tests/direct.rs', 1, 0);
4459 INSERT INTO pending_relationships VALUES ('pending', 'calculate', 'calls', 'autotests/tst_pending.qml', 1, 0, NULL, '[]', 'calculate');",
4460 )
4461 .unwrap();
4462 let target = get_symbol_by_name(&conn, "calculate", None)
4463 .unwrap()
4464 .unwrap();
4465
4466 let names: Vec<String> = find_related_tests(&conn, &target, 5)
4467 .unwrap()
4468 .into_iter()
4469 .map(|test| test.name)
4470 .collect();
4471
4472 assert_eq!(
4473 names,
4474 vec![
4475 "direct_case",
4476 "pending_case",
4477 "calculate_named_case",
4478 "fts_case"
4479 ]
4480 );
4481 }
4482
4483 #[test]
4484 fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
4485 let conn = search_fixture(
4486 &[
4487 code_row(
4488 "c1",
4489 "src/queries.rs",
4490 "rust",
4491 "fts_search_symbols_scoped",
4492 "search symbols scoped with fts",
4493 ),
4494 code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
4495 ]
4496 .join(","),
4497 );
4498
4499 assert_eq!(
4500 search_names(&conn, "search_symbols_scoped")[0],
4501 "search_symbols_scoped"
4502 );
4503 }
4504
4505 fn sidecar_fixture() -> Connection {
4506 search_fixture(
4507 &[
4508 code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
4509 code_row(
4510 "c2",
4511 "src/sidecar.rs",
4512 "rust",
4513 "parse_sidecar_file",
4514 "parse the sha256 sidecar file",
4515 ),
4516 ]
4517 .join(","),
4518 )
4519 }
4520
4521 fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
4522 candidates
4523 .iter()
4524 .find(|c| c.result.symbol.name == name)
4525 .unwrap_or_else(|| panic!("{name} is not a candidate"))
4526 }
4527
4528 #[test]
4529 fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
4530 let conn = sidecar_fixture();
4531
4532 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4533
4534 let target = candidate(&candidates, "parseSha256Sidecar");
4535 assert!(target.name_match);
4536 assert!(!target.word_match);
4537 assert!(!target.exact_name);
4538 assert!(target.name_terms.contains(&"sha256".to_string()));
4539 }
4540
4541 #[test]
4542 fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
4543 let conn = search_fixture(
4544 &[
4545 code_row(
4546 "c1",
4547 "examples/demo.rs",
4548 "rust",
4549 "demo",
4550 "restore offline state",
4551 ),
4552 code_row(
4553 "c2",
4554 "src/replay.rs",
4555 "rust",
4556 "replay",
4557 "restore offline records",
4558 ),
4559 ]
4560 .join(","),
4561 );
4562
4563 let candidates =
4564 collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
4565 .unwrap();
4566
4567 assert!(candidate(&candidates, "demo").word_match);
4568 assert!(candidate(&candidates, "replay").word_match);
4569 assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
4570 }
4571
4572 #[test]
4573 fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
4574 let mut rows: Vec<String> = (1..=170)
4575 .map(|i| {
4576 code_row(
4577 &format!("h{i:03}"),
4578 "src/sidecar.rs",
4579 "rust",
4580 &format!("sidecar_helper_{i:03}"),
4581 "parse sidecar file",
4582 )
4583 })
4584 .collect();
4585 rows.push(code_row(
4586 "c1",
4587 "src/sidecar.rs",
4588 "rust",
4589 "parseSha256Sidecar",
4590 "",
4591 ));
4592 let conn = search_fixture(&rows.join(","));
4593
4594 let candidates = collect_search_candidates(
4595 &conn,
4596 "parse the sha256 sidecar file",
4597 None,
4598 None,
4599 false,
4600 40,
4601 )
4602 .unwrap();
4603
4604 assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
4605 assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
4606 }
4607
4608 #[test]
4609 fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
4610 let mut rows: Vec<String> = (1..=20)
4611 .map(|i| {
4612 code_row(
4613 &format!("a{i:02}"),
4614 "src/a.rs",
4615 "rust",
4616 &format!("both_{i:02}"),
4617 "restore offline",
4618 )
4619 })
4620 .collect();
4621 rows.extend((1..=50).map(|i| {
4622 code_row(
4623 &format!("p{i:02}"),
4624 "src/p.rs",
4625 "rust",
4626 &format!("partial_{i:02}"),
4627 "restore records",
4628 )
4629 }));
4630 let conn = search_fixture(&rows.join(","));
4631
4632 let candidates =
4633 collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
4634
4635 let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
4636 assert_eq!(word_rows.len(), 40);
4637 assert_eq!(
4638 word_rows
4639 .iter()
4640 .filter(|c| c.result.symbol.name.starts_with("both_"))
4641 .count(),
4642 20
4643 );
4644 }
4645
4646 #[test]
4647 fn exact_name_is_admitted_regardless_of_case() {
4648 let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
4649
4650 let candidates =
4651 collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
4652 assert!(candidate(&candidates, "xyzzy_q").exact_name);
4653
4654 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4655 let candidates =
4656 collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
4657 assert!(candidate(&candidates, "xyzzy_q").exact_name);
4658 }
4659
4660 #[test]
4661 fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
4662 let conn = search_fixture(&code_row(
4663 "c1",
4664 "src/say.js",
4665 "javascript",
4666 "say \"hi\"",
4667 "",
4668 ));
4669
4670 let candidates =
4671 collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
4672
4673 let target = candidate(&candidates, "say \"hi\"");
4674 assert!(target.exact_name && target.name_match);
4675 }
4676
4677 #[test]
4678 fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
4679 let conn = search_fixture(
4680 &[
4681 code_row("c1", "src/a.rs", "rust", "sidecar", ""),
4682 code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
4683 ]
4684 .join(","),
4685 );
4686
4687 let candidates =
4688 collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
4689
4690 assert_eq!(candidates.len(), 2);
4691 let target = candidate(&candidates, "sidecar");
4692 assert!(target.exact_name && target.word_match && target.name_match);
4693 assert!(target.bm25.is_some());
4694 let helper = candidate(&candidates, "sidecar_helper");
4695 assert!(!helper.exact_name && helper.word_match && helper.name_match);
4696 }
4697
4698 #[test]
4699 fn an_index_without_the_trigram_table_returns_word_rows_only() {
4700 let conn = sidecar_fixture();
4701 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4702
4703 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4704
4705 let names: Vec<&str> = candidates
4706 .iter()
4707 .map(|c| c.result.symbol.name.as_str())
4708 .collect();
4709 assert_eq!(names, vec!["parse_sidecar_file"]);
4710 assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
4711 assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
4712 }
4713
4714 #[test]
4715 fn words_under_three_characters_skip_the_name_branch() {
4716 let conn = search_fixture(
4717 &[
4718 code_row("c1", "src/a.rs", "rust", "ab", ""),
4719 code_row("c2", "src/b.rs", "rust", "cab", ""),
4720 ]
4721 .join(","),
4722 );
4723
4724 let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
4725
4726 assert!(candidates.iter().all(|c| !c.name_match));
4727 assert!(candidate(&candidates, "ab").exact_name);
4728 }
4729
4730 #[test]
4731 fn trigram_terms_include_the_identifier_parts_of_each_word() {
4732 assert_eq!(
4733 trigram_name_terms("collapse_name"),
4734 vec!["collapse_name", "collapse", "name"]
4735 );
4736 assert_eq!(
4737 trigram_name_terms("parse the sha256 sidecar"),
4738 vec!["parse", "sha256", "sha", "256", "sidecar"]
4739 );
4740 assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
4741 assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
4742 assert!(trigram_name_terms("ab").is_empty());
4743 }
4744
4745 #[test]
4746 fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
4747 let conn = search_fixture(
4748 &[
4749 code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
4750 code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
4751 ]
4752 .join(","),
4753 );
4754
4755 let candidates =
4756 collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
4757
4758 let target = candidate(&candidates, "CollapseName");
4759 assert!(target.name_match);
4760 assert_eq!(target.name_terms, vec!["collapse", "name"]);
4761 assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
4762 }
4763
4764 fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
4765 Candidate {
4766 result: SymbolSearchResult {
4767 symbol: Symbol {
4768 symbol_id: format!("{path}:{name}"),
4769 file_id: "f".into(),
4770 path: path.into(),
4771 language: "rust".into(),
4772 name: name.into(),
4773 kind: kind.into(),
4774 signature: None,
4775 doc_comment: None,
4776 visibility: None,
4777 parent_symbol_id: None,
4778 start_line: 1,
4779 start_column: 0,
4780 end_line: 1,
4781 end_column: 0,
4782 start_byte: 0,
4783 end_byte: 0,
4784 body_start_line: None,
4785 body_start_column: None,
4786 body_end_line: None,
4787 body_end_column: None,
4788 body_start_byte: None,
4789 body_end_byte: None,
4790 body_hash: None,
4791 semantic_group: None,
4792 is_test: false,
4793 test_container: false,
4794 },
4795 score: 0.0,
4796 snippet: None,
4797 explain: None,
4798 },
4799 bm25: None,
4800 exact_name: false,
4801 word_match: false,
4802 name_match: false,
4803 name_terms: Vec::new(),
4804 documentation: false,
4805 }
4806 }
4807
4808 fn function(name: &str) -> Candidate {
4809 plain_candidate(name, "function", "src/lib.rs")
4810 }
4811
4812 fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
4813 rerank_with(candidates, query, false, None)
4814 }
4815
4816 fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
4817 ranked(candidates, query)
4818 .into_iter()
4819 .map(|(r, _)| r.symbol.name)
4820 .collect()
4821 }
4822
4823 fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
4824 let mut candidate = function(name);
4825 candidate.result.symbol.signature = signature.map(str::to_string);
4826 candidate.result.symbol.doc_comment = Some(doc.into());
4827 candidate
4828 }
4829
4830 fn strip_ansi_case() -> Vec<Candidate> {
4831 vec![
4832 documented("strip_ansi", None, "Remove ANSI escape sequences"),
4833 documented(
4834 "_strip_code_fences",
4835 Some("def _strip_code_fences(text: str)"),
4836 "The first fenced code block's body, or the stripped text",
4837 ),
4838 ]
4839 }
4840
4841 #[test]
4842 fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4843 assert_eq!(
4844 rerank_words("parse the sha256 sidecar file"),
4845 vec!["parse", "sha", "256", "sidecar", "file"]
4846 );
4847 assert_eq!(
4848 rerank_words("parse_sha256_sidecar"),
4849 vec!["parse", "sha", "256", "sidecar"]
4850 );
4851 assert_eq!(
4852 rerank_words("ParseHTTPResponse"),
4853 vec!["parse", "http", "response"]
4854 );
4855 assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4856 assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4857 }
4858
4859 #[test]
4860 fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4861 for word in ["was", "whether", "another"] {
4862 assert!(is_stop_word(word), "{word} must be a stop word");
4863 }
4864 for word in ["down", "into", "run"] {
4865 assert!(!is_stop_word(word), "{word} must stay a content word");
4866 }
4867 assert_eq!(rerank_words("what was the file"), vec!["file"]);
4868 }
4869
4870 #[test]
4871 fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4872 let mut private = function("_create_skill");
4873 private.bm25 = Some(-9.0);
4874 let mut public = function("create_skill");
4875 public.bm25 = Some(-1.0);
4876
4877 let rows = ranked(vec![private, public], "create skill");
4878
4879 assert_eq!(rows[0].0.score, rows[1].0.score);
4880 assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4881 assert_eq!(
4882 rows.iter()
4883 .map(|(r, _)| r.symbol.name.as_str())
4884 .collect::<Vec<_>>(),
4885 vec!["create_skill", "_create_skill"]
4886 );
4887 }
4888
4889 #[test]
4890 fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4891 let rows = ranked(
4892 vec![
4893 plain_candidate("Glob", "constant", "src/glob.rs"),
4894 function("matches_glob_pattern"),
4895 ],
4896 "glob",
4897 );
4898
4899 assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4900 assert_eq!(rows[1].1.name_tier, "whole");
4901 assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4902 }
4903
4904 #[test]
4905 fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4906 let rows = ranked(
4907 vec![
4908 function("validate_everything"),
4909 function("validate_syntax_now"),
4910 function("validate_syntax"),
4911 function("unrelated"),
4912 ],
4913 "validate syntax",
4914 );
4915 let tiers: Vec<(&str, &str, f64)> = rows
4916 .iter()
4917 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4918 .collect();
4919
4920 assert_eq!(
4921 tiers,
4922 vec![
4923 ("validate_syntax", "whole", W_NAME_WHOLE),
4924 ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4925 ("validate_everything", "partial", 0.0),
4926 ("unrelated", "none", 0.0),
4927 ]
4928 );
4929 assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4930 assert_eq!(
4931 rows[1].0.score,
4932 W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4933 );
4934 assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4935 assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4936 }
4937
4938 #[test]
4939 fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4940 assert_eq!(
4941 ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4942 vec!["strip_ansi", "_strip_code_fences"]
4943 );
4944 }
4945
4946 #[test]
4947 fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4948 let candidates = vec![
4949 function("execute_julie_extract"),
4950 documented("slice_bytes", None, "cut a byte range"),
4951 ];
4952
4953 let rows = ranked(candidates, "cut");
4954 let bonuses: Vec<(&str, &str, f64)> = rows
4955 .iter()
4956 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4957 .collect();
4958
4959 assert_eq!(
4960 bonuses,
4961 vec![
4962 ("execute_julie_extract", "partial", 0.0),
4963 ("slice_bytes", "none", 0.0),
4964 ]
4965 );
4966 assert_eq!(rows[0].0.score, rows[1].0.score);
4967 }
4968
4969 #[test]
4970 fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4971 let candidates = vec![
4972 function("validate_everything"),
4973 function("validate_syntax_now"),
4974 function("validate_syntax"),
4975 function("unrelated"),
4976 ];
4977
4978 assert_eq!(
4979 ranked_names(candidates, "validate syntax"),
4980 vec![
4981 "validate_syntax",
4982 "validate_syntax_now",
4983 "validate_everything",
4984 "unrelated"
4985 ]
4986 );
4987 }
4988
4989 #[test]
4990 fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4991 let mut rows: Vec<String> = (0..10)
4992 .map(|i| {
4993 code_row(
4994 &format!("s{i}"),
4995 &format!("src/f{i}.rs"),
4996 "rust",
4997 &format!("search_{i}"),
4998 "searches the index",
4999 )
5000 })
5001 .collect();
5002 rows.push(code_row(
5003 "rare",
5004 "src/rare.rs",
5005 "rust",
5006 "sanitize_input",
5007 "sanitize the input",
5008 ));
5009 let conn = search_fixture(&rows.join(", "));
5010 let terms = vec!["sanitize".to_string(), "search".to_string()];
5011
5012 let weights = idf_weights(&conn, &terms);
5013 assert!(weights[0] > weights[1]);
5014 }
5015
5016 #[test]
5017 fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
5018 let conn = search_fixture(&code_row(
5019 "n",
5020 "src/news.rs",
5021 "rust",
5022 "fetch_news",
5023 "fetch the news feed",
5024 ));
5025 let terms = vec!["news".to_string(), "unseen".to_string()];
5026
5027 let weights = idf_weights(&conn, &terms);
5028
5029 assert!(weights[0] < weights[1]);
5030 }
5031
5032 #[test]
5033 fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
5034 let crediting_field = |padding: usize| {
5035 let mut row = function("handler");
5036 row.result.symbol.signature = Some(format!(
5037 "fn handler({}sidecar: u8)",
5038 "a: u8, ".repeat(padding)
5039 ));
5040 ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
5041 };
5042
5043 assert_eq!(crediting_field(4), "signature");
5044 assert_eq!(crediting_field(80), "none");
5045 }
5046
5047 #[test]
5048 fn explain_terms_name_the_crediting_field_of_every_query_term() {
5049 let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
5050 let terms: Vec<(&str, &str, f64)> = rows[0]
5051 .1
5052 .terms
5053 .iter()
5054 .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
5055 .collect();
5056
5057 assert_eq!(rows[0].0.symbol.name, "strip_ansi");
5058 assert_eq!(
5059 terms,
5060 vec![
5061 ("strip", "name", 3.0),
5062 ("ansi", "name", 3.0),
5063 ("escape", "doc", TEXT_CREDIT),
5064 ("codes", "none", 0.0),
5065 ]
5066 );
5067 }
5068
5069 #[test]
5070 fn name_coverage_accepts_token_runs_substrings_and_stems() {
5071 let strengths = |name: &str, query: &str| {
5072 let stemmer = Stemmer::create(Algorithm::English);
5073 let words: Vec<QueryWord> = rerank_words(query)
5074 .into_iter()
5075 .map(|word| QueryWord {
5076 stem: stemmer.stem(&word).into_owned(),
5077 word,
5078 })
5079 .collect();
5080 name_hits(name, &words, &stemmer)
5081 };
5082
5083 assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
5084 assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
5085 assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
5086 assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
5087 assert_eq!(strengths("is_ok", "ok"), vec![3]);
5088 assert_eq!(strengths("isReady", "is"), vec![3]);
5089 assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
5090 assert_eq!(
5091 strengths("parseSha256Sidecar", "sidecar checksum"),
5092 vec![3, 0]
5093 );
5094 assert_eq!(
5095 strengths("parseSha256Sidecar", "checksum digest"),
5096 vec![0, 0]
5097 );
5098 }
5099
5100 #[test]
5101 fn a_rarer_term_moves_the_score_more_than_a_common_one() {
5102 let weights = [4.0, 1.0];
5103 let rows = rerank_with(
5104 vec![function("rare_helper"), function("common_helper")],
5105 "rare common",
5106 false,
5107 Some(&weights),
5108 );
5109
5110 assert_eq!(
5111 rows[0].1.word_weights,
5112 vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
5113 );
5114 assert_eq!(rows[0].0.symbol.name, "rare_helper");
5115 assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
5116 assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
5117 }
5118
5119 #[test]
5120 fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
5121 let rows = ranked(
5122 vec![
5123 function("render_mode"),
5124 plain_candidate("retry_count", "constant", "src/scan.rs"),
5125 ],
5126 "how many times a failed download is tried again retry limit",
5127 );
5128
5129 assert_eq!(rows[0].0.symbol.name, "retry_count");
5130 assert_eq!(rows[0].1.name_tier, "partial");
5131 assert!(rows[0].0.score > rows[1].0.score);
5132 assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
5133 }
5134
5135 #[test]
5136 fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
5137 let mut row = function("load");
5138 row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
5139 row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
5140 let (result, explain) = ranked(vec![row], "config settings").remove(0);
5141
5142 assert_eq!(
5143 explain.terms,
5144 vec![
5145 ("config".into(), "signature".into(), TEXT_CREDIT),
5146 ("settings".into(), "none".into(), 0.0),
5147 ]
5148 );
5149 assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
5150 }
5151
5152 #[test]
5153 fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
5154 let crediting_field =
5155 |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
5156 let doc_field = |doc: &str, query: &str| {
5157 let mut row = function("row");
5158 row.result.symbol.doc_comment = Some(doc.into());
5159 crediting_field(row, query)
5160 };
5161 let sig_field = |signature: &str, query: &str| {
5162 let mut row = function("row");
5163 row.result.symbol.signature = Some(signature.into());
5164 crediting_field(row, query)
5165 };
5166
5167 assert_eq!(doc_field("The system runs.", "stemming"), "none");
5168 assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
5169 assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
5170 assert_eq!(doc_field("An important port.", "porter"), "none");
5171 assert_eq!(
5172 sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
5173 "signature"
5174 );
5175 assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
5176 assert_eq!(sig_field("fn okay()", "ok"), "none");
5177 assert_eq!(
5178 sig_field("fn parseSha256Sidecar(text)", "sidecar"),
5179 "signature"
5180 );
5181 }
5182
5183 #[test]
5184 fn text_tokens_split_like_query_words_then_identifiers() {
5185 fn two_pass(text: &str) -> Vec<&str> {
5186 query_words(text)
5187 .into_iter()
5188 .flat_map(split_identifier)
5189 .collect()
5190 }
5191 fn one_pass(text: &str) -> Vec<&str> {
5192 let mut out = Vec::new();
5193 text_tokens_into(text, &mut out);
5194 out
5195 }
5196 let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
5197 let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
5198
5199 assert_eq!(one_pass(ascii), two_pass(ascii));
5200 assert_eq!(
5201 one_pass(ascii),
5202 vec![
5203 "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
5204 "sha", "256", "sum",
5205 ]
5206 );
5207 assert_eq!(one_pass(unicode), two_pass(unicode));
5208 assert!(one_pass("").is_empty());
5209 assert!(one_pass("_ __ ...").is_empty());
5210 }
5211
5212 #[test]
5213 fn a_doc_credits_a_term_by_its_stem() {
5214 let mut row = function("check");
5215 row.result.symbol.doc_comment = Some("Validates the input.".into());
5216 let explain = ranked(vec![row], "validation").remove(0).1;
5217
5218 assert_eq!(
5219 explain.terms,
5220 vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
5221 );
5222 }
5223
5224 #[test]
5225 fn kind_prior_orders_definitions_over_members_over_imports() {
5226 let rows = ranked(
5227 vec![
5228 plain_candidate("Scan", "import", "src/a.rs"),
5229 plain_candidate("Scan", "enum_member", "src/b.rs"),
5230 plain_candidate("Scan", "function", "src/c.rs"),
5231 ],
5232 "scan",
5233 );
5234 let order: Vec<(&str, f64)> = rows
5235 .iter()
5236 .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
5237 .collect();
5238
5239 assert_eq!(
5240 order,
5241 vec![
5242 ("src/c.rs", W_KIND_DEFINITION),
5243 ("src/b.rs", W_KIND_MEMBER),
5244 ("src/a.rs", W_KIND_IMPORT),
5245 ]
5246 );
5247 }
5248
5249 #[test]
5250 fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
5251 let names = ranked_names(
5252 vec![
5253 function("RenderMode"),
5254 plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
5255 ],
5256 "retry download limit timeout",
5257 );
5258
5259 assert_eq!(names[0], "MaxRetryCount");
5260 }
5261
5262 #[test]
5263 fn path_role_demotes_role_directories_unless_the_query_names_them() {
5264 let rows = |query: &str| {
5265 ranked(
5266 vec![
5267 plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
5268 plain_candidate("verify_checksum", "function", "src/archive.rs"),
5269 ],
5270 query,
5271 )
5272 };
5273
5274 let plain = rows("verify checksum");
5275 assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
5276 assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
5277
5278 let named = rows("launcher script verify checksum");
5279 assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
5280
5281 let only_launcher = rows("launcher verify checksum");
5282 assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
5283 assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
5284
5285 let windows = ranked(
5286 vec![plain_candidate(
5287 "verifyChecksum",
5288 "function",
5289 "scripts\\launcher.ts",
5290 )],
5291 "verify checksum",
5292 );
5293 assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
5294 }
5295
5296 #[test]
5297 fn documentation_rows_sort_after_every_code_row() {
5298 let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
5299 heading.documentation = true;
5300 heading.result.symbol.language = "markdown".into();
5301 heading.result.symbol.signature = Some("Verify checksum".into());
5302 heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
5303 let rows = ranked(
5304 vec![
5305 heading,
5306 plain_candidate("unrelated", "variable", "src/a.rs"),
5307 ],
5308 "verify checksum",
5309 );
5310
5311 assert_eq!(rows[0].0.symbol.name, "unrelated");
5312 assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
5313 assert_eq!(rows[1].1.name_tier, "whole");
5314 assert!(rows[1].0.score < 0.0);
5315 }
5316
5317 #[test]
5318 fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
5319 let rows = |query: &str, include_tests: bool| {
5320 let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
5321 test_row.result.symbol.is_test = true;
5322 let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
5323 rerank_with(vec![plain_row, test_row], query, include_tests, None)
5324 };
5325
5326 let boosted = rows("payment flow tests", true);
5327 assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
5328 assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
5329 assert_eq!(boosted[1].1.test_intent, 0.0);
5330
5331 assert!(
5332 rows("payment flow tests", false)
5333 .iter()
5334 .all(|(_, e)| e.test_intent == 0.0)
5335 );
5336 assert!(
5337 rows("payment flow", true)
5338 .iter()
5339 .all(|(_, e)| e.test_intent == 0.0)
5340 );
5341 }
5342
5343 #[test]
5344 fn ties_break_by_bm25_then_name_length_then_path() {
5345 let mut word_row = plain_candidate("payment", "function", "src/z.rs");
5346 word_row.word_match = true;
5347 word_row.bm25 = Some(-4.0);
5348 let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
5349 weaker_word_row.word_match = true;
5350 weaker_word_row.bm25 = Some(-2.0);
5351 let mut name_only = plain_candidate("payment", "function", "src/b.rs");
5352 name_only.name_match = true;
5353 let rows = ranked(
5354 vec![
5355 plain_candidate("payment", "function", "src/y.rs"),
5356 name_only,
5357 weaker_word_row,
5358 word_row,
5359 ],
5360 "payment",
5361 );
5362 let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
5363
5364 assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
5365
5366 let by_length = ranked_names(
5367 vec![
5368 function("payment_gateway_client"),
5369 function("payment_gateway"),
5370 ],
5371 "gateway",
5372 );
5373 assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
5374 }
5375
5376 #[test]
5377 fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
5378 let mut token = function("csr");
5379 token.bm25 = Some(-1.0);
5380 let mut substring = function("action_csrf_token");
5381 substring.bm25 = Some(-5.0);
5382
5383 let rows = ranked(vec![substring, token], "csr adjacency");
5384
5385 assert!(rows[0].0.score > rows[1].0.score);
5386 assert_eq!(rows[0].0.symbol.name, "csr");
5387 }
5388
5389 #[test]
5390 fn an_acronym_token_outranks_a_name_that_only_contains_it() {
5391 let mut token = function("http_client");
5392 token.bm25 = Some(-1.0);
5393 let mut substring = function("shttpd_config");
5394 substring.bm25 = Some(-5.0);
5395
5396 let rows = ranked(vec![substring, token], "http");
5397
5398 assert!(rows[0].0.score > rows[1].0.score);
5399 assert_eq!(rows[0].0.symbol.name, "http_client");
5400 }
5401
5402 #[test]
5403 fn explain_reports_the_sum_of_the_name_strengths() {
5404 let rows = ranked(vec![function("action_csrf_token")], "csr token");
5405
5406 assert_eq!(rows[0].1.name_strength, 4);
5407 }
5408
5409 #[test]
5410 fn snippets_follow_the_admitting_branch() {
5411 let mut word_row = function("parse_sidecar_file");
5412 word_row.word_match = true;
5413 word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
5414 let mut name_row = function("parseSha256Sidecar");
5415 name_row.name_match = true;
5416 name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
5417 let mut exact_row = function("sha256");
5418 exact_row.exact_name = true;
5419 let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
5420 let snippets: Vec<(&str, &str)> = rows
5421 .iter()
5422 .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
5423 .collect();
5424
5425 assert_eq!(
5426 snippets,
5427 vec![
5428 ("sha256", "sha256"),
5429 ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
5430 ("parse_sidecar_file", "parse the [sha256] sidecar file"),
5431 ]
5432 );
5433 assert_eq!(rows[1].1.branches, vec!["name"]);
5434 assert_eq!(rows[0].1.branches, vec!["exact"]);
5435 }
5436
5437 #[test]
5438 fn explain_is_attached_only_when_requested() {
5439 let conn = sidecar_fixture();
5440 let query = "sha256";
5441
5442 let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
5443 assert!(silent.iter().all(|r| r.explain.is_none()));
5444 assert!(silent[0].score > 0.0);
5445 assert_eq!(
5446 serde_json::to_value(&silent[0]).unwrap().get("explain"),
5447 None
5448 );
5449
5450 let explained =
5451 fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
5452 let by_name = |name: &str| {
5453 explained
5454 .iter()
5455 .find(|r| r.symbol.name == name)
5456 .and_then(|r| r.explain.as_ref())
5457 .unwrap()
5458 };
5459 let name_only = by_name("parseSha256Sidecar");
5460 assert_eq!(name_only.candidates, 2);
5461 assert_eq!(name_only.branches, vec!["name"]);
5462 assert_eq!(name_only.bm25, None);
5463 let word_row = by_name("parse_sidecar_file");
5464 assert!(word_row.bm25.unwrap() < 0.0);
5465 assert_eq!(word_row.candidates, 2);
5466 assert!(
5467 serde_json::to_value(&explained[0])
5468 .unwrap()
5469 .get("explain")
5470 .is_some()
5471 );
5472 }
5473
5474 #[test]
5475 fn search_symbols_treats_like_wildcards_as_literals() {
5476 let dir = crate::safe_tempdir();
5477 let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
5478 let conn = open_read_write(&db_path).unwrap();
5479 conn.execute_batch(
5480 "CREATE TABLE symbols (
5481 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5482 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5483 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5484 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5485 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5486 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5487 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5488 );
5489 INSERT INTO symbols VALUES (
5490 's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
5491 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5492 );
5493 INSERT INTO symbols VALUES (
5494 'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
5495 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5496 );
5497 INSERT INTO symbols VALUES (
5498 'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
5499 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5500 );
5501 CREATE TABLE files (
5502 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
5503 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
5504 );
5505 INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
5506 INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
5507 );",
5508 )
5509 .unwrap();
5510
5511 assert_eq!(
5512 search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
5513 "literal%name"
5514 );
5515 assert_eq!(
5516 search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
5517 "literal_name"
5518 );
5519 assert_eq!(
5520 load_scoped_files(&conn, Some("src/literal_path"))
5521 .unwrap()
5522 .len(),
5523 1
5524 );
5525 }
5526
5527 #[test]
5528 fn find_references_for_symbol_limits_callees_by_symbol_id() {
5529 let dir = crate::safe_tempdir();
5530 let db_path = dir.path().join("find_references_for_symbol.db");
5531 let conn = open_read_write(&db_path).unwrap();
5532 conn.execute_batch(
5533 "CREATE TABLE symbols (
5534 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5535 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5536 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5537 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5538 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5539 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5540 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5541 );
5542 CREATE TABLE relationships (
5543 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5544 start_line INTEGER, start_column INTEGER
5545 );
5546 CREATE TABLE pending_relationships (
5547 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5548 start_line INTEGER, start_column INTEGER
5549 );
5550 INSERT INTO symbols VALUES
5551 ('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),
5552 ('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),
5553 ('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),
5554 ('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);
5555 INSERT INTO relationships VALUES
5556 ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
5557 ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
5558 )
5559 .unwrap();
5560
5561 let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
5562 assert_eq!(references.len(), 1);
5563 assert_eq!(references[0].to_symbol_name, "wanted_dep");
5564 }
5565
5566 #[test]
5567 fn test_fts_search_symbols_and_porter_stemming() {
5568 let dir = crate::safe_tempdir();
5569 let db_path = dir.path().join("fts_search_symbols.db");
5570 let conn = open_read_write(&db_path).unwrap();
5571
5572 conn.execute_batch(
5573 "CREATE TABLE symbols (
5574 symbol_id TEXT PRIMARY KEY,
5575 file_id TEXT,
5576 path TEXT,
5577 language TEXT,
5578 name TEXT,
5579 kind TEXT,
5580 signature TEXT,
5581 doc_comment TEXT,
5582 visibility TEXT,
5583 parent_symbol_id TEXT,
5584 start_line INTEGER,
5585 start_column INTEGER,
5586 end_line INTEGER,
5587 end_column INTEGER,
5588 start_byte INTEGER,
5589 end_byte INTEGER,
5590 body_start_line INTEGER,
5591 body_start_column INTEGER,
5592 body_end_line INTEGER,
5593 body_end_column INTEGER,
5594 body_start_byte INTEGER,
5595 body_end_byte INTEGER,
5596 body_hash TEXT,
5597 semantic_group TEXT,
5598 is_test INTEGER,
5599 test_container INTEGER
5600 );
5601 INSERT INTO symbols VALUES (
5602 's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
5603 'pub trait PaymentGateway', 'Core payment provider interface for transactions',
5604 'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
5605 );
5606 INSERT INTO symbols VALUES (
5607 's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
5608 'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
5609 'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
5610 );
5611 INSERT INTO symbols VALUES (
5612 's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
5613 'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
5614 'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
5615 );
5616 INSERT INTO symbols VALUES (
5617 's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
5618 'fn test_payment_flow()', 'Tests payment charge workflow',
5619 NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
5620 );",
5621 )
5622 .unwrap();
5623
5624 ensure_fts_index(&conn).unwrap();
5625
5626 let results =
5628 fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
5629 assert_eq!(results.len(), 1);
5630 assert_eq!(results[0].symbol.name, "parse_tokens");
5631 assert!(results[0].snippet.is_some());
5632
5633 let results =
5635 fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
5636 assert_eq!(results.len(), 1);
5637 assert_eq!(results[0].symbol.name, "PaymentGateway");
5638
5639 let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
5641 assert_eq!(results.len(), 2);
5642 assert!(results.iter().all(|r| !r.symbol.is_test));
5643
5644 let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
5646 assert_eq!(results.len(), 3);
5647
5648 let results =
5650 fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
5651 assert_eq!(results.len(), 1);
5652 assert_eq!(results[0].symbol.name, "StripeClient");
5653 }
5654
5655 #[test]
5656 fn find_related_tests_returns_each_test_once_under_the_limit() {
5657 let dir = crate::safe_tempdir();
5658 let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
5659 conn.execute_batch(
5660 "CREATE TABLE symbols (
5661 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5662 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5663 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5664 start_column INTEGER, end_line INTEGER, end_column INTEGER,
5665 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5666 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5667 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5668 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5669 );
5670 CREATE TABLE relationships (
5671 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5672 start_line INTEGER, start_column INTEGER
5673 );
5674 CREATE TABLE pending_relationships (
5675 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5676 start_line INTEGER, start_column INTEGER,
5677 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5678 );
5679 CREATE TABLE type_facts (
5680 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5681 );
5682 INSERT INTO symbols VALUES
5683 ('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),
5684 ('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),
5685 ('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);
5686 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
5687 ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
5688 ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
5689 ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
5690 ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
5691 ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
5692 ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
5693 )
5694 .unwrap();
5695 let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
5696
5697 let tests = find_related_tests(&conn, &target, 5).unwrap();
5698
5699 let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
5700 names.sort();
5701 assert_eq!(names, vec!["first_case", "second_case"]);
5702 }
5703
5704 #[test]
5705 fn documentation_rows_rank_after_code_in_search() {
5706 let dir = crate::safe_tempdir();
5707 let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
5708 conn.execute_batch(
5709 "CREATE TABLE symbols (
5710 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5711 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5712 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5713 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5714 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5715 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5716 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5717 );
5718 INSERT INTO symbols VALUES
5719 ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
5720 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
5721 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
5722 ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
5723 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
5724 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
5725 )
5726 .unwrap();
5727 ensure_fts_index(&conn).unwrap();
5728
5729 let results =
5730 fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
5731 .unwrap();
5732
5733 assert_eq!(results.len(), 2);
5734 assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
5735 assert_eq!(results[1].symbol.name, "Reconcile offline edits");
5736 }
5737
5738 #[test]
5739 fn test_queries_nocase_and_path_normalization() {
5740 let conn = Connection::open_in_memory().unwrap();
5741 conn.execute_batch(
5742 "CREATE TABLE files (
5743 file_id TEXT PRIMARY KEY,
5744 path TEXT NOT NULL,
5745 language TEXT,
5746 content_hash TEXT,
5747 content_bytes INTEGER,
5748 line_count INTEGER,
5749 indexed_at INTEGER
5750 );
5751 CREATE TABLE symbols (
5752 symbol_id TEXT PRIMARY KEY,
5753 file_id TEXT,
5754 path TEXT NOT NULL,
5755 language TEXT,
5756 name TEXT,
5757 kind TEXT,
5758 signature TEXT,
5759 doc_comment TEXT,
5760 visibility TEXT,
5761 parent_symbol_id TEXT,
5762 start_line INTEGER,
5763 start_column INTEGER,
5764 end_line INTEGER,
5765 end_column INTEGER,
5766 start_byte INTEGER,
5767 end_byte INTEGER,
5768 body_start_line INTEGER,
5769 body_start_column INTEGER,
5770 body_end_line INTEGER,
5771 body_end_column INTEGER,
5772 body_start_byte INTEGER,
5773 body_end_byte INTEGER,
5774 body_hash TEXT,
5775 semantic_group TEXT,
5776 is_test INTEGER,
5777 test_container INTEGER
5778 );
5779 -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
5780 INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
5781 INSERT INTO symbols VALUES (
5782 's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
5783 'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5784 2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
5785 );",
5786 )
5787 .unwrap();
5788
5789 let file = get_file(&conn, "SRC/PAYMENT.RS")
5791 .unwrap()
5792 .expect("File should be found");
5793 assert_eq!(
5794 file.path, "src/Payment.rs",
5795 "Path should be normalized to forward slashes"
5796 );
5797
5798 let file2 = get_file(&conn, "src/payment.rs")
5799 .unwrap()
5800 .expect("File should be found");
5801 assert_eq!(file2.path, "src/Payment.rs");
5802
5803 let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
5805 assert_eq!(syms.len(), 1);
5806 assert_eq!(
5807 syms[0].path, "src/Payment.rs",
5808 "Symbol path should be normalized to forward slashes"
5809 );
5810
5811 let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
5813 .unwrap()
5814 .expect("Symbol should be found with case-insensitive path filter");
5815 assert_eq!(sym.path, "src/Payment.rs");
5816 }
5817
5818 #[test]
5819 fn test_exact_case_prioritized_over_nocase() {
5820 let conn = Connection::open_in_memory().unwrap();
5821 conn.execute_batch(
5822 "CREATE TABLE files (
5823 file_id TEXT PRIMARY KEY,
5824 path TEXT NOT NULL,
5825 language TEXT,
5826 content_hash TEXT,
5827 content_bytes INTEGER,
5828 line_count INTEGER,
5829 indexed_at TEXT
5830 );
5831 CREATE TABLE symbols (
5832 symbol_id TEXT PRIMARY KEY,
5833 file_id TEXT,
5834 path TEXT NOT NULL,
5835 language TEXT,
5836 name TEXT NOT NULL,
5837 kind TEXT NOT NULL,
5838 signature TEXT,
5839 doc_comment TEXT,
5840 visibility TEXT,
5841 parent_symbol_id TEXT,
5842 start_line INTEGER,
5843 start_column INTEGER,
5844 end_line INTEGER,
5845 end_column INTEGER,
5846 start_byte INTEGER,
5847 end_byte INTEGER,
5848 body_start_line INTEGER,
5849 body_start_column INTEGER,
5850 body_end_line INTEGER,
5851 body_end_column INTEGER,
5852 body_start_byte INTEGER,
5853 body_end_byte INTEGER,
5854 body_hash TEXT,
5855 semantic_group TEXT,
5856 is_test INTEGER,
5857 test_container INTEGER
5858 );
5859 INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5860 INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5861 INSERT INTO symbols VALUES (
5862 's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5863 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5864 2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5865 );
5866 INSERT INTO symbols VALUES (
5867 's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5868 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5869 2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5870 );",
5871 )
5872 .unwrap();
5873
5874 let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5876 assert_eq!(f_lower.path, "src/payment.rs");
5877 assert_eq!(f_lower.file_id, "f2");
5878
5879 let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5880 assert_eq!(f_upper.path, "src/Payment.rs");
5881 assert_eq!(f_upper.file_id, "f1");
5882
5883 let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5884 assert_eq!(syms_lower.len(), 1);
5885 assert_eq!(syms_lower[0].file_id, "f2");
5886
5887 let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5888 assert_eq!(syms_upper.len(), 1);
5889 assert_eq!(syms_upper[0].file_id, "f1");
5890 }
5891
5892 #[test]
5893 fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5894 let dir = crate::safe_tempdir();
5895 let db_path = dir.path().join("conservative_resolution.db");
5896 let conn = open_read_write(&db_path).unwrap();
5897
5898 conn.execute_batch(
5899 "CREATE TABLE symbols (
5900 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5901 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5902 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5903 start_column INTEGER, end_line INTEGER, end_column INTEGER,
5904 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5905 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5906 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5907 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5908 );
5909 CREATE TABLE relationships (
5910 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5911 start_line INTEGER, start_column INTEGER
5912 );
5913 CREATE TABLE pending_relationships (
5914 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5915 start_line INTEGER, start_column INTEGER,
5916 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5917 );
5918 CREATE TABLE type_facts (
5919 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5920 );
5921 -- Workspace struct Workspace and method Workspace::new
5922 INSERT INTO symbols VALUES
5923 ('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),
5924 ('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),
5925 ('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);
5926
5927 -- my_func calls Vec::new() (external namespace 'Vec')
5928 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
5929 ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5930 )
5931 .unwrap();
5932
5933 let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5935 assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5936
5937 let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5938 assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5939
5940 let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5942 assert!(
5943 callers.is_empty(),
5944 "Expected 0 callers for Workspace::new, got: {:?}",
5945 callers
5946 );
5947
5948 let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5950 assert!(
5951 !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5952 "my_func should not be impacted before calling Workspace::new: {:?}",
5953 blast.impacted_symbols
5954 );
5955
5956 conn.execute(
5958 "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')",
5959 [],
5960 )
5961 .unwrap();
5962
5963 let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5964 assert_eq!(
5965 sigs2.len(),
5966 1,
5967 "Expected 1 signature for Workspace::new, got: {:?}",
5968 sigs2
5969 );
5970 assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5971
5972 let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5974 assert!(
5975 blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5976 "my_func should be impacted after calling Workspace::new: {:?}",
5977 blast2.impacted_symbols
5978 );
5979
5980 conn.execute(
5982 "INSERT INTO symbols VALUES
5983 ('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);",
5984 [],
5985 )
5986 .unwrap();
5987 conn.execute(
5988 "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')",
5989 [],
5990 )
5991 .unwrap();
5992
5993 let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
5995 assert!(
5996 sigs_other.is_empty(),
5997 "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
5998 sigs_other
5999 );
6000
6001 conn.execute(
6003 "INSERT INTO symbols VALUES
6004 ('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);",
6005 [],
6006 )
6007 .unwrap();
6008 conn.execute(
6009 "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')",
6010 [],
6011 )
6012 .unwrap();
6013
6014 let sigs_sibling =
6015 find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
6016 assert_eq!(
6017 sigs_sibling.len(),
6018 1,
6019 "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
6020 sigs_sibling
6021 );
6022
6023 let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
6025 assert!(
6026 ext_sigs.iter().any(|s| s.contains("Vec")),
6027 "include_external: true should include external Vec::new: {:?}",
6028 ext_sigs
6029 );
6030 }
6031
6032 #[test]
6033 fn test_find_structural_facts_and_literals_scoped() {
6034 let dir = crate::safe_tempdir();
6035 let db_path = dir.path().join("facts_test.db");
6036 let conn = open_read_write(&db_path).unwrap();
6037 conn.execute_batch(
6038 "CREATE TABLE symbols (
6039 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
6040 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
6041 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
6042 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
6043 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
6044 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
6045 semantic_group TEXT, is_test INTEGER, test_container INTEGER
6046 );
6047 CREATE TABLE structural_facts (
6048 structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
6049 pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
6050 start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
6051 );
6052 CREATE TABLE literals (
6053 literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
6054 kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
6055 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
6056 start_byte INTEGER, end_byte INTEGER
6057 );
6058 INSERT INTO structural_facts VALUES
6059 ('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\"}'),
6060 ('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\"}'),
6061 ('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\"}'),
6062 ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
6063 ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
6064 ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
6065 ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
6066 INSERT INTO literals VALUES
6067 ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
6068 ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
6069 ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
6070 ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
6071 )
6072 .unwrap();
6073
6074 let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
6076 assert_eq!(facts_config.len(), 2);
6077 assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
6078 assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
6079 assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
6080 assert_eq!(
6081 facts_config[1].key.as_deref(),
6082 Some("mcp_servers.code-kb.command")
6083 );
6084 let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
6085 assert_eq!(lits_config.len(), 1);
6086 assert_eq!(lits_config[0].kind, "toml_key");
6087
6088 let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
6090 assert_eq!(facts_route.len(), 1);
6091 assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
6092 assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
6093 let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
6094 assert_eq!(facts_routes.len(), 1);
6095 let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
6096 assert_eq!(lits_route.len(), 1);
6097 assert_eq!(lits_route[0].kind, "http_route");
6098
6099 for q in &["query", "queries", "sql"] {
6101 let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
6102 assert_eq!(facts.len(), 2, "Failed for {}", q);
6103 assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
6104 let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
6105 assert_eq!(lits.len(), 1, "Failed for {}", q);
6106 assert_eq!(lits[0].kind, "sql_query");
6107 }
6108
6109 for m in &["model", "models"] {
6111 let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
6112 assert_eq!(facts.len(), 1, "Failed for {}", m);
6113 assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
6114 let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
6115 assert_eq!(lits.len(), 1, "Failed for {}", m);
6116 assert_eq!(lits[0].kind, "model_table");
6117 }
6118
6119 let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
6121 assert_eq!(facts_custom.len(), 1);
6122 assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
6123 assert_eq!(facts_custom[0].key, None);
6124
6125 let facts_exact =
6127 find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
6128 assert_eq!(facts_exact.len(), 1);
6129 let facts_miss =
6130 find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
6131 assert_eq!(facts_miss.len(), 0);
6132
6133 let facts_dir =
6135 find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
6136 assert_eq!(facts_dir.len(), 1);
6137 let facts_dir_miss =
6138 find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
6139 assert_eq!(facts_dir_miss.len(), 0);
6140
6141 let f_del = find_structural_facts(&conn, "config", 10).unwrap();
6143 assert_eq!(f_del.len(), 2);
6144 let l_del = find_literals(&conn, "config", 10).unwrap();
6145 assert_eq!(l_del.len(), 1);
6146 }
6147
6148 fn local_variable_fixture() -> Connection {
6149 let conn = Connection::open_in_memory().unwrap();
6150 conn.execute_batch(
6151 "CREATE TABLE symbols (
6152 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
6153 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
6154 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
6155 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
6156 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
6157 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
6158 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
6159 );
6160 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
6161 parent_symbol_id, start_line, start_column, end_line, end_column,
6162 start_byte, end_byte, is_test, test_container)
6163 VALUES
6164 ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
6165 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
6166 ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6167 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
6168 ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
6169 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
6170 ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6171 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
6172 ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6173 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
6174 ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
6175 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
6176 ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
6177 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
6178 )
6179 .unwrap();
6180 conn
6181 }
6182
6183 fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
6184 let mut stmt = conn
6185 .prepare(
6186 "SELECT s.symbol_id FROM symbols_fts f
6187 JOIN symbols s ON s.rowid = f.rowid
6188 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
6189 )
6190 .unwrap();
6191 let mut ids = stmt
6192 .query_map(params![query], |row| row.get::<_, String>(0))
6193 .unwrap()
6194 .collect::<Result<Vec<_>, _>>()
6195 .unwrap();
6196 ids.sort();
6197 ids
6198 }
6199
6200 #[test]
6201 fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
6202 let conn = local_variable_fixture();
6203 conn.execute_batch(
6204 "CREATE VIRTUAL TABLE symbols_fts USING fts5(
6205 name, signature, doc_comment,
6206 content='symbols', content_rowid='rowid', tokenize='porter unicode61'
6207 );
6208 INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
6209 SELECT rowid, name, signature, doc_comment FROM symbols;",
6210 )
6211 .unwrap();
6212
6213 ensure_fts_index(&conn).unwrap();
6214
6215 assert_eq!(
6216 matched_symbol_ids(&conn, "sqlite"),
6217 vec!["field", "func", "global", "pool"]
6218 );
6219 }
6220
6221 #[test]
6222 fn lookup_excludes_locals_and_parameters() {
6223 let conn = local_variable_fixture();
6224
6225 let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
6226 .unwrap()
6227 .into_iter()
6228 .map(|s| s.symbol_id)
6229 .collect();
6230
6231 assert!(!ids.contains(&"local".to_string()));
6232 assert!(!ids.contains(&"nested".to_string()));
6233 assert!(ids.contains(&"field".to_string()));
6234 assert!(ids.contains(&"global".to_string()));
6235 }
6236
6237 #[test]
6238 fn search_excludes_locals_and_parameters() {
6239 let conn = local_variable_fixture();
6240 ensure_fts_index(&conn).unwrap();
6241
6242 let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
6243 .unwrap()
6244 .into_iter()
6245 .map(|r| r.symbol.symbol_id)
6246 .collect();
6247
6248 assert!(!ids.contains(&"local".to_string()));
6249 assert!(ids.contains(&"func".to_string()));
6250 }
6251
6252 #[test]
6253 fn variable_kind_search_keeps_full_text_matching() {
6254 let conn = local_variable_fixture();
6255 ensure_fts_index(&conn).unwrap();
6256
6257 let ids: Vec<String> = fts_search_symbols_scoped(
6258 &conn,
6259 "sqlite connection",
6260 Some("variable"),
6261 None,
6262 false,
6263 10,
6264 )
6265 .unwrap()
6266 .into_iter()
6267 .map(|r| r.symbol.symbol_id)
6268 .collect();
6269
6270 assert!(ids.contains(&"global".to_string()));
6271 assert!(ids.contains(&"field".to_string()));
6272 }
6273
6274 #[test]
6275 fn qualified_lookup_returns_the_named_local_variable() {
6276 let conn = local_variable_fixture();
6277
6278 let ids: Vec<String> =
6279 search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
6280 .unwrap()
6281 .into_iter()
6282 .map(|s| s.symbol_id)
6283 .collect();
6284
6285 assert_eq!(ids, vec!["local".to_string()]);
6286 }
6287
6288 #[test]
6289 fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
6290 let conn = Connection::open_in_memory().unwrap();
6291 conn.execute_batch(
6292 "CREATE TABLE symbols (
6293 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
6294 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
6295 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
6296 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
6297 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
6298 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
6299 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
6300 );
6301 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
6302 parent_symbol_id, start_line, start_column, end_line, end_column,
6303 start_byte, end_byte, is_test, test_container)
6304 VALUES
6305 ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
6306 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
6307 ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
6308 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
6309 ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
6310 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
6311 )
6312 .unwrap();
6313 ensure_fts_index(&conn).unwrap();
6314
6315 let rows =
6316 fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
6317 .unwrap();
6318
6319 assert_eq!(rows.len(), 1);
6320 assert_eq!(rows[0].symbol.symbol_id, "local");
6321 let explain = rows[0].explain.as_ref().unwrap();
6322 assert_eq!(explain.name_tier, "whole");
6323 assert_eq!(explain.branches, vec!["exact", "name"]);
6324 assert_eq!(explain.candidates, 2);
6325 }
6326
6327 #[test]
6328 fn variable_kind_filter_returns_locals_and_parameters() {
6329 let conn = local_variable_fixture();
6330 ensure_fts_index(&conn).unwrap();
6331
6332 let lookup_ids: Vec<String> =
6333 search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6334 .unwrap()
6335 .into_iter()
6336 .map(|s| s.symbol_id)
6337 .collect();
6338 assert!(lookup_ids.contains(&"local".to_string()));
6339 assert!(lookup_ids.contains(&"nested".to_string()));
6340
6341 let search_ids: Vec<String> =
6342 fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6343 .unwrap()
6344 .into_iter()
6345 .map(|r| r.symbol.symbol_id)
6346 .collect();
6347 assert!(search_ids.contains(&"local".to_string()));
6348 }
6349}