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