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."];
3080
3081pub const CATEGORY_ALIASES: &[(&str, &[&str])] = &[
3086 ("sql", SQL_FAMILIES),
3087 ("query", SQL_FAMILIES),
3088 ("queries", SQL_FAMILIES),
3089 ("route", ROUTE_FAMILIES),
3090 ("routes", ROUTE_FAMILIES),
3091 ("config", CONFIG_FAMILIES),
3092 ("model", MODEL_FAMILIES),
3093 ("models", MODEL_FAMILIES),
3094 ("signal", SIGNAL_FAMILIES),
3095 ("signals", SIGNAL_FAMILIES),
3096 ("import", IMPORT_FAMILIES),
3097 ("imports", IMPORT_FAMILIES),
3098 ("binding", BINDING_FAMILIES),
3099 ("bindings", BINDING_FAMILIES),
3100 ("component", COMPONENT_FAMILIES),
3101 ("components", COMPONENT_FAMILIES),
3102 ("module", MODULE_FAMILIES),
3103 ("modules", MODULE_FAMILIES),
3104 ("pragma", PRAGMA_FAMILIES),
3105];
3106
3107fn category_families(category: &str) -> Option<&'static [&'static str]> {
3108 let wanted = category.trim().to_ascii_lowercase();
3109 CATEGORY_ALIASES
3110 .iter()
3111 .find(|(alias, _)| *alias == wanted)
3112 .map(|(_, families)| *families)
3113}
3114
3115fn matches_family(pattern_id: &str, rule: &str) -> bool {
3116 if rule.starts_with('.') {
3117 pattern_id.contains(rule)
3118 } else if rule.ends_with('.') {
3119 pattern_id.starts_with(rule)
3120 } else {
3121 pattern_id == rule
3122 }
3123}
3124
3125fn family_clause(column: &str, families: &[&str]) -> String {
3126 let alternatives: Vec<String> = families
3127 .iter()
3128 .map(|rule| {
3129 let escaped = escape_like(rule);
3130 if rule.starts_with('.') {
3131 format!("{column} LIKE '%{escaped}%' ESCAPE '\\'")
3132 } else if rule.ends_with('.') {
3133 format!("{column} LIKE '{escaped}%' ESCAPE '\\'")
3134 } else {
3135 format!("{column} = '{rule}'")
3136 }
3137 })
3138 .collect();
3139 format!("({})", alternatives.join(" OR "))
3140}
3141
3142pub fn is_category_alias(category: &str) -> bool {
3144 category_families(category).is_some()
3145}
3146
3147pub fn alias_fact_counts(categories: &[(String, usize)]) -> Vec<(&'static str, usize, usize)> {
3151 let mut reported: Vec<&[&str]> = Vec::new();
3152 let mut counts = Vec::new();
3153 for (alias, families) in CATEGORY_ALIASES {
3154 if reported.contains(families) {
3155 continue;
3156 }
3157 reported.push(families);
3158 let mut patterns = 0;
3159 let mut facts = 0;
3160 for (pattern_id, count) in categories {
3161 if families.iter().any(|rule| matches_family(pattern_id, rule)) {
3162 patterns += 1;
3163 facts += count;
3164 }
3165 }
3166 if facts > 0 {
3167 counts.push((*alias, patterns, facts));
3168 }
3169 }
3170 counts
3171}
3172
3173pub fn find_structural_facts_scoped(
3175 conn: &Connection,
3176 category: &str,
3177 path_filter: Option<&str>,
3178 limit: usize,
3179) -> Result<Vec<StructuralFact>, QueryError> {
3180 validate_result_limit(limit)?;
3181 let norm_path = path_filter
3182 .map(|p| {
3183 p.replace('\\', "/")
3184 .trim_start_matches("./")
3185 .trim_matches('/')
3186 .to_string()
3187 })
3188 .filter(|p| !p.is_empty());
3189 let dir_prefix = norm_path
3190 .as_deref()
3191 .map(|p| format!("{}/%", escape_like(p)));
3192 let cat_pattern = format!("%{}%", escape_like(category));
3193
3194 let cat_clause = match category_families(category) {
3195 Some(families) => family_clause("sf.pattern_id", families),
3196 None => {
3197 "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
3198 .to_string()
3199 }
3200 };
3201
3202 let sql = format!(
3203 "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
3204 sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
3205 sf.start_line, sf.end_line, sf.confidence,
3206 COALESCE(
3207 CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
3208 THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
3209 ELSE json_extract(sf.metadata_json, '$.key_path') END,
3210 json_extract(sf.metadata_json, '$.key'),
3211 json_extract(sf.metadata_json, '$.normalized_route_template')
3212 ) AS display_key
3213 FROM structural_facts sf
3214 LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
3215 WHERE (:cat IS NOT NULL AND {cat_clause})
3216 AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3217 ORDER BY sf.path ASC, sf.start_line ASC
3218 LIMIT :limit"
3219 );
3220
3221 let mut stmt = conn.prepare(&sql)?;
3222 let rows = stmt.query_map(
3223 rusqlite::named_params! {
3224 ":cat": cat_pattern,
3225 ":path": norm_path.as_deref(),
3226 ":dir_prefix": dir_prefix.as_deref(),
3227 ":limit": limit as i64,
3228 },
3229 |row| {
3230 Ok(StructuralFact {
3231 structural_fact_id: row.get(0)?,
3232 path: row.get::<_, String>(1)?.replace('\\', "/"),
3233 language: row.get(2)?,
3234 pattern_id: row.get(3)?,
3235 capture_name: row.get(4)?,
3236 node_kind: row.get(5)?,
3237 key: row.get(10)?,
3238 containing_symbol_name: row.get(6)?,
3239 start_line: row.get::<_, i64>(7)? as usize,
3240 end_line: row.get::<_, i64>(8)? as usize,
3241 confidence: row.get(9)?,
3242 })
3243 },
3244 )?;
3245
3246 let mut results = Vec::new();
3247 for r in rows {
3248 results.push(r?);
3249 }
3250 Ok(results)
3251}
3252
3253pub fn find_structural_facts(
3255 conn: &Connection,
3256 category: &str,
3257 limit: usize,
3258) -> Result<Vec<StructuralFact>, QueryError> {
3259 find_structural_facts_scoped(conn, category, None, limit)
3260}
3261
3262pub fn find_literals_scoped(
3264 conn: &Connection,
3265 category: &str,
3266 path_filter: Option<&str>,
3267 limit: usize,
3268) -> Result<Vec<LiteralFact>, QueryError> {
3269 validate_result_limit(limit)?;
3270 let norm_path = path_filter
3271 .map(|p| {
3272 p.replace('\\', "/")
3273 .trim_start_matches("./")
3274 .trim_matches('/')
3275 .to_string()
3276 })
3277 .filter(|p| !p.is_empty());
3278 let dir_prefix = norm_path
3279 .as_deref()
3280 .map(|p| format!("{}/%", escape_like(p)));
3281 let cat_pattern = format!("%{}%", escape_like(category));
3282
3283 let cat_lower = category.trim().to_ascii_lowercase();
3284 let cat_clause = match cat_lower.as_str() {
3285 "config" => {
3286 "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
3287 }
3288 "route" | "routes" => "l.kind LIKE '%route%'",
3289 "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
3290 "model" | "models" => "l.kind LIKE '%model%'",
3291 _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
3292 };
3293
3294 let sql = format!(
3295 "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
3296 l.start_line, s.name AS containing_symbol_name
3297 FROM literals l
3298 LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
3299 WHERE (:cat IS NOT NULL AND {cat_clause})
3300 AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3301 ORDER BY l.path ASC, l.start_line ASC
3302 LIMIT :limit"
3303 );
3304
3305 let mut stmt = conn.prepare(&sql)?;
3306 let rows = stmt.query_map(
3307 rusqlite::named_params! {
3308 ":cat": cat_pattern,
3309 ":path": norm_path.as_deref(),
3310 ":dir_prefix": dir_prefix.as_deref(),
3311 ":limit": limit as i64,
3312 },
3313 |row| {
3314 Ok(LiteralFact {
3315 literal_id: row.get(0)?,
3316 path: row.get::<_, String>(1)?.replace('\\', "/"),
3317 literal_text: row.get(2)?,
3318 kind: row.get(3)?,
3319 carrier: row.get(4)?,
3320 start_line: row.get::<_, i64>(5)? as usize,
3321 containing_symbol_name: row.get(6)?,
3322 })
3323 },
3324 )?;
3325
3326 let mut results = Vec::new();
3327 for r in rows {
3328 results.push(r?);
3329 }
3330 Ok(results)
3331}
3332
3333pub fn find_literals(
3335 conn: &Connection,
3336 category: &str,
3337 limit: usize,
3338) -> Result<Vec<LiteralFact>, QueryError> {
3339 find_literals_scoped(conn, category, None, limit)
3340}
3341
3342pub fn list_structural_fact_categories_scoped(
3344 conn: &Connection,
3345 path_filter: Option<&str>,
3346) -> Result<Vec<(String, usize)>, QueryError> {
3347 let norm_path = path_filter
3348 .map(|p| {
3349 p.replace('\\', "/")
3350 .trim_start_matches("./")
3351 .trim_matches('/')
3352 .to_string()
3353 })
3354 .filter(|p| !p.is_empty());
3355 let dir_prefix = norm_path
3356 .as_deref()
3357 .map(|p| format!("{}/%", escape_like(p)));
3358
3359 let mut categories = Vec::new();
3360
3361 let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
3362 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3363 GROUP BY pattern_id ORDER BY cnt DESC";
3364 let mut stmt = conn.prepare(sql)?;
3365 let rows = stmt.query_map(
3366 rusqlite::named_params! {
3367 ":path": norm_path.as_deref(),
3368 ":dir_prefix": dir_prefix.as_deref(),
3369 },
3370 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3371 )?;
3372 for r in rows {
3373 categories.push(r?);
3374 }
3375
3376 let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
3377 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3378 GROUP BY kind ORDER BY cnt DESC";
3379 let mut lit_stmt = conn.prepare(lit_sql)?;
3380 let lit_rows = lit_stmt.query_map(
3381 rusqlite::named_params! {
3382 ":path": norm_path.as_deref(),
3383 ":dir_prefix": dir_prefix.as_deref(),
3384 },
3385 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3386 )?;
3387 for r in lit_rows {
3388 categories.push(r?);
3389 }
3390
3391 Ok(categories)
3392}
3393
3394pub fn list_structural_fact_categories(
3396 conn: &Connection,
3397) -> Result<Vec<(String, usize)>, QueryError> {
3398 list_structural_fact_categories_scoped(conn, None)
3399}
3400
3401pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
3403 let has_table: bool = conn
3404 .query_row(
3405 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
3406 [],
3407 |_| Ok(true),
3408 )
3409 .unwrap_or(false);
3410 if !has_table {
3411 return Ok(Vec::new());
3412 }
3413
3414 let mut stmt = conn.prepare(
3415 "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
3416 FROM type_facts
3417 WHERE symbol_id = ?1",
3418 )?;
3419
3420 let rows = stmt.query_map(params![symbol_id], |row| {
3421 Ok(TypeFact {
3422 type_fact_id: row.get(0)?,
3423 symbol_id: row.get(1)?,
3424 language: row.get(2)?,
3425 resolved_type: row.get(3)?,
3426 generic_params: row.get(4)?,
3427 })
3428 })?;
3429
3430 let mut results = Vec::new();
3431 for r in rows {
3432 results.push(r?);
3433 }
3434 Ok(results)
3435}
3436
3437pub fn is_test_path(path: &str) -> bool {
3444 let p = path.replace('\\', "/");
3445 let cut = p.rfind('/').map_or(0, |i| i + 1);
3446 let directories = format!("/{}/", p[..cut].to_lowercase());
3447 let file_name = &p[cut..];
3448 let lower_name = file_name.to_lowercase();
3449 directories.contains("/test/")
3450 || directories.contains("/tests/")
3451 || directories.contains("/autotests/")
3452 || directories.contains("/__tests__/")
3453 || lower_name.starts_with("tst_")
3454 || (lower_name.starts_with("test_")
3455 && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
3456 || lower_name.contains("_test.")
3457 || lower_name.contains(".test.")
3458 || lower_name.contains(".spec.")
3459 || lower_name == "test.rs"
3460 || lower_name == "tests.rs"
3461 || file_name.ends_with("Tests.cs")
3462}
3463
3464pub(crate) fn test_path_predicate(alias: &str) -> String {
3473 let guard = format!(
3474 "(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%' OR lower({alias}.path) LIKE '%tst\\_%' ESCAPE '\\')"
3475 );
3476 let p = format!("replace({alias}.path, '\\', '/')");
3477 let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
3478 let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
3479 let lower_name = format!("lower({file_name})");
3480 let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
3481 let clauses = [
3482 like(&directories, "%/test/%"),
3483 like(&directories, "%/tests/%"),
3484 like(&directories, "%/autotests/%"),
3485 like(&directories, "%/\\_\\_tests\\_\\_/%"),
3486 like(&lower_name, "tst\\_%"),
3487 like(&lower_name, "test\\_%.py"),
3488 like(&lower_name, "test\\_%.rb"),
3489 like(&lower_name, "%\\_test.%"),
3490 like(&lower_name, "%.test.%"),
3491 like(&lower_name, "%.spec.%"),
3492 format!("{lower_name} = 'test.rs'"),
3493 format!("{lower_name} = 'tests.rs'"),
3494 format!("{file_name} GLOB '*Tests.cs'"),
3495 ]
3496 .join(" OR ");
3497 format!("({guard} AND ({clauses}))")
3498}
3499
3500pub fn compute_blast_radius_scoped(
3503 conn: &Connection,
3504 seed_symbols: &[&str],
3505 symbol_path_filter: Option<&str>,
3506 seed_paths: &[&str],
3507 max_depth: usize,
3508 limit: usize,
3509) -> Result<BlastRadiusResult, QueryError> {
3510 validate_result_limit(limit)?;
3511 let max_depth = max_depth.min(5);
3512 let resolved_seed_symbols = seed_symbols
3513 .iter()
3514 .map(|name| {
3515 get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
3516 let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
3517 QueryError::SymbolNotFound {
3518 name: (*name).to_string(),
3519 workspace,
3520 hint,
3521 }
3522 })
3523 })
3524 .collect::<Result<Vec<_>, _>>()?;
3525 let mut seeds = Vec::new();
3526 let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
3527 for s in seed_symbols {
3528 seeds.push(s.to_string());
3529 }
3530 for p in seed_paths {
3531 seeds.push(p.to_string());
3532 }
3533 "mixed".to_string()
3534 } else if !seed_symbols.is_empty() {
3535 for s in seed_symbols {
3536 seeds.push(s.to_string());
3537 }
3538 "symbol".to_string()
3539 } else if !seed_paths.is_empty() {
3540 for p in seed_paths {
3541 seeds.push(p.to_string());
3542 }
3543 "file".to_string()
3544 } else {
3545 return Ok(BlastRadiusResult {
3546 seed_type: "none".to_string(),
3547 seeds: Vec::new(),
3548 likely_tests: Vec::new(),
3549 impacted_symbols: Vec::new(),
3550 traversal_ceiling_reached: false,
3551 });
3552 };
3553
3554 let mut where_clauses = Vec::new();
3555 let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
3556
3557 if !resolved_seed_symbols.is_empty() {
3558 let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3559 .map(|i| format!("?{}", i))
3560 .collect();
3561 where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3562 for symbol in &resolved_seed_symbols {
3563 params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3564 }
3565 }
3566
3567 if !seed_paths.is_empty() {
3568 let mut path_conds = Vec::new();
3569 for p in seed_paths.iter() {
3570 let raw = p
3571 .replace('\\', "/")
3572 .trim_start_matches("./")
3573 .trim_matches('/')
3574 .to_string();
3575 let exact_idx = params_vec.len() + 1;
3576 params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3577 let dir_pattern = format!("{}/%", escape_like(&raw));
3578 let like_idx = params_vec.len() + 1;
3579 params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3580 path_conds.push(format!(
3581 "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3582 ));
3583 }
3584 where_clauses.push(format!("({})", path_conds.join(" OR ")));
3585 }
3586
3587 let seed_condition = where_clauses.join(" OR ");
3588 let max_depth_idx = params_vec.len() + 1;
3589 params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3590
3591 let mut traversal_ceiling_reached = false;
3592
3593 let has_relationships: bool = conn
3594 .query_row(
3595 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3596 [],
3597 |_| Ok(true),
3598 )
3599 .unwrap_or(false);
3600
3601 let has_pending: bool = conn
3602 .query_row(
3603 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3604 [],
3605 |_| Ok(true),
3606 )
3607 .unwrap_or(false);
3608
3609 let mut likely_tests = Vec::new();
3610 let mut impacted_symbols = Vec::new();
3611 let mut seen_test_keys = HashSet::new();
3612
3613 let mut recursive_branches = Vec::new();
3614
3615 if has_relationships {
3616 recursive_branches.push(format!(
3617 "SELECT r.from_symbol_id, iw.depth + 1
3618 FROM relationships r
3619 JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3620 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3621 WHERE iw.depth < ?{max_depth_idx}
3622 AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
3623 ));
3624 }
3625
3626 if has_pending {
3627 let (parent_join, ns_condition) = if conn
3628 .query_row(
3629 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3630 [],
3631 |_| Ok(true),
3632 )
3633 .unwrap_or(false)
3634 {
3635 (
3636 "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3637 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3638 format!("AND {pred}", pred = pending_target_predicate(conn, "s_target", "s_target_parent")),
3639 )
3640 } else {
3641 ("", String::new())
3642 };
3643
3644 recursive_branches.push(format!(
3645 "SELECT p.from_symbol_id, iw.depth + 1
3646 FROM pending_relationships p
3647 JOIN symbols s_target ON p.target_terminal_name = s_target.name
3648 JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3649 {parent_join}
3650 WHERE iw.depth < ?{max_depth_idx}
3651 AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3652 {ns_condition}"
3653 ));
3654 }
3655
3656 if !recursive_branches.is_empty() {
3657 let recursive_sql = recursive_branches.join("\n UNION \n");
3658 let not_documentation = not_documentation(conn, "s");
3659 let sql = format!(
3660 "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3661 SELECT symbol_id, 0
3662 FROM symbols
3663 WHERE ({seed_condition})
3664 AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3665
3666 UNION
3667
3668 {recursive_sql}
3669 )
3670 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
3671 FROM impact_walk iw
3672 CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3673 WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3674 AND {not_documentation}
3675 GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3676 HAVING MIN(iw.depth) > 0
3677 ORDER BY min_depth ASC, s.path ASC, s.name ASC
3678 LIMIT 200"
3679 );
3680
3681 let mut stmt = conn.prepare(&sql)?;
3682 let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3683 .iter()
3684 .map(|v| v as &dyn rusqlite::ToSql)
3685 .collect();
3686
3687 let rows = stmt.query_map(param_refs.as_slice(), |row| {
3688 Ok((
3689 row.get::<_, String>(0)?,
3690 row.get::<_, String>(1)?,
3691 row.get::<_, String>(2)?,
3692 row.get::<_, String>(3)?,
3693 row.get::<_, i64>(4)? as usize,
3694 row.get::<_, bool>(5)?,
3695 row.get::<_, bool>(6)?,
3696 row.get::<_, i64>(7)? as usize,
3697 ))
3698 })?;
3699
3700 let mut row_count = 0;
3701 for r in rows {
3702 row_count += 1;
3703 let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3704 let path = raw_path.replace('\\', "/");
3705 let is_test_target = is_test || test_container || is_test_path(&path);
3706
3707 if is_test_target {
3708 let key = format!("{}:{}", path, line);
3709 if seen_test_keys.insert(key) {
3710 likely_tests.push(TestTarget {
3711 name,
3712 path,
3713 line,
3714 reason: format!("transitive caller [depth {depth}]"),
3715 });
3716 }
3717 } else {
3718 impacted_symbols.push(ImpactedSymbol {
3719 name,
3720 kind,
3721 path,
3722 line,
3723 depth,
3724 });
3725 }
3726 }
3727 traversal_ceiling_reached = row_count >= 200;
3728 }
3729
3730 let mut file_stems = Vec::new();
3732 for p in seed_paths {
3733 if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
3734 && stem.len() >= 3
3735 && !file_stems.contains(&stem.to_string())
3736 {
3737 file_stems.push(stem.to_string());
3738 }
3739 }
3740 for symbol in &resolved_seed_symbols {
3741 if let Some(stem) = std::path::Path::new(&symbol.path)
3742 .file_stem()
3743 .and_then(|s| s.to_str())
3744 && stem.len() >= 3
3745 && !file_stems.contains(&stem.to_string())
3746 {
3747 file_stems.push(stem.to_string());
3748 }
3749 }
3750
3751 let has_files: bool = conn
3752 .query_row(
3753 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3754 [],
3755 |_| Ok(true),
3756 )
3757 .unwrap_or(false);
3758
3759 if has_files {
3760 let doc_file = format!(
3761 "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3762 not_documentation(conn, "d")
3763 );
3764 let mut test_files_stmt = conn.prepare(&format!(
3765 "SELECT DISTINCT path FROM files
3766 WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3767 AND NOT {doc_file}
3768 LIMIT 10"
3769 ))?;
3770 for stem in file_stems {
3771 let stem_pattern = format!("%{}%", escape_like(&stem));
3772 let t_rows =
3773 test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
3774 for p in t_rows.flatten() {
3775 let p = p.replace('\\', "/");
3776 let key = format!("{}:1", p);
3777 if seen_test_keys.insert(key) {
3778 likely_tests.push(TestTarget {
3779 name: p.clone(),
3780 path: p,
3781 line: 1,
3782 reason: "stem-matched test file".to_string(),
3783 });
3784 }
3785 }
3786 }
3787 }
3788
3789 if likely_tests.len() > limit {
3791 likely_tests.truncate(limit);
3792 }
3793 if impacted_symbols.len() > limit {
3794 impacted_symbols.truncate(limit);
3795 }
3796
3797 Ok(BlastRadiusResult {
3798 seed_type,
3799 seeds,
3800 likely_tests,
3801 impacted_symbols,
3802 traversal_ceiling_reached,
3803 })
3804}
3805
3806pub fn compute_blast_radius(
3808 conn: &Connection,
3809 seed_symbols: &[&str],
3810 seed_paths: &[&str],
3811 max_depth: usize,
3812 limit: usize,
3813) -> Result<BlastRadiusResult, QueryError> {
3814 compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3815}
3816
3817#[cfg(test)]
3818mod tests {
3819 #[test]
3820 fn result_limit_rejects_values_above_the_shared_ceiling() {
3821 assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3822 assert!(matches!(
3823 validate_result_limit(usize::MAX),
3824 Err(QueryError::InvalidResultLimit(usize::MAX))
3825 ));
3826 }
3827
3828 #[test]
3829 fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
3830 let conn = Connection::open_in_memory().unwrap();
3831
3832 assert!(matches!(
3833 find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
3834 Err(QueryError::InvalidResultLimit(usize::MAX))
3835 ));
3836 }
3837
3838 use super::*;
3839 use crate::db::{ensure_fts_index, open_read_write};
3840
3841 #[test]
3842 fn count_parse_diagnostics_counts_rows_for_one_file() {
3843 let dir = crate::safe_tempdir();
3844 let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
3845
3846 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
3847
3848 conn.execute_batch(
3849 "CREATE TABLE parse_diagnostics (
3850 diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
3851 );
3852 INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
3853 INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
3854 INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
3855 )
3856 .unwrap();
3857
3858 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
3859 assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
3860 assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
3861 }
3862
3863 #[test]
3864 fn test_sanitize_fts5_query() {
3865 let (and_q, or_q) = sanitize_fts5_query("parse tokens");
3866 assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
3867 assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
3868
3869 let (and_q, or_q) = sanitize_fts5_query(" Option<T> ");
3870 assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
3871 assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
3872
3873 let (and_q, or_q) = sanitize_fts5_query(" ");
3874 assert!(and_q.is_empty());
3875 assert!(or_q.is_empty());
3876 }
3877
3878 #[test]
3879 fn sanitize_splits_case_boundaries_and_drops_stop_words() {
3880 let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
3881 assert_eq!(
3882 and_q,
3883 "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
3884 );
3885 assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
3886
3887 let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
3888 assert_eq!(
3889 and_q,
3890 "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
3891 );
3892
3893 let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
3894 assert_eq!(
3895 and_q,
3896 "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
3897 );
3898 assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
3899
3900 let (and_q, _) = sanitize_fts5_query("validate_syntax");
3901 assert_eq!(
3902 and_q,
3903 "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
3904 );
3905
3906 let (and_q, _) = sanitize_fts5_query("isReady");
3907 assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
3908
3909 let (and_q, _) = sanitize_fts5_query("before");
3910 assert_eq!(and_q, "\"before\"*");
3911
3912 let (and_q, _) = sanitize_fts5_query("fooBar quux");
3913 assert_eq!(
3914 and_q,
3915 "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
3916 );
3917
3918 let (and_q, _) = sanitize_fts5_query("the for a");
3919 assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
3920 }
3921
3922 fn search_fixture(rows: &str) -> Connection {
3923 let conn = Connection::open_in_memory().unwrap();
3924 conn.execute_batch(&format!(
3925 "CREATE TABLE symbols (
3926 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3927 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
3928 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3929 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3930 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3931 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3932 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
3933 );
3934 INSERT INTO symbols VALUES {rows};"
3935 ))
3936 .unwrap();
3937 ensure_fts_index(&conn).unwrap();
3938 conn
3939 }
3940
3941 fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
3942 format!(
3943 "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
3944 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
3945 )
3946 }
3947
3948 fn doc_row(id: &str, name: &str, doc: &str) -> String {
3949 format!(
3950 "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
3951 3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
3952 )
3953 }
3954
3955 fn search_names(conn: &Connection, query: &str) -> Vec<String> {
3956 fts_search_symbols_scoped(conn, query, None, None, false, 10)
3957 .unwrap()
3958 .into_iter()
3959 .map(|r| r.symbol.name)
3960 .collect()
3961 }
3962
3963 const TEST_PATH_CASES: &[(&str, bool)] = &[
3964 ("tests/foo.py", true),
3965 ("tests/x.py", true),
3966 ("tests/tools/test_web.py", true),
3967 ("src/tests/x.rs", true),
3968 ("__tests__/a.ts", true),
3969 ("a/__tests__/b.ts", true),
3970 ("test/x.java", true),
3971 ("src/test/Helper.java", true),
3972 ("src/test_utils.py", true),
3973 ("lib/test_helper.rb", true),
3974 ("test_config.py", true),
3975 ("pkg/test_data/x.json", false),
3976 ("src/test_detection.rs", false),
3977 ("autotests/tst_pagerow.qml", true),
3978 ("autotests/helper.qml", true),
3979 ("src/autotests/columnview.cpp", true),
3980 ("tst_foo.qml", true),
3981 ("src/tst_columnview.qml", true),
3982 ("autotests\\tst_bar.qml", true),
3983 ("autotests_helper/x.rs", false),
3984 ("src/autotest.rs", false),
3985 ("src/tstamp.rs", false),
3986 ("src/tst.rs", false),
3987 ("crates/julie-index/src/analysis/test_quality.rs", false),
3988 ("x/foo_test.go", true),
3989 ("x/foo.test.ts", true),
3990 ("x/foo.spec.js", true),
3991 ("src/lib_test.rs", true),
3992 ("src/test.rs", true),
3993 ("tests.rs", true),
3994 ("src/tests.rs", true),
3995 ("Foo.Tests.cs", true),
3996 ("x/FooTests.cs", true),
3997 ("src/FooTests.cs", true),
3998 ("src/Foo.Tests.cs", true),
3999 ("tests/Foo.cs", true),
4000 ("x/parser.spec.ts", true),
4001 ("test_x.py", true),
4002 ("test.rs", true),
4003 ("tests\\x.py", true),
4004 ("src/protocol.spec.v1/parser.rs", false),
4005 ("pkg/test_support/runtime.py", false),
4006 ("src/Contests.cs", false),
4007 ("spec/x.rb", false),
4008 ("crates/x/src/impact/likely_tests.rs", false),
4009 ("x/foo_tests.rs", false),
4010 ("src/latest.rs", false),
4011 ("x/latest.go", false),
4012 ("x/manifest.rs", false),
4013 ("src/attest.rs", false),
4014 ("contest/x.py", false),
4015 ("src/testing.rs", false),
4016 ("src/main.rs", false),
4017 ("pkg/service.go", false),
4018 ];
4019
4020 #[test]
4021 fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
4022 let conn = Connection::open_in_memory().unwrap();
4023 let sql = format!(
4024 "SELECT {} FROM (SELECT :path AS path) s",
4025 test_path_predicate("s")
4026 );
4027 let mut stmt = conn.prepare(&sql).unwrap();
4028 for (path, expected) in TEST_PATH_CASES {
4029 assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
4030 let from_sql: bool = stmt
4031 .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
4032 .unwrap();
4033 assert_eq!(from_sql, *expected, "sql mirror: {path}");
4034 }
4035 }
4036
4037 #[test]
4038 fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
4039 let conn = search_fixture(
4040 &[
4041 code_row(
4042 "a",
4043 "src/parser.rs",
4044 "rust",
4045 "parse_sidecar",
4046 "Parse a sidecar.",
4047 ),
4048 code_row(
4049 "b",
4050 "src/tests/helpers.py",
4051 "python",
4052 "parse_sidecar_fixture",
4053 "Parse a sidecar.",
4054 ),
4055 ]
4056 .join(", "),
4057 );
4058
4059 let default_search: Vec<String> =
4060 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
4061 .unwrap()
4062 .into_iter()
4063 .map(|r| r.symbol.name)
4064 .collect();
4065 assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
4066
4067 let with_tests: Vec<String> =
4068 fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
4069 .unwrap()
4070 .into_iter()
4071 .map(|r| r.symbol.name)
4072 .collect();
4073 assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
4074
4075 let default_lookup: Vec<String> =
4076 search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
4077 .unwrap()
4078 .into_iter()
4079 .map(|s| s.name)
4080 .collect();
4081 assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
4082
4083 let lookup_with_tests: Vec<String> =
4084 search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
4085 .unwrap()
4086 .into_iter()
4087 .map(|s| s.name)
4088 .collect();
4089 assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
4090 }
4091
4092 #[test]
4093 fn count_file_symbols_prefers_the_exact_case_path_like_the_loader() {
4094 let conn = search_fixture(
4095 &[
4096 code_row("a", "src/Foo.rs", "rust", "one", ""),
4097 code_row("b", "src/foo.rs", "rust", "two", ""),
4098 code_row("c", "src/foo.rs", "rust", "three", ""),
4099 ]
4100 .join(", "),
4101 );
4102 for path in ["src/Foo.rs", "src/foo.rs", "src/FOO.rs", "src\\foo.rs"] {
4103 assert_eq!(
4104 count_file_symbols(&conn, path),
4105 load_file_symbols(&conn, path).unwrap().len(),
4106 "{path}"
4107 );
4108 }
4109 assert_eq!(count_file_symbols(&conn, "src/Foo.rs"), 1);
4110 assert_eq!(count_file_symbols(&conn, "src/FOO.rs"), 3);
4111 }
4112
4113 #[test]
4114 fn lookup_statement_is_not_planned_as_a_multi_index_or() {
4115 let conn = search_fixture(&code_row("a", "src/lib.rs", "rust", "needle", ""));
4116 conn.execute_batch(
4117 "CREATE INDEX idx_symbols_name_kind ON symbols(name, kind);
4118 CREATE INDEX idx_symbols_test_container ON symbols(test_container);
4119 CREATE INDEX idx_symbols_is_test ON symbols(is_test);",
4120 )
4121 .unwrap();
4122 let sql = format!(
4123 "EXPLAIN QUERY PLAN {}",
4124 search_symbols_sql(false, false, 20)
4125 );
4126 let plan: Vec<String> = conn
4127 .prepare(&sql)
4128 .unwrap()
4129 .query_map(
4130 rusqlite::named_params! {
4131 ":query": "needle",
4132 ":pattern": "%needle%",
4133 ":kind": None::<&str>,
4134 ":path": None::<&str>,
4135 ":path_like": None::<&str>,
4136 },
4137 |row| row.get::<_, String>(3),
4138 )
4139 .unwrap()
4140 .collect::<Result<_, _>>()
4141 .unwrap();
4142 assert!(
4143 !plan.iter().any(|step| step.contains("MULTI-INDEX OR")),
4144 "{plan:?}"
4145 );
4146 }
4147
4148 #[test]
4149 fn qualified_lookup_in_a_test_file_returns_the_named_row() {
4150 let conn = search_fixture(
4151 &[
4152 "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
4153 1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
4154 "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
4155 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
4156 ]
4157 .join(", "),
4158 );
4159
4160 assert_eq!(
4161 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
4162 .unwrap()
4163 .len(),
4164 1
4165 );
4166 assert_eq!(
4167 search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
4168 .unwrap()
4169 .len(),
4170 1
4171 );
4172 assert!(
4173 search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
4174 .unwrap()
4175 .is_empty()
4176 );
4177 }
4178
4179 #[test]
4180 fn concept_query_prefers_partial_code_match_over_full_doc_match() {
4181 let conn = search_fixture(
4182 &[
4183 doc_row(
4184 "d1",
4185 "Safety guarantees",
4186 "Pre-flight syntax validation runs before the edit touches disk",
4187 ),
4188 doc_row(
4189 "d2",
4190 "Audit",
4191 "The syntax validation before an edit is the invariant",
4192 ),
4193 code_row(
4194 "c1",
4195 "src/syntax.rs",
4196 "rust",
4197 "validate_syntax",
4198 "Validate the syntax of a file",
4199 ),
4200 code_row(
4201 "c2",
4202 "src/edit.rs",
4203 "rust",
4204 "replace_symbol_body",
4205 "Atomic edit with validation",
4206 ),
4207 ]
4208 .join(","),
4209 );
4210
4211 let names = search_names(&conn, "syntax validation before edit");
4212
4213 assert_eq!(names[0], "validate_syntax");
4214 assert!(names.contains(&"replace_symbol_body".to_string()));
4215 assert!(names.contains(&"Safety guarantees".to_string()));
4216 }
4217
4218 #[test]
4219 fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
4220 let conn = search_fixture(
4221 &[
4222 code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
4223 code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
4224 ]
4225 .join(","),
4226 );
4227
4228 let mut camel = search_names(&conn, "ValidateSyntax");
4229 camel.sort();
4230 assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
4231 let mut words = search_names(&conn, "validate syntax");
4232 words.sort();
4233 assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
4234 }
4235
4236 #[test]
4237 fn stop_word_prefixed_camel_case_symbol_is_still_found() {
4238 let conn = search_fixture(
4239 &[
4240 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4241 code_row("c2", "src/hooks.rs", "rust", "before", ""),
4242 code_row(
4243 "c3",
4244 "src/x.rs",
4245 "rust",
4246 "fooBar",
4247 "has fooBar but not the other word",
4248 ),
4249 ]
4250 .join(","),
4251 );
4252
4253 assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
4254 assert_eq!(search_names(&conn, "before"), vec!["before"]);
4255 }
4256
4257 #[test]
4258 fn related_tests_use_the_name_as_typed_without_splitting() {
4259 let conn = search_fixture(
4260 &[
4261 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4262 "('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(),
4263 "('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(),
4264 ]
4265 .join(","),
4266 );
4267 let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
4268
4269 let names: Vec<String> = find_related_tests(&conn, &target, 5)
4270 .unwrap()
4271 .into_iter()
4272 .map(|t| t.name)
4273 .collect();
4274
4275 assert_eq!(names, vec!["isReady_reports_true"]);
4276 }
4277
4278 #[test]
4279 fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
4280 let conn = search_fixture(
4281 &[
4282 code_row(
4283 "c1",
4284 "src/queries.rs",
4285 "rust",
4286 "fts_search_symbols_scoped",
4287 "search symbols scoped with fts",
4288 ),
4289 code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
4290 ]
4291 .join(","),
4292 );
4293
4294 assert_eq!(
4295 search_names(&conn, "search_symbols_scoped")[0],
4296 "search_symbols_scoped"
4297 );
4298 }
4299
4300 fn sidecar_fixture() -> Connection {
4301 search_fixture(
4302 &[
4303 code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
4304 code_row(
4305 "c2",
4306 "src/sidecar.rs",
4307 "rust",
4308 "parse_sidecar_file",
4309 "parse the sha256 sidecar file",
4310 ),
4311 ]
4312 .join(","),
4313 )
4314 }
4315
4316 fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
4317 candidates
4318 .iter()
4319 .find(|c| c.result.symbol.name == name)
4320 .unwrap_or_else(|| panic!("{name} is not a candidate"))
4321 }
4322
4323 #[test]
4324 fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
4325 let conn = sidecar_fixture();
4326
4327 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4328
4329 let target = candidate(&candidates, "parseSha256Sidecar");
4330 assert!(target.name_match);
4331 assert!(!target.word_match);
4332 assert!(!target.exact_name);
4333 assert!(target.name_terms.contains(&"sha256".to_string()));
4334 }
4335
4336 #[test]
4337 fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
4338 let conn = search_fixture(
4339 &[
4340 code_row(
4341 "c1",
4342 "examples/demo.rs",
4343 "rust",
4344 "demo",
4345 "restore offline state",
4346 ),
4347 code_row(
4348 "c2",
4349 "src/replay.rs",
4350 "rust",
4351 "replay",
4352 "restore offline records",
4353 ),
4354 ]
4355 .join(","),
4356 );
4357
4358 let candidates =
4359 collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
4360 .unwrap();
4361
4362 assert!(candidate(&candidates, "demo").word_match);
4363 assert!(candidate(&candidates, "replay").word_match);
4364 assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
4365 }
4366
4367 #[test]
4368 fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
4369 let mut rows: Vec<String> = (1..=170)
4370 .map(|i| {
4371 code_row(
4372 &format!("h{i:03}"),
4373 "src/sidecar.rs",
4374 "rust",
4375 &format!("sidecar_helper_{i:03}"),
4376 "parse sidecar file",
4377 )
4378 })
4379 .collect();
4380 rows.push(code_row(
4381 "c1",
4382 "src/sidecar.rs",
4383 "rust",
4384 "parseSha256Sidecar",
4385 "",
4386 ));
4387 let conn = search_fixture(&rows.join(","));
4388
4389 let candidates = collect_search_candidates(
4390 &conn,
4391 "parse the sha256 sidecar file",
4392 None,
4393 None,
4394 false,
4395 40,
4396 )
4397 .unwrap();
4398
4399 assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
4400 assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
4401 }
4402
4403 #[test]
4404 fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
4405 let mut rows: Vec<String> = (1..=20)
4406 .map(|i| {
4407 code_row(
4408 &format!("a{i:02}"),
4409 "src/a.rs",
4410 "rust",
4411 &format!("both_{i:02}"),
4412 "restore offline",
4413 )
4414 })
4415 .collect();
4416 rows.extend((1..=50).map(|i| {
4417 code_row(
4418 &format!("p{i:02}"),
4419 "src/p.rs",
4420 "rust",
4421 &format!("partial_{i:02}"),
4422 "restore records",
4423 )
4424 }));
4425 let conn = search_fixture(&rows.join(","));
4426
4427 let candidates =
4428 collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
4429
4430 let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
4431 assert_eq!(word_rows.len(), 40);
4432 assert_eq!(
4433 word_rows
4434 .iter()
4435 .filter(|c| c.result.symbol.name.starts_with("both_"))
4436 .count(),
4437 20
4438 );
4439 }
4440
4441 #[test]
4442 fn exact_name_is_admitted_regardless_of_case() {
4443 let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
4444
4445 let candidates =
4446 collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
4447 assert!(candidate(&candidates, "xyzzy_q").exact_name);
4448
4449 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4450 let candidates =
4451 collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
4452 assert!(candidate(&candidates, "xyzzy_q").exact_name);
4453 }
4454
4455 #[test]
4456 fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
4457 let conn = search_fixture(&code_row(
4458 "c1",
4459 "src/say.js",
4460 "javascript",
4461 "say \"hi\"",
4462 "",
4463 ));
4464
4465 let candidates =
4466 collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
4467
4468 let target = candidate(&candidates, "say \"hi\"");
4469 assert!(target.exact_name && target.name_match);
4470 }
4471
4472 #[test]
4473 fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
4474 let conn = search_fixture(
4475 &[
4476 code_row("c1", "src/a.rs", "rust", "sidecar", ""),
4477 code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
4478 ]
4479 .join(","),
4480 );
4481
4482 let candidates =
4483 collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
4484
4485 assert_eq!(candidates.len(), 2);
4486 let target = candidate(&candidates, "sidecar");
4487 assert!(target.exact_name && target.word_match && target.name_match);
4488 assert!(target.bm25.is_some());
4489 let helper = candidate(&candidates, "sidecar_helper");
4490 assert!(!helper.exact_name && helper.word_match && helper.name_match);
4491 }
4492
4493 #[test]
4494 fn an_index_without_the_trigram_table_returns_word_rows_only() {
4495 let conn = sidecar_fixture();
4496 conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4497
4498 let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4499
4500 let names: Vec<&str> = candidates
4501 .iter()
4502 .map(|c| c.result.symbol.name.as_str())
4503 .collect();
4504 assert_eq!(names, vec!["parse_sidecar_file"]);
4505 assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
4506 assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
4507 }
4508
4509 #[test]
4510 fn words_under_three_characters_skip_the_name_branch() {
4511 let conn = search_fixture(
4512 &[
4513 code_row("c1", "src/a.rs", "rust", "ab", ""),
4514 code_row("c2", "src/b.rs", "rust", "cab", ""),
4515 ]
4516 .join(","),
4517 );
4518
4519 let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
4520
4521 assert!(candidates.iter().all(|c| !c.name_match));
4522 assert!(candidate(&candidates, "ab").exact_name);
4523 }
4524
4525 #[test]
4526 fn trigram_terms_include_the_identifier_parts_of_each_word() {
4527 assert_eq!(
4528 trigram_name_terms("collapse_name"),
4529 vec!["collapse_name", "collapse", "name"]
4530 );
4531 assert_eq!(
4532 trigram_name_terms("parse the sha256 sidecar"),
4533 vec!["parse", "sha256", "sha", "256", "sidecar"]
4534 );
4535 assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
4536 assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
4537 assert!(trigram_name_terms("ab").is_empty());
4538 }
4539
4540 #[test]
4541 fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
4542 let conn = search_fixture(
4543 &[
4544 code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
4545 code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
4546 ]
4547 .join(","),
4548 );
4549
4550 let candidates =
4551 collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
4552
4553 let target = candidate(&candidates, "CollapseName");
4554 assert!(target.name_match);
4555 assert_eq!(target.name_terms, vec!["collapse", "name"]);
4556 assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
4557 }
4558
4559 fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
4560 Candidate {
4561 result: SymbolSearchResult {
4562 symbol: Symbol {
4563 symbol_id: format!("{path}:{name}"),
4564 file_id: "f".into(),
4565 path: path.into(),
4566 language: "rust".into(),
4567 name: name.into(),
4568 kind: kind.into(),
4569 signature: None,
4570 doc_comment: None,
4571 visibility: None,
4572 parent_symbol_id: None,
4573 start_line: 1,
4574 start_column: 0,
4575 end_line: 1,
4576 end_column: 0,
4577 start_byte: 0,
4578 end_byte: 0,
4579 body_start_line: None,
4580 body_start_column: None,
4581 body_end_line: None,
4582 body_end_column: None,
4583 body_start_byte: None,
4584 body_end_byte: None,
4585 body_hash: None,
4586 semantic_group: None,
4587 is_test: false,
4588 test_container: false,
4589 },
4590 score: 0.0,
4591 snippet: None,
4592 explain: None,
4593 },
4594 bm25: None,
4595 exact_name: false,
4596 word_match: false,
4597 name_match: false,
4598 name_terms: Vec::new(),
4599 documentation: false,
4600 }
4601 }
4602
4603 fn function(name: &str) -> Candidate {
4604 plain_candidate(name, "function", "src/lib.rs")
4605 }
4606
4607 fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
4608 rerank_with(candidates, query, false, None)
4609 }
4610
4611 fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
4612 ranked(candidates, query)
4613 .into_iter()
4614 .map(|(r, _)| r.symbol.name)
4615 .collect()
4616 }
4617
4618 fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
4619 let mut candidate = function(name);
4620 candidate.result.symbol.signature = signature.map(str::to_string);
4621 candidate.result.symbol.doc_comment = Some(doc.into());
4622 candidate
4623 }
4624
4625 fn strip_ansi_case() -> Vec<Candidate> {
4626 vec![
4627 documented("strip_ansi", None, "Remove ANSI escape sequences"),
4628 documented(
4629 "_strip_code_fences",
4630 Some("def _strip_code_fences(text: str)"),
4631 "The first fenced code block's body, or the stripped text",
4632 ),
4633 ]
4634 }
4635
4636 #[test]
4637 fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4638 assert_eq!(
4639 rerank_words("parse the sha256 sidecar file"),
4640 vec!["parse", "sha", "256", "sidecar", "file"]
4641 );
4642 assert_eq!(
4643 rerank_words("parse_sha256_sidecar"),
4644 vec!["parse", "sha", "256", "sidecar"]
4645 );
4646 assert_eq!(
4647 rerank_words("ParseHTTPResponse"),
4648 vec!["parse", "http", "response"]
4649 );
4650 assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4651 assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4652 }
4653
4654 #[test]
4655 fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4656 for word in ["was", "whether", "another"] {
4657 assert!(is_stop_word(word), "{word} must be a stop word");
4658 }
4659 for word in ["down", "into", "run"] {
4660 assert!(!is_stop_word(word), "{word} must stay a content word");
4661 }
4662 assert_eq!(rerank_words("what was the file"), vec!["file"]);
4663 }
4664
4665 #[test]
4666 fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4667 let mut private = function("_create_skill");
4668 private.bm25 = Some(-9.0);
4669 let mut public = function("create_skill");
4670 public.bm25 = Some(-1.0);
4671
4672 let rows = ranked(vec![private, public], "create skill");
4673
4674 assert_eq!(rows[0].0.score, rows[1].0.score);
4675 assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4676 assert_eq!(
4677 rows.iter()
4678 .map(|(r, _)| r.symbol.name.as_str())
4679 .collect::<Vec<_>>(),
4680 vec!["create_skill", "_create_skill"]
4681 );
4682 }
4683
4684 #[test]
4685 fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4686 let rows = ranked(
4687 vec![
4688 plain_candidate("Glob", "constant", "src/glob.rs"),
4689 function("matches_glob_pattern"),
4690 ],
4691 "glob",
4692 );
4693
4694 assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4695 assert_eq!(rows[1].1.name_tier, "whole");
4696 assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4697 }
4698
4699 #[test]
4700 fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4701 let rows = ranked(
4702 vec![
4703 function("validate_everything"),
4704 function("validate_syntax_now"),
4705 function("validate_syntax"),
4706 function("unrelated"),
4707 ],
4708 "validate syntax",
4709 );
4710 let tiers: Vec<(&str, &str, f64)> = rows
4711 .iter()
4712 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4713 .collect();
4714
4715 assert_eq!(
4716 tiers,
4717 vec![
4718 ("validate_syntax", "whole", W_NAME_WHOLE),
4719 ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4720 ("validate_everything", "partial", 0.0),
4721 ("unrelated", "none", 0.0),
4722 ]
4723 );
4724 assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4725 assert_eq!(
4726 rows[1].0.score,
4727 W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4728 );
4729 assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4730 assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4731 }
4732
4733 #[test]
4734 fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4735 assert_eq!(
4736 ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4737 vec!["strip_ansi", "_strip_code_fences"]
4738 );
4739 }
4740
4741 #[test]
4742 fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4743 let candidates = vec![
4744 function("execute_julie_extract"),
4745 documented("slice_bytes", None, "cut a byte range"),
4746 ];
4747
4748 let rows = ranked(candidates, "cut");
4749 let bonuses: Vec<(&str, &str, f64)> = rows
4750 .iter()
4751 .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4752 .collect();
4753
4754 assert_eq!(
4755 bonuses,
4756 vec![
4757 ("execute_julie_extract", "partial", 0.0),
4758 ("slice_bytes", "none", 0.0),
4759 ]
4760 );
4761 assert_eq!(rows[0].0.score, rows[1].0.score);
4762 }
4763
4764 #[test]
4765 fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4766 let candidates = vec![
4767 function("validate_everything"),
4768 function("validate_syntax_now"),
4769 function("validate_syntax"),
4770 function("unrelated"),
4771 ];
4772
4773 assert_eq!(
4774 ranked_names(candidates, "validate syntax"),
4775 vec![
4776 "validate_syntax",
4777 "validate_syntax_now",
4778 "validate_everything",
4779 "unrelated"
4780 ]
4781 );
4782 }
4783
4784 #[test]
4785 fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4786 let mut rows: Vec<String> = (0..10)
4787 .map(|i| {
4788 code_row(
4789 &format!("s{i}"),
4790 &format!("src/f{i}.rs"),
4791 "rust",
4792 &format!("search_{i}"),
4793 "searches the index",
4794 )
4795 })
4796 .collect();
4797 rows.push(code_row(
4798 "rare",
4799 "src/rare.rs",
4800 "rust",
4801 "sanitize_input",
4802 "sanitize the input",
4803 ));
4804 let conn = search_fixture(&rows.join(", "));
4805 let terms = vec!["sanitize".to_string(), "search".to_string()];
4806
4807 let weights = idf_weights(&conn, &terms);
4808 assert!(weights[0] > weights[1]);
4809 }
4810
4811 #[test]
4812 fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
4813 let conn = search_fixture(&code_row(
4814 "n",
4815 "src/news.rs",
4816 "rust",
4817 "fetch_news",
4818 "fetch the news feed",
4819 ));
4820 let terms = vec!["news".to_string(), "unseen".to_string()];
4821
4822 let weights = idf_weights(&conn, &terms);
4823
4824 assert!(weights[0] < weights[1]);
4825 }
4826
4827 #[test]
4828 fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4829 let crediting_field = |padding: usize| {
4830 let mut row = function("handler");
4831 row.result.symbol.signature = Some(format!(
4832 "fn handler({}sidecar: u8)",
4833 "a: u8, ".repeat(padding)
4834 ));
4835 ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
4836 };
4837
4838 assert_eq!(crediting_field(4), "signature");
4839 assert_eq!(crediting_field(80), "none");
4840 }
4841
4842 #[test]
4843 fn explain_terms_name_the_crediting_field_of_every_query_term() {
4844 let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
4845 let terms: Vec<(&str, &str, f64)> = rows[0]
4846 .1
4847 .terms
4848 .iter()
4849 .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
4850 .collect();
4851
4852 assert_eq!(rows[0].0.symbol.name, "strip_ansi");
4853 assert_eq!(
4854 terms,
4855 vec![
4856 ("strip", "name", 3.0),
4857 ("ansi", "name", 3.0),
4858 ("escape", "doc", TEXT_CREDIT),
4859 ("codes", "none", 0.0),
4860 ]
4861 );
4862 }
4863
4864 #[test]
4865 fn name_coverage_accepts_token_runs_substrings_and_stems() {
4866 let strengths = |name: &str, query: &str| {
4867 let stemmer = Stemmer::create(Algorithm::English);
4868 let words: Vec<QueryWord> = rerank_words(query)
4869 .into_iter()
4870 .map(|word| QueryWord {
4871 stem: stemmer.stem(&word).into_owned(),
4872 word,
4873 })
4874 .collect();
4875 name_hits(name, &words, &stemmer)
4876 };
4877
4878 assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
4879 assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
4880 assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
4881 assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
4882 assert_eq!(strengths("is_ok", "ok"), vec![3]);
4883 assert_eq!(strengths("isReady", "is"), vec![3]);
4884 assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
4885 assert_eq!(
4886 strengths("parseSha256Sidecar", "sidecar checksum"),
4887 vec![3, 0]
4888 );
4889 assert_eq!(
4890 strengths("parseSha256Sidecar", "checksum digest"),
4891 vec![0, 0]
4892 );
4893 }
4894
4895 #[test]
4896 fn a_rarer_term_moves_the_score_more_than_a_common_one() {
4897 let weights = [4.0, 1.0];
4898 let rows = rerank_with(
4899 vec![function("rare_helper"), function("common_helper")],
4900 "rare common",
4901 false,
4902 Some(&weights),
4903 );
4904
4905 assert_eq!(
4906 rows[0].1.word_weights,
4907 vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
4908 );
4909 assert_eq!(rows[0].0.symbol.name, "rare_helper");
4910 assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
4911 assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
4912 }
4913
4914 #[test]
4915 fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
4916 let rows = ranked(
4917 vec![
4918 function("render_mode"),
4919 plain_candidate("retry_count", "constant", "src/scan.rs"),
4920 ],
4921 "how many times a failed download is tried again retry limit",
4922 );
4923
4924 assert_eq!(rows[0].0.symbol.name, "retry_count");
4925 assert_eq!(rows[0].1.name_tier, "partial");
4926 assert!(rows[0].0.score > rows[1].0.score);
4927 assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
4928 }
4929
4930 #[test]
4931 fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4932 let mut row = function("load");
4933 row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
4934 row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
4935 let (result, explain) = ranked(vec![row], "config settings").remove(0);
4936
4937 assert_eq!(
4938 explain.terms,
4939 vec![
4940 ("config".into(), "signature".into(), TEXT_CREDIT),
4941 ("settings".into(), "none".into(), 0.0),
4942 ]
4943 );
4944 assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
4945 }
4946
4947 #[test]
4948 fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
4949 let crediting_field =
4950 |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
4951 let doc_field = |doc: &str, query: &str| {
4952 let mut row = function("row");
4953 row.result.symbol.doc_comment = Some(doc.into());
4954 crediting_field(row, query)
4955 };
4956 let sig_field = |signature: &str, query: &str| {
4957 let mut row = function("row");
4958 row.result.symbol.signature = Some(signature.into());
4959 crediting_field(row, query)
4960 };
4961
4962 assert_eq!(doc_field("The system runs.", "stemming"), "none");
4963 assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
4964 assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
4965 assert_eq!(doc_field("An important port.", "porter"), "none");
4966 assert_eq!(
4967 sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
4968 "signature"
4969 );
4970 assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
4971 assert_eq!(sig_field("fn okay()", "ok"), "none");
4972 assert_eq!(
4973 sig_field("fn parseSha256Sidecar(text)", "sidecar"),
4974 "signature"
4975 );
4976 }
4977
4978 #[test]
4979 fn text_tokens_split_like_query_words_then_identifiers() {
4980 fn two_pass(text: &str) -> Vec<&str> {
4981 query_words(text)
4982 .into_iter()
4983 .flat_map(split_identifier)
4984 .collect()
4985 }
4986 fn one_pass(text: &str) -> Vec<&str> {
4987 let mut out = Vec::new();
4988 text_tokens_into(text, &mut out);
4989 out
4990 }
4991 let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
4992 let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
4993
4994 assert_eq!(one_pass(ascii), two_pass(ascii));
4995 assert_eq!(
4996 one_pass(ascii),
4997 vec![
4998 "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
4999 "sha", "256", "sum",
5000 ]
5001 );
5002 assert_eq!(one_pass(unicode), two_pass(unicode));
5003 assert!(one_pass("").is_empty());
5004 assert!(one_pass("_ __ ...").is_empty());
5005 }
5006
5007 #[test]
5008 fn a_doc_credits_a_term_by_its_stem() {
5009 let mut row = function("check");
5010 row.result.symbol.doc_comment = Some("Validates the input.".into());
5011 let explain = ranked(vec![row], "validation").remove(0).1;
5012
5013 assert_eq!(
5014 explain.terms,
5015 vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
5016 );
5017 }
5018
5019 #[test]
5020 fn kind_prior_orders_definitions_over_members_over_imports() {
5021 let rows = ranked(
5022 vec![
5023 plain_candidate("Scan", "import", "src/a.rs"),
5024 plain_candidate("Scan", "enum_member", "src/b.rs"),
5025 plain_candidate("Scan", "function", "src/c.rs"),
5026 ],
5027 "scan",
5028 );
5029 let order: Vec<(&str, f64)> = rows
5030 .iter()
5031 .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
5032 .collect();
5033
5034 assert_eq!(
5035 order,
5036 vec![
5037 ("src/c.rs", W_KIND_DEFINITION),
5038 ("src/b.rs", W_KIND_MEMBER),
5039 ("src/a.rs", W_KIND_IMPORT),
5040 ]
5041 );
5042 }
5043
5044 #[test]
5045 fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
5046 let names = ranked_names(
5047 vec![
5048 function("RenderMode"),
5049 plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
5050 ],
5051 "retry download limit timeout",
5052 );
5053
5054 assert_eq!(names[0], "MaxRetryCount");
5055 }
5056
5057 #[test]
5058 fn path_role_demotes_role_directories_unless_the_query_names_them() {
5059 let rows = |query: &str| {
5060 ranked(
5061 vec![
5062 plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
5063 plain_candidate("verify_checksum", "function", "src/archive.rs"),
5064 ],
5065 query,
5066 )
5067 };
5068
5069 let plain = rows("verify checksum");
5070 assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
5071 assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
5072
5073 let named = rows("launcher script verify checksum");
5074 assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
5075
5076 let only_launcher = rows("launcher verify checksum");
5077 assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
5078 assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
5079
5080 let windows = ranked(
5081 vec![plain_candidate(
5082 "verifyChecksum",
5083 "function",
5084 "scripts\\launcher.ts",
5085 )],
5086 "verify checksum",
5087 );
5088 assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
5089 }
5090
5091 #[test]
5092 fn documentation_rows_sort_after_every_code_row() {
5093 let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
5094 heading.documentation = true;
5095 heading.result.symbol.language = "markdown".into();
5096 heading.result.symbol.signature = Some("Verify checksum".into());
5097 heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
5098 let rows = ranked(
5099 vec![
5100 heading,
5101 plain_candidate("unrelated", "variable", "src/a.rs"),
5102 ],
5103 "verify checksum",
5104 );
5105
5106 assert_eq!(rows[0].0.symbol.name, "unrelated");
5107 assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
5108 assert_eq!(rows[1].1.name_tier, "whole");
5109 assert!(rows[1].0.score < 0.0);
5110 }
5111
5112 #[test]
5113 fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
5114 let rows = |query: &str, include_tests: bool| {
5115 let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
5116 test_row.result.symbol.is_test = true;
5117 let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
5118 rerank_with(vec![plain_row, test_row], query, include_tests, None)
5119 };
5120
5121 let boosted = rows("payment flow tests", true);
5122 assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
5123 assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
5124 assert_eq!(boosted[1].1.test_intent, 0.0);
5125
5126 assert!(
5127 rows("payment flow tests", false)
5128 .iter()
5129 .all(|(_, e)| e.test_intent == 0.0)
5130 );
5131 assert!(
5132 rows("payment flow", true)
5133 .iter()
5134 .all(|(_, e)| e.test_intent == 0.0)
5135 );
5136 }
5137
5138 #[test]
5139 fn ties_break_by_bm25_then_name_length_then_path() {
5140 let mut word_row = plain_candidate("payment", "function", "src/z.rs");
5141 word_row.word_match = true;
5142 word_row.bm25 = Some(-4.0);
5143 let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
5144 weaker_word_row.word_match = true;
5145 weaker_word_row.bm25 = Some(-2.0);
5146 let mut name_only = plain_candidate("payment", "function", "src/b.rs");
5147 name_only.name_match = true;
5148 let rows = ranked(
5149 vec![
5150 plain_candidate("payment", "function", "src/y.rs"),
5151 name_only,
5152 weaker_word_row,
5153 word_row,
5154 ],
5155 "payment",
5156 );
5157 let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
5158
5159 assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
5160
5161 let by_length = ranked_names(
5162 vec![
5163 function("payment_gateway_client"),
5164 function("payment_gateway"),
5165 ],
5166 "gateway",
5167 );
5168 assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
5169 }
5170
5171 #[test]
5172 fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
5173 let mut token = function("csr");
5174 token.bm25 = Some(-1.0);
5175 let mut substring = function("action_csrf_token");
5176 substring.bm25 = Some(-5.0);
5177
5178 let rows = ranked(vec![substring, token], "csr adjacency");
5179
5180 assert!(rows[0].0.score > rows[1].0.score);
5181 assert_eq!(rows[0].0.symbol.name, "csr");
5182 }
5183
5184 #[test]
5185 fn an_acronym_token_outranks_a_name_that_only_contains_it() {
5186 let mut token = function("http_client");
5187 token.bm25 = Some(-1.0);
5188 let mut substring = function("shttpd_config");
5189 substring.bm25 = Some(-5.0);
5190
5191 let rows = ranked(vec![substring, token], "http");
5192
5193 assert!(rows[0].0.score > rows[1].0.score);
5194 assert_eq!(rows[0].0.symbol.name, "http_client");
5195 }
5196
5197 #[test]
5198 fn explain_reports_the_sum_of_the_name_strengths() {
5199 let rows = ranked(vec![function("action_csrf_token")], "csr token");
5200
5201 assert_eq!(rows[0].1.name_strength, 4);
5202 }
5203
5204 #[test]
5205 fn snippets_follow_the_admitting_branch() {
5206 let mut word_row = function("parse_sidecar_file");
5207 word_row.word_match = true;
5208 word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
5209 let mut name_row = function("parseSha256Sidecar");
5210 name_row.name_match = true;
5211 name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
5212 let mut exact_row = function("sha256");
5213 exact_row.exact_name = true;
5214 let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
5215 let snippets: Vec<(&str, &str)> = rows
5216 .iter()
5217 .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
5218 .collect();
5219
5220 assert_eq!(
5221 snippets,
5222 vec![
5223 ("sha256", "sha256"),
5224 ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
5225 ("parse_sidecar_file", "parse the [sha256] sidecar file"),
5226 ]
5227 );
5228 assert_eq!(rows[1].1.branches, vec!["name"]);
5229 assert_eq!(rows[0].1.branches, vec!["exact"]);
5230 }
5231
5232 #[test]
5233 fn explain_is_attached_only_when_requested() {
5234 let conn = sidecar_fixture();
5235 let query = "sha256";
5236
5237 let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
5238 assert!(silent.iter().all(|r| r.explain.is_none()));
5239 assert!(silent[0].score > 0.0);
5240 assert_eq!(
5241 serde_json::to_value(&silent[0]).unwrap().get("explain"),
5242 None
5243 );
5244
5245 let explained =
5246 fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
5247 let by_name = |name: &str| {
5248 explained
5249 .iter()
5250 .find(|r| r.symbol.name == name)
5251 .and_then(|r| r.explain.as_ref())
5252 .unwrap()
5253 };
5254 let name_only = by_name("parseSha256Sidecar");
5255 assert_eq!(name_only.candidates, 2);
5256 assert_eq!(name_only.branches, vec!["name"]);
5257 assert_eq!(name_only.bm25, None);
5258 let word_row = by_name("parse_sidecar_file");
5259 assert!(word_row.bm25.unwrap() < 0.0);
5260 assert_eq!(word_row.candidates, 2);
5261 assert!(
5262 serde_json::to_value(&explained[0])
5263 .unwrap()
5264 .get("explain")
5265 .is_some()
5266 );
5267 }
5268
5269 #[test]
5270 fn search_symbols_treats_like_wildcards_as_literals() {
5271 let dir = crate::safe_tempdir();
5272 let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
5273 let conn = open_read_write(&db_path).unwrap();
5274 conn.execute_batch(
5275 "CREATE TABLE symbols (
5276 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5277 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5278 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5279 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5280 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5281 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5282 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5283 );
5284 INSERT INTO symbols VALUES (
5285 's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
5286 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5287 );
5288 INSERT INTO symbols VALUES (
5289 'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
5290 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5291 );
5292 INSERT INTO symbols VALUES (
5293 'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
5294 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5295 );
5296 CREATE TABLE files (
5297 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
5298 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
5299 );
5300 INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
5301 INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
5302 );",
5303 )
5304 .unwrap();
5305
5306 assert_eq!(
5307 search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
5308 "literal%name"
5309 );
5310 assert_eq!(
5311 search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
5312 "literal_name"
5313 );
5314 assert_eq!(
5315 load_scoped_files(&conn, Some("src/literal_path"))
5316 .unwrap()
5317 .len(),
5318 1
5319 );
5320 }
5321
5322 #[test]
5323 fn find_references_for_symbol_limits_callees_by_symbol_id() {
5324 let dir = crate::safe_tempdir();
5325 let db_path = dir.path().join("find_references_for_symbol.db");
5326 let conn = open_read_write(&db_path).unwrap();
5327 conn.execute_batch(
5328 "CREATE TABLE symbols (
5329 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5330 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5331 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5332 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5333 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5334 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5335 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5336 );
5337 CREATE TABLE relationships (
5338 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5339 start_line INTEGER, start_column INTEGER
5340 );
5341 CREATE TABLE pending_relationships (
5342 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5343 start_line INTEGER, start_column INTEGER
5344 );
5345 INSERT INTO symbols VALUES
5346 ('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),
5347 ('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),
5348 ('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),
5349 ('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);
5350 INSERT INTO relationships VALUES
5351 ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
5352 ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
5353 )
5354 .unwrap();
5355
5356 let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
5357 assert_eq!(references.len(), 1);
5358 assert_eq!(references[0].to_symbol_name, "wanted_dep");
5359 }
5360
5361 #[test]
5362 fn test_fts_search_symbols_and_porter_stemming() {
5363 let dir = crate::safe_tempdir();
5364 let db_path = dir.path().join("fts_search_symbols.db");
5365 let conn = open_read_write(&db_path).unwrap();
5366
5367 conn.execute_batch(
5368 "CREATE TABLE symbols (
5369 symbol_id TEXT PRIMARY KEY,
5370 file_id TEXT,
5371 path TEXT,
5372 language TEXT,
5373 name TEXT,
5374 kind TEXT,
5375 signature TEXT,
5376 doc_comment TEXT,
5377 visibility TEXT,
5378 parent_symbol_id TEXT,
5379 start_line INTEGER,
5380 start_column INTEGER,
5381 end_line INTEGER,
5382 end_column INTEGER,
5383 start_byte INTEGER,
5384 end_byte INTEGER,
5385 body_start_line INTEGER,
5386 body_start_column INTEGER,
5387 body_end_line INTEGER,
5388 body_end_column INTEGER,
5389 body_start_byte INTEGER,
5390 body_end_byte INTEGER,
5391 body_hash TEXT,
5392 semantic_group TEXT,
5393 is_test INTEGER,
5394 test_container INTEGER
5395 );
5396 INSERT INTO symbols VALUES (
5397 's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
5398 'pub trait PaymentGateway', 'Core payment provider interface for transactions',
5399 'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
5400 );
5401 INSERT INTO symbols VALUES (
5402 's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
5403 'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
5404 'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
5405 );
5406 INSERT INTO symbols VALUES (
5407 's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
5408 'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
5409 'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
5410 );
5411 INSERT INTO symbols VALUES (
5412 's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
5413 'fn test_payment_flow()', 'Tests payment charge workflow',
5414 NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
5415 );",
5416 )
5417 .unwrap();
5418
5419 ensure_fts_index(&conn).unwrap();
5420
5421 let results =
5423 fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
5424 assert_eq!(results.len(), 1);
5425 assert_eq!(results[0].symbol.name, "parse_tokens");
5426 assert!(results[0].snippet.is_some());
5427
5428 let results =
5430 fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
5431 assert_eq!(results.len(), 1);
5432 assert_eq!(results[0].symbol.name, "PaymentGateway");
5433
5434 let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
5436 assert_eq!(results.len(), 2);
5437 assert!(results.iter().all(|r| !r.symbol.is_test));
5438
5439 let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
5441 assert_eq!(results.len(), 3);
5442
5443 let results =
5445 fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
5446 assert_eq!(results.len(), 1);
5447 assert_eq!(results[0].symbol.name, "StripeClient");
5448 }
5449
5450 #[test]
5451 fn find_related_tests_returns_each_test_once_under_the_limit() {
5452 let dir = crate::safe_tempdir();
5453 let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
5454 conn.execute_batch(
5455 "CREATE TABLE symbols (
5456 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5457 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5458 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5459 start_column INTEGER, end_line INTEGER, end_column INTEGER,
5460 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5461 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5462 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5463 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5464 );
5465 CREATE TABLE relationships (
5466 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5467 start_line INTEGER, start_column INTEGER
5468 );
5469 CREATE TABLE pending_relationships (
5470 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5471 start_line INTEGER, start_column INTEGER,
5472 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5473 );
5474 CREATE TABLE type_facts (
5475 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5476 );
5477 INSERT INTO symbols VALUES
5478 ('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),
5479 ('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),
5480 ('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);
5481 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
5482 ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
5483 ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
5484 ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
5485 ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
5486 ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
5487 ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
5488 )
5489 .unwrap();
5490 let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
5491
5492 let tests = find_related_tests(&conn, &target, 5).unwrap();
5493
5494 let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
5495 names.sort();
5496 assert_eq!(names, vec!["first_case", "second_case"]);
5497 }
5498
5499 #[test]
5500 fn documentation_rows_rank_after_code_in_search() {
5501 let dir = crate::safe_tempdir();
5502 let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
5503 conn.execute_batch(
5504 "CREATE TABLE symbols (
5505 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5506 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5507 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5508 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5509 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5510 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5511 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5512 );
5513 INSERT INTO symbols VALUES
5514 ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
5515 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
5516 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
5517 ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
5518 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
5519 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
5520 )
5521 .unwrap();
5522 ensure_fts_index(&conn).unwrap();
5523
5524 let results =
5525 fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
5526 .unwrap();
5527
5528 assert_eq!(results.len(), 2);
5529 assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
5530 assert_eq!(results[1].symbol.name, "Reconcile offline edits");
5531 }
5532
5533 #[test]
5534 fn test_queries_nocase_and_path_normalization() {
5535 let conn = Connection::open_in_memory().unwrap();
5536 conn.execute_batch(
5537 "CREATE TABLE files (
5538 file_id TEXT PRIMARY KEY,
5539 path TEXT NOT NULL,
5540 language TEXT,
5541 content_hash TEXT,
5542 content_bytes INTEGER,
5543 line_count INTEGER,
5544 indexed_at INTEGER
5545 );
5546 CREATE TABLE symbols (
5547 symbol_id TEXT PRIMARY KEY,
5548 file_id TEXT,
5549 path TEXT NOT NULL,
5550 language TEXT,
5551 name TEXT,
5552 kind TEXT,
5553 signature TEXT,
5554 doc_comment TEXT,
5555 visibility TEXT,
5556 parent_symbol_id TEXT,
5557 start_line INTEGER,
5558 start_column INTEGER,
5559 end_line INTEGER,
5560 end_column INTEGER,
5561 start_byte INTEGER,
5562 end_byte INTEGER,
5563 body_start_line INTEGER,
5564 body_start_column INTEGER,
5565 body_end_line INTEGER,
5566 body_end_column INTEGER,
5567 body_start_byte INTEGER,
5568 body_end_byte INTEGER,
5569 body_hash TEXT,
5570 semantic_group TEXT,
5571 is_test INTEGER,
5572 test_container INTEGER
5573 );
5574 -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
5575 INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
5576 INSERT INTO symbols VALUES (
5577 's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
5578 'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5579 2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
5580 );",
5581 )
5582 .unwrap();
5583
5584 let file = get_file(&conn, "SRC/PAYMENT.RS")
5586 .unwrap()
5587 .expect("File should be found");
5588 assert_eq!(
5589 file.path, "src/Payment.rs",
5590 "Path should be normalized to forward slashes"
5591 );
5592
5593 let file2 = get_file(&conn, "src/payment.rs")
5594 .unwrap()
5595 .expect("File should be found");
5596 assert_eq!(file2.path, "src/Payment.rs");
5597
5598 let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
5600 assert_eq!(syms.len(), 1);
5601 assert_eq!(
5602 syms[0].path, "src/Payment.rs",
5603 "Symbol path should be normalized to forward slashes"
5604 );
5605
5606 let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
5608 .unwrap()
5609 .expect("Symbol should be found with case-insensitive path filter");
5610 assert_eq!(sym.path, "src/Payment.rs");
5611 }
5612
5613 #[test]
5614 fn test_exact_case_prioritized_over_nocase() {
5615 let conn = Connection::open_in_memory().unwrap();
5616 conn.execute_batch(
5617 "CREATE TABLE files (
5618 file_id TEXT PRIMARY KEY,
5619 path TEXT NOT NULL,
5620 language TEXT,
5621 content_hash TEXT,
5622 content_bytes INTEGER,
5623 line_count INTEGER,
5624 indexed_at TEXT
5625 );
5626 CREATE TABLE symbols (
5627 symbol_id TEXT PRIMARY KEY,
5628 file_id TEXT,
5629 path TEXT NOT NULL,
5630 language TEXT,
5631 name TEXT NOT NULL,
5632 kind TEXT NOT NULL,
5633 signature TEXT,
5634 doc_comment TEXT,
5635 visibility TEXT,
5636 parent_symbol_id TEXT,
5637 start_line INTEGER,
5638 start_column INTEGER,
5639 end_line INTEGER,
5640 end_column INTEGER,
5641 start_byte INTEGER,
5642 end_byte INTEGER,
5643 body_start_line INTEGER,
5644 body_start_column INTEGER,
5645 body_end_line INTEGER,
5646 body_end_column INTEGER,
5647 body_start_byte INTEGER,
5648 body_end_byte INTEGER,
5649 body_hash TEXT,
5650 semantic_group TEXT,
5651 is_test INTEGER,
5652 test_container INTEGER
5653 );
5654 INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5655 INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5656 INSERT INTO symbols VALUES (
5657 's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5658 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5659 2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5660 );
5661 INSERT INTO symbols VALUES (
5662 's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5663 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5664 2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5665 );",
5666 )
5667 .unwrap();
5668
5669 let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5671 assert_eq!(f_lower.path, "src/payment.rs");
5672 assert_eq!(f_lower.file_id, "f2");
5673
5674 let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5675 assert_eq!(f_upper.path, "src/Payment.rs");
5676 assert_eq!(f_upper.file_id, "f1");
5677
5678 let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5679 assert_eq!(syms_lower.len(), 1);
5680 assert_eq!(syms_lower[0].file_id, "f2");
5681
5682 let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5683 assert_eq!(syms_upper.len(), 1);
5684 assert_eq!(syms_upper[0].file_id, "f1");
5685 }
5686
5687 #[test]
5688 fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5689 let dir = crate::safe_tempdir();
5690 let db_path = dir.path().join("conservative_resolution.db");
5691 let conn = open_read_write(&db_path).unwrap();
5692
5693 conn.execute_batch(
5694 "CREATE TABLE symbols (
5695 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5696 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5697 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5698 start_column INTEGER, end_line INTEGER, end_column INTEGER,
5699 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5700 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5701 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5702 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5703 );
5704 CREATE TABLE relationships (
5705 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5706 start_line INTEGER, start_column INTEGER
5707 );
5708 CREATE TABLE pending_relationships (
5709 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5710 start_line INTEGER, start_column INTEGER,
5711 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5712 );
5713 CREATE TABLE type_facts (
5714 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5715 );
5716 -- Workspace struct Workspace and method Workspace::new
5717 INSERT INTO symbols VALUES
5718 ('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),
5719 ('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),
5720 ('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);
5721
5722 -- my_func calls Vec::new() (external namespace 'Vec')
5723 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
5724 ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5725 )
5726 .unwrap();
5727
5728 let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5730 assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5731
5732 let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5733 assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5734
5735 let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5737 assert!(
5738 callers.is_empty(),
5739 "Expected 0 callers for Workspace::new, got: {:?}",
5740 callers
5741 );
5742
5743 let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5745 assert!(
5746 !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5747 "my_func should not be impacted before calling Workspace::new: {:?}",
5748 blast.impacted_symbols
5749 );
5750
5751 conn.execute(
5753 "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')",
5754 [],
5755 )
5756 .unwrap();
5757
5758 let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5759 assert_eq!(
5760 sigs2.len(),
5761 1,
5762 "Expected 1 signature for Workspace::new, got: {:?}",
5763 sigs2
5764 );
5765 assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5766
5767 let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5769 assert!(
5770 blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5771 "my_func should be impacted after calling Workspace::new: {:?}",
5772 blast2.impacted_symbols
5773 );
5774
5775 conn.execute(
5777 "INSERT INTO symbols VALUES
5778 ('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);",
5779 [],
5780 )
5781 .unwrap();
5782 conn.execute(
5783 "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')",
5784 [],
5785 )
5786 .unwrap();
5787
5788 let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
5790 assert!(
5791 sigs_other.is_empty(),
5792 "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
5793 sigs_other
5794 );
5795
5796 conn.execute(
5798 "INSERT INTO symbols VALUES
5799 ('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);",
5800 [],
5801 )
5802 .unwrap();
5803 conn.execute(
5804 "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')",
5805 [],
5806 )
5807 .unwrap();
5808
5809 let sigs_sibling =
5810 find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
5811 assert_eq!(
5812 sigs_sibling.len(),
5813 1,
5814 "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
5815 sigs_sibling
5816 );
5817
5818 let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
5820 assert!(
5821 ext_sigs.iter().any(|s| s.contains("Vec")),
5822 "include_external: true should include external Vec::new: {:?}",
5823 ext_sigs
5824 );
5825 }
5826
5827 #[test]
5828 fn test_find_structural_facts_and_literals_scoped() {
5829 let dir = crate::safe_tempdir();
5830 let db_path = dir.path().join("facts_test.db");
5831 let conn = open_read_write(&db_path).unwrap();
5832 conn.execute_batch(
5833 "CREATE TABLE symbols (
5834 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5835 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5836 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5837 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5838 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5839 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5840 semantic_group TEXT, is_test INTEGER, test_container INTEGER
5841 );
5842 CREATE TABLE structural_facts (
5843 structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5844 pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
5845 start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
5846 );
5847 CREATE TABLE literals (
5848 literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5849 kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
5850 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5851 start_byte INTEGER, end_byte INTEGER
5852 );
5853 INSERT INTO structural_facts VALUES
5854 ('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\"}'),
5855 ('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\"}'),
5856 ('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\"}'),
5857 ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
5858 ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
5859 ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
5860 ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
5861 INSERT INTO literals VALUES
5862 ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
5863 ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
5864 ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
5865 ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
5866 )
5867 .unwrap();
5868
5869 let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
5871 assert_eq!(facts_config.len(), 2);
5872 assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
5873 assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
5874 assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
5875 assert_eq!(
5876 facts_config[1].key.as_deref(),
5877 Some("mcp_servers.code-kb.command")
5878 );
5879 let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
5880 assert_eq!(lits_config.len(), 1);
5881 assert_eq!(lits_config[0].kind, "toml_key");
5882
5883 let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
5885 assert_eq!(facts_route.len(), 1);
5886 assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
5887 assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
5888 let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
5889 assert_eq!(facts_routes.len(), 1);
5890 let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
5891 assert_eq!(lits_route.len(), 1);
5892 assert_eq!(lits_route[0].kind, "http_route");
5893
5894 for q in &["query", "queries", "sql"] {
5896 let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
5897 assert_eq!(facts.len(), 2, "Failed for {}", q);
5898 assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
5899 let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
5900 assert_eq!(lits.len(), 1, "Failed for {}", q);
5901 assert_eq!(lits[0].kind, "sql_query");
5902 }
5903
5904 for m in &["model", "models"] {
5906 let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
5907 assert_eq!(facts.len(), 1, "Failed for {}", m);
5908 assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
5909 let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
5910 assert_eq!(lits.len(), 1, "Failed for {}", m);
5911 assert_eq!(lits[0].kind, "model_table");
5912 }
5913
5914 let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
5916 assert_eq!(facts_custom.len(), 1);
5917 assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
5918 assert_eq!(facts_custom[0].key, None);
5919
5920 let facts_exact =
5922 find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
5923 assert_eq!(facts_exact.len(), 1);
5924 let facts_miss =
5925 find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
5926 assert_eq!(facts_miss.len(), 0);
5927
5928 let facts_dir =
5930 find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
5931 assert_eq!(facts_dir.len(), 1);
5932 let facts_dir_miss =
5933 find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
5934 assert_eq!(facts_dir_miss.len(), 0);
5935
5936 let f_del = find_structural_facts(&conn, "config", 10).unwrap();
5938 assert_eq!(f_del.len(), 2);
5939 let l_del = find_literals(&conn, "config", 10).unwrap();
5940 assert_eq!(l_del.len(), 1);
5941 }
5942
5943 fn local_variable_fixture() -> Connection {
5944 let conn = Connection::open_in_memory().unwrap();
5945 conn.execute_batch(
5946 "CREATE TABLE symbols (
5947 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5948 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5949 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5950 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5951 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5952 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5953 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5954 );
5955 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5956 parent_symbol_id, start_line, start_column, end_line, end_column,
5957 start_byte, end_byte, is_test, test_container)
5958 VALUES
5959 ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
5960 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5961 ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5962 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5963 ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
5964 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
5965 ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5966 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
5967 ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5968 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
5969 ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
5970 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
5971 ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5972 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
5973 )
5974 .unwrap();
5975 conn
5976 }
5977
5978 fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
5979 let mut stmt = conn
5980 .prepare(
5981 "SELECT s.symbol_id FROM symbols_fts f
5982 JOIN symbols s ON s.rowid = f.rowid
5983 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
5984 )
5985 .unwrap();
5986 let mut ids = stmt
5987 .query_map(params![query], |row| row.get::<_, String>(0))
5988 .unwrap()
5989 .collect::<Result<Vec<_>, _>>()
5990 .unwrap();
5991 ids.sort();
5992 ids
5993 }
5994
5995 #[test]
5996 fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
5997 let conn = local_variable_fixture();
5998 conn.execute_batch(
5999 "CREATE VIRTUAL TABLE symbols_fts USING fts5(
6000 name, signature, doc_comment,
6001 content='symbols', content_rowid='rowid', tokenize='porter unicode61'
6002 );
6003 INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
6004 SELECT rowid, name, signature, doc_comment FROM symbols;",
6005 )
6006 .unwrap();
6007
6008 ensure_fts_index(&conn).unwrap();
6009
6010 assert_eq!(
6011 matched_symbol_ids(&conn, "sqlite"),
6012 vec!["field", "func", "global", "pool"]
6013 );
6014 }
6015
6016 #[test]
6017 fn lookup_excludes_locals_and_parameters() {
6018 let conn = local_variable_fixture();
6019
6020 let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
6021 .unwrap()
6022 .into_iter()
6023 .map(|s| s.symbol_id)
6024 .collect();
6025
6026 assert!(!ids.contains(&"local".to_string()));
6027 assert!(!ids.contains(&"nested".to_string()));
6028 assert!(ids.contains(&"field".to_string()));
6029 assert!(ids.contains(&"global".to_string()));
6030 }
6031
6032 #[test]
6033 fn search_excludes_locals_and_parameters() {
6034 let conn = local_variable_fixture();
6035 ensure_fts_index(&conn).unwrap();
6036
6037 let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
6038 .unwrap()
6039 .into_iter()
6040 .map(|r| r.symbol.symbol_id)
6041 .collect();
6042
6043 assert!(!ids.contains(&"local".to_string()));
6044 assert!(ids.contains(&"func".to_string()));
6045 }
6046
6047 #[test]
6048 fn variable_kind_search_keeps_full_text_matching() {
6049 let conn = local_variable_fixture();
6050 ensure_fts_index(&conn).unwrap();
6051
6052 let ids: Vec<String> = fts_search_symbols_scoped(
6053 &conn,
6054 "sqlite connection",
6055 Some("variable"),
6056 None,
6057 false,
6058 10,
6059 )
6060 .unwrap()
6061 .into_iter()
6062 .map(|r| r.symbol.symbol_id)
6063 .collect();
6064
6065 assert!(ids.contains(&"global".to_string()));
6066 assert!(ids.contains(&"field".to_string()));
6067 }
6068
6069 #[test]
6070 fn qualified_lookup_returns_the_named_local_variable() {
6071 let conn = local_variable_fixture();
6072
6073 let ids: Vec<String> =
6074 search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
6075 .unwrap()
6076 .into_iter()
6077 .map(|s| s.symbol_id)
6078 .collect();
6079
6080 assert_eq!(ids, vec!["local".to_string()]);
6081 }
6082
6083 #[test]
6084 fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
6085 let conn = Connection::open_in_memory().unwrap();
6086 conn.execute_batch(
6087 "CREATE TABLE symbols (
6088 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
6089 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
6090 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
6091 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
6092 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
6093 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
6094 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
6095 );
6096 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
6097 parent_symbol_id, start_line, start_column, end_line, end_column,
6098 start_byte, end_byte, is_test, test_container)
6099 VALUES
6100 ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
6101 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
6102 ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
6103 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
6104 ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
6105 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
6106 )
6107 .unwrap();
6108 ensure_fts_index(&conn).unwrap();
6109
6110 let rows =
6111 fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
6112 .unwrap();
6113
6114 assert_eq!(rows.len(), 1);
6115 assert_eq!(rows[0].symbol.symbol_id, "local");
6116 let explain = rows[0].explain.as_ref().unwrap();
6117 assert_eq!(explain.name_tier, "whole");
6118 assert_eq!(explain.branches, vec!["exact", "name"]);
6119 assert_eq!(explain.candidates, 2);
6120 }
6121
6122 #[test]
6123 fn variable_kind_filter_returns_locals_and_parameters() {
6124 let conn = local_variable_fixture();
6125 ensure_fts_index(&conn).unwrap();
6126
6127 let lookup_ids: Vec<String> =
6128 search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6129 .unwrap()
6130 .into_iter()
6131 .map(|s| s.symbol_id)
6132 .collect();
6133 assert!(lookup_ids.contains(&"local".to_string()));
6134 assert!(lookup_ids.contains(&"nested".to_string()));
6135
6136 let search_ids: Vec<String> =
6137 fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
6138 .unwrap()
6139 .into_iter()
6140 .map(|r| r.symbol.symbol_id)
6141 .collect();
6142 assert!(search_ids.contains(&"local".to_string()));
6143 }
6144}