1use rusqlite::{Connection, Row, ToSql, params};
2use rust_stemmers::{Algorithm, Stemmer};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5
6use crate::db::local_variable_predicate;
7use crate::models::{
8 BlastRadiusResult, FileFact, ImpactedSymbol, LiteralFact, ReferenceSite, SearchExplain,
9 StructuralFact, Symbol, SymbolSearchResult, TestTarget, TypeFact,
10};
11
12#[derive(Debug, Error)]
13pub enum QueryError {
14 #[error("Database query error: {0}")]
15 Sqlite(#[from] rusqlite::Error),
16 #[error("Symbol '{name}' not found in {workspace}. {hint}")]
17 SymbolNotFound {
18 name: String,
19 workspace: String,
20 hint: String,
21 },
22 #[error(
23 "Ambiguous symbol '{0}': found {1} matching candidates. Specify file_path or qualified name to disambiguate:\n{2}"
24 )]
25 AmbiguousSymbol(String, usize, String),
26 #[error("Invalid direction '{0}': must be 'callers' or 'callees'")]
27 InvalidDirection(String),
28 #[error("Result limit must be between 0 and {MAX_RESULT_LIMIT}, got {0}")]
29 InvalidResultLimit(usize),
30}
31
32pub const MAX_RESULT_LIMIT: usize = 200;
33
34pub fn validate_result_limit(limit: usize) -> Result<(), QueryError> {
35 if limit > MAX_RESULT_LIMIT {
36 return Err(QueryError::InvalidResultLimit(limit));
37 }
38 Ok(())
39}
40
41fn map_symbol(row: &Row) -> rusqlite::Result<Symbol> {
42 Ok(Symbol {
43 symbol_id: row.get("symbol_id")?,
44 file_id: row.get("file_id")?,
45 path: row.get::<_, String>("path")?.replace('\\', "/"),
46 language: row.get("language")?,
47 name: row.get("name")?,
48 kind: row.get("kind")?,
49 signature: row.get("signature")?,
50 doc_comment: row.get("doc_comment")?,
51 visibility: row.get("visibility")?,
52 parent_symbol_id: row.get("parent_symbol_id")?,
53 start_line: row.get::<_, i64>("start_line")? as usize,
54 start_column: row.get::<_, i64>("start_column")? as usize,
55 end_line: row.get::<_, i64>("end_line")? as usize,
56 end_column: row.get::<_, i64>("end_column")? as usize,
57 start_byte: row.get::<_, i64>("start_byte")? as usize,
58 end_byte: row.get::<_, i64>("end_byte")? as usize,
59 body_start_line: row
60 .get::<_, Option<i64>>("body_start_line")?
61 .map(|v| v as usize),
62 body_start_column: row
63 .get::<_, Option<i64>>("body_start_column")?
64 .map(|v| v as usize),
65 body_end_line: row
66 .get::<_, Option<i64>>("body_end_line")?
67 .map(|v| v as usize),
68 body_end_column: row
69 .get::<_, Option<i64>>("body_end_column")?
70 .map(|v| v as usize),
71 body_start_byte: row
72 .get::<_, Option<i64>>("body_start_byte")?
73 .map(|v| v as usize),
74 body_end_byte: row
75 .get::<_, Option<i64>>("body_end_byte")?
76 .map(|v| v as usize),
77 body_hash: row.get("body_hash")?,
78 semantic_group: row.get("semantic_group")?,
79 is_test: row.get::<_, i64>("is_test")? != 0,
80 test_container: row.get::<_, i64>("test_container")? != 0,
81 })
82}
83
84pub(crate) fn escape_like(value: &str) -> String {
85 value
86 .replace('\\', "\\\\")
87 .replace('%', "\\%")
88 .replace('_', "\\_")
89}
90
91pub fn load_scoped_files(
93 conn: &Connection,
94 path_filter: Option<&str>,
95) -> Result<Vec<FileFact>, QueryError> {
96 let norm = path_filter
97 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
98 .filter(|p| !p.is_empty());
99 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
100 let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
101 let prefix_bs = norm_bs
102 .as_ref()
103 .map(|path| format!("{}\\\\%", escape_like(path)));
104
105 let sql = "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
106 FROM files
107 WHERE (:path IS NULL
108 OR path = :path COLLATE NOCASE
109 OR path = :path_bs COLLATE NOCASE
110 OR path LIKE :path_prefix ESCAPE '\\'
111 OR path LIKE :path_prefix_bs ESCAPE '\\')
112 ORDER BY (:path IS NOT NULL AND (path = :path OR path = :path_bs)) DESC, path ASC";
113
114 let mut stmt = conn.prepare(sql)?;
115 let files = stmt
116 .query_map(
117 rusqlite::named_params! {
118 ":path": norm.as_deref(),
119 ":path_bs": norm_bs.as_deref(),
120 ":path_prefix": prefix.as_deref(),
121 ":path_prefix_bs": prefix_bs.as_deref(),
122 },
123 |row| {
124 Ok(FileFact {
125 file_id: row.get(0)?,
126 path: row.get::<_, String>(1)?.replace('\\', "/"),
127 language: row.get(2)?,
128 content_hash: row.get(3)?,
129 content_bytes: row.get(4)?,
130 line_count: row.get(5)?,
131 indexed_at: row.get(6)?,
132 })
133 },
134 )?
135 .collect::<Result<Vec<_>, _>>()?;
136
137 Ok(files)
138}
139
140pub fn load_scoped_outline_symbols(
143 conn: &Connection,
144 path_filter: Option<&str>,
145 depth: usize,
146 limit_per_file: usize,
147) -> Result<HashMap<String, Vec<Symbol>>, QueryError> {
148 let norm = path_filter
149 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
150 .filter(|p| !p.is_empty());
151 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
152 let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
153 let prefix_bs = norm_bs
154 .as_ref()
155 .map(|path| format!("{}\\\\%", escape_like(path)));
156
157 let max_slashes = match &norm {
158 None => {
159 if depth > 0 {
160 (depth - 1) as i64
161 } else {
162 0
163 }
164 }
165 Some(f) => {
166 let filter_slashes = f.chars().filter(|&c| c == '/').count();
167 (filter_slashes + depth) as i64
168 }
169 };
170
171 let sql = "
172 WITH bounded_files AS (
173 SELECT path FROM files
174 WHERE (:path IS NULL
175 OR path = :path COLLATE NOCASE
176 OR path = :path_bs COLLATE NOCASE
177 OR path LIKE :path_prefix ESCAPE '\\'
178 OR path LIKE :path_prefix_bs ESCAPE '\\')
179 ORDER BY path ASC
180 LIMIT 1000
181 ),
182 ranked AS (
183 SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
184 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
185 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
186 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
187 s.is_test, s.test_container,
188 ROW_NUMBER() OVER (PARTITION BY s.path ORDER BY s.start_line ASC) as rn
189 FROM symbols s
190 JOIN bounded_files bf ON (s.path = bf.path COLLATE NOCASE OR replace(s.path, '\\', '/') = replace(bf.path, '\\', '/') COLLATE NOCASE)
191 WHERE (length(s.path) - length(replace(replace(s.path, '/', ''), '\\', '')) <= :max_slashes)
192 AND s.kind IN ('function', 'method', 'struct', 'enum', 'trait', 'class', 'interface', 'type')
193 AND s.parent_symbol_id IS NULL
194 )
195 SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
196 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
197 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
198 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
199 is_test, test_container
200 FROM ranked
201 WHERE rn <= :limit
202 ORDER BY path ASC, start_line ASC
203 ";
204
205 let mut stmt = conn.prepare(sql)?;
206 let mut rows = stmt.query(rusqlite::named_params! {
207 ":path": norm.as_deref(),
208 ":path_bs": norm_bs.as_deref(),
209 ":path_prefix": prefix.as_deref(),
210 ":path_prefix_bs": prefix_bs.as_deref(),
211 ":max_slashes": max_slashes,
212 ":limit": limit_per_file as i64,
213 })?;
214
215 let mut symbols_by_file: HashMap<String, Vec<Symbol>> = HashMap::new();
216 while let Some(row) = rows.next()? {
217 let sym = map_symbol(row)?;
218 symbols_by_file
219 .entry(sym.path.clone())
220 .or_default()
221 .push(sym);
222 }
223
224 Ok(symbols_by_file)
225}
226
227pub const BASELINE_PATH_CAP: usize = 20;
229
230pub fn file_sizes_for_paths(conn: &Connection, paths: &[String]) -> usize {
233 let mut seen = std::collections::HashSet::new();
234 let distinct: Vec<&String> = paths
235 .iter()
236 .filter(|path| seen.insert(path.as_str()))
237 .take(BASELINE_PATH_CAP)
238 .collect();
239 if distinct.is_empty() {
240 return 0;
241 }
242
243 let placeholders = vec!["?"; distinct.len()].join(", ");
244 let sql =
245 format!("SELECT COALESCE(SUM(content_bytes), 0) FROM files WHERE path IN ({placeholders})");
246 conn.query_row(&sql, rusqlite::params_from_iter(distinct), |row| {
247 row.get::<_, i64>(0)
248 })
249 .map(|bytes| bytes.max(0) as usize)
250 .unwrap_or(0)
251}
252
253pub fn get_file(conn: &Connection, path: &str) -> Result<Option<FileFact>, QueryError> {
255 let normalized = path.replace('\\', "/");
256 let backslash = path.replace('/', "\\");
257
258 let mut stmt = conn.prepare(
260 "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
261 FROM files
262 WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
263 ORDER BY (path = ?1 OR path = ?2) DESC
264 LIMIT 1",
265 )?;
266
267 let mut rows = stmt.query(params![normalized, backslash])?;
268 if let Some(row) = rows.next()? {
269 Ok(Some(FileFact {
270 file_id: row.get(0)?,
271 path: row.get::<_, String>(1)?.replace('\\', "/"),
272 language: row.get(2)?,
273 content_hash: row.get(3)?,
274 content_bytes: row.get(4)?,
275 line_count: row.get(5)?,
276 indexed_at: row.get(6)?,
277 }))
278 } else {
279 Ok(None)
280 }
281}
282
283pub fn count_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
1596pub fn find_related_tests(
1598 conn: &Connection,
1599 target_symbol: &Symbol,
1600 limit: usize,
1601) -> Result<Vec<Symbol>, QueryError> {
1602 if limit == 0 {
1603 return Ok(Vec::new());
1604 }
1605
1606 const COLUMNS: &str = "s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1607 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1608 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1609 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1610 s.is_test, s.test_container";
1611 const IS_TEST: &str = "(s.is_test = 1 OR s.test_container = 1)";
1612 let not_documentation = not_documentation(conn, "s");
1613
1614 let mut tests = Vec::new();
1615 let mut seen_ids = std::collections::HashSet::new();
1616
1617 let callers_sql = format!(
1618 "SELECT {COLUMNS}
1619 FROM symbols s
1620 JOIN relationships r ON r.from_symbol_id = s.symbol_id
1621 WHERE r.to_symbol_id = ?1 AND {IS_TEST} AND {not_documentation}
1622 LIMIT ?2"
1623 );
1624
1625 if let Ok(mut stmt) = conn.prepare(&callers_sql)
1626 && let Ok(rows) = stmt.query_map(params![target_symbol.symbol_id, limit as i64], map_symbol)
1627 {
1628 for row in rows.flatten() {
1629 if seen_ids.insert(row.symbol_id.clone()) {
1630 tests.push(row);
1631 if tests.len() >= limit {
1632 return Ok(tests);
1633 }
1634 }
1635 }
1636 }
1637
1638 let remaining = limit - tests.len();
1640 if remaining > 0 && has_pending_namespace_column(conn) {
1641 let pending_sql = format!(
1642 "SELECT DISTINCT {COLUMNS}
1643 FROM pending_relationships p
1644 JOIN symbols s ON p.from_symbol_id = s.symbol_id
1645 JOIN symbols s_from ON s_from.symbol_id = s.symbol_id
1646 JOIN symbols s_target ON s_target.symbol_id = ?1
1647 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1648 WHERE p.target_terminal_name = s_target.name
1649 AND {IS_TEST}
1650 AND {not_documentation}
1651 AND {pred}
1652 LIMIT ?2",
1653 pred = pending_target_predicate(conn, "s_target", "s_target_parent")
1654 );
1655
1656 if let Ok(mut stmt) = conn.prepare(&pending_sql)
1657 && let Ok(rows) = stmt.query_map(
1658 params![target_symbol.symbol_id, remaining as i64],
1659 map_symbol,
1660 )
1661 {
1662 for row in rows.flatten() {
1663 if seen_ids.insert(row.symbol_id.clone()) {
1664 tests.push(row);
1665 if tests.len() >= limit {
1666 return Ok(tests);
1667 }
1668 }
1669 }
1670 }
1671 }
1672
1673 let remaining = limit - tests.len();
1674 let name_sql = format!(
1675 "SELECT {COLUMNS}
1676 FROM symbols s
1677 WHERE {IS_TEST}
1678 AND {not_documentation}
1679 AND (s.name LIKE '%' || ?1 || '%' OR s.signature LIKE '%' || ?1 || '%')
1680 ORDER BY (s.name LIKE '%' || ?1 || '%') DESC
1681 LIMIT ?2"
1682 );
1683
1684 if let Ok(mut stmt) = conn.prepare(&name_sql)
1685 && let Ok(rows) = stmt.query_map(
1686 params![target_symbol.name, (remaining * 2) as i64],
1687 map_symbol,
1688 )
1689 {
1690 for row in rows.flatten() {
1691 if seen_ids.insert(row.symbol_id.clone()) {
1692 tests.push(row);
1693 if tests.len() >= limit {
1694 return Ok(tests);
1695 }
1696 }
1697 }
1698 }
1699
1700 let remaining = limit - tests.len();
1701 let fts_exists: bool = conn
1702 .query_row(
1703 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
1704 [],
1705 |_| Ok(true),
1706 )
1707 .unwrap_or(false);
1708
1709 if remaining > 0 && fts_exists {
1710 let fts_sql = format!(
1711 "SELECT {COLUMNS}
1712 FROM symbols_fts
1713 CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
1714 WHERE symbols_fts MATCH ?1 AND {IS_TEST} AND {not_documentation}
1715 LIMIT ?2"
1716 );
1717
1718 let and_q = name_prefix_query(&target_symbol.name);
1719 if !and_q.is_empty()
1720 && let Ok(mut stmt) = conn.prepare(&fts_sql)
1721 && let Ok(rows) = stmt.query_map(params![and_q, (remaining * 2) as i64], map_symbol)
1722 {
1723 for row in rows.flatten() {
1724 if seen_ids.insert(row.symbol_id.clone()) {
1725 tests.push(row);
1726 if tests.len() >= limit {
1727 break;
1728 }
1729 }
1730 }
1731 }
1732 }
1733
1734 Ok(tests)
1735}
1736
1737pub fn get_symbol_by_name(
1739 conn: &Connection,
1740 name: &str,
1741 path_filter: Option<&str>,
1742) -> Result<Option<Symbol>, QueryError> {
1743 get_symbol_by_name_internal(conn, name, path_filter, false)
1744}
1745
1746pub fn get_symbol_by_name_exact(
1748 conn: &Connection,
1749 name: &str,
1750 exact_path: &str,
1751) -> Result<Option<Symbol>, QueryError> {
1752 get_symbol_by_name_internal(conn, name, Some(exact_path), true)
1753}
1754
1755fn split_qualified_name(name: &str) -> (Vec<&str>, &str) {
1757 let separator = if name.contains("::") {
1758 "::"
1759 } else if name.contains('.') {
1760 "."
1761 } else {
1762 return (Vec::new(), name);
1763 };
1764 let mut segments: Vec<&str> = name.split(separator).collect();
1765 let terminal = segments.pop().unwrap_or(name);
1766 (segments, terminal)
1767}
1768
1769fn ancestor_names(conn: &Connection, symbol_id: &str) -> Result<Vec<String>, QueryError> {
1771 let mut stmt = conn.prepare(
1772 "SELECT p.symbol_id, p.name FROM symbols s
1773 JOIN symbols p ON s.parent_symbol_id = p.symbol_id
1774 WHERE s.symbol_id = ?1",
1775 )?;
1776 let mut names = Vec::new();
1777 let mut current = symbol_id.to_string();
1778 for _ in 0..32 {
1779 let mut rows = stmt.query(params![current])?;
1780 let Some(row) = rows.next()? else { break };
1781 let parent_id: String = row.get(0)?;
1782 let parent_name: String = row.get(1)?;
1783 names.push(parent_name);
1784 current = parent_id;
1785 }
1786 Ok(names)
1787}
1788
1789const ANCESTOR_WALK_ROW_CAP: i64 = 2000;
1792
1793fn chain_contains(chain: &[String], wanted: &[&str]) -> bool {
1794 let mut remaining = chain.iter();
1795 wanted
1796 .iter()
1797 .all(|segment| remaining.any(|name| name == segment))
1798}
1799
1800fn get_symbol_by_name_internal(
1801 conn: &Connection,
1802 name: &str,
1803 path_filter: Option<&str>,
1804 exact_path: bool,
1805) -> Result<Option<Symbol>, QueryError> {
1806 let (ancestor_segments, terminal_name) = split_qualified_name(name);
1807 let parent_name = ancestor_segments.last().copied();
1808
1809 let sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1810 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1811 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1812 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1813 s.is_test, s.test_container
1814 FROM symbols s
1815 LEFT JOIN symbols p ON s.parent_symbol_id = p.symbol_id
1816 WHERE (s.name = :name OR (s.name = :term AND (:parent IS NULL OR p.name = :parent)))
1817 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 '\\')))
1818 ORDER BY (s.kind != 'import') DESC,
1819 (s.kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC,
1820 (s.name = :name) DESC,
1821 (:path IS NOT NULL AND (s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE)) DESC,
1822 s.is_test ASC
1823 LIMIT :limit";
1824
1825 let row_cap: i64 = if ancestor_segments.len() > 1 {
1826 ANCESTOR_WALK_ROW_CAP
1827 } else {
1828 25
1829 };
1830 let mut stmt = conn.prepare(sql)?;
1831 let normalized_path = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
1832 let backslash_path = normalized_path.as_deref().map(|p| p.replace('/', "\\"));
1833 let path_like = normalized_path.as_deref().map(escape_like);
1834 let path_like_bs = backslash_path.as_deref().map(escape_like);
1835
1836 let mut rows = stmt.query(rusqlite::named_params! {
1837 ":name": name,
1838 ":term": terminal_name,
1839 ":parent": parent_name,
1840 ":path": normalized_path.as_deref(),
1841 ":path_bs": backslash_path.as_deref(),
1842 ":path_like": path_like.as_deref(),
1843 ":path_like_bs": path_like_bs.as_deref(),
1844 ":exact": if exact_path { 1 } else { 0 },
1845 ":limit": row_cap,
1846 })?;
1847
1848 let mut matches: Vec<Symbol> = Vec::new();
1849 while let Some(row) = rows.next()? {
1850 matches.push(map_symbol(row)?);
1851 }
1852 drop(rows);
1853
1854 if ancestor_segments.len() > 1 {
1855 let required: Vec<&str> = ancestor_segments.iter().rev().copied().collect();
1856 let mut kept = Vec::with_capacity(matches.len());
1857 for symbol in matches {
1858 if symbol.name == name
1859 || chain_contains(&ancestor_names(conn, &symbol.symbol_id)?, &required)
1860 {
1861 kept.push(symbol);
1862 }
1863 }
1864 matches = kept;
1865 }
1866
1867 if matches.is_empty() {
1868 return Ok(None);
1869 }
1870
1871 if matches.len() == 1 {
1872 return Ok(Some(matches.remove(0)));
1873 }
1874
1875 let candidates: Vec<Symbol> = if matches.iter().any(|s| s.kind != "import") {
1877 matches.into_iter().filter(|s| s.kind != "import").collect()
1878 } else {
1879 matches
1880 };
1881
1882 if candidates.len() == 1 {
1883 return Ok(Some(candidates.into_iter().next().unwrap()));
1884 }
1885
1886 let exact_name_matches: Vec<_> = candidates
1888 .iter()
1889 .filter(|s| s.name == name)
1890 .cloned()
1891 .collect();
1892 if exact_name_matches.len() == 1 {
1893 return Ok(Some(exact_name_matches.into_iter().next().unwrap()));
1894 }
1895
1896 let definition_candidates = if exact_name_matches.is_empty() {
1897 &candidates
1898 } else {
1899 &exact_name_matches
1900 };
1901 let def_matches: Vec<_> = definition_candidates
1902 .iter()
1903 .filter(|s| {
1904 matches!(
1905 s.kind.as_str(),
1906 "function"
1907 | "struct"
1908 | "class"
1909 | "trait"
1910 | "method"
1911 | "enum"
1912 | "interface"
1913 | "type"
1914 )
1915 })
1916 .cloned()
1917 .collect();
1918 if def_matches.len() == 1 {
1919 return Ok(Some(def_matches.into_iter().next().unwrap()));
1920 }
1921
1922 let active_pool = if !def_matches.is_empty() {
1923 def_matches
1924 } else if !exact_name_matches.is_empty() {
1925 exact_name_matches
1926 } else {
1927 candidates
1928 };
1929
1930 if let Some(ref p) = normalized_path {
1932 let exact_path_matches: Vec<_> = active_pool
1933 .iter()
1934 .filter(|s| s.path == *p)
1935 .cloned()
1936 .collect();
1937 if exact_path_matches.len() == 1 {
1938 return Ok(Some(exact_path_matches.into_iter().next().unwrap()));
1939 }
1940 }
1941
1942 if active_pool.len() == 1 {
1943 return Ok(Some(active_pool.into_iter().next().unwrap()));
1944 }
1945
1946 let mut candidate_list = String::new();
1948 for s in &active_pool {
1949 candidate_list.push_str(&format!(
1950 "- {} `{}` in {}:{}\n",
1951 s.kind, s.name, s.path, s.start_line
1952 ));
1953 }
1954
1955 Err(QueryError::AmbiguousSymbol(
1956 name.to_string(),
1957 active_pool.len(),
1958 candidate_list,
1959 ))
1960}
1961
1962pub fn workspace_name(conn: &Connection) -> String {
1964 conn.query_row(
1965 "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
1966 [],
1967 |row| row.get::<_, String>(0),
1968 )
1969 .ok()
1970 .map(|root| root.replace('\\', "/"))
1971 .and_then(|root| {
1972 root.trim_end_matches('/')
1973 .rsplit('/')
1974 .next()
1975 .filter(|name| !name.is_empty())
1976 .map(str::to_string)
1977 })
1978 .unwrap_or_else(|| "this workspace".to_string())
1979}
1980
1981fn edit_distance(left: &str, right: &str) -> usize {
1982 let left: Vec<char> = left.chars().collect();
1983 let right: Vec<char> = right.chars().collect();
1984 let mut previous: Vec<usize> = (0..=right.len()).collect();
1985 let mut current = vec![0usize; right.len() + 1];
1986 for (i, a) in left.iter().enumerate() {
1987 current[0] = i + 1;
1988 for (j, b) in right.iter().enumerate() {
1989 let substitution = previous[j] + usize::from(a != b);
1990 current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
1991 }
1992 std::mem::swap(&mut previous, &mut current);
1993 }
1994 previous[right.len()]
1995}
1996
1997fn near_names_by_trigram(
1999 conn: &Connection,
2000 name: &str,
2001 path_filter: Option<&str>,
2002) -> Result<Vec<Symbol>, QueryError> {
2003 if !has_table(conn, "symbol_names_tri") {
2004 return Ok(Vec::new());
2005 }
2006 let lower = name.to_lowercase();
2007 let chars: Vec<char> = lower.chars().collect();
2008 let chunks: Vec<String> = chars
2009 .windows(3)
2010 .map(|window| window.iter().collect::<String>())
2011 .filter(|chunk| chunk.chars().all(|c| c.is_alphanumeric() || c == '_'))
2012 .collect();
2013 if chunks.is_empty() {
2014 return Ok(Vec::new());
2015 }
2016 let match_clause = chunks
2017 .iter()
2018 .map(|chunk| format!("\"{chunk}\""))
2019 .collect::<Vec<_>>()
2020 .join(" OR ");
2021
2022 let normalized_path = path_filter.map(|p| {
2023 p.replace('\\', "/")
2024 .trim_start_matches("./")
2025 .trim_matches('/')
2026 .to_string()
2027 });
2028 let escaped_path = normalized_path.as_deref().map(escape_like);
2029 let kind_val: Option<&str> = None;
2030 let columns = candidate_columns(conn);
2031 let filters = candidate_filters(false, false);
2032 let sql = format!(
2033 "SELECT {columns} FROM symbol_names_tri
2034 CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
2035 WHERE symbol_names_tri MATCH :match {filters}
2036 ORDER BY bm25(symbol_names_tri) ASC, length(s.name) ASC, s.path ASC LIMIT 20"
2037 );
2038 let rows = conn
2039 .prepare(&sql)?
2040 .query_map(
2041 rusqlite::named_params! {
2042 ":match": match_clause,
2043 ":kind": kind_val,
2044 ":path": normalized_path.as_deref(),
2045 ":path_like": escaped_path.as_deref(),
2046 },
2047 map_symbol,
2048 )?
2049 .collect::<Result<Vec<_>, _>>()?;
2050
2051 let budget = (chars.len() / 4).max(2);
2052 let mut scored: Vec<(usize, Symbol)> = rows
2053 .into_iter()
2054 .map(|symbol| (edit_distance(&lower, &symbol.name.to_lowercase()), symbol))
2055 .filter(|(distance, _)| *distance <= budget)
2056 .collect();
2057 scored.sort_by_key(|(distance, symbol)| (*distance, symbol.name.chars().count()));
2058 Ok(scored
2059 .into_iter()
2060 .map(|(_, symbol)| symbol)
2061 .take(3)
2062 .collect())
2063}
2064
2065pub fn suggest_symbol_names(
2067 conn: &Connection,
2068 name: &str,
2069 path_filter: Option<&str>,
2070) -> Vec<Symbol> {
2071 let (ancestors, terminal) = split_qualified_name(name);
2072 let mut found =
2073 search_symbols_scoped(conn, terminal, None, path_filter, false, 3).unwrap_or_default();
2074 if found.is_empty() && terminal.chars().count() >= 4 {
2075 found = near_names_by_trigram(conn, terminal, path_filter).unwrap_or_default();
2076 }
2077 if let Some(parent) = ancestors.last().copied() {
2078 let mut ranked: Vec<(bool, Symbol)> = found
2079 .into_iter()
2080 .map(|symbol| {
2081 let shares_parent = ancestor_names(conn, &symbol.symbol_id)
2082 .unwrap_or_default()
2083 .first()
2084 .is_some_and(|found_parent| found_parent == parent);
2085 (!shares_parent, symbol)
2086 })
2087 .collect();
2088 ranked.sort_by_key(|(demoted, _)| *demoted);
2089 found = ranked.into_iter().map(|(_, symbol)| symbol).collect();
2090 }
2091 found.truncate(3);
2092 found
2093}
2094
2095pub fn suggest_file_paths(conn: &Connection, rel_path: &str) -> Vec<String> {
2097 let wanted = rel_path.replace('\\', "/");
2098 let wanted = wanted.trim_start_matches("./").trim_matches('/');
2099 let basename = wanted.rsplit('/').next().unwrap_or(wanted).to_lowercase();
2100 if basename.is_empty() {
2101 return Vec::new();
2102 }
2103 let stem = basename.split('.').next().unwrap_or(&basename).to_string();
2104 let segments: Vec<&str> = wanted.split('/').collect();
2105 let tail = if segments.len() >= 2 {
2106 segments[segments.len() - 2..].join("/").to_lowercase()
2107 } else {
2108 basename.clone()
2109 };
2110
2111 let path_expr = "replace(files.path, '\\', '/')";
2112 let file_name = format!(
2113 "lower(replace({path_expr}, rtrim({path_expr}, replace({path_expr}, '/', '')), ''))"
2114 );
2115 let rules = [
2116 (format!("{file_name} = :value"), basename.clone()),
2117 (
2118 format!("{file_name} LIKE :value ESCAPE '\\'"),
2119 format!("{}%", escape_like(&stem)),
2120 ),
2121 (
2122 format!("lower({path_expr}) LIKE :value ESCAPE '\\'"),
2123 format!("%{}", escape_like(&tail)),
2124 ),
2125 ];
2126
2127 for (predicate, value) in rules {
2128 let sql = format!(
2129 "SELECT {path_expr} FROM files WHERE {predicate}
2130 ORDER BY length(files.path) ASC, files.path ASC LIMIT 3"
2131 );
2132 let found: Vec<String> = conn
2133 .prepare(&sql)
2134 .and_then(|mut stmt| {
2135 stmt.query_map(rusqlite::named_params! { ":value": value }, |row| {
2136 row.get::<_, String>(0)
2137 })?
2138 .collect()
2139 })
2140 .unwrap_or_default();
2141 if !found.is_empty() {
2142 return found;
2143 }
2144 }
2145 Vec::new()
2146}
2147
2148pub fn symbol_not_found_parts(
2150 conn: &Connection,
2151 name: &str,
2152 path_filter: Option<&str>,
2153) -> (String, String) {
2154 let candidates = suggest_symbol_names(conn, name, path_filter);
2155 let hint = if candidates.is_empty() {
2156 "No similar name is indexed; check the workspace and spelling.".to_string()
2157 } else {
2158 let list = candidates
2159 .iter()
2160 .map(|s| format!(" - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
2161 .collect::<Vec<_>>()
2162 .join("\n");
2163 format!("Did you mean one of:\n{list}")
2164 };
2165 (workspace_name(conn), hint)
2166}
2167
2168pub fn file_not_found_parts(conn: &Connection, rel_path: &str) -> (String, String) {
2170 let candidates = suggest_file_paths(conn, rel_path);
2171 let hint = if candidates.is_empty() {
2172 "No similar path is indexed; check the workspace and spelling.".to_string()
2173 } else {
2174 let list = candidates
2175 .iter()
2176 .map(|path| format!(" - {path}"))
2177 .collect::<Vec<_>>()
2178 .join("\n");
2179 format!("Did you mean one of:\n{list}")
2180 };
2181 (workspace_name(conn), hint)
2182}
2183
2184pub fn find_references(
2186 conn: &Connection,
2187 symbol_name: &str,
2188 direction: &str,
2189 limit: usize,
2190) -> Result<Vec<ReferenceSite>, QueryError> {
2191 find_references_ext(conn, symbol_name, direction, limit, false)
2192}
2193
2194pub fn find_references_ext(
2196 conn: &Connection,
2197 symbol_name: &str,
2198 direction: &str,
2199 limit: usize,
2200 include_external: bool,
2201) -> Result<Vec<ReferenceSite>, QueryError> {
2202 find_references_scoped(conn, symbol_name, direction, limit, include_external, None)
2203}
2204
2205pub fn find_references_scoped(
2207 conn: &Connection,
2208 symbol_name: &str,
2209 direction: &str,
2210 limit: usize,
2211 include_external: bool,
2212 path_filter: Option<&str>,
2213) -> Result<Vec<ReferenceSite>, QueryError> {
2214 validate_result_limit(limit)?;
2215 if direction != "callers" && direction != "callees" {
2216 return Err(QueryError::InvalidDirection(direction.to_string()));
2217 }
2218
2219 match get_symbol_by_name(conn, symbol_name, path_filter)? {
2220 Some(target) => find_references_internal(
2221 conn,
2222 &target.name,
2223 direction,
2224 limit,
2225 Some(&target.symbol_id),
2226 include_external,
2227 ),
2228 None => {
2229 let (workspace, hint) = symbol_not_found_parts(conn, symbol_name, path_filter);
2230 Err(QueryError::SymbolNotFound {
2231 name: symbol_name.to_string(),
2232 workspace,
2233 hint,
2234 })
2235 }
2236 }
2237}
2238
2239pub fn find_references_for_symbol(
2240 conn: &Connection,
2241 symbol_name: &str,
2242 direction: &str,
2243 limit: usize,
2244 symbol_id: &str,
2245) -> Result<Vec<ReferenceSite>, QueryError> {
2246 find_references_internal(conn, symbol_name, direction, limit, Some(symbol_id), false)
2247}
2248
2249fn call_site_proximity(candidate_path: &str) -> String {
2252 let normalized = format!("replace({candidate_path}, '\\', '/')");
2253 let call_site = "replace(p.path, '\\', '/')";
2254 format!(
2255 "CASE WHEN {normalized} = {call_site} THEN 2
2256 WHEN rtrim({normalized}, replace({normalized}, '/', '')) = rtrim({call_site}, replace({call_site}, '/', '')) THEN 1
2257 ELSE 0 END"
2258 )
2259}
2260
2261fn pending_target_predicate(conn: &Connection, target: &str, parent: &str) -> String {
2264 let ns = "json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END)";
2265 let target_path = format!("('/' || replace({target}.path, '\\', '/'))");
2266 let like_value = "replace(replace(replace(value, '\\', '\\\\'), '%', '\\%'), '_', '\\_')";
2267 let closer_rank = call_site_proximity("closer.path");
2268 let target_rank = call_site_proximity(&format!("{target}.path"));
2269 let import_alias_receiver = if has_column(conn, "symbols", "metadata_json") {
2270 "OR EXISTS (
2271 SELECT 1 FROM symbols alias_import
2272 WHERE alias_import.kind = 'import'
2273 AND alias_import.path = p.path
2274 AND json_valid(alias_import.metadata_json)
2275 AND (json_extract(alias_import.metadata_json, '$.alias') = p.target_receiver
2276 OR json_extract(alias_import.metadata_json, '$.local_name') = p.target_receiver)
2277 AND COALESCE(json_extract(alias_import.metadata_json, '$.source'), '') NOT LIKE 'Qt%'
2278 )"
2279 } else {
2280 ""
2281 };
2282 format!(
2283 "(
2284 NOT (p.kind IS 'extends' AND p.from_symbol_id = {target}.symbol_id)
2285 AND (
2286 (
2287 {target}.parent_symbol_id IS NOT NULL
2288 AND {parent}.name IS NOT NULL
2289 AND (
2290 EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
2291 OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
2292 AND s_from.parent_symbol_id = {target}.parent_symbol_id)
2293 OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver)
2294 OR EXISTS (
2295 SELECT 1 FROM symbols receiver
2296 JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
2297 WHERE receiver.name = p.target_receiver
2298 AND receiver.path = p.path
2299 AND receiver_type.resolved_type = {parent}.name
2300 )
2301 )
2302 AND NOT EXISTS (
2303 SELECT 1 FROM {ns}
2304 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
2305 AND NOT EXISTS (
2306 WITH RECURSIVE ancestor(symbol_id, depth) AS (
2307 SELECT {target}.parent_symbol_id, 0
2308 UNION ALL
2309 SELECT s.parent_symbol_id, ancestor.depth + 1
2310 FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
2311 WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
2312 )
2313 SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
2314 WHERE a.name = value
2315 )
2316 AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2317 AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
2318 )
2319 )
2320 OR (
2321 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2322 AND (
2323 p.target_receiver IS NULL
2324 OR p.target_receiver = ''
2325 {import_alias_receiver}
2326 )
2327 AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
2328 AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
2329 SELECT 1 FROM symbols closer
2330 WHERE closer.name = {target}.name
2331 AND closer.symbol_id != {target}.symbol_id
2332 AND closer.parent_symbol_id IS NULL
2333 AND closer.kind = {target}.kind
2334 AND NOT (p.kind IS 'extends' AND closer.symbol_id = p.from_symbol_id)
2335 AND {closer_rank} > {target_rank}
2336 ))
2337 )
2338 OR (
2339 {target}.parent_symbol_id IS NULL
2340 AND EXISTS (
2341 SELECT 1 FROM {ns}
2342 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
2343 AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2344 )
2345 )
2346 )
2347 )"
2348 )
2349}
2350
2351const DOCUMENTATION_LANGUAGES: &[&str] = &[
2354 "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
2355];
2356
2357fn documentation_language_list() -> String {
2358 DOCUMENTATION_LANGUAGES
2359 .iter()
2360 .map(|l| format!("'{l}'"))
2361 .collect::<Vec<_>>()
2362 .join(", ")
2363}
2364
2365fn not_documentation(conn: &Connection, alias: &str) -> String {
2366 let has_content_type: bool = conn
2367 .query_row(
2368 "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
2369 [],
2370 |_| Ok(true),
2371 )
2372 .unwrap_or(false);
2373 if has_content_type {
2374 format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
2375 } else {
2376 "1 = 1".to_string()
2377 }
2378}
2379
2380fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
2381 conn.query_row(
2382 "SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2",
2383 [table, column],
2384 |_| Ok(true),
2385 )
2386 .unwrap_or(false)
2387}
2388
2389pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
2390 conn.query_row(
2391 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
2392 [name],
2393 |_| Ok(true),
2394 )
2395 .unwrap_or(false)
2396}
2397
2398fn has_pending_namespace_column(conn: &Connection) -> bool {
2399 let has_ns: bool = conn
2400 .query_row(
2401 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
2402 [],
2403 |_| Ok(true),
2404 )
2405 .unwrap_or(false);
2406 let has_display: bool = conn
2407 .query_row(
2408 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
2409 [],
2410 |_| Ok(true),
2411 )
2412 .unwrap_or(false);
2413 let has_receiver: bool = conn
2414 .query_row(
2415 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
2416 [],
2417 |_| Ok(true),
2418 )
2419 .unwrap_or(false);
2420 has_ns && has_display && has_receiver
2421}
2422
2423fn find_references_internal(
2424 conn: &Connection,
2425 symbol_name: &str,
2426 direction: &str,
2427 limit: usize,
2428 symbol_id: Option<&str>,
2429 include_external: bool,
2430) -> Result<Vec<ReferenceSite>, QueryError> {
2431 let mut results = Vec::new();
2432
2433 if direction == "callers" {
2434 let mut stmt = conn.prepare(
2436 "SELECT s_from.name AS from_name,
2437 r.from_symbol_id,
2438 s_to.name AS to_name,
2439 r.kind,
2440 r.path,
2441 r.start_line,
2442 r.start_column
2443 FROM relationships r
2444 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2445 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2446 WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
2447 LIMIT ?2",
2448 )?;
2449
2450 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2451 Ok(ReferenceSite {
2452 from_symbol_name: row.get(0)?,
2453 from_symbol_id: row.get(1)?,
2454 to_symbol_name: row.get(2)?,
2455 kind: row.get(3)?,
2456 path: row.get::<_, String>(4)?.replace('\\', "/"),
2457 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2458 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2459 occurrences: None,
2460 })
2461 })?;
2462
2463 for r in rows {
2464 results.push(r?);
2465 }
2466
2467 if results.len() < limit {
2469 let remaining = limit - results.len();
2470 if has_pending_namespace_column(conn) {
2471 if let Some(sid) = symbol_id {
2472 let mut pending_stmt = conn.prepare(
2473 &format!("SELECT s_from.name AS from_name,
2474 p.from_symbol_id,
2475 p.target_terminal_name AS to_name,
2476 p.kind,
2477 p.path,
2478 p.start_line,
2479 p.start_column
2480 FROM pending_relationships p
2481 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2482 JOIN symbols s_target ON s_target.symbol_id = ?3
2483 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2484 WHERE p.target_terminal_name = ?1
2485 AND {pred}
2486 LIMIT ?2", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
2487 )?;
2488
2489 let p_rows = pending_stmt.query_map(
2490 params![symbol_name, remaining as i64, sid],
2491 |row| {
2492 Ok(ReferenceSite {
2493 from_symbol_name: row.get(0)?,
2494 from_symbol_id: row.get(1)?,
2495 to_symbol_name: row.get(2)?,
2496 kind: row.get(3)?,
2497 path: row.get::<_, String>(4)?.replace('\\', "/"),
2498 start_line: Some(row.get::<_, i64>(5)? as usize),
2499 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2500 occurrences: None,
2501 })
2502 },
2503 )?;
2504 for r in p_rows {
2505 results.push(r?);
2506 }
2507 } else {
2508 let mut pending_stmt = conn.prepare(
2509 "SELECT s_from.name AS from_name,
2510 p.from_symbol_id,
2511 p.target_terminal_name AS to_name,
2512 p.kind,
2513 p.path,
2514 p.start_line,
2515 p.start_column
2516 FROM pending_relationships p
2517 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2518 WHERE p.target_terminal_name = ?1
2519 AND (
2520 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2521 OR EXISTS (
2522 SELECT 1 FROM symbols s_any
2523 JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
2524 WHERE s_any.name = p.target_terminal_name
2525 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)
2526 )
2527 )
2528 LIMIT ?2",
2529 )?;
2530
2531 let p_rows =
2532 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2533 Ok(ReferenceSite {
2534 from_symbol_name: row.get(0)?,
2535 from_symbol_id: row.get(1)?,
2536 to_symbol_name: row.get(2)?,
2537 kind: row.get(3)?,
2538 path: row.get::<_, String>(4)?.replace('\\', "/"),
2539 start_line: Some(row.get::<_, i64>(5)? as usize),
2540 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2541 occurrences: None,
2542 })
2543 })?;
2544 for r in p_rows {
2545 results.push(r?);
2546 }
2547 }
2548 } else {
2549 let is_nested = if let Some(sid) = symbol_id {
2550 conn.query_row(
2551 "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
2552 params![sid],
2553 |_| Ok(true),
2554 )
2555 .unwrap_or(false)
2556 } else {
2557 false
2558 };
2559
2560 if !is_nested {
2561 let mut pending_stmt = conn.prepare(
2562 "SELECT s_from.name AS from_name,
2563 p.from_symbol_id,
2564 p.target_terminal_name AS to_name,
2565 p.kind,
2566 p.path,
2567 p.start_line,
2568 p.start_column
2569 FROM pending_relationships p
2570 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2571 WHERE p.target_terminal_name = ?1
2572 LIMIT ?2",
2573 )?;
2574
2575 let p_rows =
2576 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2577 Ok(ReferenceSite {
2578 from_symbol_name: row.get(0)?,
2579 from_symbol_id: row.get(1)?,
2580 to_symbol_name: row.get(2)?,
2581 kind: row.get(3)?,
2582 path: row.get::<_, String>(4)?.replace('\\', "/"),
2583 start_line: Some(row.get::<_, i64>(5)? as usize),
2584 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2585 occurrences: None,
2586 })
2587 })?;
2588
2589 for r in p_rows {
2590 results.push(r?);
2591 }
2592 }
2593 }
2594 }
2595
2596 if results.len() < limit && has_table(conn, "identifiers") {
2597 let remaining = limit - results.len();
2598 let mut ident_stmt = conn.prepare(
2599 "SELECT COALESCE(s.name, ''),
2600 COALESCE(i.containing_symbol_id, ''),
2601 i.name,
2602 CASE WHEN json_valid(i.metadata_json)
2603 AND json_extract(i.metadata_json, '$.role') = 'signal_handler'
2604 THEN CASE WHEN json_extract(i.metadata_json, '$.receiver') = (
2605 SELECT CASE WHEN t.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
2606 THEN t.name ELSE tp.name END
2607 FROM symbols t
2608 LEFT JOIN symbols tp ON tp.symbol_id = t.parent_symbol_id
2609 WHERE t.symbol_id = ?3)
2610 THEN 'handler' ELSE 'handler (candidate)' END
2611 ELSE i.kind END,
2612 i.path,
2613 i.start_line,
2614 i.start_column
2615 FROM identifiers i
2616 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2617 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
2618 AND COALESCE(s.kind, '') != 'import'
2619 AND NOT EXISTS (
2620 SELECT 1 FROM relationships covered
2621 JOIN symbols covered_to ON covered.to_symbol_id = covered_to.symbol_id
2622 WHERE covered_to.name = i.name
2623 AND covered.path = i.path
2624 AND covered.start_line = i.start_line
2625 AND (?3 IS NULL OR covered.to_symbol_id = ?3)
2626 )
2627 AND NOT EXISTS (
2628 SELECT 1 FROM pending_relationships covered
2629 WHERE covered.target_terminal_name = i.name
2630 AND covered.path = i.path
2631 AND covered.start_line = i.start_line
2632 )
2633 AND (?3 IS NULL OR NOT EXISTS (
2634 SELECT 1 FROM symbols owner
2635 JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
2636 WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
2637 AND member.name = i.name
2638 AND owner.name IS NOT (SELECT parent.name FROM symbols target
2639 JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
2640 WHERE target.symbol_id = ?3)
2641 ))
2642 ORDER BY i.path, i.start_line
2643 LIMIT ?2",
2644 )?;
2645 let rows =
2646 ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
2647 Ok(ReferenceSite {
2648 from_symbol_name: row.get(0)?,
2649 from_symbol_id: row.get(1)?,
2650 to_symbol_name: row.get(2)?,
2651 kind: row.get(3)?,
2652 path: row.get::<_, String>(4)?.replace('\\', "/"),
2653 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2654 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2655 occurrences: None,
2656 })
2657 })?;
2658 for r in rows {
2659 results.push(r?);
2660 }
2661
2662 if let Some(sid) = symbol_id.filter(|_| results.len() < limit) {
2663 let remaining = limit - results.len();
2664 let mut receiver_stmt = conn.prepare(
2665 "SELECT COALESCE(s.name, ''),
2666 COALESCE(i.containing_symbol_id, ''),
2667 i.name,
2668 i.kind,
2669 i.path,
2670 MIN(i.start_line),
2671 i.start_column,
2672 COUNT(*)
2673 FROM identifiers i
2674 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2675 JOIN symbols target ON target.symbol_id = ?2
2676 LEFT JOIN symbols target_parent ON target_parent.symbol_id = target.parent_symbol_id
2677 WHERE i.kind = 'member_access'
2678 AND i.name != target.name
2679 AND COALESCE(s.kind, '') != 'import'
2680 AND target.kind IN ('class', 'struct', 'enum', 'interface', 'trait', 'module', 'namespace')
2681 AND json_valid(i.metadata_json)
2682 AND json_extract(i.metadata_json, '$.receiver') = target.name
2683 AND (json_extract(i.metadata_json, '$.receiver_qualifier') IS NULL
2684 OR json_extract(i.metadata_json, '$.receiver_qualifier') = target_parent.name)
2685 GROUP BY i.path
2686 ORDER BY i.path, i.start_line
2687 LIMIT ?1",
2688 )?;
2689 let rows = receiver_stmt.query_map(params![remaining as i64, sid], |row| {
2690 Ok(ReferenceSite {
2691 from_symbol_name: row.get(0)?,
2692 from_symbol_id: row.get(1)?,
2693 to_symbol_name: row.get(2)?,
2694 kind: row.get(3)?,
2695 path: row.get::<_, String>(4)?.replace('\\', "/"),
2696 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2697 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2698 occurrences: Some(row.get::<_, i64>(7)? as usize),
2699 })
2700 })?;
2701 for r in rows {
2702 results.push(r?);
2703 }
2704 }
2705 }
2706 } else {
2707 let mut stmt = conn.prepare(
2709 "SELECT s_from.name AS from_name,
2710 r.from_symbol_id,
2711 s_to.name AS to_name,
2712 r.kind,
2713 r.path,
2714 r.start_line,
2715 r.start_column
2716 FROM relationships r
2717 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2718 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2719 WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
2720 LIMIT ?2",
2721 )?;
2722
2723 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2724 Ok(ReferenceSite {
2725 from_symbol_name: row.get(0)?,
2726 from_symbol_id: row.get(1)?,
2727 to_symbol_name: row.get(2)?,
2728 kind: row.get(3)?,
2729 path: row.get::<_, String>(4)?.replace('\\', "/"),
2730 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2731 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2732 occurrences: None,
2733 })
2734 })?;
2735
2736 for r in rows {
2737 results.push(r?);
2738 }
2739
2740 if results.len() < limit {
2742 let remaining = limit - results.len();
2743 let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
2744 let sql = if include_external {
2745 String::from("SELECT DISTINCT s_from.name AS from_name,
2746 p.from_symbol_id,
2747 COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
2748 p.kind,
2749 p.path,
2750 p.start_line,
2751 p.start_column
2752 FROM pending_relationships p
2753 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2754 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2755 LIMIT ?2")
2756 } else {
2757 format!("SELECT DISTINCT s_from.name AS from_name,
2758 p.from_symbol_id,
2759 p.target_terminal_name AS to_name,
2760 p.kind,
2761 p.path,
2762 p.start_line,
2763 p.start_column
2764 FROM pending_relationships p
2765 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2766 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2767 AND EXISTS (
2768 SELECT 1 FROM symbols s_to
2769 LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
2770 WHERE s_to.name = p.target_terminal_name
2771 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2772 AND {pred}
2773 )
2774 LIMIT ?2", pred = pending_target_predicate(conn, "s_to", "s_to_parent"))
2775 };
2776 let mut pending_stmt = conn.prepare(&sql)?;
2777 let rows = pending_stmt.query_map(
2778 params![symbol_name, remaining as i64, symbol_id],
2779 |row| {
2780 Ok(ReferenceSite {
2781 from_symbol_name: row.get(0)?,
2782 from_symbol_id: row.get(1)?,
2783 to_symbol_name: row.get(2)?,
2784 kind: row.get(3)?,
2785 path: row.get::<_, String>(4)?.replace('\\', "/"),
2786 start_line: Some(row.get::<_, i64>(5)? as usize),
2787 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2788 occurrences: None,
2789 })
2790 },
2791 )?;
2792 let mut out = Vec::new();
2793 for r in rows {
2794 out.push(r?);
2795 }
2796 out
2797 } else {
2798 let sql = if include_external {
2799 "SELECT DISTINCT s_from.name AS from_name,
2800 p.from_symbol_id,
2801 p.target_terminal_name AS to_name,
2802 p.kind,
2803 p.path,
2804 p.start_line,
2805 p.start_column
2806 FROM pending_relationships p
2807 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2808 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2809 LIMIT ?2"
2810 } else {
2811 "SELECT DISTINCT s_from.name AS from_name,
2812 p.from_symbol_id,
2813 p.target_terminal_name AS to_name,
2814 p.kind,
2815 p.path,
2816 p.start_line,
2817 p.start_column
2818 FROM pending_relationships p
2819 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2820 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2821 AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
2822 LIMIT ?2"
2823 };
2824 let mut pending_stmt = conn.prepare(sql)?;
2825
2826 let rows = pending_stmt.query_map(
2827 params![symbol_name, remaining as i64, symbol_id],
2828 |row| {
2829 Ok(ReferenceSite {
2830 from_symbol_name: row.get(0)?,
2831 from_symbol_id: row.get(1)?,
2832 to_symbol_name: row.get(2)?,
2833 kind: row.get(3)?,
2834 path: row.get::<_, String>(4)?.replace('\\', "/"),
2835 start_line: Some(row.get::<_, i64>(5)? as usize),
2836 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2837 occurrences: None,
2838 })
2839 },
2840 )?;
2841 let mut out = Vec::new();
2842 for r in rows {
2843 out.push(r?);
2844 }
2845 out
2846 };
2847
2848 for r in p_rows {
2849 results.push(r);
2850 }
2851 }
2852 }
2853
2854 Ok(results)
2855}
2856
2857pub fn find_callee_signatures(
2860 conn: &Connection,
2861 symbol_name: &str,
2862 symbol_id: &str,
2863 limit: usize,
2864 include_external: bool,
2865) -> Result<Vec<String>, QueryError> {
2866 let mut stmt = conn.prepare(
2867 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2868 FROM relationships r
2869 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2870 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2871 WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
2872 LIMIT ?3",
2873 )?;
2874
2875 let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
2876 Ok((
2877 row.get::<_, String>(0)?,
2878 row.get::<_, Option<String>>(1)?,
2879 row.get::<_, String>(2)?.replace('\\', "/"),
2880 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2881 row.get::<_, String>(4)?,
2882 ))
2883 })?;
2884
2885 let mut signatures = Vec::new();
2886 let mut variants = Vec::new();
2887
2888 for r in rows.flatten() {
2889 let (name, sig_opt, path, line, kind) = r;
2890 let sig = sig_opt.unwrap_or(name);
2891 let entry = format!("{sig} ({path}:{line})");
2892 if kind == "variant" {
2893 if !variants.contains(&entry) {
2894 variants.push(entry);
2895 }
2896 } else if !signatures.contains(&entry) {
2897 signatures.push(entry);
2898 }
2899 }
2900
2901 if signatures.len() < limit {
2902 let remaining = (limit - signatures.len()) * 2;
2903 let p_rows: Vec<(String, Option<String>, String, usize, String)> =
2904 if has_pending_namespace_column(conn) {
2905 let mut p_stmt = conn.prepare(
2906 &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2907 FROM pending_relationships p
2908 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2909 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2910 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2911 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2912 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2913 AND {pred}
2914 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
2915 )?;
2916
2917 let rows =
2918 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2919 Ok((
2920 row.get::<_, String>(0)?,
2921 row.get::<_, Option<String>>(1)?,
2922 row.get::<_, String>(2)?.replace('\\', "/"),
2923 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2924 row.get::<_, String>(4)?,
2925 ))
2926 })?;
2927 rows.flatten().collect()
2928 } else {
2929 let mut p_stmt = conn.prepare(
2930 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2931 FROM pending_relationships p
2932 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2933 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2934 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2935 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2936 LIMIT ?3",
2937 )?;
2938
2939 let rows =
2940 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2941 Ok((
2942 row.get::<_, String>(0)?,
2943 row.get::<_, Option<String>>(1)?,
2944 row.get::<_, String>(2)?.replace('\\', "/"),
2945 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2946 row.get::<_, String>(4)?,
2947 ))
2948 })?;
2949 rows.flatten().collect()
2950 };
2951
2952 for r in p_rows {
2953 let (name, sig_opt, path, line, kind) = r;
2954 let sig = sig_opt.unwrap_or(name);
2955 let entry = format!("{sig} ({path}:{line})");
2956 if kind == "variant" {
2957 if !variants.contains(&entry) {
2958 variants.push(entry);
2959 }
2960 } else if !signatures.contains(&entry) {
2961 signatures.push(entry);
2962 }
2963 }
2964 }
2965
2966 if include_external && signatures.len() < limit {
2967 let remaining = (limit - signatures.len()) * 2;
2968 let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
2969 let mut ext_stmt = conn.prepare(
2970 &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
2971 FROM pending_relationships p
2972 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2973 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2974 AND NOT EXISTS (
2975 SELECT 1 FROM symbols s_to
2976 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2977 WHERE s_to.name = p.target_terminal_name
2978 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2979 AND {pred}
2980 )
2981 LIMIT ?3", pred = pending_target_predicate(conn, "s_to", "s_parent")),
2982 )?;
2983
2984 let rows =
2985 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2986 Ok((
2987 row.get::<_, String>(0)?,
2988 row.get::<_, String>(1)?.replace('\\', "/"),
2989 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
2990 ))
2991 })?;
2992 rows.flatten().collect()
2993 } else {
2994 let mut ext_stmt = conn.prepare(
2995 "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
2996 FROM pending_relationships p
2997 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2998 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2999 AND NOT EXISTS (
3000 SELECT 1 FROM symbols s_to
3001 WHERE s_to.name = p.target_terminal_name
3002 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
3003 )
3004 LIMIT ?3",
3005 )?;
3006
3007 let rows =
3008 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
3009 Ok((
3010 row.get::<_, String>(0)?,
3011 row.get::<_, String>(1)?.replace('\\', "/"),
3012 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
3013 ))
3014 })?;
3015 rows.flatten().collect()
3016 };
3017
3018 for r in ext_rows {
3019 let (name, path, line) = r;
3020 let entry = format!("{name} ({path}:{line})");
3021 if !signatures.contains(&entry) {
3022 signatures.push(entry);
3023 }
3024 }
3025 }
3026
3027 for v in variants {
3028 if signatures.len() >= limit {
3029 break;
3030 }
3031 if !signatures.contains(&v) {
3032 signatures.push(v);
3033 }
3034 }
3035
3036 signatures.truncate(limit);
3037 Ok(signatures)
3038}
3039
3040const SQL_FAMILIES: &[&str] = &["sql."];
3041
3042const ROUTE_FAMILIES: &[&str] = &[
3043 ".route.",
3044 ".attribute_route.",
3045 ".scope_route.",
3046 ".file_route.",
3047 ".route_handler.",
3048 ".route_reference.",
3049 ".route_group.",
3050 ".route_prefix.",
3051 ".resource_route.",
3052 ".router_mount.",
3053 ".include_router.",
3054 ".server_route.",
3055 ".route_definition.",
3056];
3057
3058const CONFIG_FAMILIES: &[&str] = &["toml.", "yaml.", "json.", "env."];
3059
3060const MODEL_FAMILIES: &[&str] = &[
3061 "sql.table_definition.v1",
3062 "sql.column_definition.v1",
3063 "sql.constraint.v1",
3064 "sql.foreign_key.v1",
3065 "sql.index_definition.v1",
3066 "json.schema.v1",
3067];
3068
3069const SIGNAL_FAMILIES: &[&str] = &[".signal_declaration."];
3070
3071const IMPORT_FAMILIES: &[&str] = &[".import_statement.", ".import."];
3072
3073const BINDING_FAMILIES: &[&str] = &[".binding."];
3074
3075const COMPONENT_FAMILIES: &[&str] = &[".object_instantiation.", ".object_type."];
3076
3077const MODULE_FAMILIES: &[&str] = &[".module."];
3078
3079const PRAGMA_FAMILIES: &[&str] = &[".pragma.", "javascript.qml_directive.v1"];
3080
3081const PROPERTY_FAMILIES: &[&str] = &[".property_declaration.", ".qt_property."];
3082
3083pub const CATEGORY_ALIASES: &[(&str, &[&str])] = &[
3088 ("sql", SQL_FAMILIES),
3089 ("query", SQL_FAMILIES),
3090 ("queries", SQL_FAMILIES),
3091 ("route", ROUTE_FAMILIES),
3092 ("routes", ROUTE_FAMILIES),
3093 ("config", CONFIG_FAMILIES),
3094 ("model", MODEL_FAMILIES),
3095 ("models", MODEL_FAMILIES),
3096 ("signal", SIGNAL_FAMILIES),
3097 ("signals", SIGNAL_FAMILIES),
3098 ("import", IMPORT_FAMILIES),
3099 ("imports", IMPORT_FAMILIES),
3100 ("binding", BINDING_FAMILIES),
3101 ("bindings", BINDING_FAMILIES),
3102 ("component", COMPONENT_FAMILIES),
3103 ("components", COMPONENT_FAMILIES),
3104 ("module", MODULE_FAMILIES),
3105 ("modules", MODULE_FAMILIES),
3106 ("pragma", PRAGMA_FAMILIES),
3107 ("property", PROPERTY_FAMILIES),
3108 ("properties", PROPERTY_FAMILIES),
3109];
3110
3111fn category_families(category: &str) -> Option<&'static [&'static str]> {
3112 let wanted = category.trim().to_ascii_lowercase();
3113 CATEGORY_ALIASES
3114 .iter()
3115 .find(|(alias, _)| *alias == wanted)
3116 .map(|(_, families)| *families)
3117}
3118
3119fn matches_family(pattern_id: &str, rule: &str) -> bool {
3120 if rule.starts_with('.') {
3121 pattern_id.contains(rule)
3122 } else if rule.ends_with('.') {
3123 pattern_id.starts_with(rule)
3124 } else {
3125 pattern_id == rule
3126 }
3127}
3128
3129fn family_clause(column: &str, families: &[&str]) -> String {
3130 let alternatives: Vec<String> = families
3131 .iter()
3132 .map(|rule| {
3133 let escaped = escape_like(rule);
3134 if rule.starts_with('.') {
3135 format!("{column} LIKE '%{escaped}%' ESCAPE '\\'")
3136 } else if rule.ends_with('.') {
3137 format!("{column} LIKE '{escaped}%' ESCAPE '\\'")
3138 } else {
3139 format!("{column} = '{rule}'")
3140 }
3141 })
3142 .collect();
3143 format!("({})", alternatives.join(" OR "))
3144}
3145
3146pub fn is_category_alias(category: &str) -> bool {
3148 category_families(category).is_some()
3149}
3150
3151pub fn alias_fact_counts(categories: &[(String, usize)]) -> Vec<(&'static str, usize, usize)> {
3155 let mut reported: Vec<&[&str]> = Vec::new();
3156 let mut counts = Vec::new();
3157 for (alias, families) in CATEGORY_ALIASES {
3158 if reported.contains(families) {
3159 continue;
3160 }
3161 reported.push(families);
3162 let mut patterns = 0;
3163 let mut facts = 0;
3164 for (pattern_id, count) in categories {
3165 if families.iter().any(|rule| matches_family(pattern_id, rule)) {
3166 patterns += 1;
3167 facts += count;
3168 }
3169 }
3170 if facts > 0 {
3171 counts.push((*alias, patterns, facts));
3172 }
3173 }
3174 counts
3175}
3176
3177pub fn find_structural_facts_scoped(
3179 conn: &Connection,
3180 category: &str,
3181 path_filter: Option<&str>,
3182 limit: usize,
3183) -> Result<Vec<StructuralFact>, QueryError> {
3184 validate_result_limit(limit)?;
3185 let norm_path = path_filter
3186 .map(|p| {
3187 p.replace('\\', "/")
3188 .trim_start_matches("./")
3189 .trim_matches('/')
3190 .to_string()
3191 })
3192 .filter(|p| !p.is_empty());
3193 let dir_prefix = norm_path
3194 .as_deref()
3195 .map(|p| format!("{}/%", escape_like(p)));
3196 let cat_pattern = format!("%{}%", escape_like(category));
3197
3198 let cat_clause = match category_families(category) {
3199 Some(families) => family_clause("sf.pattern_id", families),
3200 None => {
3201 "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
3202 .to_string()
3203 }
3204 };
3205
3206 let sql = format!(
3207 "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
3208 sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
3209 sf.start_line, sf.end_line, sf.confidence,
3210 COALESCE(
3211 CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
3212 THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
3213 ELSE json_extract(sf.metadata_json, '$.key_path') END,
3214 json_extract(sf.metadata_json, '$.key'),
3215 json_extract(sf.metadata_json, '$.normalized_route_template'),
3216 CASE WHEN sf.pattern_id = 'cpp.qt_property.v1'
3217 THEN json_extract(sf.metadata_json, '$.name') END
3218 ) AS display_key,
3219 sf.metadata_json
3220 FROM structural_facts sf
3221 LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
3222 WHERE (:cat IS NOT NULL AND {cat_clause})
3223 AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3224 ORDER BY sf.path ASC, sf.start_line ASC
3225 LIMIT :limit"
3226 );
3227
3228 let mut stmt = conn.prepare(&sql)?;
3229 let rows = stmt.query_map(
3230 rusqlite::named_params! {
3231 ":cat": cat_pattern,
3232 ":path": norm_path.as_deref(),
3233 ":dir_prefix": dir_prefix.as_deref(),
3234 ":limit": limit as i64,
3235 },
3236 |row| {
3237 Ok(StructuralFact {
3238 structural_fact_id: row.get(0)?,
3239 path: row.get::<_, String>(1)?.replace('\\', "/"),
3240 language: row.get(2)?,
3241 pattern_id: row.get(3)?,
3242 capture_name: row.get(4)?,
3243 node_kind: row.get(5)?,
3244 key: row.get(10)?,
3245 metadata: row
3246 .get::<_, Option<String>>(11)?
3247 .map(|json| serde_json::from_str(&json))
3248 .transpose()
3249 .map_err(|err| {
3250 rusqlite::Error::FromSqlConversionFailure(
3251 11,
3252 rusqlite::types::Type::Text,
3253 Box::new(err),
3254 )
3255 })?,
3256 containing_symbol_name: row.get(6)?,
3257 start_line: row.get::<_, i64>(7)? as usize,
3258 end_line: row.get::<_, i64>(8)? as usize,
3259 confidence: row.get(9)?,
3260 })
3261 },
3262 )?;
3263
3264 let mut results = Vec::new();
3265 for r in rows {
3266 results.push(r?);
3267 }
3268 Ok(results)
3269}
3270
3271pub fn find_structural_facts(
3273 conn: &Connection,
3274 category: &str,
3275 limit: usize,
3276) -> Result<Vec<StructuralFact>, QueryError> {
3277 find_structural_facts_scoped(conn, category, None, limit)
3278}
3279
3280pub fn find_literals_scoped(
3282 conn: &Connection,
3283 category: &str,
3284 path_filter: Option<&str>,
3285 limit: usize,
3286) -> Result<Vec<LiteralFact>, QueryError> {
3287 validate_result_limit(limit)?;
3288 let norm_path = path_filter
3289 .map(|p| {
3290 p.replace('\\', "/")
3291 .trim_start_matches("./")
3292 .trim_matches('/')
3293 .to_string()
3294 })
3295 .filter(|p| !p.is_empty());
3296 let dir_prefix = norm_path
3297 .as_deref()
3298 .map(|p| format!("{}/%", escape_like(p)));
3299 let cat_pattern = format!("%{}%", escape_like(category));
3300
3301 let cat_lower = category.trim().to_ascii_lowercase();
3302 let cat_clause = match cat_lower.as_str() {
3303 "config" => {
3304 "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
3305 }
3306 "route" | "routes" => "l.kind LIKE '%route%'",
3307 "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
3308 "model" | "models" => "l.kind LIKE '%model%'",
3309 _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
3310 };
3311
3312 let sql = format!(
3313 "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
3314 l.start_line, s.name AS containing_symbol_name
3315 FROM literals l
3316 LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
3317 WHERE (:cat IS NOT NULL AND {cat_clause})
3318 AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3319 ORDER BY l.path ASC, l.start_line ASC
3320 LIMIT :limit"
3321 );
3322
3323 let mut stmt = conn.prepare(&sql)?;
3324 let rows = stmt.query_map(
3325 rusqlite::named_params! {
3326 ":cat": cat_pattern,
3327 ":path": norm_path.as_deref(),
3328 ":dir_prefix": dir_prefix.as_deref(),
3329 ":limit": limit as i64,
3330 },
3331 |row| {
3332 Ok(LiteralFact {
3333 literal_id: row.get(0)?,
3334 path: row.get::<_, String>(1)?.replace('\\', "/"),
3335 literal_text: row.get(2)?,
3336 kind: row.get(3)?,
3337 carrier: row.get(4)?,
3338 start_line: row.get::<_, i64>(5)? as usize,
3339 containing_symbol_name: row.get(6)?,
3340 })
3341 },
3342 )?;
3343
3344 let mut results = Vec::new();
3345 for r in rows {
3346 results.push(r?);
3347 }
3348 Ok(results)
3349}
3350
3351pub fn find_literals(
3353 conn: &Connection,
3354 category: &str,
3355 limit: usize,
3356) -> Result<Vec<LiteralFact>, QueryError> {
3357 find_literals_scoped(conn, category, None, limit)
3358}
3359
3360pub fn list_structural_fact_categories_scoped(
3362 conn: &Connection,
3363 path_filter: Option<&str>,
3364) -> Result<Vec<(String, usize)>, QueryError> {
3365 let norm_path = path_filter
3366 .map(|p| {
3367 p.replace('\\', "/")
3368 .trim_start_matches("./")
3369 .trim_matches('/')
3370 .to_string()
3371 })
3372 .filter(|p| !p.is_empty());
3373 let dir_prefix = norm_path
3374 .as_deref()
3375 .map(|p| format!("{}/%", escape_like(p)));
3376
3377 let mut categories = Vec::new();
3378
3379 let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
3380 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3381 GROUP BY pattern_id ORDER BY cnt DESC";
3382 let mut stmt = conn.prepare(sql)?;
3383 let rows = stmt.query_map(
3384 rusqlite::named_params! {
3385 ":path": norm_path.as_deref(),
3386 ":dir_prefix": dir_prefix.as_deref(),
3387 },
3388 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3389 )?;
3390 for r in rows {
3391 categories.push(r?);
3392 }
3393
3394 let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
3395 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3396 GROUP BY kind ORDER BY cnt DESC";
3397 let mut lit_stmt = conn.prepare(lit_sql)?;
3398 let lit_rows = lit_stmt.query_map(
3399 rusqlite::named_params! {
3400 ":path": norm_path.as_deref(),
3401 ":dir_prefix": dir_prefix.as_deref(),
3402 },
3403 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3404 )?;
3405 for r in lit_rows {
3406 categories.push(r?);
3407 }
3408
3409 Ok(categories)
3410}
3411
3412pub fn list_structural_fact_categories(
3414 conn: &Connection,
3415) -> Result<Vec<(String, usize)>, QueryError> {
3416 list_structural_fact_categories_scoped(conn, None)
3417}
3418
3419pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
3421 let has_table: bool = conn
3422 .query_row(
3423 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
3424 [],
3425 |_| Ok(true),
3426 )
3427 .unwrap_or(false);
3428 if !has_table {
3429 return Ok(Vec::new());
3430 }
3431
3432 let mut stmt = conn.prepare(
3433 "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
3434 FROM type_facts
3435 WHERE symbol_id = ?1",
3436 )?;
3437
3438 let rows = stmt.query_map(params![symbol_id], |row| {
3439 Ok(TypeFact {
3440 type_fact_id: row.get(0)?,
3441 symbol_id: row.get(1)?,
3442 language: row.get(2)?,
3443 resolved_type: row.get(3)?,
3444 generic_params: row.get(4)?,
3445 })
3446 })?;
3447
3448 let mut results = Vec::new();
3449 for r in rows {
3450 results.push(r?);
3451 }
3452 Ok(results)
3453}
3454
3455pub fn is_test_path(path: &str) -> bool {
3462 let p = path.replace('\\', "/");
3463 let cut = p.rfind('/').map_or(0, |i| i + 1);
3464 let directories = format!("/{}/", p[..cut].to_lowercase());
3465 let file_name = &p[cut..];
3466 let lower_name = file_name.to_lowercase();
3467 directories.contains("/test/")
3468 || directories.contains("/tests/")
3469 || directories.contains("/autotests/")
3470 || directories.contains("/__tests__/")
3471 || lower_name.starts_with("tst_")
3472 || (lower_name.starts_with("test_")
3473 && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
3474 || lower_name.contains("_test.")
3475 || lower_name.contains(".test.")
3476 || lower_name.contains(".spec.")
3477 || lower_name == "test.rs"
3478 || lower_name == "tests.rs"
3479 || file_name.ends_with("Tests.cs")
3480}
3481
3482pub(crate) fn test_path_predicate(alias: &str) -> String {
3491 let guard = format!(
3492 "(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%' OR lower({alias}.path) LIKE '%tst\\_%' ESCAPE '\\')"
3493 );
3494 let p = format!("replace({alias}.path, '\\', '/')");
3495 let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
3496 let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
3497 let lower_name = format!("lower({file_name})");
3498 let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
3499 let clauses = [
3500 like(&directories, "%/test/%"),
3501 like(&directories, "%/tests/%"),
3502 like(&directories, "%/autotests/%"),
3503 like(&directories, "%/\\_\\_tests\\_\\_/%"),
3504 like(&lower_name, "tst\\_%"),
3505 like(&lower_name, "test\\_%.py"),
3506 like(&lower_name, "test\\_%.rb"),
3507 like(&lower_name, "%\\_test.%"),
3508 like(&lower_name, "%.test.%"),
3509 like(&lower_name, "%.spec.%"),
3510 format!("{lower_name} = 'test.rs'"),
3511 format!("{lower_name} = 'tests.rs'"),
3512 format!("{file_name} GLOB '*Tests.cs'"),
3513 ]
3514 .join(" OR ");
3515 format!("({guard} AND ({clauses}))")
3516}
3517
3518pub fn compute_blast_radius_scoped(
3521 conn: &Connection,
3522 seed_symbols: &[&str],
3523 symbol_path_filter: Option<&str>,
3524 seed_paths: &[&str],
3525 max_depth: usize,
3526 limit: usize,
3527) -> Result<BlastRadiusResult, QueryError> {
3528 validate_result_limit(limit)?;
3529 let max_depth = max_depth.min(5);
3530 let resolved_seed_symbols = seed_symbols
3531 .iter()
3532 .map(|name| {
3533 get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
3534 let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
3535 QueryError::SymbolNotFound {
3536 name: (*name).to_string(),
3537 workspace,
3538 hint,
3539 }
3540 })
3541 })
3542 .collect::<Result<Vec<_>, _>>()?;
3543 let mut seeds = Vec::new();
3544 let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
3545 for s in seed_symbols {
3546 seeds.push(s.to_string());
3547 }
3548 for p in seed_paths {
3549 seeds.push(p.to_string());
3550 }
3551 "mixed".to_string()
3552 } else if !seed_symbols.is_empty() {
3553 for s in seed_symbols {
3554 seeds.push(s.to_string());
3555 }
3556 "symbol".to_string()
3557 } else if !seed_paths.is_empty() {
3558 for p in seed_paths {
3559 seeds.push(p.to_string());
3560 }
3561 "file".to_string()
3562 } else {
3563 return Ok(BlastRadiusResult {
3564 seed_type: "none".to_string(),
3565 seeds: Vec::new(),
3566 likely_tests: Vec::new(),
3567 impacted_symbols: Vec::new(),
3568 traversal_ceiling_reached: false,
3569 });
3570 };
3571
3572 let mut where_clauses = Vec::new();
3573 let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
3574
3575 if !resolved_seed_symbols.is_empty() {
3576 let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3577 .map(|i| format!("?{}", i))
3578 .collect();
3579 where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3580 for symbol in &resolved_seed_symbols {
3581 params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3582 }
3583 }
3584
3585 if !seed_paths.is_empty() {
3586 let mut path_conds = Vec::new();
3587 for p in seed_paths.iter() {
3588 let raw = p
3589 .replace('\\', "/")
3590 .trim_start_matches("./")
3591 .trim_matches('/')
3592 .to_string();
3593 let exact_idx = params_vec.len() + 1;
3594 params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3595 let dir_pattern = format!("{}/%", escape_like(&raw));
3596 let like_idx = params_vec.len() + 1;
3597 params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3598 path_conds.push(format!(
3599 "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3600 ));
3601 }
3602 where_clauses.push(format!("({})", path_conds.join(" OR ")));
3603 }
3604
3605 let seed_condition = where_clauses.join(" OR ");
3606 let max_depth_idx = params_vec.len() + 1;
3607 params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3608
3609 let mut traversal_ceiling_reached = false;
3610
3611 let has_relationships: bool = conn
3612 .query_row(
3613 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3614 [],
3615 |_| Ok(true),
3616 )
3617 .unwrap_or(false);
3618
3619 let has_pending: bool = conn
3620 .query_row(
3621 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3622 [],
3623 |_| Ok(true),
3624 )
3625 .unwrap_or(false);
3626
3627 let mut likely_tests = Vec::new();
3628 let mut impacted_symbols = Vec::new();
3629 let mut seen_test_keys = HashSet::new();
3630
3631 let mut recursive_branches = Vec::new();
3632
3633 if has_relationships {
3634 recursive_branches.push(format!(
3635 "SELECT r.from_symbol_id, iw.depth + 1
3636 FROM relationships r
3637 JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3638 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3639 WHERE iw.depth < ?{max_depth_idx}
3640 AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
3641 ));
3642 }
3643
3644 if has_pending {
3645 let (parent_join, ns_condition) = if conn
3646 .query_row(
3647 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3648 [],
3649 |_| Ok(true),
3650 )
3651 .unwrap_or(false)
3652 {
3653 (
3654 "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3655 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3656 format!("AND {pred}", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
3657 )
3658 } else {
3659 ("", String::new())
3660 };
3661
3662 recursive_branches.push(format!(
3663 "SELECT p.from_symbol_id, iw.depth + 1
3664 FROM pending_relationships p
3665 JOIN symbols s_target ON p.target_terminal_name = s_target.name
3666 JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3667 {parent_join}
3668 WHERE iw.depth < ?{max_depth_idx}
3669 AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3670 {ns_condition}"
3671 ));
3672 }
3673
3674 if !recursive_branches.is_empty() {
3675 let recursive_sql = recursive_branches.join("\n UNION \n");
3676 let not_documentation = not_documentation(conn, "s");
3677 let sql = format!(
3678 "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3679 SELECT symbol_id, 0
3680 FROM symbols
3681 WHERE ({seed_condition})
3682 AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3683
3684 UNION
3685
3686 {recursive_sql}
3687 )
3688 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
3689 FROM impact_walk iw
3690 CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3691 WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3692 AND {not_documentation}
3693 GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3694 HAVING MIN(iw.depth) > 0
3695 ORDER BY min_depth ASC, s.path ASC, s.name ASC
3696 LIMIT 200"
3697 );
3698
3699 let mut stmt = conn.prepare(&sql)?;
3700 let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3701 .iter()
3702 .map(|v| v as &dyn rusqlite::ToSql)
3703 .collect();
3704
3705 let rows = stmt.query_map(param_refs.as_slice(), |row| {
3706 Ok((
3707 row.get::<_, String>(0)?,
3708 row.get::<_, String>(1)?,
3709 row.get::<_, String>(2)?,
3710 row.get::<_, String>(3)?,
3711 row.get::<_, i64>(4)? as usize,
3712 row.get::<_, bool>(5)?,
3713 row.get::<_, bool>(6)?,
3714 row.get::<_, i64>(7)? as usize,
3715 ))
3716 })?;
3717
3718 let mut row_count = 0;
3719 for r in rows {
3720 row_count += 1;
3721 let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3722 let path = raw_path.replace('\\', "/");
3723 let is_test_target = is_test || test_container || is_test_path(&path);
3724
3725 if is_test_target {
3726 let key = format!("{}:{}", path, line);
3727 if seen_test_keys.insert(key) {
3728 likely_tests.push(TestTarget {
3729 name,
3730 path,
3731 line,
3732 reason: format!("transitive caller [depth {depth}]"),
3733 });
3734 }
3735 } else {
3736 impacted_symbols.push(ImpactedSymbol {
3737 name,
3738 kind,
3739 path,
3740 line,
3741 depth,
3742 });
3743 }
3744 }
3745 traversal_ceiling_reached = row_count >= 200;
3746 }
3747
3748 let mut file_stems = Vec::new();
3750 for p in seed_paths {
3751 if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
3752 && stem.len() >= 3
3753 && !file_stems.contains(&stem.to_string())
3754 {
3755 file_stems.push(stem.to_string());
3756 }
3757 }
3758 for symbol in &resolved_seed_symbols {
3759 if let Some(stem) = std::path::Path::new(&symbol.path)
3760 .file_stem()
3761 .and_then(|s| s.to_str())
3762 && stem.len() >= 3
3763 && !file_stems.contains(&stem.to_string())
3764 {
3765 file_stems.push(stem.to_string());
3766 }
3767 }
3768
3769 let has_files: bool = conn
3770 .query_row(
3771 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3772 [],
3773 |_| Ok(true),
3774 )
3775 .unwrap_or(false);
3776
3777 if has_files {
3778 let doc_file = format!(
3779 "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3780 not_documentation(conn, "d")
3781 );
3782 let mut test_files_stmt = conn.prepare(&format!(
3783 "SELECT DISTINCT path FROM files
3784 WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3785 AND NOT {doc_file}
3786 LIMIT 10"
3787 ))?;
3788 for stem in file_stems {
3789 let stem_pattern = format!("%{}%", escape_like(&stem));
3790 let t_rows =
3791 test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
3792 for p in t_rows.flatten() {
3793 let p = p.replace('\\', "/");
3794 let key = format!("{}:1", p);
3795 if seen_test_keys.insert(key) {
3796 likely_tests.push(TestTarget {
3797 name: p.clone(),
3798 path: p,
3799 line: 1,
3800 reason: "stem-matched test file".to_string(),
3801 });
3802 }
3803 }
3804 }
3805 }
3806
3807 if likely_tests.len() > limit {
3809 likely_tests.truncate(limit);
3810 }
3811 if impacted_symbols.len() > limit {
3812 impacted_symbols.truncate(limit);
3813 }
3814
3815 Ok(BlastRadiusResult {
3816 seed_type,
3817 seeds,
3818 likely_tests,
3819 impacted_symbols,
3820 traversal_ceiling_reached,
3821 })
3822}
3823
3824pub fn compute_blast_radius(
3826 conn: &Connection,
3827 seed_symbols: &[&str],
3828 seed_paths: &[&str],
3829 max_depth: usize,
3830 limit: usize,
3831) -> Result<BlastRadiusResult, QueryError> {
3832 compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3833}
3834
3835#[cfg(test)]
3836mod tests {
3837 #[test]
3838 fn result_limit_rejects_values_above_the_shared_ceiling() {
3839 assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3840 assert!(matches!(
3841 validate_result_limit(usize::MAX),
3842 Err(QueryError::InvalidResultLimit(usize::MAX))
3843 ));
3844 }
3845
3846 #[test]
3847 fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
3848 let conn = Connection::open_in_memory().unwrap();
3849
3850 assert!(matches!(
3851 find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
3852 Err(QueryError::InvalidResultLimit(usize::MAX))
3853 ));
3854 }
3855
3856 use super::*;
3857 use crate::db::{ensure_fts_index, open_read_write};
3858
3859 #[test]
3860 fn count_parse_diagnostics_counts_rows_for_one_file() {
3861 let dir = crate::safe_tempdir();
3862 let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
3863
3864 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
3865
3866 conn.execute_batch(
3867 "CREATE TABLE parse_diagnostics (
3868 diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
3869 );
3870 INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
3871 INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
3872 INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
3873 )
3874 .unwrap();
3875
3876 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
3877 assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
3878 assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
3879 }
3880
3881 #[test]
3882 fn test_sanitize_fts5_query() {
3883 let (and_q, or_q) = sanitize_fts5_query("parse tokens");
3884 assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
3885 assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
3886
3887 let (and_q, or_q) = sanitize_fts5_query(" Option<T> ");
3888 assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
3889 assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
3890
3891 let (and_q, or_q) = sanitize_fts5_query(" ");
3892 assert!(and_q.is_empty());
3893 assert!(or_q.is_empty());
3894 }
3895
3896 #[test]
3897 fn sanitize_splits_case_boundaries_and_drops_stop_words() {
3898 let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
3899 assert_eq!(
3900 and_q,
3901 "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
3902 );
3903 assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
3904
3905 let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
3906 assert_eq!(
3907 and_q,
3908 "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
3909 );
3910
3911 let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
3912 assert_eq!(
3913 and_q,
3914 "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
3915 );
3916 assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
3917
3918 let (and_q, _) = sanitize_fts5_query("validate_syntax");
3919 assert_eq!(
3920 and_q,
3921 "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
3922 );
3923
3924 let (and_q, _) = sanitize_fts5_query("isReady");
3925 assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
3926
3927 let (and_q, _) = sanitize_fts5_query("before");
3928 assert_eq!(and_q, "\"before\"*");
3929
3930 let (and_q, _) = sanitize_fts5_query("fooBar quux");
3931 assert_eq!(
3932 and_q,
3933 "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
3934 );
3935
3936 let (and_q, _) = sanitize_fts5_query("the for a");
3937 assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
3938 }
3939
3940 fn search_fixture(rows: &str) -> Connection {
3941 let conn = Connection::open_in_memory().unwrap();
3942 conn.execute_batch(&format!(
3943 "CREATE TABLE symbols (
3944 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3945 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
3946 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3947 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3948 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3949 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3950 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
3951 );
3952 INSERT INTO symbols VALUES {rows};"
3953 ))
3954 .unwrap();
3955 ensure_fts_index(&conn).unwrap();
3956 conn
3957 }
3958
3959 fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
3960 format!(
3961 "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
3962 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
3963 )
3964 }
3965
3966 fn doc_row(id: &str, name: &str, doc: &str) -> String {
3967 format!(
3968 "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
3969 3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
3970 )
3971 }
3972
3973 fn search_names(conn: &Connection, query: &str) -> Vec<String> {
3974 fts_search_symbols_scoped(conn, query, None, None, false, 10)
3975 .unwrap()
3976 .into_iter()
3977 .map(|r| r.symbol.name)
3978 .collect()
3979 }
3980
3981 const TEST_PATH_CASES: &[(&str, bool)] = &[
3982 ("tests/foo.py", true),
3983 ("tests/x.py", true),
3984 ("tests/tools/test_web.py", true),
3985 ("src/tests/x.rs", true),
3986 ("__tests__/a.ts", true),
3987 ("a/__tests__/b.ts", true),
3988 ("test/x.java", true),
3989 ("src/test/Helper.java", true),
3990 ("src/test_utils.py", true),
3991 ("lib/test_helper.rb", true),
3992 ("test_config.py", true),
3993 ("pkg/test_data/x.json", false),
3994 ("src/test_detection.rs", false),
3995 ("autotests/tst_pagerow.qml", true),
3996 ("autotests/helper.qml", true),
3997 ("src/autotests/columnview.cpp", true),
3998 ("tst_foo.qml", true),
3999 ("src/tst_columnview.qml", true),
4000 ("autotests\\tst_bar.qml", true),
4001 ("autotests_helper/x.rs", false),
4002 ("src/autotest.rs", false),
4003 ("src/tstamp.rs", false),
4004 ("src/tst.rs", false),
4005 ("crates/julie-index/src/analysis/test_quality.rs", false),
4006 ("x/foo_test.go", true),
4007 ("x/foo.test.ts", true),
4008 ("x/foo.spec.js", true),
4009 ("src/lib_test.rs", true),
4010 ("src/test.rs", true),
4011 ("tests.rs", true),
4012 ("src/tests.rs", true),
4013 ("Foo.Tests.cs", true),
4014 ("x/FooTests.cs", true),
4015 ("src/FooTests.cs", true),
4016 ("src/Foo.Tests.cs", true),
4017 ("tests/Foo.cs", true),
4018 ("x/parser.spec.ts", true),
4019 ("test_x.py", true),
4020 ("test.rs", true),
4021 ("tests\\x.py", true),
4022 ("src/protocol.spec.v1/parser.rs", false),
4023 ("pkg/test_support/runtime.py", false),
4024 ("src/Contests.cs", false),
4025 ("spec/x.rb", false),
4026 ("crates/x/src/impact/likely_tests.rs", false),
4027 ("x/foo_tests.rs", false),
4028 ("src/latest.rs", false),
4029 ("x/latest.go", false),
4030 ("x/manifest.rs", false),
4031 ("src/attest.rs", false),
4032 ("contest/x.py", false),
4033 ("src/testing.rs", false),
4034 ("src/main.rs", false),
4035 ("pkg/service.go", false),
4036 ];
4037
4038 #[test]
4039 fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
4040 let conn = Connection::open_in_memory().unwrap();
4041 let sql = format!(
4042 "SELECT {} FROM (SELECT :path AS path) s",
4043 test_path_predicate("s")
4044 );
4045 let mut stmt = conn.prepare(&sql).unwrap();
4046 for (path, expected) in TEST_PATH_CASES {
4047 assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
4048 let from_sql: bool = stmt
4049 .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
4050 .unwrap();
4051 assert_eq!(from_sql, *expected, "sql mirror: {path}");
4052 }
4053 }
4054
4055 #[test]
4056 fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
4057 let conn = search_fixture(
4058 &[
4059 code_row(
4060 "a",
4061 "src/parser.rs",
4062 "rust",
4063 "parse_sidecar",
4064 "Parse a sidecar.",
4065 ),
4066 code_row(
4067 "b",
4068 "src/tests/helpers.py",
4069 "python",
4070 "parse_sidecar_fixture",
4071 "Parse a sidecar.",
4072 ),
4073 ]
4074 .join(", "),
4075 );
4076
4077 let default_search: Vec<String> =
4078 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
4079 .unwrap()
4080 .into_iter()
4081 .map(|r| r.symbol.name)
4082 .collect();
4083 assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
4084
4085 let with_tests: Vec<String> =
4086 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
4087 .unwrap()
4088 .into_iter()
4089 .map(|r| r.symbol.name)
4090 .collect();
4091 assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
4092
4093 let default_lookup: Vec<String> =
4094 search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
4095 .unwrap()
4096 .into_iter()
4097 .map(|s| s.name)
4098 .collect();
4099 assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
4100
4101 let lookup_with_tests: Vec<String> =
4102 search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
4103 .unwrap()
4104 .into_iter()
4105 .map(|s| s.name)
4106 .collect();
4107 assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
4108 }
4109
4110 #[test]
4111 fn count_file_symbols_prefers_the_exact_case_path_like_the_loader() {
4112 let conn = search_fixture(
4113 &[
4114 code_row("a", "src/Foo.rs", "rust", "one", ""),
4115 code_row("b", "src/foo.rs", "rust", "two", ""),
4116 code_row("c", "src/foo.rs", "rust", "three", ""),
4117 ]
4118 .join(", "),
4119 );
4120 for path in ["src/Foo.rs", "src/foo.rs", "src/FOO.rs", "src\\foo.rs"] {
4121 assert_eq!(
4122 count_file_symbols(&conn, path),
4123 load_file_symbols(&conn, path).unwrap().len(),
4124 "{path}"
4125 );
4126 }
4127 assert_eq!(count_file_symbols(&conn, "src/Foo.rs"), 1);
4128 assert_eq!(count_file_symbols(&conn, "src/FOO.rs"), 3);
4129 }
4130
4131 #[test]
4132 fn lookup_statement_is_not_planned_as_a_multi_index_or() {
4133 let conn = search_fixture(&code_row("a", "src/lib.rs", "rust", "needle", ""));
4134 conn.execute_batch(
4135 "CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
4136 CREATE INDEX idx_symbols_test_container ON symbols(test_container);
4137 CREATE INDEX idx_symbols_is_test ON symbols(is_test);",
4138 )
4139 .unwrap();
4140 let sql = format!(
4141 "EXPLAIN QUERY PLAN {}",
4142 search_symbols_sql(false, false, 20)
4143 );
4144 let plan: Vec<String> = conn
4145 .prepare(&sql)
4146 .unwrap()
4147 .query_map(
4148 rusqlite::named_params! {
4149 ":query": "needle",
4150 ":pattern": "%needle%",
4151 ":kind": None::<&str>,
4152 ":path": None::<&str>,
4153 ":path_like": None::<&str>,
4154 },
4155 |row| row.get::<_, String>(3),
4156 )
4157 .unwrap()
4158 .collect::<Result<_, _>>()
4159 .unwrap();
4160 assert!(
4161 !plan.iter().any(|step| step.contains("MULTI-INDEX OR")),
4162 "{plan:?}"
4163 );
4164 }
4165
4166 #[test]
4167 fn qualified_lookup_in_a_test_file_returns_the_named_row() {
4168 let conn = search_fixture(
4169 &[
4170 "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
4171 1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
4172 "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
4173 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
4174 ]
4175 .join(", "),
4176 );
4177
4178 assert_eq!(
4179 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
4180 .unwrap()
4181 .len(),
4182 1
4183 );
4184 assert_eq!(
4185 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
4186 .unwrap()
4187 .len(),
4188 1
4189 );
4190 assert!(
4191 search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
4192 .unwrap()
4193 .is_empty()
4194 );
4195 }
4196
4197 #[test]
4198 fn concept_query_prefers_partial_code_match_over_full_doc_match() {
4199 let conn = search_fixture(
4200 &[
4201 doc_row(
4202 "d1",
4203 "Safety guarantees",
4204 "Pre-flight syntax validation runs before the edit touches disk",
4205 ),
4206 doc_row(
4207 "d2",
4208 "Audit",
4209 "The syntax validation before an edit is the invariant",
4210 ),
4211 code_row(
4212 "c1",
4213 "src/syntax.rs",
4214 "rust",
4215 "validate_syntax",
4216 "Validate the syntax of a file",
4217 ),
4218 code_row(
4219 "c2",
4220 "src/edit.rs",
4221 "rust",
4222 "replace_symbol_body",
4223 "Atomic edit with validation",
4224 ),
4225 ]
4226 .join(","),
4227 );
4228
4229 let names = search_names(&conn, "syntax validation before edit");
4230
4231 assert_eq!(names[0], "validate_syntax");
4232 assert!(names.contains(&"replace_symbol_body".to_string()));
4233 assert!(names.contains(&"Safety guarantees".to_string()));
4234 }
4235
4236 #[test]
4237 fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
4238 let conn = search_fixture(
4239 &[
4240 code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
4241 code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
4242 ]
4243 .join(","),
4244 );
4245
4246 let mut camel = search_names(&conn, "ValidateSyntax");
4247 camel.sort();
4248 assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
4249 let mut words = search_names(&conn, "validate syntax");
4250 words.sort();
4251 assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
4252 }
4253
4254 #[test]
4255 fn stop_word_prefixed_camel_case_symbol_is_still_found() {
4256 let conn = search_fixture(
4257 &[
4258 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4259 code_row("c2", "src/hooks.rs", "rust", "before", ""),
4260 code_row(
4261 "c3",
4262 "src/x.rs",
4263 "rust",
4264 "fooBar",
4265 "has fooBar but not the other word",
4266 ),
4267 ]
4268 .join(","),
4269 );
4270
4271 assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
4272 assert_eq!(search_names(&conn, "before"), vec!["before"]);
4273 }
4274
4275 #[test]
4276 fn related_tests_use_the_name_as_typed_without_splitting() {
4277 let conn = search_fixture(
4278 &[
4279 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4280 "('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(),
4281 "('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(),
4282 ]
4283 .join(","),
4284 );
4285 let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
4286
4287 let names: Vec<String> = find_related_tests(&conn, &target, 5)
4288 .unwrap()
4289 .into_iter()
4290 .map(|t| t.name)
4291 .collect();
4292
4293 assert_eq!(names, vec!["isReady_reports_true"]);
4294 }
4295
4296 #[test]
4297 fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
4298 let conn = search_fixture(
4299 &[
4300 code_row(
4301 "c1",
4302 "src/queries.rs",
4303 "rust",
4304 "fts_search_symbols_scoped",
4305 "search symbols scoped with fts",
4306 ),
4307 code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
4308 ]
4309 .join(","),
4310 );
4311
4312 assert_eq!(
4313 search_names(&conn, "search_symbols_scoped")[0],
4314 "search_symbols_scoped"
4315 );
4316 }
4317
4318 fn sidecar_fixture() -> Connection {
4319 search_fixture(
4320 &[
4321 code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
4322 code_row(
4323 "c2",
4324 "src/sidecar.rs",
4325 "rust",
4326 "parse_sidecar_file",
4327 "parse the sha256 sidecar file",
4328 ),
4329 ]
4330 .join(","),
4331 )
4332 }
4333
4334 fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
4335 candidates
4336 .iter()
4337 .find(|c| c.result.symbol.name == name)
4338 .unwrap_or_else(|| panic!("{name} is not a candidate"))
4339 }
4340
4341 #[test]
4342 fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
4343 let conn = sidecar_fixture();
4344
4345 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4346
4347 let target = candidate(&candidates, "parseSha256Sidecar");
4348 assert!(target.name_match);
4349 assert!(!target.word_match);
4350 assert!(!target.exact_name);
4351 assert!(target.name_terms.contains(&"sha256".to_string()));
4352 }
4353
4354 #[test]
4355 fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
4356 let conn = search_fixture(
4357 &[
4358 code_row(
4359 "c1",
4360 "examples/demo.rs",
4361 "rust",
4362 "demo",
4363 "restore offline state",
4364 ),
4365 code_row(
4366 "c2",
4367 "src/replay.rs",
4368 "rust",
4369 "replay",
4370 "restore offline records",
4371 ),
4372 ]
4373 .join(","),
4374 );
4375
4376 let candidates =
4377 collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
4378 .unwrap();
4379
4380 assert!(candidate(&candidates, "demo").word_match);
4381 assert!(candidate(&candidates, "replay").word_match);
4382 assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
4383 }
4384
4385 #[test]
4386 fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
4387 let mut rows: Vec<String> = (1..=170)
4388 .map(|i| {
4389 code_row(
4390 &format!("h{i:03}"),
4391 "src/sidecar.rs",
4392 "rust",
4393 &format!("sidecar_helper_{i:03}"),
4394 "parse sidecar file",
4395 )
4396 })
4397 .collect();
4398 rows.push(code_row(
4399 "c1",
4400 "src/sidecar.rs",
4401 "rust",
4402 "parseSha256Sidecar",
4403 "",
4404 ));
4405 let conn = search_fixture(&rows.join(","));
4406
4407 let candidates = collect_search_candidates(
4408 &conn,
4409 "parse the sha256 sidecar file",
4410 None,
4411 None,
4412 false,
4413 40,
4414 )
4415 .unwrap();
4416
4417 assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
4418 assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
4419 }
4420
4421 #[test]
4422 fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
4423 let mut rows: Vec<String> = (1..=20)
4424 .map(|i| {
4425 code_row(
4426 &format!("a{i:02}"),
4427 "src/a.rs",
4428 "rust",
4429 &format!("both_{i:02}"),
4430 "restore offline",
4431 )
4432 })
4433 .collect();
4434 rows.extend((1..=50).map(|i| {
4435 code_row(
4436 &format!("p{i:02}"),
4437 "src/p.rs",
4438 "rust",
4439 &format!("partial_{i:02}"),
4440 "restore records",
4441 )
4442 }));
4443 let conn = search_fixture(&rows.join(","));
4444
4445 let candidates =
4446 collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
4447
4448 let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
4449 assert_eq!(word_rows.len(), 40);
4450 assert_eq!(
4451 word_rows
4452 .iter()
4453 .filter(|c| c.result.symbol.name.starts_with("both_"))
4454 .count(),
4455 20
4456 );
4457 }
4458
4459 #[test]
4460 fn exact_name_is_admitted_regardless_of_case() {
4461 let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
4462
4463 let candidates =
4464 collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
4465 assert!(candidate(&candidates, "xyzzy_q").exact_name);
4466
4467 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4468 let candidates =
4469 collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
4470 assert!(candidate(&candidates, "xyzzy_q").exact_name);
4471 }
4472
4473 #[test]
4474 fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
4475 let conn = search_fixture(&code_row(
4476 "c1",
4477 "src/say.js",
4478 "javascript",
4479 "say \"hi\"",
4480 "",
4481 ));
4482
4483 let candidates =
4484 collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
4485
4486 let target = candidate(&candidates, "say \"hi\"");
4487 assert!(target.exact_name && target.name_match);
4488 }
4489
4490 #[test]
4491 fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
4492 let conn = search_fixture(
4493 &[
4494 code_row("c1", "src/a.rs", "rust", "sidecar", ""),
4495 code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
4496 ]
4497 .join(","),
4498 );
4499
4500 let candidates =
4501 collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
4502
4503 assert_eq!(candidates.len(), 2);
4504 let target = candidate(&candidates, "sidecar");
4505 assert!(target.exact_name && target.word_match && target.name_match);
4506 assert!(target.bm25.is_some());
4507 let helper = candidate(&candidates, "sidecar_helper");
4508 assert!(!helper.exact_name && helper.word_match && helper.name_match);
4509 }
4510
4511 #[test]
4512 fn an_index_without_the_trigram_table_returns_word_rows_only() {
4513 let conn = sidecar_fixture();
4514 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4515
4516 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4517
4518 let names: Vec<&str> = candidates
4519 .iter()
4520 .map(|c| c.result.symbol.name.as_str())
4521 .collect();
4522 assert_eq!(names, vec!["parse_sidecar_file"]);
4523 assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
4524 assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
4525 }
4526
4527 #[test]
4528 fn words_under_three_characters_skip_the_name_branch() {
4529 let conn = search_fixture(
4530 &[
4531 code_row("c1", "src/a.rs", "rust", "ab", ""),
4532 code_row("c2", "src/b.rs", "rust", "cab", ""),
4533 ]
4534 .join(","),
4535 );
4536
4537 let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
4538
4539 assert!(candidates.iter().all(|c| !c.name_match));
4540 assert!(candidate(&candidates, "ab").exact_name);
4541 }
4542
4543 #[test]
4544 fn trigram_terms_include_the_identifier_parts_of_each_word() {
4545 assert_eq!(
4546 trigram_name_terms("collapse_name"),
4547 vec!["collapse_name", "collapse", "name"]
4548 );
4549 assert_eq!(
4550 trigram_name_terms("parse the sha256 sidecar"),
4551 vec!["parse", "sha256", "sha", "256", "sidecar"]
4552 );
4553 assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
4554 assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
4555 assert!(trigram_name_terms("ab").is_empty());
4556 }
4557
4558 #[test]
4559 fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
4560 let conn = search_fixture(
4561 &[
4562 code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
4563 code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
4564 ]
4565 .join(","),
4566 );
4567
4568 let candidates =
4569 collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
4570
4571 let target = candidate(&candidates, "CollapseName");
4572 assert!(target.name_match);
4573 assert_eq!(target.name_terms, vec!["collapse", "name"]);
4574 assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
4575 }
4576
4577 fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
4578 Candidate {
4579 result: SymbolSearchResult {
4580 symbol: Symbol {
4581 symbol_id: format!("{path}:{name}"),
4582 file_id: "f".into(),
4583 path: path.into(),
4584 language: "rust".into(),
4585 name: name.into(),
4586 kind: kind.into(),
4587 signature: None,
4588 doc_comment: None,
4589 visibility: None,
4590 parent_symbol_id: None,
4591 start_line: 1,
4592 start_column: 0,
4593 end_line: 1,
4594 end_column: 0,
4595 start_byte: 0,
4596 end_byte: 0,
4597 body_start_line: None,
4598 body_start_column: None,
4599 body_end_line: None,
4600 body_end_column: None,
4601 body_start_byte: None,
4602 body_end_byte: None,
4603 body_hash: None,
4604 semantic_group: None,
4605 is_test: false,
4606 test_container: false,
4607 },
4608 score: 0.0,
4609 snippet: None,
4610 explain: None,
4611 },
4612 bm25: None,
4613 exact_name: false,
4614 word_match: false,
4615 name_match: false,
4616 name_terms: Vec::new(),
4617 documentation: false,
4618 }
4619 }
4620
4621 fn function(name: &str) -> Candidate {
4622 plain_candidate(name, "function", "src/lib.rs")
4623 }
4624
4625 fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
4626 rerank_with(candidates, query, false, None)
4627 }
4628
4629 fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
4630 ranked(candidates, query)
4631 .into_iter()
4632 .map(|(r, _)| r.symbol.name)
4633 .collect()
4634 }
4635
4636 fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
4637 let mut candidate = function(name);
4638 candidate.result.symbol.signature = signature.map(str::to_string);
4639 candidate.result.symbol.doc_comment = Some(doc.into());
4640 candidate
4641 }
4642
4643 fn strip_ansi_case() -> Vec<Candidate> {
4644 vec![
4645 documented("strip_ansi", None, "Remove ANSI escape sequences"),
4646 documented(
4647 "_strip_code_fences",
4648 Some("def _strip_code_fences(text: str)"),
4649 "The first fenced code block's body, or the stripped text",
4650 ),
4651 ]
4652 }
4653
4654 #[test]
4655 fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4656 assert_eq!(
4657 rerank_words("parse the sha256 sidecar file"),
4658 vec!["parse", "sha", "256", "sidecar", "file"]
4659 );
4660 assert_eq!(
4661 rerank_words("parse_sha256_sidecar"),
4662 vec!["parse", "sha", "256", "sidecar"]
4663 );
4664 assert_eq!(
4665 rerank_words("ParseHTTPResponse"),
4666 vec!["parse", "http", "response"]
4667 );
4668 assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4669 assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4670 }
4671
4672 #[test]
4673 fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4674 for word in ["was", "whether", "another"] {
4675 assert!(is_stop_word(word), "{word} must be a stop word");
4676 }
4677 for word in ["down", "into", "run"] {
4678 assert!(!is_stop_word(word), "{word} must stay a content word");
4679 }
4680 assert_eq!(rerank_words("what was the file"), vec!["file"]);
4681 }
4682
4683 #[test]
4684 fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4685 let mut private = function("_create_skill");
4686 private.bm25 = Some(-9.0);
4687 let mut public = function("create_skill");
4688 public.bm25 = Some(-1.0);
4689
4690 let rows = ranked(vec![private, public], "create skill");
4691
4692 assert_eq!(rows[0].0.score, rows[1].0.score);
4693 assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4694 assert_eq!(
4695 rows.iter()
4696 .map(|(r, _)| r.symbol.name.as_str())
4697 .collect::<Vec<_>>(),
4698 vec!["create_skill", "_create_skill"]
4699 );
4700 }
4701
4702 #[test]
4703 fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4704 let rows = ranked(
4705 vec![
4706 plain_candidate("Glob", "constant", "src/glob.rs"),
4707 function("matches_glob_pattern"),
4708 ],
4709 "glob",
4710 );
4711
4712 assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4713 assert_eq!(rows[1].1.name_tier, "whole");
4714 assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4715 }
4716
4717 #[test]
4718 fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4719 let rows = ranked(
4720 vec![
4721 function("validate_everything"),
4722 function("validate_syntax_now"),
4723 function("validate_syntax"),
4724 function("unrelated"),
4725 ],
4726 "validate syntax",
4727 );
4728 let tiers: Vec<(&str, &str, f64)> = rows
4729 .iter()
4730 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4731 .collect();
4732
4733 assert_eq!(
4734 tiers,
4735 vec![
4736 ("validate_syntax", "whole", W_NAME_WHOLE),
4737 ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4738 ("validate_everything", "partial", 0.0),
4739 ("unrelated", "none", 0.0),
4740 ]
4741 );
4742 assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4743 assert_eq!(
4744 rows[1].0.score,
4745 W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4746 );
4747 assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4748 assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4749 }
4750
4751 #[test]
4752 fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4753 assert_eq!(
4754 ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4755 vec!["strip_ansi", "_strip_code_fences"]
4756 );
4757 }
4758
4759 #[test]
4760 fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4761 let candidates = vec![
4762 function("execute_julie_extract"),
4763 documented("slice_bytes", None, "cut a byte range"),
4764 ];
4765
4766 let rows = ranked(candidates, "cut");
4767 let bonuses: Vec<(&str, &str, f64)> = rows
4768 .iter()
4769 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4770 .collect();
4771
4772 assert_eq!(
4773 bonuses,
4774 vec![
4775 ("execute_julie_extract", "partial", 0.0),
4776 ("slice_bytes", "none", 0.0),
4777 ]
4778 );
4779 assert_eq!(rows[0].0.score, rows[1].0.score);
4780 }
4781
4782 #[test]
4783 fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4784 let candidates = vec![
4785 function("validate_everything"),
4786 function("validate_syntax_now"),
4787 function("validate_syntax"),
4788 function("unrelated"),
4789 ];
4790
4791 assert_eq!(
4792 ranked_names(candidates, "validate syntax"),
4793 vec![
4794 "validate_syntax",
4795 "validate_syntax_now",
4796 "validate_everything",
4797 "unrelated"
4798 ]
4799 );
4800 }
4801
4802 #[test]
4803 fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4804 let mut rows: Vec<String> = (0..10)
4805 .map(|i| {
4806 code_row(
4807 &format!("s{i}"),
4808 &format!("src/f{i}.rs"),
4809 "rust",
4810 &format!("search_{i}"),
4811 "searches the index",
4812 )
4813 })
4814 .collect();
4815 rows.push(code_row(
4816 "rare",
4817 "src/rare.rs",
4818 "rust",
4819 "sanitize_input",
4820 "sanitize the input",
4821 ));
4822 let conn = search_fixture(&rows.join(", "));
4823 let terms = vec!["sanitize".to_string(), "search".to_string()];
4824
4825 let weights = idf_weights(&conn, &terms);
4826 assert!(weights[0] > weights[1]);
4827 }
4828
4829 #[test]
4830 fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
4831 let conn = search_fixture(&code_row(
4832 "n",
4833 "src/news.rs",
4834 "rust",
4835 "fetch_news",
4836 "fetch the news feed",
4837 ));
4838 let terms = vec!["news".to_string(), "unseen".to_string()];
4839
4840 let weights = idf_weights(&conn, &terms);
4841
4842 assert!(weights[0] < weights[1]);
4843 }
4844
4845 #[test]
4846 fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4847 let crediting_field = |padding: usize| {
4848 let mut row = function("handler");
4849 row.result.symbol.signature = Some(format!(
4850 "fn handler({}sidecar: u8)",
4851 "a: u8, ".repeat(padding)
4852 ));
4853 ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
4854 };
4855
4856 assert_eq!(crediting_field(4), "signature");
4857 assert_eq!(crediting_field(80), "none");
4858 }
4859
4860 #[test]
4861 fn explain_terms_name_the_crediting_field_of_every_query_term() {
4862 let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
4863 let terms: Vec<(&str, &str, f64)> = rows[0]
4864 .1
4865 .terms
4866 .iter()
4867 .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
4868 .collect();
4869
4870 assert_eq!(rows[0].0.symbol.name, "strip_ansi");
4871 assert_eq!(
4872 terms,
4873 vec![
4874 ("strip", "name", 3.0),
4875 ("ansi", "name", 3.0),
4876 ("escape", "doc", TEXT_CREDIT),
4877 ("codes", "none", 0.0),
4878 ]
4879 );
4880 }
4881
4882 #[test]
4883 fn name_coverage_accepts_token_runs_substrings_and_stems() {
4884 let strengths = |name: &str, query: &str| {
4885 let stemmer = Stemmer::create(Algorithm::English);
4886 let words: Vec<QueryWord> = rerank_words(query)
4887 .into_iter()
4888 .map(|word| QueryWord {
4889 stem: stemmer.stem(&word).into_owned(),
4890 word,
4891 })
4892 .collect();
4893 name_hits(name, &words, &stemmer)
4894 };
4895
4896 assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
4897 assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
4898 assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
4899 assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
4900 assert_eq!(strengths("is_ok", "ok"), vec![3]);
4901 assert_eq!(strengths("isReady", "is"), vec![3]);
4902 assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
4903 assert_eq!(
4904 strengths("parseSha256Sidecar", "sidecar checksum"),
4905 vec![3, 0]
4906 );
4907 assert_eq!(
4908 strengths("parseSha256Sidecar", "checksum digest"),
4909 vec![0, 0]
4910 );
4911 }
4912
4913 #[test]
4914 fn a_rarer_term_moves_the_score_more_than_a_common_one() {
4915 let weights = [4.0, 1.0];
4916 let rows = rerank_with(
4917 vec![function("rare_helper"), function("common_helper")],
4918 "rare common",
4919 false,
4920 Some(&weights),
4921 );
4922
4923 assert_eq!(
4924 rows[0].1.word_weights,
4925 vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
4926 );
4927 assert_eq!(rows[0].0.symbol.name, "rare_helper");
4928 assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
4929 assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
4930 }
4931
4932 #[test]
4933 fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
4934 let rows = ranked(
4935 vec![
4936 function("render_mode"),
4937 plain_candidate("retry_count", "constant", "src/scan.rs"),
4938 ],
4939 "how many times a failed download is tried again retry limit",
4940 );
4941
4942 assert_eq!(rows[0].0.symbol.name, "retry_count");
4943 assert_eq!(rows[0].1.name_tier, "partial");
4944 assert!(rows[0].0.score > rows[1].0.score);
4945 assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
4946 }
4947
4948 #[test]
4949 fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4950 let mut row = function("load");
4951 row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
4952 row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
4953 let (result, explain) = ranked(vec![row], "config settings").remove(0);
4954
4955 assert_eq!(
4956 explain.terms,
4957 vec![
4958 ("config".into(), "signature".into(), TEXT_CREDIT),
4959 ("settings".into(), "none".into(), 0.0),
4960 ]
4961 );
4962 assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
4963 }
4964
4965 #[test]
4966 fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
4967 let crediting_field =
4968 |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
4969 let doc_field = |doc: &str, query: &str| {
4970 let mut row = function("row");
4971 row.result.symbol.doc_comment = Some(doc.into());
4972 crediting_field(row, query)
4973 };
4974 let sig_field = |signature: &str, query: &str| {
4975 let mut row = function("row");
4976 row.result.symbol.signature = Some(signature.into());
4977 crediting_field(row, query)
4978 };
4979
4980 assert_eq!(doc_field("The system runs.", "stemming"), "none");
4981 assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
4982 assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
4983 assert_eq!(doc_field("An important port.", "porter"), "none");
4984 assert_eq!(
4985 sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
4986 "signature"
4987 );
4988 assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
4989 assert_eq!(sig_field("fn okay()", "ok"), "none");
4990 assert_eq!(
4991 sig_field("fn parseSha256Sidecar(text)", "sidecar"),
4992 "signature"
4993 );
4994 }
4995
4996 #[test]
4997 fn text_tokens_split_like_query_words_then_identifiers() {
4998 fn two_pass(text: &str) -> Vec<&str> {
4999 query_words(text)
5000 .into_iter()
5001 .flat_map(split_identifier)
5002 .collect()
5003 }
5004 fn one_pass(text: &str) -> Vec<&str> {
5005 let mut out = Vec::new();
5006 text_tokens_into(text, &mut out);
5007 out
5008 }
5009 let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
5010 let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
5011
5012 assert_eq!(one_pass(ascii), two_pass(ascii));
5013 assert_eq!(
5014 one_pass(ascii),
5015 vec![
5016 "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
5017 "sha", "256", "sum",
5018 ]
5019 );
5020 assert_eq!(one_pass(unicode), two_pass(unicode));
5021 assert!(one_pass("").is_empty());
5022 assert!(one_pass("_ __ ...").is_empty());
5023 }
5024
5025 #[test]
5026 fn a_doc_credits_a_term_by_its_stem() {
5027 let mut row = function("check");
5028 row.result.symbol.doc_comment = Some("Validates the input.".into());
5029 let explain = ranked(vec![row], "validation").remove(0).1;
5030
5031 assert_eq!(
5032 explain.terms,
5033 vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
5034 );
5035 }
5036
5037 #[test]
5038 fn kind_prior_orders_definitions_over_members_over_imports() {
5039 let rows = ranked(
5040 vec![
5041 plain_candidate("Scan", "import", "src/a.rs"),
5042 plain_candidate("Scan", "enum_member", "src/b.rs"),
5043 plain_candidate("Scan", "function", "src/c.rs"),
5044 ],
5045 "scan",
5046 );
5047 let order: Vec<(&str, f64)> = rows
5048 .iter()
5049 .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
5050 .collect();
5051
5052 assert_eq!(
5053 order,
5054 vec![
5055 ("src/c.rs", W_KIND_DEFINITION),
5056 ("src/b.rs", W_KIND_MEMBER),
5057 ("src/a.rs", W_KIND_IMPORT),
5058 ]
5059 );
5060 }
5061
5062 #[test]
5063 fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
5064 let names = ranked_names(
5065 vec![
5066 function("RenderMode"),
5067 plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
5068 ],
5069 "retry download limit timeout",
5070 );
5071
5072 assert_eq!(names[0], "MaxRetryCount");
5073 }
5074
5075 #[test]
5076 fn path_role_demotes_role_directories_unless_the_query_names_them() {
5077 let rows = |query: &str| {
5078 ranked(
5079 vec![
5080 plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
5081 plain_candidate("verify_checksum", "function", "src/archive.rs"),
5082 ],
5083 query,
5084 )
5085 };
5086
5087 let plain = rows("verify checksum");
5088 assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
5089 assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
5090
5091 let named = rows("launcher script verify checksum");
5092 assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
5093
5094 let only_launcher = rows("launcher verify checksum");
5095 assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
5096 assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
5097
5098 let windows = ranked(
5099 vec![plain_candidate(
5100 "verifyChecksum",
5101 "function",
5102 "scripts\\launcher.ts",
5103 )],
5104 "verify checksum",
5105 );
5106 assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
5107 }
5108
5109 #[test]
5110 fn documentation_rows_sort_after_every_code_row() {
5111 let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
5112 heading.documentation = true;
5113 heading.result.symbol.language = "markdown".into();
5114 heading.result.symbol.signature = Some("Verify checksum".into());
5115 heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
5116 let rows = ranked(
5117 vec![
5118 heading,
5119 plain_candidate("unrelated", "variable", "src/a.rs"),
5120 ],
5121 "verify checksum",
5122 );
5123
5124 assert_eq!(rows[0].0.symbol.name, "unrelated");
5125 assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
5126 assert_eq!(rows[1].1.name_tier, "whole");
5127 assert!(rows[1].0.score < 0.0);
5128 }
5129
5130 #[test]
5131 fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
5132 let rows = |query: &str, include_tests: bool| {
5133 let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
5134 test_row.result.symbol.is_test = true;
5135 let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
5136 rerank_with(vec![plain_row, test_row], query, include_tests, None)
5137 };
5138
5139 let boosted = rows("payment flow tests", true);
5140 assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
5141 assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
5142 assert_eq!(boosted[1].1.test_intent, 0.0);
5143
5144 assert!(
5145 rows("payment flow tests", false)
5146 .iter()
5147 .all(|(_, e)| e.test_intent == 0.0)
5148 );
5149 assert!(
5150 rows("payment flow", true)
5151 .iter()
5152 .all(|(_, e)| e.test_intent == 0.0)
5153 );
5154 }
5155
5156 #[test]
5157 fn ties_break_by_bm25_then_name_length_then_path() {
5158 let mut word_row = plain_candidate("payment", "function", "src/z.rs");
5159 word_row.word_match = true;
5160 word_row.bm25 = Some(-4.0);
5161 let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
5162 weaker_word_row.word_match = true;
5163 weaker_word_row.bm25 = Some(-2.0);
5164 let mut name_only = plain_candidate("payment", "function", "src/b.rs");
5165 name_only.name_match = true;
5166 let rows = ranked(
5167 vec![
5168 plain_candidate("payment", "function", "src/y.rs"),
5169 name_only,
5170 weaker_word_row,
5171 word_row,
5172 ],
5173 "payment",
5174 );
5175 let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
5176
5177 assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
5178
5179 let by_length = ranked_names(
5180 vec![
5181 function("payment_gateway_client"),
5182 function("payment_gateway"),
5183 ],
5184 "gateway",
5185 );
5186 assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
5187 }
5188
5189 #[test]
5190 fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
5191 let mut token = function("csr");
5192 token.bm25 = Some(-1.0);
5193 let mut substring = function("action_csrf_token");
5194 substring.bm25 = Some(-5.0);
5195
5196 let rows = ranked(vec![substring, token], "csr adjacency");
5197
5198 assert!(rows[0].0.score > rows[1].0.score);
5199 assert_eq!(rows[0].0.symbol.name, "csr");
5200 }
5201
5202 #[test]
5203 fn an_acronym_token_outranks_a_name_that_only_contains_it() {
5204 let mut token = function("http_client");
5205 token.bm25 = Some(-1.0);
5206 let mut substring = function("shttpd_config");
5207 substring.bm25 = Some(-5.0);
5208
5209 let rows = ranked(vec![substring, token], "http");
5210
5211 assert!(rows[0].0.score > rows[1].0.score);
5212 assert_eq!(rows[0].0.symbol.name, "http_client");
5213 }
5214
5215 #[test]
5216 fn explain_reports_the_sum_of_the_name_strengths() {
5217 let rows = ranked(vec![function("action_csrf_token")], "csr token");
5218
5219 assert_eq!(rows[0].1.name_strength, 4);
5220 }
5221
5222 #[test]
5223 fn snippets_follow_the_admitting_branch() {
5224 let mut word_row = function("parse_sidecar_file");
5225 word_row.word_match = true;
5226 word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
5227 let mut name_row = function("parseSha256Sidecar");
5228 name_row.name_match = true;
5229 name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
5230 let mut exact_row = function("sha256");
5231 exact_row.exact_name = true;
5232 let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
5233 let snippets: Vec<(&str, &str)> = rows
5234 .iter()
5235 .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
5236 .collect();
5237
5238 assert_eq!(
5239 snippets,
5240 vec![
5241 ("sha256", "sha256"),
5242 ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
5243 ("parse_sidecar_file", "parse the [sha256] sidecar file"),
5244 ]
5245 );
5246 assert_eq!(rows[1].1.branches, vec!["name"]);
5247 assert_eq!(rows[0].1.branches, vec!["exact"]);
5248 }
5249
5250 #[test]
5251 fn explain_is_attached_only_when_requested() {
5252 let conn = sidecar_fixture();
5253 let query = "sha256";
5254
5255 let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
5256 assert!(silent.iter().all(|r| r.explain.is_none()));
5257 assert!(silent[0].score > 0.0);
5258 assert_eq!(
5259 serde_json::to_value(&silent[0]).unwrap().get("explain"),
5260 None
5261 );
5262
5263 let explained =
5264 fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
5265 let by_name = |name: &str| {
5266 explained
5267 .iter()
5268 .find(|r| r.symbol.name == name)
5269 .and_then(|r| r.explain.as_ref())
5270 .unwrap()
5271 };
5272 let name_only = by_name("parseSha256Sidecar");
5273 assert_eq!(name_only.candidates, 2);
5274 assert_eq!(name_only.branches, vec!["name"]);
5275 assert_eq!(name_only.bm25, None);
5276 let word_row = by_name("parse_sidecar_file");
5277 assert!(word_row.bm25.unwrap() < 0.0);
5278 assert_eq!(word_row.candidates, 2);
5279 assert!(
5280 serde_json::to_value(&explained[0])
5281 .unwrap()
5282 .get("explain")
5283 .is_some()
5284 );
5285 }
5286
5287 #[test]
5288 fn search_symbols_treats_like_wildcards_as_literals() {
5289 let dir = crate::safe_tempdir();
5290 let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
5291 let conn = open_read_write(&db_path).unwrap();
5292 conn.execute_batch(
5293 "CREATE TABLE symbols (
5294 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5295 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5296 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5297 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5298 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5299 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5300 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5301 );
5302 INSERT INTO symbols VALUES (
5303 's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
5304 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5305 );
5306 INSERT INTO symbols VALUES (
5307 'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
5308 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5309 );
5310 INSERT INTO symbols VALUES (
5311 'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
5312 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5313 );
5314 CREATE TABLE files (
5315 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
5316 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
5317 );
5318 INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
5319 INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
5320 );",
5321 )
5322 .unwrap();
5323
5324 assert_eq!(
5325 search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
5326 "literal%name"
5327 );
5328 assert_eq!(
5329 search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
5330 "literal_name"
5331 );
5332 assert_eq!(
5333 load_scoped_files(&conn, Some("src/literal_path"))
5334 .unwrap()
5335 .len(),
5336 1
5337 );
5338 }
5339
5340 #[test]
5341 fn find_references_for_symbol_limits_callees_by_symbol_id() {
5342 let dir = crate::safe_tempdir();
5343 let db_path = dir.path().join("find_references_for_symbol.db");
5344 let conn = open_read_write(&db_path).unwrap();
5345 conn.execute_batch(
5346 "CREATE TABLE symbols (
5347 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5348 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5349 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5350 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5351 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5352 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5353 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5354 );
5355 CREATE TABLE relationships (
5356 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5357 start_line INTEGER, start_column INTEGER
5358 );
5359 CREATE TABLE pending_relationships (
5360 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5361 start_line INTEGER, start_column INTEGER
5362 );
5363 INSERT INTO symbols VALUES
5364 ('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),
5365 ('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),
5366 ('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),
5367 ('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);
5368 INSERT INTO relationships VALUES
5369 ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
5370 ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
5371 )
5372 .unwrap();
5373
5374 let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
5375 assert_eq!(references.len(), 1);
5376 assert_eq!(references[0].to_symbol_name, "wanted_dep");
5377 }
5378
5379 #[test]
5380 fn test_fts_search_symbols_and_porter_stemming() {
5381 let dir = crate::safe_tempdir();
5382 let db_path = dir.path().join("fts_search_symbols.db");
5383 let conn = open_read_write(&db_path).unwrap();
5384
5385 conn.execute_batch(
5386 "CREATE TABLE symbols (
5387 symbol_id TEXT PRIMARY KEY,
5388 file_id TEXT,
5389 path TEXT,
5390 language TEXT,
5391 name TEXT,
5392 kind TEXT,
5393 signature TEXT,
5394 doc_comment TEXT,
5395 visibility TEXT,
5396 parent_symbol_id TEXT,
5397 start_line INTEGER,
5398 start_column INTEGER,
5399 end_line INTEGER,
5400 end_column INTEGER,
5401 start_byte INTEGER,
5402 end_byte INTEGER,
5403 body_start_line INTEGER,
5404 body_start_column INTEGER,
5405 body_end_line INTEGER,
5406 body_end_column INTEGER,
5407 body_start_byte INTEGER,
5408 body_end_byte INTEGER,
5409 body_hash TEXT,
5410 semantic_group TEXT,
5411 is_test INTEGER,
5412 test_container INTEGER
5413 );
5414 INSERT INTO symbols VALUES (
5415 's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
5416 'pub trait PaymentGateway', 'Core payment provider interface for transactions',
5417 'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
5418 );
5419 INSERT INTO symbols VALUES (
5420 's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
5421 'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
5422 'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
5423 );
5424 INSERT INTO symbols VALUES (
5425 's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
5426 'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
5427 'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
5428 );
5429 INSERT INTO symbols VALUES (
5430 's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
5431 'fn test_payment_flow()', 'Tests payment charge workflow',
5432 NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
5433 );",
5434 )
5435 .unwrap();
5436
5437 ensure_fts_index(&conn).unwrap();
5438
5439 let results =
5441 fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
5442 assert_eq!(results.len(), 1);
5443 assert_eq!(results[0].symbol.name, "parse_tokens");
5444 assert!(results[0].snippet.is_some());
5445
5446 let results =
5448 fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
5449 assert_eq!(results.len(), 1);
5450 assert_eq!(results[0].symbol.name, "PaymentGateway");
5451
5452 let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
5454 assert_eq!(results.len(), 2);
5455 assert!(results.iter().all(|r| !r.symbol.is_test));
5456
5457 let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
5459 assert_eq!(results.len(), 3);
5460
5461 let results =
5463 fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
5464 assert_eq!(results.len(), 1);
5465 assert_eq!(results[0].symbol.name, "StripeClient");
5466 }
5467
5468 #[test]
5469 fn find_related_tests_returns_each_test_once_under_the_limit() {
5470 let dir = crate::safe_tempdir();
5471 let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
5472 conn.execute_batch(
5473 "CREATE TABLE symbols (
5474 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5475 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5476 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5477 start_column INTEGER, end_line INTEGER, end_column INTEGER,
5478 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5479 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5480 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5481 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5482 );
5483 CREATE TABLE relationships (
5484 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5485 start_line INTEGER, start_column INTEGER
5486 );
5487 CREATE TABLE pending_relationships (
5488 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5489 start_line INTEGER, start_column INTEGER,
5490 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5491 );
5492 CREATE TABLE type_facts (
5493 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5494 );
5495 INSERT INTO symbols VALUES
5496 ('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),
5497 ('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),
5498 ('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);
5499 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
5500 ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
5501 ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
5502 ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
5503 ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
5504 ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
5505 ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
5506 )
5507 .unwrap();
5508 let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
5509
5510 let tests = find_related_tests(&conn, &target, 5).unwrap();
5511
5512 let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
5513 names.sort();
5514 assert_eq!(names, vec!["first_case", "second_case"]);
5515 }
5516
5517 #[test]
5518 fn documentation_rows_rank_after_code_in_search() {
5519 let dir = crate::safe_tempdir();
5520 let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
5521 conn.execute_batch(
5522 "CREATE TABLE symbols (
5523 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5524 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5525 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5526 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5527 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5528 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5529 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5530 );
5531 INSERT INTO symbols VALUES
5532 ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
5533 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
5534 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
5535 ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
5536 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
5537 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
5538 )
5539 .unwrap();
5540 ensure_fts_index(&conn).unwrap();
5541
5542 let results =
5543 fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
5544 .unwrap();
5545
5546 assert_eq!(results.len(), 2);
5547 assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
5548 assert_eq!(results[1].symbol.name, "Reconcile offline edits");
5549 }
5550
5551 #[test]
5552 fn test_queries_nocase_and_path_normalization() {
5553 let conn = Connection::open_in_memory().unwrap();
5554 conn.execute_batch(
5555 "CREATE TABLE files (
5556 file_id TEXT PRIMARY KEY,
5557 path TEXT NOT NULL,
5558 language TEXT,
5559 content_hash TEXT,
5560 content_bytes INTEGER,
5561 line_count INTEGER,
5562 indexed_at INTEGER
5563 );
5564 CREATE TABLE symbols (
5565 symbol_id TEXT PRIMARY KEY,
5566 file_id TEXT,
5567 path TEXT NOT NULL,
5568 language TEXT,
5569 name TEXT,
5570 kind TEXT,
5571 signature TEXT,
5572 doc_comment TEXT,
5573 visibility TEXT,
5574 parent_symbol_id TEXT,
5575 start_line INTEGER,
5576 start_column INTEGER,
5577 end_line INTEGER,
5578 end_column INTEGER,
5579 start_byte INTEGER,
5580 end_byte INTEGER,
5581 body_start_line INTEGER,
5582 body_start_column INTEGER,
5583 body_end_line INTEGER,
5584 body_end_column INTEGER,
5585 body_start_byte INTEGER,
5586 body_end_byte INTEGER,
5587 body_hash TEXT,
5588 semantic_group TEXT,
5589 is_test INTEGER,
5590 test_container INTEGER
5591 );
5592 -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
5593 INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
5594 INSERT INTO symbols VALUES (
5595 's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
5596 'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5597 2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
5598 );",
5599 )
5600 .unwrap();
5601
5602 let file = get_file(&conn, "SRC/PAYMENT.RS")
5604 .unwrap()
5605 .expect("File should be found");
5606 assert_eq!(
5607 file.path, "src/Payment.rs",
5608 "Path should be normalized to forward slashes"
5609 );
5610
5611 let file2 = get_file(&conn, "src/payment.rs")
5612 .unwrap()
5613 .expect("File should be found");
5614 assert_eq!(file2.path, "src/Payment.rs");
5615
5616 let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
5618 assert_eq!(syms.len(), 1);
5619 assert_eq!(
5620 syms[0].path, "src/Payment.rs",
5621 "Symbol path should be normalized to forward slashes"
5622 );
5623
5624 let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
5626 .unwrap()
5627 .expect("Symbol should be found with case-insensitive path filter");
5628 assert_eq!(sym.path, "src/Payment.rs");
5629 }
5630
5631 #[test]
5632 fn test_exact_case_prioritized_over_nocase() {
5633 let conn = Connection::open_in_memory().unwrap();
5634 conn.execute_batch(
5635 "CREATE TABLE files (
5636 file_id TEXT PRIMARY KEY,
5637 path TEXT NOT NULL,
5638 language TEXT,
5639 content_hash TEXT,
5640 content_bytes INTEGER,
5641 line_count INTEGER,
5642 indexed_at TEXT
5643 );
5644 CREATE TABLE symbols (
5645 symbol_id TEXT PRIMARY KEY,
5646 file_id TEXT,
5647 path TEXT NOT NULL,
5648 language TEXT,
5649 name TEXT NOT NULL,
5650 kind TEXT NOT NULL,
5651 signature TEXT,
5652 doc_comment TEXT,
5653 visibility TEXT,
5654 parent_symbol_id TEXT,
5655 start_line INTEGER,
5656 start_column INTEGER,
5657 end_line INTEGER,
5658 end_column INTEGER,
5659 start_byte INTEGER,
5660 end_byte INTEGER,
5661 body_start_line INTEGER,
5662 body_start_column INTEGER,
5663 body_end_line INTEGER,
5664 body_end_column INTEGER,
5665 body_start_byte INTEGER,
5666 body_end_byte INTEGER,
5667 body_hash TEXT,
5668 semantic_group TEXT,
5669 is_test INTEGER,
5670 test_container INTEGER
5671 );
5672 INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5673 INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5674 INSERT INTO symbols VALUES (
5675 's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5676 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5677 2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5678 );
5679 INSERT INTO symbols VALUES (
5680 's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5681 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5682 2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5683 );",
5684 )
5685 .unwrap();
5686
5687 let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5689 assert_eq!(f_lower.path, "src/payment.rs");
5690 assert_eq!(f_lower.file_id, "f2");
5691
5692 let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5693 assert_eq!(f_upper.path, "src/Payment.rs");
5694 assert_eq!(f_upper.file_id, "f1");
5695
5696 let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5697 assert_eq!(syms_lower.len(), 1);
5698 assert_eq!(syms_lower[0].file_id, "f2");
5699
5700 let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5701 assert_eq!(syms_upper.len(), 1);
5702 assert_eq!(syms_upper[0].file_id, "f1");
5703 }
5704
5705 #[test]
5706 fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5707 let dir = crate::safe_tempdir();
5708 let db_path = dir.path().join("conservative_resolution.db");
5709 let conn = open_read_write(&db_path).unwrap();
5710
5711 conn.execute_batch(
5712 "CREATE TABLE symbols (
5713 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5714 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5715 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5716 start_column INTEGER, end_line INTEGER, end_column INTEGER,
5717 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5718 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5719 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5720 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5721 );
5722 CREATE TABLE relationships (
5723 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5724 start_line INTEGER, start_column INTEGER
5725 );
5726 CREATE TABLE pending_relationships (
5727 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5728 start_line INTEGER, start_column INTEGER,
5729 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5730 );
5731 CREATE TABLE type_facts (
5732 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5733 );
5734 -- Workspace struct Workspace and method Workspace::new
5735 INSERT INTO symbols VALUES
5736 ('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),
5737 ('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),
5738 ('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);
5739
5740 -- my_func calls Vec::new() (external namespace 'Vec')
5741 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
5742 ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5743 )
5744 .unwrap();
5745
5746 let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5748 assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5749
5750 let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5751 assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5752
5753 let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5755 assert!(
5756 callers.is_empty(),
5757 "Expected 0 callers for Workspace::new, got: {:?}",
5758 callers
5759 );
5760
5761 let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5763 assert!(
5764 !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5765 "my_func should not be impacted before calling Workspace::new: {:?}",
5766 blast.impacted_symbols
5767 );
5768
5769 conn.execute(
5771 "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')",
5772 [],
5773 )
5774 .unwrap();
5775
5776 let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5777 assert_eq!(
5778 sigs2.len(),
5779 1,
5780 "Expected 1 signature for Workspace::new, got: {:?}",
5781 sigs2
5782 );
5783 assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5784
5785 let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5787 assert!(
5788 blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5789 "my_func should be impacted after calling Workspace::new: {:?}",
5790 blast2.impacted_symbols
5791 );
5792
5793 conn.execute(
5795 "INSERT INTO symbols VALUES
5796 ('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);",
5797 [],
5798 )
5799 .unwrap();
5800 conn.execute(
5801 "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')",
5802 [],
5803 )
5804 .unwrap();
5805
5806 let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
5808 assert!(
5809 sigs_other.is_empty(),
5810 "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
5811 sigs_other
5812 );
5813
5814 conn.execute(
5816 "INSERT INTO symbols VALUES
5817 ('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);",
5818 [],
5819 )
5820 .unwrap();
5821 conn.execute(
5822 "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')",
5823 [],
5824 )
5825 .unwrap();
5826
5827 let sigs_sibling =
5828 find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
5829 assert_eq!(
5830 sigs_sibling.len(),
5831 1,
5832 "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
5833 sigs_sibling
5834 );
5835
5836 let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
5838 assert!(
5839 ext_sigs.iter().any(|s| s.contains("Vec")),
5840 "include_external: true should include external Vec::new: {:?}",
5841 ext_sigs
5842 );
5843 }
5844
5845 #[test]
5846 fn test_find_structural_facts_and_literals_scoped() {
5847 let dir = crate::safe_tempdir();
5848 let db_path = dir.path().join("facts_test.db");
5849 let conn = open_read_write(&db_path).unwrap();
5850 conn.execute_batch(
5851 "CREATE TABLE symbols (
5852 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5853 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5854 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5855 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5856 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5857 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5858 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5859 );
5860 CREATE TABLE structural_facts (
5861 structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5862 pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
5863 start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
5864 );
5865 CREATE TABLE literals (
5866 literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5867 kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
5868 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5869 start_byte INTEGER, end_byte INTEGER
5870 );
5871 INSERT INTO structural_facts VALUES
5872 ('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\"}'),
5873 ('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\"}'),
5874 ('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\"}'),
5875 ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
5876 ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
5877 ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
5878 ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
5879 INSERT INTO literals VALUES
5880 ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
5881 ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
5882 ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
5883 ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
5884 )
5885 .unwrap();
5886
5887 let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
5889 assert_eq!(facts_config.len(), 2);
5890 assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
5891 assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
5892 assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
5893 assert_eq!(
5894 facts_config[1].key.as_deref(),
5895 Some("mcp_servers.code-kb.command")
5896 );
5897 let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
5898 assert_eq!(lits_config.len(), 1);
5899 assert_eq!(lits_config[0].kind, "toml_key");
5900
5901 let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
5903 assert_eq!(facts_route.len(), 1);
5904 assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
5905 assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
5906 let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
5907 assert_eq!(facts_routes.len(), 1);
5908 let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
5909 assert_eq!(lits_route.len(), 1);
5910 assert_eq!(lits_route[0].kind, "http_route");
5911
5912 for q in &["query", "queries", "sql"] {
5914 let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
5915 assert_eq!(facts.len(), 2, "Failed for {}", q);
5916 assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
5917 let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
5918 assert_eq!(lits.len(), 1, "Failed for {}", q);
5919 assert_eq!(lits[0].kind, "sql_query");
5920 }
5921
5922 for m in &["model", "models"] {
5924 let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
5925 assert_eq!(facts.len(), 1, "Failed for {}", m);
5926 assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
5927 let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
5928 assert_eq!(lits.len(), 1, "Failed for {}", m);
5929 assert_eq!(lits[0].kind, "model_table");
5930 }
5931
5932 let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
5934 assert_eq!(facts_custom.len(), 1);
5935 assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
5936 assert_eq!(facts_custom[0].key, None);
5937
5938 let facts_exact =
5940 find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
5941 assert_eq!(facts_exact.len(), 1);
5942 let facts_miss =
5943 find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
5944 assert_eq!(facts_miss.len(), 0);
5945
5946 let facts_dir =
5948 find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
5949 assert_eq!(facts_dir.len(), 1);
5950 let facts_dir_miss =
5951 find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
5952 assert_eq!(facts_dir_miss.len(), 0);
5953
5954 let f_del = find_structural_facts(&conn, "config", 10).unwrap();
5956 assert_eq!(f_del.len(), 2);
5957 let l_del = find_literals(&conn, "config", 10).unwrap();
5958 assert_eq!(l_del.len(), 1);
5959 }
5960
5961 fn local_variable_fixture() -> Connection {
5962 let conn = Connection::open_in_memory().unwrap();
5963 conn.execute_batch(
5964 "CREATE TABLE symbols (
5965 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5966 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5967 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5968 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5969 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5970 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5971 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5972 );
5973 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5974 parent_symbol_id, start_line, start_column, end_line, end_column,
5975 start_byte, end_byte, is_test, test_container)
5976 VALUES
5977 ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
5978 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5979 ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5980 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5981 ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
5982 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
5983 ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5984 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
5985 ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5986 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
5987 ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
5988 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
5989 ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5990 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
5991 )
5992 .unwrap();
5993 conn
5994 }
5995
5996 fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
5997 let mut stmt = conn
5998 .prepare(
5999 "SELECT s.symbol_id FROM symbols_fts f
6000 JOIN symbols s ON s.rowid = f.rowid
6001 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
6002 )
6003 .unwrap();
6004 let mut ids = stmt
6005 .query_map(params![query], |row| row.get::<_, String>(0))
6006 .unwrap()
6007 .collect::<Result<Vec<_>, _>>()
6008 .unwrap();
6009 ids.sort();
6010 ids
6011 }
6012
6013 #[test]
6014 fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
6015 let conn = local_variable_fixture();
6016 conn.execute_batch(
6017 "CREATE VIRTUAL TABLE symbols_fts USING fts5(
6018 name, signature, doc_comment,
6019 content='symbols', content_rowid='rowid', tokenize='porter unicode61'
6020 );
6021 INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
6022 SELECT rowid, name, signature, doc_comment FROM symbols;",
6023 )
6024 .unwrap();
6025
6026 ensure_fts_index(&conn).unwrap();
6027
6028 assert_eq!(
6029 matched_symbol_ids(&conn, "sqlite"),
6030 vec!["field", "func", "global", "pool"]
6031 );
6032 }
6033
6034 #[test]
6035 fn lookup_excludes_locals_and_parameters() {
6036 let conn = local_variable_fixture();
6037
6038 let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
6039 .unwrap()
6040 .into_iter()
6041 .map(|s| s.symbol_id)
6042 .collect();
6043
6044 assert!(!ids.contains(&"local".to_string()));
6045 assert!(!ids.contains(&"nested".to_string()));
6046 assert!(ids.contains(&"field".to_string()));
6047 assert!(ids.contains(&"global".to_string()));
6048 }
6049
6050 #[test]
6051 fn search_excludes_locals_and_parameters() {
6052 let conn = local_variable_fixture();
6053 ensure_fts_index(&conn).unwrap();
6054
6055 let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
6056 .unwrap()
6057 .into_iter()
6058 .map(|r| r.symbol.symbol_id)
6059 .collect();
6060
6061 assert!(!ids.contains(&"local".to_string()));
6062 assert!(ids.contains(&"func".to_string()));
6063 }
6064
6065 #[test]
6066 fn variable_kind_search_keeps_full_text_matching() {
6067 let conn = local_variable_fixture();
6068 ensure_fts_index(&conn).unwrap();
6069
6070 let ids: Vec<String> = fts_search_symbols_scoped(
6071 &conn,
6072 "sqlite connection",
6073 Some("variable"),
6074 None,
6075 false,
6076 10,
6077 )
6078 .unwrap()
6079 .into_iter()
6080 .map(|r| r.symbol.symbol_id)
6081 .collect();
6082
6083 assert!(ids.contains(&"global".to_string()));
6084 assert!(ids.contains(&"field".to_string()));
6085 }
6086
6087 #[test]
6088 fn qualified_lookup_returns_the_named_local_variable() {
6089 let conn = local_variable_fixture();
6090
6091 let ids: Vec<String> =
6092 search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
6093 .unwrap()
6094 .into_iter()
6095 .map(|s| s.symbol_id)
6096 .collect();
6097
6098 assert_eq!(ids, vec!["local".to_string()]);
6099 }
6100
6101 #[test]
6102 fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
6103 let conn = Connection::open_in_memory().unwrap();
6104 conn.execute_batch(
6105 "CREATE TABLE symbols (
6106 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
6107 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
6108 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
6109 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
6110 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
6111 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
6112 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
6113 );
6114 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
6115 parent_symbol_id, start_line, start_column, end_line, end_column,
6116 start_byte, end_byte, is_test, test_container)
6117 VALUES
6118 ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
6119 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
6120 ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
6121 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
6122 ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
6123 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
6124 )
6125 .unwrap();
6126 ensure_fts_index(&conn).unwrap();
6127
6128 let rows =
6129 fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
6130 .unwrap();
6131
6132 assert_eq!(rows.len(), 1);
6133 assert_eq!(rows[0].symbol.symbol_id, "local");
6134 let explain = rows[0].explain.as_ref().unwrap();
6135 assert_eq!(explain.name_tier, "whole");
6136 assert_eq!(explain.branches, vec!["exact", "name"]);
6137 assert_eq!(explain.candidates, 2);
6138 }
6139
6140 #[test]
6141 fn variable_kind_filter_returns_locals_and_parameters() {
6142 let conn = local_variable_fixture();
6143 ensure_fts_index(&conn).unwrap();
6144
6145 let lookup_ids: Vec<String> =
6146 search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6147 .unwrap()
6148 .into_iter()
6149 .map(|s| s.symbol_id)
6150 .collect();
6151 assert!(lookup_ids.contains(&"local".to_string()));
6152 assert!(lookup_ids.contains(&"nested".to_string()));
6153
6154 let search_ids: Vec<String> =
6155 fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6156 .unwrap()
6157 .into_iter()
6158 .map(|r| r.symbol.symbol_id)
6159 .collect();
6160 assert!(search_ids.contains(&"local".to_string()));
6161 }
6162}