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