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