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