1use rusqlite::{Connection, Row, params};
2use std::collections::{HashMap, HashSet};
3use thiserror::Error;
4
5use crate::db::local_variable_predicate;
6use crate::models::{
7 BlastRadiusResult, FileFact, ImpactedSymbol, LiteralFact, ReferenceSite, StructuralFact,
8 Symbol, SymbolSearchResult, TestTarget, TypeFact,
9};
10
11#[derive(Debug, Error)]
12pub enum QueryError {
13 #[error("Database query error: {0}")]
14 Sqlite(#[from] rusqlite::Error),
15 #[error("Symbol '{0}' not found")]
16 SymbolNotFound(String),
17 #[error("Symbol '{0}' not found. Did you mean one of:\n{1}")]
18 SymbolNotFoundWithSuggestions(String, String),
19 #[error(
20 "Ambiguous symbol '{0}': found {1} matching candidates. Specify file_path or qualified name to disambiguate:\n{2}"
21 )]
22 AmbiguousSymbol(String, usize, String),
23 #[error("Invalid direction '{0}': must be 'callers' or 'callees'")]
24 InvalidDirection(String),
25 #[error("Result limit must be between 0 and {MAX_RESULT_LIMIT}, got {0}")]
26 InvalidResultLimit(usize),
27}
28
29pub const MAX_RESULT_LIMIT: usize = 200;
30
31pub fn validate_result_limit(limit: usize) -> Result<(), QueryError> {
32 if limit > MAX_RESULT_LIMIT {
33 return Err(QueryError::InvalidResultLimit(limit));
34 }
35 Ok(())
36}
37
38fn map_symbol(row: &Row) -> rusqlite::Result<Symbol> {
39 Ok(Symbol {
40 symbol_id: row.get("symbol_id")?,
41 file_id: row.get("file_id")?,
42 path: row.get::<_, String>("path")?.replace('\\', "/"),
43 language: row.get("language")?,
44 name: row.get("name")?,
45 kind: row.get("kind")?,
46 signature: row.get("signature")?,
47 doc_comment: row.get("doc_comment")?,
48 visibility: row.get("visibility")?,
49 parent_symbol_id: row.get("parent_symbol_id")?,
50 start_line: row.get::<_, i64>("start_line")? as usize,
51 start_column: row.get::<_, i64>("start_column")? as usize,
52 end_line: row.get::<_, i64>("end_line")? as usize,
53 end_column: row.get::<_, i64>("end_column")? as usize,
54 start_byte: row.get::<_, i64>("start_byte")? as usize,
55 end_byte: row.get::<_, i64>("end_byte")? as usize,
56 body_start_line: row
57 .get::<_, Option<i64>>("body_start_line")?
58 .map(|v| v as usize),
59 body_start_column: row
60 .get::<_, Option<i64>>("body_start_column")?
61 .map(|v| v as usize),
62 body_end_line: row
63 .get::<_, Option<i64>>("body_end_line")?
64 .map(|v| v as usize),
65 body_end_column: row
66 .get::<_, Option<i64>>("body_end_column")?
67 .map(|v| v as usize),
68 body_start_byte: row
69 .get::<_, Option<i64>>("body_start_byte")?
70 .map(|v| v as usize),
71 body_end_byte: row
72 .get::<_, Option<i64>>("body_end_byte")?
73 .map(|v| v as usize),
74 body_hash: row.get("body_hash")?,
75 semantic_group: row.get("semantic_group")?,
76 is_test: row.get::<_, i64>("is_test")? != 0,
77 test_container: row.get::<_, i64>("test_container")? != 0,
78 })
79}
80
81pub(crate) fn escape_like(value: &str) -> String {
82 value
83 .replace('\\', "\\\\")
84 .replace('%', "\\%")
85 .replace('_', "\\_")
86}
87
88pub fn load_scoped_files(
90 conn: &Connection,
91 path_filter: Option<&str>,
92) -> Result<Vec<FileFact>, QueryError> {
93 let norm = path_filter
94 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
95 .filter(|p| !p.is_empty());
96 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
97 let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
98 let prefix_bs = norm_bs
99 .as_ref()
100 .map(|path| format!("{}\\\\%", escape_like(path)));
101
102 let sql = "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
103 FROM files
104 WHERE (:path IS NULL
105 OR path = :path COLLATE NOCASE
106 OR path = :path_bs COLLATE NOCASE
107 OR path LIKE :path_prefix ESCAPE '\\'
108 OR path LIKE :path_prefix_bs ESCAPE '\\')
109 ORDER BY (:path IS NOT NULL AND (path = :path OR path = :path_bs)) DESC, path ASC";
110
111 let mut stmt = conn.prepare(sql)?;
112 let files = stmt
113 .query_map(
114 rusqlite::named_params! {
115 ":path": norm.as_deref(),
116 ":path_bs": norm_bs.as_deref(),
117 ":path_prefix": prefix.as_deref(),
118 ":path_prefix_bs": prefix_bs.as_deref(),
119 },
120 |row| {
121 Ok(FileFact {
122 file_id: row.get(0)?,
123 path: row.get::<_, String>(1)?.replace('\\', "/"),
124 language: row.get(2)?,
125 content_hash: row.get(3)?,
126 content_bytes: row.get(4)?,
127 line_count: row.get(5)?,
128 indexed_at: row.get(6)?,
129 })
130 },
131 )?
132 .collect::<Result<Vec<_>, _>>()?;
133
134 Ok(files)
135}
136
137pub fn load_scoped_outline_symbols(
140 conn: &Connection,
141 path_filter: Option<&str>,
142 depth: usize,
143 limit_per_file: usize,
144) -> Result<HashMap<String, Vec<Symbol>>, QueryError> {
145 let norm = path_filter
146 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
147 .filter(|p| !p.is_empty());
148 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
149 let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
150 let prefix_bs = norm_bs
151 .as_ref()
152 .map(|path| format!("{}\\\\%", escape_like(path)));
153
154 let max_slashes = match &norm {
155 None => {
156 if depth > 0 {
157 (depth - 1) as i64
158 } else {
159 0
160 }
161 }
162 Some(f) => {
163 let filter_slashes = f.chars().filter(|&c| c == '/').count();
164 (filter_slashes + depth) as i64
165 }
166 };
167
168 let sql = "
169 WITH bounded_files AS (
170 SELECT path FROM files
171 WHERE (:path IS NULL
172 OR path = :path COLLATE NOCASE
173 OR path = :path_bs COLLATE NOCASE
174 OR path LIKE :path_prefix ESCAPE '\\'
175 OR path LIKE :path_prefix_bs ESCAPE '\\')
176 ORDER BY path ASC
177 LIMIT 1000
178 ),
179 ranked AS (
180 SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
181 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
182 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
183 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
184 s.is_test, s.test_container,
185 ROW_NUMBER() OVER (PARTITION BY s.path ORDER BY s.start_line ASC) as rn
186 FROM symbols s
187 JOIN bounded_files bf ON (s.path = bf.path COLLATE NOCASE OR replace(s.path, '\\', '/') = replace(bf.path, '\\', '/') COLLATE NOCASE)
188 WHERE (length(s.path) - length(replace(replace(s.path, '/', ''), '\\', '')) <= :max_slashes)
189 AND s.kind IN ('function', 'method', 'struct', 'enum', 'trait', 'class', 'interface', 'type')
190 AND s.parent_symbol_id IS NULL
191 )
192 SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
193 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
194 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
195 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
196 is_test, test_container
197 FROM ranked
198 WHERE rn <= :limit
199 ORDER BY path ASC, start_line ASC
200 ";
201
202 let mut stmt = conn.prepare(sql)?;
203 let mut rows = stmt.query(rusqlite::named_params! {
204 ":path": norm.as_deref(),
205 ":path_bs": norm_bs.as_deref(),
206 ":path_prefix": prefix.as_deref(),
207 ":path_prefix_bs": prefix_bs.as_deref(),
208 ":max_slashes": max_slashes,
209 ":limit": limit_per_file as i64,
210 })?;
211
212 let mut symbols_by_file: HashMap<String, Vec<Symbol>> = HashMap::new();
213 while let Some(row) = rows.next()? {
214 let sym = map_symbol(row)?;
215 symbols_by_file
216 .entry(sym.path.clone())
217 .or_default()
218 .push(sym);
219 }
220
221 Ok(symbols_by_file)
222}
223
224pub fn get_file(conn: &Connection, path: &str) -> Result<Option<FileFact>, QueryError> {
226 let normalized = path.replace('\\', "/");
227 let backslash = path.replace('/', "\\");
228
229 let mut stmt = conn.prepare(
231 "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
232 FROM files
233 WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
234 ORDER BY (path = ?1 OR path = ?2) DESC
235 LIMIT 1",
236 )?;
237
238 let mut rows = stmt.query(params![normalized, backslash])?;
239 if let Some(row) = rows.next()? {
240 Ok(Some(FileFact {
241 file_id: row.get(0)?,
242 path: row.get::<_, String>(1)?.replace('\\', "/"),
243 language: row.get(2)?,
244 content_hash: row.get(3)?,
245 content_bytes: row.get(4)?,
246 line_count: row.get(5)?,
247 indexed_at: row.get(6)?,
248 }))
249 } else {
250 Ok(None)
251 }
252}
253
254pub fn count_parse_diagnostics(conn: &Connection, path: &str) -> usize {
256 conn.query_row(
257 "SELECT COUNT(*) FROM parse_diagnostics
258 WHERE path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE",
259 params![path.replace('\\', "/"), path.replace('/', "\\")],
260 |row| row.get::<_, i64>(0),
261 )
262 .map(|count| count as usize)
263 .unwrap_or(0)
264}
265
266pub fn count_unsupported_files(conn: &Connection, path_filter: Option<&str>) -> usize {
268 let norm = path_filter
269 .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
270 .filter(|p| !p.is_empty());
271 let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
272 let prefix = norm.as_ref().map(|p| format!("{}/%", escape_like(p)));
273 let prefix_bs = norm_bs.as_ref().map(|p| format!("{}\\\\%", escape_like(p)));
274
275 conn.query_row(
276 "SELECT COUNT(*) FROM files
277 WHERE status = 'unsupported'
278 AND (:path IS NULL
279 OR path = :path COLLATE NOCASE
280 OR path = :path_bs COLLATE NOCASE
281 OR path LIKE :path_prefix ESCAPE '\\'
282 OR path LIKE :path_prefix_bs ESCAPE '\\')",
283 rusqlite::named_params! {
284 ":path": norm.as_deref(),
285 ":path_bs": norm_bs.as_deref(),
286 ":path_prefix": prefix.as_deref(),
287 ":path_prefix_bs": prefix_bs.as_deref(),
288 },
289 |row| row.get::<_, i64>(0),
290 )
291 .map(|count| count as usize)
292 .unwrap_or(0)
293}
294
295pub fn load_file_symbols(conn: &Connection, file_path: &str) -> Result<Vec<Symbol>, QueryError> {
297 let normalized = file_path.replace('\\', "/");
299 let backslash = file_path.replace('/', "\\");
300
301 let mut stmt = conn.prepare(
303 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
304 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
305 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
306 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
307 is_test, test_container
308 FROM symbols
309 WHERE (path = ?1 OR path = ?2)
310 ORDER BY start_line ASC, start_column ASC",
311 )?;
312
313 let rows = stmt
314 .query_map(params![&normalized, &backslash], map_symbol)?
315 .collect::<Result<Vec<_>, _>>()?;
316
317 if !rows.is_empty() {
318 return Ok(rows);
319 }
320
321 let mut stmt = conn.prepare(
323 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
324 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
325 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
326 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
327 is_test, test_container
328 FROM symbols
329 WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
330 ORDER BY start_line ASC, start_column ASC",
331 )?;
332
333 let rows = stmt
334 .query_map(params![normalized, backslash], map_symbol)?
335 .collect::<Result<Vec<_>, _>>()?;
336
337 Ok(rows)
338}
339
340pub fn normalize_kind(kind: &str) -> String {
342 let lower = kind.trim().to_lowercase();
343 match lower.as_str() {
344 "fn" | "func" | "function" => "function".to_string(),
345 "method" => "method".to_string(),
346 "struct" => "struct".to_string(),
347 "class" => "class".to_string(),
348 "enum" => "enum".to_string(),
349 "trait" => "trait".to_string(),
350 "interface" => "interface".to_string(),
351 "type" | "typedef" => "type".to_string(),
352 "mod" | "module" => "module".to_string(),
353 "const" | "constant" => "constant".to_string(),
354 "var" | "variable" => "variable".to_string(),
355 _ => lower,
356 }
357}
358
359pub fn search_symbols(
361 conn: &Connection,
362 query: &str,
363 kind_filter: Option<&str>,
364 include_tests: bool,
365 limit: usize,
366) -> Result<Vec<Symbol>, QueryError> {
367 search_symbols_scoped(conn, query, kind_filter, None, include_tests, limit)
368}
369
370pub fn search_symbols_scoped(
375 conn: &Connection,
376 query: &str,
377 kind_filter: Option<&str>,
378 path_filter: Option<&str>,
379 include_tests: bool,
380 limit: usize,
381) -> Result<Vec<Symbol>, QueryError> {
382 validate_result_limit(limit)?;
383 if limit == 0 {
384 return Ok(Vec::new());
385 }
386 let norm_kind = kind_filter.map(normalize_kind);
387 if (query.contains("::") || query.contains('.'))
388 && let Some(sym) = get_symbol_by_name(conn, query, path_filter)?
389 {
390 let kind_matches = norm_kind.as_deref().is_none_or(|kind| sym.kind == kind);
391 let test_matches = include_tests || (!sym.is_test && !sym.test_container);
392 return Ok(if kind_matches && test_matches {
393 vec![sym]
394 } else {
395 Vec::new()
396 });
397 }
398
399 let pattern = format!("%{}%", escape_like(query));
400 let normalized_path = path_filter.map(|p| {
401 p.replace('\\', "/")
402 .trim_start_matches("./")
403 .trim_matches('/')
404 .to_string()
405 });
406 let escaped_path = normalized_path.as_deref().map(escape_like);
407
408 let mut sql = String::from(
409 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
410 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
411 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
412 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
413 is_test, test_container
414 FROM symbols s
415 WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
416 AND (:kind IS NULL OR kind = :kind)
417 AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
418 );
419
420 if norm_kind.as_deref() != Some("variable") {
421 sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
422 }
423
424 if !include_tests {
425 sql.push_str(" AND is_test = 0 AND test_container = 0");
426 }
427
428 sql.push_str(
429 " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
430 );
431 sql.push_str(&limit.to_string());
432
433 let mut stmt = conn.prepare(&sql)?;
434
435 let path_val = normalized_path.as_deref();
436 let path_like = escaped_path.as_deref();
437 let kind_val = norm_kind.as_deref();
438 let rows = stmt
439 .query_map(
440 rusqlite::named_params! {
441 ":query": query,
442 ":pattern": pattern,
443 ":kind": kind_val,
444 ":path": path_val,
445 ":path_like": path_like,
446 },
447 map_symbol,
448 )?
449 .collect::<Result<Vec<_>, _>>()?;
450
451 Ok(rows)
452}
453
454pub fn sanitize_fts5_query(query: &str) -> (String, String) {
463 const STOP_WORDS: &[&str] = &[
464 "a", "an", "the", "for", "to", "of", "in", "on", "and", "or", "with", "from", "by",
465 "before", "after", "that", "this", "is", "are", "be", "it", "as", "at",
466 ];
467 let is_stop = |s: &str| STOP_WORDS.contains(&s.to_ascii_lowercase().as_str());
468
469 let raw_words: Vec<&str> = query
470 .split(|c: char| !c.is_alphanumeric() && c != '_')
471 .filter(|s| !s.is_empty())
472 .collect();
473 let split: Vec<(Vec<&str>, Option<&str>)> = raw_words
474 .iter()
475 .map(|raw| {
476 let parts = split_identifier(raw);
477 let whole = (parts.len() > 1).then_some(*raw);
478 (parts, whole)
479 })
480 .collect();
481 let any_content = split
482 .iter()
483 .any(|(parts, _)| parts.iter().any(|p| !is_stop(p)));
484
485 let mut and_groups: Vec<String> = Vec::new();
486 let mut or_terms: Vec<String> = Vec::new();
487 for (parts, whole) in split {
488 let parts: Vec<String> = parts
489 .into_iter()
490 .filter(|p| !any_content || !is_stop(p))
491 .map(fts5_term)
492 .collect();
493 let whole = whole.map(fts5_term);
494 let group = match (parts.is_empty(), whole.as_deref()) {
495 (true, None) => continue,
496 (true, Some(w)) => w.to_string(),
497 (false, None) => parts.join(" "),
498 (false, Some(w)) => format!("(({}) OR {w})", parts.join(" ")),
499 };
500 and_groups.push(group);
501 or_terms.extend(parts);
502 or_terms.extend(whole);
503 }
504
505 if and_groups.is_empty() {
506 return (String::new(), String::new());
507 }
508
509 let mut and_query = and_groups.join(" AND ");
510 if (2..=3).contains(&raw_words.len()) {
511 let all: String = raw_words.concat();
512 if all.len() <= 64 {
513 let all = fts5_term(&all);
514 and_query = format!("({and_query}) OR {all}");
515 or_terms.push(all);
516 }
517 }
518 (and_query, or_terms.join(" OR "))
519}
520
521fn fts5_term(token: &str) -> String {
523 if token.chars().count() < 3 {
524 format!("\"{token}\"")
525 } else {
526 format!("\"{token}\"*")
527 }
528}
529
530fn name_prefix_query(name: &str) -> String {
533 name.split(|c: char| !c.is_alphanumeric() && c != '_')
534 .filter(|s| !s.is_empty())
535 .map(|s| format!("\"{s}\"*"))
536 .collect::<Vec<_>>()
537 .join(" ")
538}
539
540fn split_identifier(word: &str) -> Vec<&str> {
543 let chars: Vec<(usize, char)> = word.char_indices().collect();
544 let mut out = Vec::new();
545 let mut start = 0;
546 for i in 1..chars.len() {
547 let (idx, c) = chars[i];
548 let prev = chars[i - 1].1;
549 let next_lower = chars
550 .get(i + 1)
551 .map(|(_, n)| n.is_lowercase())
552 .unwrap_or(false);
553 let boundary = c == '_'
554 || prev == '_'
555 || (c.is_uppercase() && (prev.is_lowercase() || prev.is_ascii_digit()))
556 || (c.is_uppercase() && prev.is_uppercase() && next_lower)
557 || (c.is_ascii_digit() != prev.is_ascii_digit());
558 if boundary {
559 out.push(&word[start..idx]);
560 start = idx;
561 }
562 }
563 out.push(&word[start..]);
564 out.into_iter()
565 .filter(|p| !p.is_empty() && *p != "_")
566 .collect()
567}
568
569pub fn fts_search_symbols_scoped(
571 conn: &Connection,
572 query: &str,
573 kind_filter: Option<&str>,
574 path_filter: Option<&str>,
575 include_tests: bool,
576 limit: usize,
577) -> Result<Vec<SymbolSearchResult>, QueryError> {
578 validate_result_limit(limit)?;
579 if limit == 0 {
580 return Ok(Vec::new());
581 }
582 let (and_q, or_q) = sanitize_fts5_query(query);
583 if and_q.is_empty() {
584 return Ok(Vec::new());
585 }
586
587 let normalized_path = path_filter.map(|p| {
588 p.replace('\\', "/")
589 .trim_start_matches("./")
590 .trim_matches('/')
591 .to_string()
592 });
593 let norm_kind = kind_filter.map(normalize_kind);
594
595 let fts_exists: bool = conn
596 .query_row(
597 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
598 [],
599 |_| Ok(true),
600 )
601 .unwrap_or(false);
602
603 let escaped_path = normalized_path.as_deref().map(escape_like);
604 let searching_variables = norm_kind.as_deref() == Some("variable");
605
606 let name_search = |local_clause: &str| -> Result<Vec<SymbolSearchResult>, QueryError> {
607 let pattern = format!("%{}%", escape_like(query));
608 let mut sql = String::from(
609 "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
610 visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
611 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
612 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
613 is_test, test_container
614 FROM symbols s
615 WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
616 AND (:kind IS NULL OR kind = :kind)
617 AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
618 );
619 sql.push_str(local_clause);
620 if !include_tests {
621 sql.push_str(" AND is_test = 0 AND test_container = 0");
622 }
623 sql.push_str(
624 " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
625 );
626 sql.push_str(&limit.to_string());
627
628 let mut stmt = conn.prepare(&sql)?;
629 let path_val = normalized_path.as_deref();
630 let path_like = escaped_path.as_deref();
631 let kind_val = norm_kind.as_deref();
632 let rows = stmt
633 .query_map(
634 rusqlite::named_params! {
635 ":query": query,
636 ":pattern": pattern,
637 ":kind": kind_val,
638 ":path": path_val,
639 ":path_like": path_like,
640 },
641 map_symbol,
642 )?
643 .collect::<Result<Vec<_>, _>>()?;
644
645 Ok(rows
646 .into_iter()
647 .map(|s| SymbolSearchResult {
648 symbol: s,
649 score: 0.0,
650 snippet: None,
651 })
652 .collect())
653 };
654
655 if !fts_exists {
656 let local_clause = if searching_variables {
657 String::new()
658 } else {
659 format!(" AND NOT {}", local_variable_predicate("s"))
660 };
661 return name_search(&local_clause);
662 }
663
664 let execute_search = |match_clause: &str| -> Result<Vec<SymbolSearchResult>, QueryError> {
665 let mut sql = String::from(
666 "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
667 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, end_line, end_column,
668 start_byte, end_byte, body_start_line, body_start_column, body_end_line,
669 body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
670 is_test, test_container,
671 bm25(symbols_fts, 10.0, 5.0, 1.0) AS rank_score,
672 snippet(symbols_fts, 2, '[', ']', '...', 12) AS doc_snippet,
673 snippet(symbols_fts, 1, '[', ']', '...', 12) AS sig_snippet,
674 snippet(symbols_fts, 0, '[', ']', '...', 12) AS name_snippet
675 FROM symbols_fts
676 CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
677 WHERE symbols_fts MATCH :match
678 AND (:kind IS NULL OR s.kind = :kind)
679 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 '\\')",
680 );
681
682 if !searching_variables {
683 sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
684 }
685
686 if !include_tests {
687 sql.push_str(" AND s.is_test = 0 AND s.test_container = 0");
688 }
689
690 sql.push_str(&format!(
691 " 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 ",
692 doc_langs = documentation_language_list(),
693 not_doc = not_documentation(conn, "s")
694 ));
695 sql.push_str(&limit.to_string());
696
697 let mut stmt = conn.prepare(&sql)?;
698
699 let map_fn = |row: &Row| -> rusqlite::Result<SymbolSearchResult> {
700 let symbol = map_symbol(row)?;
701 let score: f64 = row.get("rank_score")?;
702 let doc_snip: Option<String> = row.get("doc_snippet").ok();
703 let sig_snip: Option<String> = row.get("sig_snippet").ok();
704 let name_snip: Option<String> = row.get("name_snippet").ok();
705
706 let snippet = if doc_snip.as_ref().map(|s| s.contains('[')).unwrap_or(false) {
708 doc_snip
709 } else if sig_snip.as_ref().map(|s| s.contains('[')).unwrap_or(false) {
710 sig_snip
711 } else if name_snip.as_ref().map(|s| s.contains('[')).unwrap_or(false) {
712 name_snip
713 } else {
714 doc_snip.or(sig_snip).or(name_snip)
715 };
716
717 Ok(SymbolSearchResult {
718 symbol,
719 score,
720 snippet,
721 })
722 };
723
724 let path_val = normalized_path.as_deref();
725 let path_like = escaped_path.as_deref();
726 let kind_val = norm_kind.as_deref();
727 let rows = stmt
728 .query_map(
729 rusqlite::named_params! {
730 ":match": match_clause,
731 ":query": query.trim(),
732 ":kind": kind_val,
733 ":path": path_val,
734 ":path_like": path_like,
735 },
736 map_fn,
737 )?
738 .collect::<Result<Vec<_>, _>>()?;
739
740 Ok(rows)
741 };
742
743 let mut results = execute_search(&and_q)?;
744 let only_documentation = results
745 .iter()
746 .all(|r| is_documentation_language(&r.symbol.language));
747 if only_documentation && and_q != or_q {
748 results = execute_search(&or_q)?;
749 }
750
751 if searching_variables {
752 let locals = name_search(&format!(" AND {}", local_variable_predicate("s")))?;
753 let already_found: HashSet<String> =
754 results.iter().map(|r| r.symbol.symbol_id.clone()).collect();
755 results.extend(
756 locals
757 .into_iter()
758 .filter(|r| !already_found.contains(&r.symbol.symbol_id)),
759 );
760 results.truncate(limit);
761 }
762
763 Ok(results)
764}
765
766pub fn find_related_tests(
768 conn: &Connection,
769 target_symbol: &Symbol,
770 limit: usize,
771) -> Result<Vec<Symbol>, QueryError> {
772 if limit == 0 {
773 return Ok(Vec::new());
774 }
775
776 const COLUMNS: &str = "s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
777 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
778 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
779 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
780 s.is_test, s.test_container";
781 const IS_TEST: &str = "(s.is_test = 1 OR s.test_container = 1)";
782 let not_documentation = not_documentation(conn, "s");
783
784 let mut tests = Vec::new();
785 let mut seen_ids = std::collections::HashSet::new();
786
787 let callers_sql = format!(
788 "SELECT {COLUMNS}
789 FROM symbols s
790 JOIN relationships r ON r.from_symbol_id = s.symbol_id
791 WHERE r.to_symbol_id = ?1 AND {IS_TEST} AND {not_documentation}
792 LIMIT ?2"
793 );
794
795 if let Ok(mut stmt) = conn.prepare(&callers_sql)
796 && let Ok(rows) = stmt.query_map(params![target_symbol.symbol_id, limit as i64], map_symbol)
797 {
798 for row in rows.flatten() {
799 if seen_ids.insert(row.symbol_id.clone()) {
800 tests.push(row);
801 if tests.len() >= limit {
802 return Ok(tests);
803 }
804 }
805 }
806 }
807
808 let remaining = limit - tests.len();
810 if remaining > 0 && has_pending_namespace_column(conn) {
811 let pending_sql = format!(
812 "SELECT DISTINCT {COLUMNS}
813 FROM pending_relationships p
814 JOIN symbols s ON p.from_symbol_id = s.symbol_id
815 JOIN symbols s_from ON s_from.symbol_id = s.symbol_id
816 JOIN symbols s_target ON s_target.symbol_id = ?1
817 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
818 WHERE p.target_terminal_name = s_target.name
819 AND {IS_TEST}
820 AND {not_documentation}
821 AND {pred}
822 LIMIT ?2",
823 pred = pending_target_predicate("s_target", "s_target_parent")
824 );
825
826 if let Ok(mut stmt) = conn.prepare(&pending_sql)
827 && let Ok(rows) = stmt.query_map(
828 params![target_symbol.symbol_id, remaining as i64],
829 map_symbol,
830 )
831 {
832 for row in rows.flatten() {
833 if seen_ids.insert(row.symbol_id.clone()) {
834 tests.push(row);
835 if tests.len() >= limit {
836 return Ok(tests);
837 }
838 }
839 }
840 }
841 }
842
843 let remaining = limit - tests.len();
844 let name_sql = format!(
845 "SELECT {COLUMNS}
846 FROM symbols s
847 WHERE {IS_TEST}
848 AND {not_documentation}
849 AND (s.name LIKE '%' || ?1 || '%' OR s.signature LIKE '%' || ?1 || '%')
850 ORDER BY (s.name LIKE '%' || ?1 || '%') DESC
851 LIMIT ?2"
852 );
853
854 if let Ok(mut stmt) = conn.prepare(&name_sql)
855 && let Ok(rows) = stmt.query_map(
856 params![target_symbol.name, (remaining * 2) as i64],
857 map_symbol,
858 )
859 {
860 for row in rows.flatten() {
861 if seen_ids.insert(row.symbol_id.clone()) {
862 tests.push(row);
863 if tests.len() >= limit {
864 return Ok(tests);
865 }
866 }
867 }
868 }
869
870 let remaining = limit - tests.len();
871 let fts_exists: bool = conn
872 .query_row(
873 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
874 [],
875 |_| Ok(true),
876 )
877 .unwrap_or(false);
878
879 if remaining > 0 && fts_exists {
880 let fts_sql = format!(
881 "SELECT {COLUMNS}
882 FROM symbols_fts
883 CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
884 WHERE symbols_fts MATCH ?1 AND {IS_TEST} AND {not_documentation}
885 LIMIT ?2"
886 );
887
888 let and_q = name_prefix_query(&target_symbol.name);
889 if !and_q.is_empty()
890 && let Ok(mut stmt) = conn.prepare(&fts_sql)
891 && let Ok(rows) = stmt.query_map(params![and_q, (remaining * 2) as i64], map_symbol)
892 {
893 for row in rows.flatten() {
894 if seen_ids.insert(row.symbol_id.clone()) {
895 tests.push(row);
896 if tests.len() >= limit {
897 break;
898 }
899 }
900 }
901 }
902 }
903
904 Ok(tests)
905}
906
907pub fn get_symbol_by_name(
909 conn: &Connection,
910 name: &str,
911 path_filter: Option<&str>,
912) -> Result<Option<Symbol>, QueryError> {
913 get_symbol_by_name_internal(conn, name, path_filter, false)
914}
915
916pub fn get_symbol_by_name_exact(
918 conn: &Connection,
919 name: &str,
920 exact_path: &str,
921) -> Result<Option<Symbol>, QueryError> {
922 get_symbol_by_name_internal(conn, name, Some(exact_path), true)
923}
924
925fn get_symbol_by_name_internal(
926 conn: &Connection,
927 name: &str,
928 path_filter: Option<&str>,
929 exact_path: bool,
930) -> Result<Option<Symbol>, QueryError> {
931 let (parent_name, terminal_name) = if let Some(idx) = name.rfind("::") {
933 let parent = &name[..idx];
934 let term = &name[idx + 2..];
935 let immediate_parent = if let Some(p_idx) = parent.rfind("::") {
936 &parent[p_idx + 2..]
937 } else {
938 parent
939 };
940 (Some(immediate_parent), term)
941 } else if let Some(idx) = name.rfind('.') {
942 let parent = &name[..idx];
943 let term = &name[idx + 1..];
944 let immediate_parent = if let Some(p_idx) = parent.rfind('.') {
945 &parent[p_idx + 1..]
946 } else {
947 parent
948 };
949 (Some(immediate_parent), term)
950 } else {
951 (None, name)
952 };
953
954 let sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
955 s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
956 s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
957 s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
958 s.is_test, s.test_container
959 FROM symbols s
960 LEFT JOIN symbols p ON s.parent_symbol_id = p.symbol_id
961 WHERE (s.name = :name OR (s.name = :term AND (:parent IS NULL OR p.name = :parent)))
962 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 '\\')))
963 ORDER BY (s.kind != 'import') DESC,
964 (s.kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC,
965 (s.name = :name) DESC,
966 (:path IS NOT NULL AND (s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE)) DESC,
967 s.is_test ASC
968 LIMIT 25";
969
970 let mut stmt = conn.prepare(sql)?;
971 let normalized_path = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
972 let backslash_path = normalized_path.as_deref().map(|p| p.replace('/', "\\"));
973 let path_like = normalized_path.as_deref().map(escape_like);
974 let path_like_bs = backslash_path.as_deref().map(escape_like);
975
976 let mut rows = stmt.query(rusqlite::named_params! {
977 ":name": name,
978 ":term": terminal_name,
979 ":parent": parent_name,
980 ":path": normalized_path.as_deref(),
981 ":path_bs": backslash_path.as_deref(),
982 ":path_like": path_like.as_deref(),
983 ":path_like_bs": path_like_bs.as_deref(),
984 ":exact": if exact_path { 1 } else { 0 },
985 })?;
986
987 let mut matches: Vec<Symbol> = Vec::new();
988 while let Some(row) = rows.next()? {
989 matches.push(map_symbol(row)?);
990 }
991
992 if matches.is_empty() {
993 return Ok(None);
994 }
995
996 if matches.len() == 1 {
997 return Ok(Some(matches.remove(0)));
998 }
999
1000 let candidates: Vec<Symbol> = if matches.iter().any(|s| s.kind != "import") {
1002 matches.into_iter().filter(|s| s.kind != "import").collect()
1003 } else {
1004 matches
1005 };
1006
1007 if candidates.len() == 1 {
1008 return Ok(Some(candidates.into_iter().next().unwrap()));
1009 }
1010
1011 let exact_name_matches: Vec<_> = candidates
1013 .iter()
1014 .filter(|s| s.name == name)
1015 .cloned()
1016 .collect();
1017 if exact_name_matches.len() == 1 {
1018 return Ok(Some(exact_name_matches.into_iter().next().unwrap()));
1019 }
1020
1021 let definition_candidates = if exact_name_matches.is_empty() {
1022 &candidates
1023 } else {
1024 &exact_name_matches
1025 };
1026 let def_matches: Vec<_> = definition_candidates
1027 .iter()
1028 .filter(|s| {
1029 matches!(
1030 s.kind.as_str(),
1031 "function"
1032 | "struct"
1033 | "class"
1034 | "trait"
1035 | "method"
1036 | "enum"
1037 | "interface"
1038 | "type"
1039 )
1040 })
1041 .cloned()
1042 .collect();
1043 if def_matches.len() == 1 {
1044 return Ok(Some(def_matches.into_iter().next().unwrap()));
1045 }
1046
1047 let active_pool = if !def_matches.is_empty() {
1048 def_matches
1049 } else if !exact_name_matches.is_empty() {
1050 exact_name_matches
1051 } else {
1052 candidates
1053 };
1054
1055 if let Some(ref p) = normalized_path {
1057 let exact_path_matches: Vec<_> = active_pool
1058 .iter()
1059 .filter(|s| s.path == *p)
1060 .cloned()
1061 .collect();
1062 if exact_path_matches.len() == 1 {
1063 return Ok(Some(exact_path_matches.into_iter().next().unwrap()));
1064 }
1065 }
1066
1067 if active_pool.len() == 1 {
1068 return Ok(Some(active_pool.into_iter().next().unwrap()));
1069 }
1070
1071 let mut candidate_list = String::new();
1073 for s in &active_pool {
1074 candidate_list.push_str(&format!(
1075 "- {} `{}` in {}:{}\n",
1076 s.kind, s.name, s.path, s.start_line
1077 ));
1078 }
1079
1080 Err(QueryError::AmbiguousSymbol(
1081 name.to_string(),
1082 active_pool.len(),
1083 candidate_list,
1084 ))
1085}
1086
1087pub fn find_references(
1089 conn: &Connection,
1090 symbol_name: &str,
1091 direction: &str,
1092 limit: usize,
1093) -> Result<Vec<ReferenceSite>, QueryError> {
1094 find_references_ext(conn, symbol_name, direction, limit, false)
1095}
1096
1097pub fn find_references_ext(
1099 conn: &Connection,
1100 symbol_name: &str,
1101 direction: &str,
1102 limit: usize,
1103 include_external: bool,
1104) -> Result<Vec<ReferenceSite>, QueryError> {
1105 find_references_scoped(conn, symbol_name, direction, limit, include_external, None)
1106}
1107
1108pub fn find_references_scoped(
1110 conn: &Connection,
1111 symbol_name: &str,
1112 direction: &str,
1113 limit: usize,
1114 include_external: bool,
1115 path_filter: Option<&str>,
1116) -> Result<Vec<ReferenceSite>, QueryError> {
1117 validate_result_limit(limit)?;
1118 if direction != "callers" && direction != "callees" {
1119 return Err(QueryError::InvalidDirection(direction.to_string()));
1120 }
1121
1122 match get_symbol_by_name(conn, symbol_name, path_filter)? {
1123 Some(target) => find_references_internal(
1124 conn,
1125 &target.name,
1126 direction,
1127 limit,
1128 Some(&target.symbol_id),
1129 include_external,
1130 ),
1131 None => {
1132 let suggestions = search_symbols_scoped(conn, symbol_name, None, path_filter, false, 3)
1133 .unwrap_or_default();
1134 if suggestions.is_empty() {
1135 Err(QueryError::SymbolNotFound(symbol_name.to_string()))
1136 } else {
1137 let list = suggestions
1138 .into_iter()
1139 .map(|s| format!(" - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
1140 .collect::<Vec<_>>()
1141 .join("\n");
1142 Err(QueryError::SymbolNotFoundWithSuggestions(
1143 symbol_name.to_string(),
1144 list,
1145 ))
1146 }
1147 }
1148 }
1149}
1150
1151pub fn find_references_for_symbol(
1152 conn: &Connection,
1153 symbol_name: &str,
1154 direction: &str,
1155 limit: usize,
1156 symbol_id: &str,
1157) -> Result<Vec<ReferenceSite>, QueryError> {
1158 find_references_internal(conn, symbol_name, direction, limit, Some(symbol_id), false)
1159}
1160
1161fn call_site_proximity(candidate_path: &str) -> String {
1164 let normalized = format!("replace({candidate_path}, '\\', '/')");
1165 let call_site = "replace(p.path, '\\', '/')";
1166 format!(
1167 "CASE WHEN {normalized} = {call_site} THEN 2
1168 WHEN rtrim({normalized}, replace({normalized}, '/', '')) = rtrim({call_site}, replace({call_site}, '/', '')) THEN 1
1169 ELSE 0 END"
1170 )
1171}
1172
1173fn pending_target_predicate(target: &str, parent: &str) -> String {
1176 let ns = "json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END)";
1177 let target_path = format!("('/' || replace({target}.path, '\\', '/'))");
1178 let like_value = "replace(replace(replace(value, '\\', '\\\\'), '%', '\\%'), '_', '\\_')";
1179 let closer_rank = call_site_proximity("closer.path");
1180 let target_rank = call_site_proximity(&format!("{target}.path"));
1181 format!(
1182 "(
1183 (
1184 {target}.parent_symbol_id IS NOT NULL
1185 AND {parent}.name IS NOT NULL
1186 AND (
1187 EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
1188 OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
1189 AND s_from.parent_symbol_id = {target}.parent_symbol_id)
1190 OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver)
1191 OR EXISTS (
1192 SELECT 1 FROM symbols receiver
1193 JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
1194 WHERE receiver.name = p.target_receiver
1195 AND receiver.path = p.path
1196 AND receiver_type.resolved_type = {parent}.name
1197 )
1198 )
1199 AND NOT EXISTS (
1200 SELECT 1 FROM {ns}
1201 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
1202 AND NOT EXISTS (
1203 WITH RECURSIVE ancestor(symbol_id, depth) AS (
1204 SELECT {target}.parent_symbol_id, 0
1205 UNION ALL
1206 SELECT s.parent_symbol_id, ancestor.depth + 1
1207 FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
1208 WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
1209 )
1210 SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
1211 WHERE a.name = value
1212 )
1213 AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1214 AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
1215 )
1216 )
1217 OR (
1218 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1219 AND (p.target_receiver IS NULL OR p.target_receiver = '')
1220 AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
1221 AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
1222 SELECT 1 FROM symbols closer
1223 WHERE closer.name = {target}.name
1224 AND closer.symbol_id != {target}.symbol_id
1225 AND closer.parent_symbol_id IS NULL
1226 AND closer.kind = {target}.kind
1227 AND {closer_rank} > {target_rank}
1228 ))
1229 )
1230 OR (
1231 {target}.parent_symbol_id IS NULL
1232 AND EXISTS (
1233 SELECT 1 FROM {ns}
1234 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
1235 AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1236 )
1237 )
1238 )"
1239 )
1240}
1241
1242const DOCUMENTATION_LANGUAGES: &[&str] = &[
1245 "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
1246];
1247
1248fn is_documentation_language(language: &str) -> bool {
1249 DOCUMENTATION_LANGUAGES.contains(&language)
1250}
1251
1252fn documentation_language_list() -> String {
1253 DOCUMENTATION_LANGUAGES
1254 .iter()
1255 .map(|l| format!("'{l}'"))
1256 .collect::<Vec<_>>()
1257 .join(", ")
1258}
1259
1260fn not_documentation(conn: &Connection, alias: &str) -> String {
1261 let has_content_type: bool = conn
1262 .query_row(
1263 "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
1264 [],
1265 |_| Ok(true),
1266 )
1267 .unwrap_or(false);
1268 if has_content_type {
1269 format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
1270 } else {
1271 "1 = 1".to_string()
1272 }
1273}
1274
1275fn has_table(conn: &Connection, name: &str) -> bool {
1276 conn.query_row(
1277 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
1278 [name],
1279 |_| Ok(true),
1280 )
1281 .unwrap_or(false)
1282}
1283
1284fn has_pending_namespace_column(conn: &Connection) -> bool {
1285 let has_ns: bool = conn
1286 .query_row(
1287 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
1288 [],
1289 |_| Ok(true),
1290 )
1291 .unwrap_or(false);
1292 let has_display: bool = conn
1293 .query_row(
1294 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
1295 [],
1296 |_| Ok(true),
1297 )
1298 .unwrap_or(false);
1299 let has_receiver: bool = conn
1300 .query_row(
1301 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
1302 [],
1303 |_| Ok(true),
1304 )
1305 .unwrap_or(false);
1306 has_ns && has_display && has_receiver
1307}
1308
1309fn find_references_internal(
1310 conn: &Connection,
1311 symbol_name: &str,
1312 direction: &str,
1313 limit: usize,
1314 symbol_id: Option<&str>,
1315 include_external: bool,
1316) -> Result<Vec<ReferenceSite>, QueryError> {
1317 let mut results = Vec::new();
1318
1319 if direction == "callers" {
1320 let mut stmt = conn.prepare(
1322 "SELECT s_from.name AS from_name,
1323 r.from_symbol_id,
1324 s_to.name AS to_name,
1325 r.kind,
1326 r.path,
1327 r.start_line,
1328 r.start_column
1329 FROM relationships r
1330 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1331 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1332 WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
1333 LIMIT ?2",
1334 )?;
1335
1336 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1337 Ok(ReferenceSite {
1338 from_symbol_name: row.get(0)?,
1339 from_symbol_id: row.get(1)?,
1340 to_symbol_name: row.get(2)?,
1341 kind: row.get(3)?,
1342 path: row.get::<_, String>(4)?.replace('\\', "/"),
1343 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1344 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1345 })
1346 })?;
1347
1348 for r in rows {
1349 results.push(r?);
1350 }
1351
1352 if results.len() < limit {
1354 let remaining = limit - results.len();
1355 if has_pending_namespace_column(conn) {
1356 if let Some(sid) = symbol_id {
1357 let mut pending_stmt = conn.prepare(
1358 &format!("SELECT s_from.name AS from_name,
1359 p.from_symbol_id,
1360 p.target_terminal_name AS to_name,
1361 p.kind,
1362 p.path,
1363 p.start_line,
1364 p.start_column
1365 FROM pending_relationships p
1366 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1367 JOIN symbols s_target ON s_target.symbol_id = ?3
1368 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1369 WHERE p.target_terminal_name = ?1
1370 AND {pred}
1371 LIMIT ?2", pred = pending_target_predicate("s_target", "s_target_parent")),
1372 )?;
1373
1374 let p_rows = pending_stmt.query_map(
1375 params![symbol_name, remaining as i64, sid],
1376 |row| {
1377 Ok(ReferenceSite {
1378 from_symbol_name: row.get(0)?,
1379 from_symbol_id: row.get(1)?,
1380 to_symbol_name: row.get(2)?,
1381 kind: row.get(3)?,
1382 path: row.get::<_, String>(4)?.replace('\\', "/"),
1383 start_line: Some(row.get::<_, i64>(5)? as usize),
1384 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1385 })
1386 },
1387 )?;
1388 for r in p_rows {
1389 results.push(r?);
1390 }
1391 } else {
1392 let mut pending_stmt = conn.prepare(
1393 "SELECT s_from.name AS from_name,
1394 p.from_symbol_id,
1395 p.target_terminal_name AS to_name,
1396 p.kind,
1397 p.path,
1398 p.start_line,
1399 p.start_column
1400 FROM pending_relationships p
1401 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1402 WHERE p.target_terminal_name = ?1
1403 AND (
1404 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1405 OR EXISTS (
1406 SELECT 1 FROM symbols s_any
1407 JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
1408 WHERE s_any.name = p.target_terminal_name
1409 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)
1410 )
1411 )
1412 LIMIT ?2",
1413 )?;
1414
1415 let p_rows =
1416 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1417 Ok(ReferenceSite {
1418 from_symbol_name: row.get(0)?,
1419 from_symbol_id: row.get(1)?,
1420 to_symbol_name: row.get(2)?,
1421 kind: row.get(3)?,
1422 path: row.get::<_, String>(4)?.replace('\\', "/"),
1423 start_line: Some(row.get::<_, i64>(5)? as usize),
1424 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1425 })
1426 })?;
1427 for r in p_rows {
1428 results.push(r?);
1429 }
1430 }
1431 } else {
1432 let is_nested = if let Some(sid) = symbol_id {
1433 conn.query_row(
1434 "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
1435 params![sid],
1436 |_| Ok(true),
1437 )
1438 .unwrap_or(false)
1439 } else {
1440 false
1441 };
1442
1443 if !is_nested {
1444 let mut pending_stmt = conn.prepare(
1445 "SELECT s_from.name AS from_name,
1446 p.from_symbol_id,
1447 p.target_terminal_name AS to_name,
1448 p.kind,
1449 p.path,
1450 p.start_line,
1451 p.start_column
1452 FROM pending_relationships p
1453 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1454 WHERE p.target_terminal_name = ?1
1455 LIMIT ?2",
1456 )?;
1457
1458 let p_rows =
1459 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1460 Ok(ReferenceSite {
1461 from_symbol_name: row.get(0)?,
1462 from_symbol_id: row.get(1)?,
1463 to_symbol_name: row.get(2)?,
1464 kind: row.get(3)?,
1465 path: row.get::<_, String>(4)?.replace('\\', "/"),
1466 start_line: Some(row.get::<_, i64>(5)? as usize),
1467 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1468 })
1469 })?;
1470
1471 for r in p_rows {
1472 results.push(r?);
1473 }
1474 }
1475 }
1476 }
1477
1478 if results.len() < limit && has_table(conn, "identifiers") {
1479 let remaining = limit - results.len();
1480 let mut ident_stmt = conn.prepare(
1481 "SELECT COALESCE(s.name, ''),
1482 COALESCE(i.containing_symbol_id, ''),
1483 i.name,
1484 i.kind,
1485 i.path,
1486 i.start_line,
1487 i.start_column
1488 FROM identifiers i
1489 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
1490 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
1491 AND COALESCE(s.kind, '') != 'import'
1492 AND (?3 IS NULL OR NOT EXISTS (
1493 SELECT 1 FROM symbols owner
1494 JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
1495 WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
1496 AND member.name = i.name
1497 AND owner.name IS NOT (SELECT parent.name FROM symbols target
1498 JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
1499 WHERE target.symbol_id = ?3)
1500 ))
1501 ORDER BY i.path, i.start_line
1502 LIMIT ?2",
1503 )?;
1504 let rows =
1505 ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
1506 Ok(ReferenceSite {
1507 from_symbol_name: row.get(0)?,
1508 from_symbol_id: row.get(1)?,
1509 to_symbol_name: row.get(2)?,
1510 kind: row.get(3)?,
1511 path: row.get::<_, String>(4)?.replace('\\', "/"),
1512 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1513 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1514 })
1515 })?;
1516 for r in rows {
1517 results.push(r?);
1518 }
1519 }
1520 } else {
1521 let mut stmt = conn.prepare(
1523 "SELECT s_from.name AS from_name,
1524 r.from_symbol_id,
1525 s_to.name AS to_name,
1526 r.kind,
1527 r.path,
1528 r.start_line,
1529 r.start_column
1530 FROM relationships r
1531 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1532 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1533 WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
1534 LIMIT ?2",
1535 )?;
1536
1537 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1538 Ok(ReferenceSite {
1539 from_symbol_name: row.get(0)?,
1540 from_symbol_id: row.get(1)?,
1541 to_symbol_name: row.get(2)?,
1542 kind: row.get(3)?,
1543 path: row.get::<_, String>(4)?.replace('\\', "/"),
1544 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1545 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1546 })
1547 })?;
1548
1549 for r in rows {
1550 results.push(r?);
1551 }
1552
1553 if results.len() < limit {
1555 let remaining = limit - results.len();
1556 let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
1557 let sql = if include_external {
1558 String::from("SELECT DISTINCT s_from.name AS from_name,
1559 p.from_symbol_id,
1560 COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
1561 p.kind,
1562 p.path,
1563 p.start_line,
1564 p.start_column
1565 FROM pending_relationships p
1566 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1567 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1568 LIMIT ?2")
1569 } else {
1570 format!("SELECT DISTINCT s_from.name AS from_name,
1571 p.from_symbol_id,
1572 p.target_terminal_name AS to_name,
1573 p.kind,
1574 p.path,
1575 p.start_line,
1576 p.start_column
1577 FROM pending_relationships p
1578 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1579 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1580 AND EXISTS (
1581 SELECT 1 FROM symbols s_to
1582 LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
1583 WHERE s_to.name = p.target_terminal_name
1584 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1585 AND {pred}
1586 )
1587 LIMIT ?2", pred = pending_target_predicate("s_to", "s_to_parent"))
1588 };
1589 let mut pending_stmt = conn.prepare(&sql)?;
1590 let rows = pending_stmt.query_map(
1591 params![symbol_name, remaining as i64, symbol_id],
1592 |row| {
1593 Ok(ReferenceSite {
1594 from_symbol_name: row.get(0)?,
1595 from_symbol_id: row.get(1)?,
1596 to_symbol_name: row.get(2)?,
1597 kind: row.get(3)?,
1598 path: row.get::<_, String>(4)?.replace('\\', "/"),
1599 start_line: Some(row.get::<_, i64>(5)? as usize),
1600 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1601 })
1602 },
1603 )?;
1604 let mut out = Vec::new();
1605 for r in rows {
1606 out.push(r?);
1607 }
1608 out
1609 } else {
1610 let sql = if include_external {
1611 "SELECT DISTINCT s_from.name AS from_name,
1612 p.from_symbol_id,
1613 p.target_terminal_name AS to_name,
1614 p.kind,
1615 p.path,
1616 p.start_line,
1617 p.start_column
1618 FROM pending_relationships p
1619 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1620 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1621 LIMIT ?2"
1622 } else {
1623 "SELECT DISTINCT s_from.name AS from_name,
1624 p.from_symbol_id,
1625 p.target_terminal_name AS to_name,
1626 p.kind,
1627 p.path,
1628 p.start_line,
1629 p.start_column
1630 FROM pending_relationships p
1631 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1632 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1633 AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
1634 LIMIT ?2"
1635 };
1636 let mut pending_stmt = conn.prepare(sql)?;
1637
1638 let rows = pending_stmt.query_map(
1639 params![symbol_name, remaining as i64, symbol_id],
1640 |row| {
1641 Ok(ReferenceSite {
1642 from_symbol_name: row.get(0)?,
1643 from_symbol_id: row.get(1)?,
1644 to_symbol_name: row.get(2)?,
1645 kind: row.get(3)?,
1646 path: row.get::<_, String>(4)?.replace('\\', "/"),
1647 start_line: Some(row.get::<_, i64>(5)? as usize),
1648 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1649 })
1650 },
1651 )?;
1652 let mut out = Vec::new();
1653 for r in rows {
1654 out.push(r?);
1655 }
1656 out
1657 };
1658
1659 for r in p_rows {
1660 results.push(r);
1661 }
1662 }
1663 }
1664
1665 Ok(results)
1666}
1667
1668pub fn find_callee_signatures(
1671 conn: &Connection,
1672 symbol_name: &str,
1673 symbol_id: &str,
1674 limit: usize,
1675 include_external: bool,
1676) -> Result<Vec<String>, QueryError> {
1677 let mut stmt = conn.prepare(
1678 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1679 FROM relationships r
1680 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1681 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1682 WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
1683 LIMIT ?3",
1684 )?;
1685
1686 let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
1687 Ok((
1688 row.get::<_, String>(0)?,
1689 row.get::<_, Option<String>>(1)?,
1690 row.get::<_, String>(2)?.replace('\\', "/"),
1691 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1692 row.get::<_, String>(4)?,
1693 ))
1694 })?;
1695
1696 let mut signatures = Vec::new();
1697 let mut variants = Vec::new();
1698
1699 for r in rows.flatten() {
1700 let (name, sig_opt, path, line, kind) = r;
1701 let sig = sig_opt.unwrap_or(name);
1702 let entry = format!("{sig} ({path}:{line})");
1703 if kind == "variant" {
1704 if !variants.contains(&entry) {
1705 variants.push(entry);
1706 }
1707 } else if !signatures.contains(&entry) {
1708 signatures.push(entry);
1709 }
1710 }
1711
1712 if signatures.len() < limit {
1713 let remaining = (limit - signatures.len()) * 2;
1714 let p_rows: Vec<(String, Option<String>, String, usize, String)> =
1715 if has_pending_namespace_column(conn) {
1716 let mut p_stmt = conn.prepare(
1717 &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1718 FROM pending_relationships p
1719 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1720 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1721 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1722 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1723 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1724 AND {pred}
1725 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1726 )?;
1727
1728 let rows =
1729 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1730 Ok((
1731 row.get::<_, String>(0)?,
1732 row.get::<_, Option<String>>(1)?,
1733 row.get::<_, String>(2)?.replace('\\', "/"),
1734 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1735 row.get::<_, String>(4)?,
1736 ))
1737 })?;
1738 rows.flatten().collect()
1739 } else {
1740 let mut p_stmt = conn.prepare(
1741 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1742 FROM pending_relationships p
1743 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1744 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1745 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1746 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1747 LIMIT ?3",
1748 )?;
1749
1750 let rows =
1751 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1752 Ok((
1753 row.get::<_, String>(0)?,
1754 row.get::<_, Option<String>>(1)?,
1755 row.get::<_, String>(2)?.replace('\\', "/"),
1756 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1757 row.get::<_, String>(4)?,
1758 ))
1759 })?;
1760 rows.flatten().collect()
1761 };
1762
1763 for r in p_rows {
1764 let (name, sig_opt, path, line, kind) = r;
1765 let sig = sig_opt.unwrap_or(name);
1766 let entry = format!("{sig} ({path}:{line})");
1767 if kind == "variant" {
1768 if !variants.contains(&entry) {
1769 variants.push(entry);
1770 }
1771 } else if !signatures.contains(&entry) {
1772 signatures.push(entry);
1773 }
1774 }
1775 }
1776
1777 if include_external && signatures.len() < limit {
1778 let remaining = (limit - signatures.len()) * 2;
1779 let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
1780 let mut ext_stmt = conn.prepare(
1781 &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
1782 FROM pending_relationships p
1783 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1784 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1785 AND NOT EXISTS (
1786 SELECT 1 FROM symbols s_to
1787 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1788 WHERE s_to.name = p.target_terminal_name
1789 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1790 AND {pred}
1791 )
1792 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1793 )?;
1794
1795 let rows =
1796 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1797 Ok((
1798 row.get::<_, String>(0)?,
1799 row.get::<_, String>(1)?.replace('\\', "/"),
1800 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1801 ))
1802 })?;
1803 rows.flatten().collect()
1804 } else {
1805 let mut ext_stmt = conn.prepare(
1806 "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
1807 FROM pending_relationships p
1808 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1809 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1810 AND NOT EXISTS (
1811 SELECT 1 FROM symbols s_to
1812 WHERE s_to.name = p.target_terminal_name
1813 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1814 )
1815 LIMIT ?3",
1816 )?;
1817
1818 let rows =
1819 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1820 Ok((
1821 row.get::<_, String>(0)?,
1822 row.get::<_, String>(1)?.replace('\\', "/"),
1823 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1824 ))
1825 })?;
1826 rows.flatten().collect()
1827 };
1828
1829 for r in ext_rows {
1830 let (name, path, line) = r;
1831 let entry = format!("{name} ({path}:{line})");
1832 if !signatures.contains(&entry) {
1833 signatures.push(entry);
1834 }
1835 }
1836 }
1837
1838 for v in variants {
1839 if signatures.len() >= limit {
1840 break;
1841 }
1842 if !signatures.contains(&v) {
1843 signatures.push(v);
1844 }
1845 }
1846
1847 signatures.truncate(limit);
1848 Ok(signatures)
1849}
1850
1851pub fn find_structural_facts_scoped(
1853 conn: &Connection,
1854 category: &str,
1855 path_filter: Option<&str>,
1856 limit: usize,
1857) -> Result<Vec<StructuralFact>, QueryError> {
1858 validate_result_limit(limit)?;
1859 let norm_path = path_filter
1860 .map(|p| {
1861 p.replace('\\', "/")
1862 .trim_start_matches("./")
1863 .trim_matches('/')
1864 .to_string()
1865 })
1866 .filter(|p| !p.is_empty());
1867 let dir_prefix = norm_path
1868 .as_deref()
1869 .map(|p| format!("{}/%", escape_like(p)));
1870 let cat_pattern = format!("%{}%", escape_like(category));
1871
1872 let cat_lower = category.trim().to_ascii_lowercase();
1873 let cat_clause = match cat_lower.as_str() {
1874 "config" => {
1875 "(sf.pattern_id LIKE '%.key_value.%' OR sf.pattern_id LIKE '%config%' OR sf.capture_name LIKE '%config%' OR sf.node_kind LIKE '%config%')"
1876 }
1877 "route" | "routes" => {
1878 "(sf.pattern_id LIKE '%.route%' OR sf.pattern_id LIKE '%route%' OR sf.capture_name LIKE '%route%')"
1879 }
1880 "query" | "queries" | "sql" => {
1881 "(sf.pattern_id LIKE '%.sql.%' OR sf.pattern_id LIKE '%query%')"
1882 }
1883 "model" | "models" => "sf.pattern_id LIKE '%.model%'",
1884 _ => {
1885 "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
1886 }
1887 };
1888
1889 let sql = format!(
1890 "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
1891 sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
1892 sf.start_line, sf.end_line, sf.confidence,
1893 COALESCE(
1894 CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
1895 THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
1896 ELSE json_extract(sf.metadata_json, '$.key_path') END,
1897 json_extract(sf.metadata_json, '$.key'),
1898 json_extract(sf.metadata_json, '$.normalized_route_template')
1899 ) AS display_key
1900 FROM structural_facts sf
1901 LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
1902 WHERE (:cat IS NOT NULL AND {cat_clause})
1903 AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1904 ORDER BY sf.path ASC, sf.start_line ASC
1905 LIMIT :limit"
1906 );
1907
1908 let mut stmt = conn.prepare(&sql)?;
1909 let rows = stmt.query_map(
1910 rusqlite::named_params! {
1911 ":cat": cat_pattern,
1912 ":path": norm_path.as_deref(),
1913 ":dir_prefix": dir_prefix.as_deref(),
1914 ":limit": limit as i64,
1915 },
1916 |row| {
1917 Ok(StructuralFact {
1918 structural_fact_id: row.get(0)?,
1919 path: row.get::<_, String>(1)?.replace('\\', "/"),
1920 language: row.get(2)?,
1921 pattern_id: row.get(3)?,
1922 capture_name: row.get(4)?,
1923 node_kind: row.get(5)?,
1924 key: row.get(10)?,
1925 containing_symbol_name: row.get(6)?,
1926 start_line: row.get::<_, i64>(7)? as usize,
1927 end_line: row.get::<_, i64>(8)? as usize,
1928 confidence: row.get(9)?,
1929 })
1930 },
1931 )?;
1932
1933 let mut results = Vec::new();
1934 for r in rows {
1935 results.push(r?);
1936 }
1937 Ok(results)
1938}
1939
1940pub fn find_structural_facts(
1942 conn: &Connection,
1943 category: &str,
1944 limit: usize,
1945) -> Result<Vec<StructuralFact>, QueryError> {
1946 find_structural_facts_scoped(conn, category, None, limit)
1947}
1948
1949pub fn find_literals_scoped(
1951 conn: &Connection,
1952 category: &str,
1953 path_filter: Option<&str>,
1954 limit: usize,
1955) -> Result<Vec<LiteralFact>, QueryError> {
1956 validate_result_limit(limit)?;
1957 let norm_path = path_filter
1958 .map(|p| {
1959 p.replace('\\', "/")
1960 .trim_start_matches("./")
1961 .trim_matches('/')
1962 .to_string()
1963 })
1964 .filter(|p| !p.is_empty());
1965 let dir_prefix = norm_path
1966 .as_deref()
1967 .map(|p| format!("{}/%", escape_like(p)));
1968 let cat_pattern = format!("%{}%", escape_like(category));
1969
1970 let cat_lower = category.trim().to_ascii_lowercase();
1971 let cat_clause = match cat_lower.as_str() {
1972 "config" => {
1973 "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
1974 }
1975 "route" | "routes" => "l.kind LIKE '%route%'",
1976 "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
1977 "model" | "models" => "l.kind LIKE '%model%'",
1978 _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
1979 };
1980
1981 let sql = format!(
1982 "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
1983 l.start_line, s.name AS containing_symbol_name
1984 FROM literals l
1985 LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
1986 WHERE (:cat IS NOT NULL AND {cat_clause})
1987 AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1988 ORDER BY l.path ASC, l.start_line ASC
1989 LIMIT :limit"
1990 );
1991
1992 let mut stmt = conn.prepare(&sql)?;
1993 let rows = stmt.query_map(
1994 rusqlite::named_params! {
1995 ":cat": cat_pattern,
1996 ":path": norm_path.as_deref(),
1997 ":dir_prefix": dir_prefix.as_deref(),
1998 ":limit": limit as i64,
1999 },
2000 |row| {
2001 Ok(LiteralFact {
2002 literal_id: row.get(0)?,
2003 path: row.get::<_, String>(1)?.replace('\\', "/"),
2004 literal_text: row.get(2)?,
2005 kind: row.get(3)?,
2006 carrier: row.get(4)?,
2007 start_line: row.get::<_, i64>(5)? as usize,
2008 containing_symbol_name: row.get(6)?,
2009 })
2010 },
2011 )?;
2012
2013 let mut results = Vec::new();
2014 for r in rows {
2015 results.push(r?);
2016 }
2017 Ok(results)
2018}
2019
2020pub fn find_literals(
2022 conn: &Connection,
2023 category: &str,
2024 limit: usize,
2025) -> Result<Vec<LiteralFact>, QueryError> {
2026 find_literals_scoped(conn, category, None, limit)
2027}
2028
2029pub fn list_structural_fact_categories_scoped(
2031 conn: &Connection,
2032 path_filter: Option<&str>,
2033) -> Result<Vec<(String, usize)>, QueryError> {
2034 let norm_path = path_filter
2035 .map(|p| {
2036 p.replace('\\', "/")
2037 .trim_start_matches("./")
2038 .trim_matches('/')
2039 .to_string()
2040 })
2041 .filter(|p| !p.is_empty());
2042 let dir_prefix = norm_path
2043 .as_deref()
2044 .map(|p| format!("{}/%", escape_like(p)));
2045
2046 let mut categories = Vec::new();
2047
2048 let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
2049 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2050 GROUP BY pattern_id ORDER BY cnt DESC";
2051 let mut stmt = conn.prepare(sql)?;
2052 let rows = stmt.query_map(
2053 rusqlite::named_params! {
2054 ":path": norm_path.as_deref(),
2055 ":dir_prefix": dir_prefix.as_deref(),
2056 },
2057 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
2058 )?;
2059 for r in rows {
2060 categories.push(r?);
2061 }
2062
2063 let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
2064 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
2065 GROUP BY kind ORDER BY cnt DESC";
2066 let mut lit_stmt = conn.prepare(lit_sql)?;
2067 let lit_rows = lit_stmt.query_map(
2068 rusqlite::named_params! {
2069 ":path": norm_path.as_deref(),
2070 ":dir_prefix": dir_prefix.as_deref(),
2071 },
2072 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
2073 )?;
2074 for r in lit_rows {
2075 categories.push(r?);
2076 }
2077
2078 Ok(categories)
2079}
2080
2081pub fn list_structural_fact_categories(
2083 conn: &Connection,
2084) -> Result<Vec<(String, usize)>, QueryError> {
2085 list_structural_fact_categories_scoped(conn, None)
2086}
2087
2088pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
2090 let has_table: bool = conn
2091 .query_row(
2092 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
2093 [],
2094 |_| Ok(true),
2095 )
2096 .unwrap_or(false);
2097 if !has_table {
2098 return Ok(Vec::new());
2099 }
2100
2101 let mut stmt = conn.prepare(
2102 "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
2103 FROM type_facts
2104 WHERE symbol_id = ?1",
2105 )?;
2106
2107 let rows = stmt.query_map(params![symbol_id], |row| {
2108 Ok(TypeFact {
2109 type_fact_id: row.get(0)?,
2110 symbol_id: row.get(1)?,
2111 language: row.get(2)?,
2112 resolved_type: row.get(3)?,
2113 generic_params: row.get(4)?,
2114 })
2115 })?;
2116
2117 let mut results = Vec::new();
2118 for r in rows {
2119 results.push(r?);
2120 }
2121 Ok(results)
2122}
2123
2124pub fn is_test_path(path: &str) -> bool {
2126 let p = path.to_lowercase().replace('\\', "/");
2127 p.contains("/test/")
2128 || p.contains("/tests/")
2129 || p.contains("/__tests__/")
2130 || p.contains("_test.")
2131 || p.contains(".test.")
2132 || p.contains(".spec.")
2133 || p.ends_with("test.rs")
2134 || p.ends_with("tests.rs")
2135 || p.ends_with("tests.cs")
2136 || p.ends_with("test.go")
2137 || p.starts_with("test_")
2138}
2139
2140pub fn compute_blast_radius_scoped(
2143 conn: &Connection,
2144 seed_symbols: &[&str],
2145 symbol_path_filter: Option<&str>,
2146 seed_paths: &[&str],
2147 max_depth: usize,
2148 limit: usize,
2149) -> Result<BlastRadiusResult, QueryError> {
2150 validate_result_limit(limit)?;
2151 let max_depth = max_depth.min(5);
2152 let resolved_seed_symbols = seed_symbols
2153 .iter()
2154 .map(|name| {
2155 get_symbol_by_name(conn, name, symbol_path_filter)?
2156 .ok_or_else(|| QueryError::SymbolNotFound((*name).to_string()))
2157 })
2158 .collect::<Result<Vec<_>, _>>()?;
2159 let mut seeds = Vec::new();
2160 let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
2161 for s in seed_symbols {
2162 seeds.push(s.to_string());
2163 }
2164 for p in seed_paths {
2165 seeds.push(p.to_string());
2166 }
2167 "mixed".to_string()
2168 } else if !seed_symbols.is_empty() {
2169 for s in seed_symbols {
2170 seeds.push(s.to_string());
2171 }
2172 "symbol".to_string()
2173 } else if !seed_paths.is_empty() {
2174 for p in seed_paths {
2175 seeds.push(p.to_string());
2176 }
2177 "file".to_string()
2178 } else {
2179 return Ok(BlastRadiusResult {
2180 seed_type: "none".to_string(),
2181 seeds: Vec::new(),
2182 likely_tests: Vec::new(),
2183 impacted_symbols: Vec::new(),
2184 traversal_ceiling_reached: false,
2185 });
2186 };
2187
2188 let mut where_clauses = Vec::new();
2189 let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
2190
2191 if !resolved_seed_symbols.is_empty() {
2192 let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
2193 .map(|i| format!("?{}", i))
2194 .collect();
2195 where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
2196 for symbol in &resolved_seed_symbols {
2197 params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
2198 }
2199 }
2200
2201 if !seed_paths.is_empty() {
2202 let mut path_conds = Vec::new();
2203 for p in seed_paths.iter() {
2204 let raw = p
2205 .replace('\\', "/")
2206 .trim_start_matches("./")
2207 .trim_matches('/')
2208 .to_string();
2209 let exact_idx = params_vec.len() + 1;
2210 params_vec.push(rusqlite::types::Value::Text(raw.clone()));
2211 let dir_pattern = format!("{}/%", escape_like(&raw));
2212 let like_idx = params_vec.len() + 1;
2213 params_vec.push(rusqlite::types::Value::Text(dir_pattern));
2214 path_conds.push(format!(
2215 "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
2216 ));
2217 }
2218 where_clauses.push(format!("({})", path_conds.join(" OR ")));
2219 }
2220
2221 let seed_condition = where_clauses.join(" OR ");
2222 let max_depth_idx = params_vec.len() + 1;
2223 params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
2224
2225 let mut traversal_ceiling_reached = false;
2226
2227 let has_relationships: bool = conn
2228 .query_row(
2229 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
2230 [],
2231 |_| Ok(true),
2232 )
2233 .unwrap_or(false);
2234
2235 let has_pending: bool = conn
2236 .query_row(
2237 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
2238 [],
2239 |_| Ok(true),
2240 )
2241 .unwrap_or(false);
2242
2243 let mut likely_tests = Vec::new();
2244 let mut impacted_symbols = Vec::new();
2245 let mut seen_test_keys = HashSet::new();
2246
2247 let mut recursive_branches = Vec::new();
2248
2249 if has_relationships {
2250 recursive_branches.push(format!(
2251 "SELECT r.from_symbol_id, iw.depth + 1
2252 FROM relationships r
2253 JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
2254 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2255 WHERE iw.depth < ?{max_depth_idx}
2256 AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
2257 ));
2258 }
2259
2260 if has_pending {
2261 let (parent_join, ns_condition) = if conn
2262 .query_row(
2263 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
2264 [],
2265 |_| Ok(true),
2266 )
2267 .unwrap_or(false)
2268 {
2269 (
2270 "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2271 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
2272 format!("AND {pred}", pred = pending_target_predicate("s_target", "s_target_parent")),
2273 )
2274 } else {
2275 ("", String::new())
2276 };
2277
2278 recursive_branches.push(format!(
2279 "SELECT p.from_symbol_id, iw.depth + 1
2280 FROM pending_relationships p
2281 JOIN symbols s_target ON p.target_terminal_name = s_target.name
2282 JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
2283 {parent_join}
2284 WHERE iw.depth < ?{max_depth_idx}
2285 AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2286 {ns_condition}"
2287 ));
2288 }
2289
2290 if !recursive_branches.is_empty() {
2291 let recursive_sql = recursive_branches.join("\n UNION \n");
2292 let not_documentation = not_documentation(conn, "s");
2293 let sql = format!(
2294 "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
2295 SELECT symbol_id, 0
2296 FROM symbols
2297 WHERE ({seed_condition})
2298 AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2299
2300 UNION
2301
2302 {recursive_sql}
2303 )
2304 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
2305 FROM impact_walk iw
2306 CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
2307 WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2308 AND {not_documentation}
2309 GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
2310 HAVING MIN(iw.depth) > 0
2311 ORDER BY min_depth ASC, s.path ASC, s.name ASC
2312 LIMIT 200"
2313 );
2314
2315 let mut stmt = conn.prepare(&sql)?;
2316 let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
2317 .iter()
2318 .map(|v| v as &dyn rusqlite::ToSql)
2319 .collect();
2320
2321 let rows = stmt.query_map(param_refs.as_slice(), |row| {
2322 Ok((
2323 row.get::<_, String>(0)?,
2324 row.get::<_, String>(1)?,
2325 row.get::<_, String>(2)?,
2326 row.get::<_, String>(3)?,
2327 row.get::<_, i64>(4)? as usize,
2328 row.get::<_, bool>(5)?,
2329 row.get::<_, bool>(6)?,
2330 row.get::<_, i64>(7)? as usize,
2331 ))
2332 })?;
2333
2334 let mut row_count = 0;
2335 for r in rows {
2336 row_count += 1;
2337 let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
2338 let path = raw_path.replace('\\', "/");
2339 let is_test_target = is_test || test_container || is_test_path(&path);
2340
2341 if is_test_target {
2342 let key = format!("{}:{}", path, line);
2343 if seen_test_keys.insert(key) {
2344 likely_tests.push(TestTarget {
2345 name,
2346 path,
2347 line,
2348 reason: format!("transitive caller [depth {depth}]"),
2349 });
2350 }
2351 } else {
2352 impacted_symbols.push(ImpactedSymbol {
2353 name,
2354 kind,
2355 path,
2356 line,
2357 depth,
2358 });
2359 }
2360 }
2361 traversal_ceiling_reached = row_count >= 200;
2362 }
2363
2364 let mut file_stems = Vec::new();
2366 for p in seed_paths {
2367 if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
2368 && stem.len() >= 3
2369 && !file_stems.contains(&stem.to_string())
2370 {
2371 file_stems.push(stem.to_string());
2372 }
2373 }
2374 for symbol in &resolved_seed_symbols {
2375 if let Some(stem) = std::path::Path::new(&symbol.path)
2376 .file_stem()
2377 .and_then(|s| s.to_str())
2378 && stem.len() >= 3
2379 && !file_stems.contains(&stem.to_string())
2380 {
2381 file_stems.push(stem.to_string());
2382 }
2383 }
2384
2385 let has_files: bool = conn
2386 .query_row(
2387 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
2388 [],
2389 |_| Ok(true),
2390 )
2391 .unwrap_or(false);
2392
2393 if has_files {
2394 let doc_file = format!(
2395 "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
2396 not_documentation(conn, "d")
2397 );
2398 let mut test_files_stmt = conn.prepare(&format!(
2399 "SELECT DISTINCT path FROM files
2400 WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
2401 AND NOT {doc_file}
2402 LIMIT 10"
2403 ))?;
2404 for stem in file_stems {
2405 let stem_pattern = format!("%{}%", escape_like(&stem));
2406 let t_rows =
2407 test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
2408 for p in t_rows.flatten() {
2409 let p = p.replace('\\', "/");
2410 let key = format!("{}:1", p);
2411 if seen_test_keys.insert(key) {
2412 likely_tests.push(TestTarget {
2413 name: p.clone(),
2414 path: p,
2415 line: 1,
2416 reason: "stem-matched test file".to_string(),
2417 });
2418 }
2419 }
2420 }
2421 }
2422
2423 if likely_tests.len() > limit {
2425 likely_tests.truncate(limit);
2426 }
2427 if impacted_symbols.len() > limit {
2428 impacted_symbols.truncate(limit);
2429 }
2430
2431 Ok(BlastRadiusResult {
2432 seed_type,
2433 seeds,
2434 likely_tests,
2435 impacted_symbols,
2436 traversal_ceiling_reached,
2437 })
2438}
2439
2440pub fn compute_blast_radius(
2442 conn: &Connection,
2443 seed_symbols: &[&str],
2444 seed_paths: &[&str],
2445 max_depth: usize,
2446 limit: usize,
2447) -> Result<BlastRadiusResult, QueryError> {
2448 compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
2449}
2450
2451#[cfg(test)]
2452mod tests {
2453 #[test]
2454 fn result_limit_rejects_values_above_the_shared_ceiling() {
2455 assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
2456 assert!(matches!(
2457 validate_result_limit(usize::MAX),
2458 Err(QueryError::InvalidResultLimit(usize::MAX))
2459 ));
2460 }
2461
2462 #[test]
2463 fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
2464 let conn = Connection::open_in_memory().unwrap();
2465
2466 assert!(matches!(
2467 find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
2468 Err(QueryError::InvalidResultLimit(usize::MAX))
2469 ));
2470 }
2471
2472 use super::*;
2473 use crate::db::{ensure_fts_index, open_read_write};
2474
2475 #[test]
2476 fn count_parse_diagnostics_counts_rows_for_one_file() {
2477 let dir = crate::safe_tempdir();
2478 let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
2479
2480 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
2481
2482 conn.execute_batch(
2483 "CREATE TABLE parse_diagnostics (
2484 diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
2485 );
2486 INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
2487 INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
2488 INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
2489 )
2490 .unwrap();
2491
2492 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
2493 assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
2494 assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
2495 }
2496
2497 #[test]
2498 fn test_sanitize_fts5_query() {
2499 let (and_q, or_q) = sanitize_fts5_query("parse tokens");
2500 assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
2501 assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
2502
2503 let (and_q, or_q) = sanitize_fts5_query(" Option<T> ");
2504 assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
2505 assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
2506
2507 let (and_q, or_q) = sanitize_fts5_query(" ");
2508 assert!(and_q.is_empty());
2509 assert!(or_q.is_empty());
2510 }
2511
2512 #[test]
2513 fn sanitize_splits_case_boundaries_and_drops_stop_words() {
2514 let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
2515 assert_eq!(
2516 and_q,
2517 "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
2518 );
2519 assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
2520
2521 let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
2522 assert_eq!(
2523 and_q,
2524 "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
2525 );
2526
2527 let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
2528 assert_eq!(
2529 and_q,
2530 "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
2531 );
2532 assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
2533
2534 let (and_q, _) = sanitize_fts5_query("validate_syntax");
2535 assert_eq!(
2536 and_q,
2537 "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
2538 );
2539
2540 let (and_q, _) = sanitize_fts5_query("isReady");
2541 assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
2542
2543 let (and_q, _) = sanitize_fts5_query("before");
2544 assert_eq!(and_q, "\"before\"*");
2545
2546 let (and_q, _) = sanitize_fts5_query("fooBar quux");
2547 assert_eq!(
2548 and_q,
2549 "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
2550 );
2551
2552 let (and_q, _) = sanitize_fts5_query("the for a");
2553 assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
2554 }
2555
2556 fn search_fixture(rows: &str) -> Connection {
2557 let conn = Connection::open_in_memory().unwrap();
2558 conn.execute_batch(&format!(
2559 "CREATE TABLE symbols (
2560 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
2561 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2562 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2563 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2564 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2565 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2566 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
2567 );
2568 INSERT INTO symbols VALUES {rows};"
2569 ))
2570 .unwrap();
2571 ensure_fts_index(&conn).unwrap();
2572 conn
2573 }
2574
2575 fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
2576 format!(
2577 "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
2578 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
2579 )
2580 }
2581
2582 fn doc_row(id: &str, name: &str, doc: &str) -> String {
2583 format!(
2584 "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
2585 3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
2586 )
2587 }
2588
2589 fn search_names(conn: &Connection, query: &str) -> Vec<String> {
2590 fts_search_symbols_scoped(conn, query, None, None, false, 10)
2591 .unwrap()
2592 .into_iter()
2593 .map(|r| r.symbol.name)
2594 .collect()
2595 }
2596
2597 #[test]
2598 fn concept_query_prefers_partial_code_match_over_full_doc_match() {
2599 let conn = search_fixture(
2600 &[
2601 doc_row(
2602 "d1",
2603 "Safety guarantees",
2604 "Pre-flight syntax validation runs before the edit touches disk",
2605 ),
2606 doc_row(
2607 "d2",
2608 "Audit",
2609 "The syntax validation before an edit is the invariant",
2610 ),
2611 code_row(
2612 "c1",
2613 "src/syntax.rs",
2614 "rust",
2615 "validate_syntax",
2616 "Validate the syntax of a file",
2617 ),
2618 code_row(
2619 "c2",
2620 "src/edit.rs",
2621 "rust",
2622 "replace_symbol_body",
2623 "Atomic edit with validation",
2624 ),
2625 ]
2626 .join(","),
2627 );
2628
2629 let names = search_names(&conn, "syntax validation before edit");
2630
2631 assert_eq!(names[0], "validate_syntax");
2632 assert!(names.contains(&"replace_symbol_body".to_string()));
2633 assert!(names.contains(&"Safety guarantees".to_string()));
2634 }
2635
2636 #[test]
2637 fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
2638 let conn = search_fixture(
2639 &[
2640 code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
2641 code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
2642 ]
2643 .join(","),
2644 );
2645
2646 let mut camel = search_names(&conn, "ValidateSyntax");
2647 camel.sort();
2648 assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
2649 let mut words = search_names(&conn, "validate syntax");
2650 words.sort();
2651 assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
2652 }
2653
2654 #[test]
2655 fn stop_word_prefixed_camel_case_symbol_is_still_found() {
2656 let conn = search_fixture(
2657 &[
2658 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
2659 code_row("c2", "src/hooks.rs", "rust", "before", ""),
2660 code_row(
2661 "c3",
2662 "src/x.rs",
2663 "rust",
2664 "fooBar",
2665 "has fooBar but not the other word",
2666 ),
2667 ]
2668 .join(","),
2669 );
2670
2671 assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
2672 assert_eq!(search_names(&conn, "before"), vec!["before"]);
2673 }
2674
2675 #[test]
2676 fn related_tests_use_the_name_as_typed_without_splitting() {
2677 let conn = search_fixture(
2678 &[
2679 code_row("c1", "src/state.ts", "typescript", "isReady", ""),
2680 "('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(),
2681 "('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(),
2682 ]
2683 .join(","),
2684 );
2685 let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
2686
2687 let names: Vec<String> = find_related_tests(&conn, &target, 5)
2688 .unwrap()
2689 .into_iter()
2690 .map(|t| t.name)
2691 .collect();
2692
2693 assert_eq!(names, vec!["isReady_reports_true"]);
2694 }
2695
2696 #[test]
2697 fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
2698 let conn = search_fixture(
2699 &[
2700 code_row(
2701 "c1",
2702 "src/queries.rs",
2703 "rust",
2704 "fts_search_symbols_scoped",
2705 "search symbols scoped with fts",
2706 ),
2707 code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
2708 ]
2709 .join(","),
2710 );
2711
2712 assert_eq!(
2713 search_names(&conn, "search_symbols_scoped")[0],
2714 "search_symbols_scoped"
2715 );
2716 }
2717
2718 #[test]
2719 fn search_symbols_treats_like_wildcards_as_literals() {
2720 let dir = crate::safe_tempdir();
2721 let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
2722 let conn = open_read_write(&db_path).unwrap();
2723 conn.execute_batch(
2724 "CREATE TABLE symbols (
2725 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2726 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2727 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2728 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2729 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2730 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2731 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2732 );
2733 INSERT INTO symbols VALUES (
2734 's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
2735 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2736 );
2737 INSERT INTO symbols VALUES (
2738 'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
2739 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2740 );
2741 INSERT INTO symbols VALUES (
2742 'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
2743 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2744 );
2745 CREATE TABLE files (
2746 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
2747 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
2748 );
2749 INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
2750 INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
2751 );",
2752 )
2753 .unwrap();
2754
2755 assert_eq!(
2756 search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
2757 "literal%name"
2758 );
2759 assert_eq!(
2760 search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
2761 "literal_name"
2762 );
2763 assert_eq!(
2764 load_scoped_files(&conn, Some("src/literal_path"))
2765 .unwrap()
2766 .len(),
2767 1
2768 );
2769 }
2770
2771 #[test]
2772 fn find_references_for_symbol_limits_callees_by_symbol_id() {
2773 let dir = crate::safe_tempdir();
2774 let db_path = dir.path().join("find_references_for_symbol.db");
2775 let conn = open_read_write(&db_path).unwrap();
2776 conn.execute_batch(
2777 "CREATE TABLE symbols (
2778 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2779 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2780 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2781 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2782 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2783 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2784 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2785 );
2786 CREATE TABLE relationships (
2787 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2788 start_line INTEGER, start_column INTEGER
2789 );
2790 CREATE TABLE pending_relationships (
2791 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2792 start_line INTEGER, start_column INTEGER
2793 );
2794 INSERT INTO symbols VALUES
2795 ('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),
2796 ('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),
2797 ('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),
2798 ('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);
2799 INSERT INTO relationships VALUES
2800 ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
2801 ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
2802 )
2803 .unwrap();
2804
2805 let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
2806 assert_eq!(references.len(), 1);
2807 assert_eq!(references[0].to_symbol_name, "wanted_dep");
2808 }
2809
2810 #[test]
2811 fn test_fts_search_symbols_and_porter_stemming() {
2812 let dir = crate::safe_tempdir();
2813 let db_path = dir.path().join("fts_search_symbols.db");
2814 let conn = open_read_write(&db_path).unwrap();
2815
2816 conn.execute_batch(
2817 "CREATE TABLE symbols (
2818 symbol_id TEXT PRIMARY KEY,
2819 file_id TEXT,
2820 path TEXT,
2821 language TEXT,
2822 name TEXT,
2823 kind TEXT,
2824 signature TEXT,
2825 doc_comment TEXT,
2826 visibility TEXT,
2827 parent_symbol_id TEXT,
2828 start_line INTEGER,
2829 start_column INTEGER,
2830 end_line INTEGER,
2831 end_column INTEGER,
2832 start_byte INTEGER,
2833 end_byte INTEGER,
2834 body_start_line INTEGER,
2835 body_start_column INTEGER,
2836 body_end_line INTEGER,
2837 body_end_column INTEGER,
2838 body_start_byte INTEGER,
2839 body_end_byte INTEGER,
2840 body_hash TEXT,
2841 semantic_group TEXT,
2842 is_test INTEGER,
2843 test_container INTEGER
2844 );
2845 INSERT INTO symbols VALUES (
2846 's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
2847 'pub trait PaymentGateway', 'Core payment provider interface for transactions',
2848 'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
2849 );
2850 INSERT INTO symbols VALUES (
2851 's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
2852 'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
2853 'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
2854 );
2855 INSERT INTO symbols VALUES (
2856 's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
2857 'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
2858 'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
2859 );
2860 INSERT INTO symbols VALUES (
2861 's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
2862 'fn test_payment_flow()', 'Tests payment charge workflow',
2863 NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
2864 );",
2865 )
2866 .unwrap();
2867
2868 ensure_fts_index(&conn).unwrap();
2869
2870 let results =
2872 fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
2873 assert_eq!(results.len(), 1);
2874 assert_eq!(results[0].symbol.name, "parse_tokens");
2875 assert!(results[0].snippet.is_some());
2876
2877 let results =
2879 fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
2880 assert_eq!(results.len(), 1);
2881 assert_eq!(results[0].symbol.name, "PaymentGateway");
2882
2883 let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
2885 assert_eq!(results.len(), 2);
2886 assert!(results.iter().all(|r| !r.symbol.is_test));
2887
2888 let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
2890 assert_eq!(results.len(), 3);
2891
2892 let results =
2894 fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
2895 assert_eq!(results.len(), 1);
2896 assert_eq!(results[0].symbol.name, "StripeClient");
2897 }
2898
2899 #[test]
2900 fn find_related_tests_returns_each_test_once_under_the_limit() {
2901 let dir = crate::safe_tempdir();
2902 let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
2903 conn.execute_batch(
2904 "CREATE TABLE symbols (
2905 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
2906 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
2907 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
2908 start_column INTEGER, end_line INTEGER, end_column INTEGER,
2909 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2910 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2911 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2912 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2913 );
2914 CREATE TABLE relationships (
2915 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2916 start_line INTEGER, start_column INTEGER
2917 );
2918 CREATE TABLE pending_relationships (
2919 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2920 start_line INTEGER, start_column INTEGER,
2921 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
2922 );
2923 CREATE TABLE type_facts (
2924 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
2925 );
2926 INSERT INTO symbols VALUES
2927 ('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),
2928 ('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),
2929 ('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);
2930 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
2931 ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
2932 ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
2933 ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
2934 ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
2935 ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
2936 ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
2937 )
2938 .unwrap();
2939 let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
2940
2941 let tests = find_related_tests(&conn, &target, 5).unwrap();
2942
2943 let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2944 names.sort();
2945 assert_eq!(names, vec!["first_case", "second_case"]);
2946 }
2947
2948 #[test]
2949 fn documentation_rows_rank_after_code_in_search() {
2950 let dir = crate::safe_tempdir();
2951 let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
2952 conn.execute_batch(
2953 "CREATE TABLE symbols (
2954 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
2955 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2956 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2957 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2958 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2959 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2960 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
2961 );
2962 INSERT INTO symbols VALUES
2963 ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
2964 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
2965 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
2966 ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
2967 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
2968 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
2969 )
2970 .unwrap();
2971 ensure_fts_index(&conn).unwrap();
2972
2973 let results =
2974 fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
2975 .unwrap();
2976
2977 assert_eq!(results.len(), 2);
2978 assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
2979 assert_eq!(results[1].symbol.name, "Reconcile offline edits");
2980 }
2981
2982 #[test]
2983 fn test_queries_nocase_and_path_normalization() {
2984 let conn = Connection::open_in_memory().unwrap();
2985 conn.execute_batch(
2986 "CREATE TABLE files (
2987 file_id TEXT PRIMARY KEY,
2988 path TEXT NOT NULL,
2989 language TEXT,
2990 content_hash TEXT,
2991 content_bytes INTEGER,
2992 line_count INTEGER,
2993 indexed_at INTEGER
2994 );
2995 CREATE TABLE symbols (
2996 symbol_id TEXT PRIMARY KEY,
2997 file_id TEXT,
2998 path TEXT NOT NULL,
2999 language TEXT,
3000 name TEXT,
3001 kind TEXT,
3002 signature TEXT,
3003 doc_comment TEXT,
3004 visibility TEXT,
3005 parent_symbol_id TEXT,
3006 start_line INTEGER,
3007 start_column INTEGER,
3008 end_line INTEGER,
3009 end_column INTEGER,
3010 start_byte INTEGER,
3011 end_byte INTEGER,
3012 body_start_line INTEGER,
3013 body_start_column INTEGER,
3014 body_end_line INTEGER,
3015 body_end_column INTEGER,
3016 body_start_byte INTEGER,
3017 body_end_byte INTEGER,
3018 body_hash TEXT,
3019 semantic_group TEXT,
3020 is_test INTEGER,
3021 test_container INTEGER
3022 );
3023 -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
3024 INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
3025 INSERT INTO symbols VALUES (
3026 's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
3027 'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
3028 2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
3029 );",
3030 )
3031 .unwrap();
3032
3033 let file = get_file(&conn, "SRC/PAYMENT.RS")
3035 .unwrap()
3036 .expect("File should be found");
3037 assert_eq!(
3038 file.path, "src/Payment.rs",
3039 "Path should be normalized to forward slashes"
3040 );
3041
3042 let file2 = get_file(&conn, "src/payment.rs")
3043 .unwrap()
3044 .expect("File should be found");
3045 assert_eq!(file2.path, "src/Payment.rs");
3046
3047 let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
3049 assert_eq!(syms.len(), 1);
3050 assert_eq!(
3051 syms[0].path, "src/Payment.rs",
3052 "Symbol path should be normalized to forward slashes"
3053 );
3054
3055 let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
3057 .unwrap()
3058 .expect("Symbol should be found with case-insensitive path filter");
3059 assert_eq!(sym.path, "src/Payment.rs");
3060 }
3061
3062 #[test]
3063 fn test_exact_case_prioritized_over_nocase() {
3064 let conn = Connection::open_in_memory().unwrap();
3065 conn.execute_batch(
3066 "CREATE TABLE files (
3067 file_id TEXT PRIMARY KEY,
3068 path TEXT NOT NULL,
3069 language TEXT,
3070 content_hash TEXT,
3071 content_bytes INTEGER,
3072 line_count INTEGER,
3073 indexed_at TEXT
3074 );
3075 CREATE TABLE symbols (
3076 symbol_id TEXT PRIMARY KEY,
3077 file_id TEXT,
3078 path TEXT NOT NULL,
3079 language TEXT,
3080 name TEXT NOT NULL,
3081 kind TEXT NOT NULL,
3082 signature TEXT,
3083 doc_comment TEXT,
3084 visibility TEXT,
3085 parent_symbol_id TEXT,
3086 start_line INTEGER,
3087 start_column INTEGER,
3088 end_line INTEGER,
3089 end_column INTEGER,
3090 start_byte INTEGER,
3091 end_byte INTEGER,
3092 body_start_line INTEGER,
3093 body_start_column INTEGER,
3094 body_end_line INTEGER,
3095 body_end_column INTEGER,
3096 body_start_byte INTEGER,
3097 body_end_byte INTEGER,
3098 body_hash TEXT,
3099 semantic_group TEXT,
3100 is_test INTEGER,
3101 test_container INTEGER
3102 );
3103 INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
3104 INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
3105 INSERT INTO symbols VALUES (
3106 's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
3107 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
3108 2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
3109 );
3110 INSERT INTO symbols VALUES (
3111 's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
3112 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
3113 2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
3114 );",
3115 )
3116 .unwrap();
3117
3118 let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
3120 assert_eq!(f_lower.path, "src/payment.rs");
3121 assert_eq!(f_lower.file_id, "f2");
3122
3123 let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
3124 assert_eq!(f_upper.path, "src/Payment.rs");
3125 assert_eq!(f_upper.file_id, "f1");
3126
3127 let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
3128 assert_eq!(syms_lower.len(), 1);
3129 assert_eq!(syms_lower[0].file_id, "f2");
3130
3131 let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
3132 assert_eq!(syms_upper.len(), 1);
3133 assert_eq!(syms_upper[0].file_id, "f1");
3134 }
3135
3136 #[test]
3137 fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
3138 let dir = crate::safe_tempdir();
3139 let db_path = dir.path().join("conservative_resolution.db");
3140 let conn = open_read_write(&db_path).unwrap();
3141
3142 conn.execute_batch(
3143 "CREATE TABLE symbols (
3144 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
3145 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
3146 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
3147 start_column INTEGER, end_line INTEGER, end_column INTEGER,
3148 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3149 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3150 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3151 semantic_group TEXT, is_test INTEGER, test_container INTEGER
3152 );
3153 CREATE TABLE relationships (
3154 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
3155 start_line INTEGER, start_column INTEGER
3156 );
3157 CREATE TABLE pending_relationships (
3158 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
3159 start_line INTEGER, start_column INTEGER,
3160 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
3161 );
3162 CREATE TABLE type_facts (
3163 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
3164 );
3165 -- Workspace struct Workspace and method Workspace::new
3166 INSERT INTO symbols VALUES
3167 ('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),
3168 ('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),
3169 ('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);
3170
3171 -- my_func calls Vec::new() (external namespace 'Vec')
3172 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
3173 ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
3174 )
3175 .unwrap();
3176
3177 let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
3179 assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
3180
3181 let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
3182 assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
3183
3184 let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
3186 assert!(
3187 callers.is_empty(),
3188 "Expected 0 callers for Workspace::new, got: {:?}",
3189 callers
3190 );
3191
3192 let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
3194 assert!(
3195 !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
3196 "my_func should not be impacted before calling Workspace::new: {:?}",
3197 blast.impacted_symbols
3198 );
3199
3200 conn.execute(
3202 "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')",
3203 [],
3204 )
3205 .unwrap();
3206
3207 let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
3208 assert_eq!(
3209 sigs2.len(),
3210 1,
3211 "Expected 1 signature for Workspace::new, got: {:?}",
3212 sigs2
3213 );
3214 assert!(sigs2[0].contains("pub fn new() -> Workspace"));
3215
3216 let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
3218 assert!(
3219 blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
3220 "my_func should be impacted after calling Workspace::new: {:?}",
3221 blast2.impacted_symbols
3222 );
3223
3224 conn.execute(
3226 "INSERT INTO symbols VALUES
3227 ('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);",
3228 [],
3229 )
3230 .unwrap();
3231 conn.execute(
3232 "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')",
3233 [],
3234 )
3235 .unwrap();
3236
3237 let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
3239 assert!(
3240 sigs_other.is_empty(),
3241 "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
3242 sigs_other
3243 );
3244
3245 conn.execute(
3247 "INSERT INTO symbols VALUES
3248 ('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);",
3249 [],
3250 )
3251 .unwrap();
3252 conn.execute(
3253 "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')",
3254 [],
3255 )
3256 .unwrap();
3257
3258 let sigs_sibling =
3259 find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
3260 assert_eq!(
3261 sigs_sibling.len(),
3262 1,
3263 "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
3264 sigs_sibling
3265 );
3266
3267 let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
3269 assert!(
3270 ext_sigs.iter().any(|s| s.contains("Vec")),
3271 "include_external: true should include external Vec::new: {:?}",
3272 ext_sigs
3273 );
3274 }
3275
3276 #[test]
3277 fn test_find_structural_facts_and_literals_scoped() {
3278 let dir = crate::safe_tempdir();
3279 let db_path = dir.path().join("facts_test.db");
3280 let conn = open_read_write(&db_path).unwrap();
3281 conn.execute_batch(
3282 "CREATE TABLE symbols (
3283 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
3284 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
3285 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3286 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3287 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3288 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3289 semantic_group TEXT, is_test INTEGER, test_container INTEGER
3290 );
3291 CREATE TABLE structural_facts (
3292 structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
3293 pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
3294 start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
3295 );
3296 CREATE TABLE literals (
3297 literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
3298 kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
3299 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3300 start_byte INTEGER, end_byte INTEGER
3301 );
3302 INSERT INTO structural_facts VALUES
3303 ('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\"}'),
3304 ('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\"}'),
3305 ('sf_route', 'f2', 'src/routes/api.rs', 'rust', 'http.route.v1', 'get_users', 'function', NULL, 10, 20, 1.0, '{\"verb\":\"GET\",\"normalized_route_template\":\"/api/v1/users/:id\"}'),
3306 ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'db.sql.select', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
3307 ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'orm.model.entity', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
3308 ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
3309 INSERT INTO literals VALUES
3310 ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
3311 ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
3312 ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
3313 ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
3314 )
3315 .unwrap();
3316
3317 let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
3319 assert_eq!(facts_config.len(), 2);
3320 assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
3321 assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
3322 assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
3323 assert_eq!(
3324 facts_config[1].key.as_deref(),
3325 Some("mcp_servers.code-kb.command")
3326 );
3327 let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
3328 assert_eq!(lits_config.len(), 1);
3329 assert_eq!(lits_config[0].kind, "toml_key");
3330
3331 let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
3333 assert_eq!(facts_route.len(), 1);
3334 assert_eq!(facts_route[0].pattern_id, "http.route.v1");
3335 assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
3336 let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
3337 assert_eq!(facts_routes.len(), 1);
3338 let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
3339 assert_eq!(lits_route.len(), 1);
3340 assert_eq!(lits_route[0].kind, "http_route");
3341
3342 for q in &["query", "queries", "sql"] {
3344 let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
3345 assert_eq!(facts.len(), 1, "Failed for {}", q);
3346 assert_eq!(facts[0].pattern_id, "db.sql.select");
3347 let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
3348 assert_eq!(lits.len(), 1, "Failed for {}", q);
3349 assert_eq!(lits[0].kind, "sql_query");
3350 }
3351
3352 for m in &["model", "models"] {
3354 let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
3355 assert_eq!(facts.len(), 1, "Failed for {}", m);
3356 assert_eq!(facts[0].pattern_id, "orm.model.entity");
3357 let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
3358 assert_eq!(lits.len(), 1, "Failed for {}", m);
3359 assert_eq!(lits[0].kind, "model_table");
3360 }
3361
3362 let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
3364 assert_eq!(facts_custom.len(), 1);
3365 assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
3366 assert_eq!(facts_custom[0].key, None);
3367
3368 let facts_exact =
3370 find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
3371 assert_eq!(facts_exact.len(), 1);
3372 let facts_miss =
3373 find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
3374 assert_eq!(facts_miss.len(), 0);
3375
3376 let facts_dir =
3378 find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
3379 assert_eq!(facts_dir.len(), 1);
3380 let facts_dir_miss =
3381 find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
3382 assert_eq!(facts_dir_miss.len(), 0);
3383
3384 let f_del = find_structural_facts(&conn, "config", 10).unwrap();
3386 assert_eq!(f_del.len(), 2);
3387 let l_del = find_literals(&conn, "config", 10).unwrap();
3388 assert_eq!(l_del.len(), 1);
3389 }
3390
3391 fn local_variable_fixture() -> Connection {
3392 let conn = Connection::open_in_memory().unwrap();
3393 conn.execute_batch(
3394 "CREATE TABLE symbols (
3395 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3396 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
3397 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
3398 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
3399 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
3400 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
3401 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
3402 );
3403 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
3404 parent_symbol_id, start_line, start_column, end_line, end_column,
3405 start_byte, end_byte, is_test, test_container)
3406 VALUES
3407 ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
3408 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
3409 ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3410 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
3411 ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
3412 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
3413 ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3414 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
3415 ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3416 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
3417 ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
3418 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
3419 ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3420 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
3421 )
3422 .unwrap();
3423 conn
3424 }
3425
3426 fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
3427 let mut stmt = conn
3428 .prepare(
3429 "SELECT s.symbol_id FROM symbols_fts f
3430 JOIN symbols s ON s.rowid = f.rowid
3431 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
3432 )
3433 .unwrap();
3434 let mut ids = stmt
3435 .query_map(params![query], |row| row.get::<_, String>(0))
3436 .unwrap()
3437 .collect::<Result<Vec<_>, _>>()
3438 .unwrap();
3439 ids.sort();
3440 ids
3441 }
3442
3443 #[test]
3444 fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
3445 let conn = local_variable_fixture();
3446 conn.execute_batch(
3447 "CREATE VIRTUAL TABLE symbols_fts USING fts5(
3448 name, signature, doc_comment,
3449 content='symbols', content_rowid='rowid', tokenize='porter unicode61'
3450 );
3451 INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
3452 SELECT rowid, name, signature, doc_comment FROM symbols;",
3453 )
3454 .unwrap();
3455
3456 ensure_fts_index(&conn).unwrap();
3457
3458 assert_eq!(
3459 matched_symbol_ids(&conn, "sqlite"),
3460 vec!["field", "func", "global", "pool"]
3461 );
3462 }
3463
3464 #[test]
3465 fn lookup_excludes_locals_and_parameters() {
3466 let conn = local_variable_fixture();
3467
3468 let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
3469 .unwrap()
3470 .into_iter()
3471 .map(|s| s.symbol_id)
3472 .collect();
3473
3474 assert!(!ids.contains(&"local".to_string()));
3475 assert!(!ids.contains(&"nested".to_string()));
3476 assert!(ids.contains(&"field".to_string()));
3477 assert!(ids.contains(&"global".to_string()));
3478 }
3479
3480 #[test]
3481 fn search_excludes_locals_and_parameters() {
3482 let conn = local_variable_fixture();
3483 ensure_fts_index(&conn).unwrap();
3484
3485 let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
3486 .unwrap()
3487 .into_iter()
3488 .map(|r| r.symbol.symbol_id)
3489 .collect();
3490
3491 assert!(!ids.contains(&"local".to_string()));
3492 assert!(ids.contains(&"func".to_string()));
3493 }
3494
3495 #[test]
3496 fn variable_kind_search_keeps_full_text_matching() {
3497 let conn = local_variable_fixture();
3498 ensure_fts_index(&conn).unwrap();
3499
3500 let ids: Vec<String> = fts_search_symbols_scoped(
3501 &conn,
3502 "sqlite connection",
3503 Some("variable"),
3504 None,
3505 false,
3506 10,
3507 )
3508 .unwrap()
3509 .into_iter()
3510 .map(|r| r.symbol.symbol_id)
3511 .collect();
3512
3513 assert!(ids.contains(&"global".to_string()));
3514 assert!(ids.contains(&"field".to_string()));
3515 }
3516
3517 #[test]
3518 fn qualified_lookup_returns_the_named_local_variable() {
3519 let conn = local_variable_fixture();
3520
3521 let ids: Vec<String> =
3522 search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
3523 .unwrap()
3524 .into_iter()
3525 .map(|s| s.symbol_id)
3526 .collect();
3527
3528 assert_eq!(ids, vec!["local".to_string()]);
3529 }
3530
3531 #[test]
3532 fn variable_kind_filter_returns_locals_and_parameters() {
3533 let conn = local_variable_fixture();
3534 ensure_fts_index(&conn).unwrap();
3535
3536 let lookup_ids: Vec<String> =
3537 search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
3538 .unwrap()
3539 .into_iter()
3540 .map(|s| s.symbol_id)
3541 .collect();
3542 assert!(lookup_ids.contains(&"local".to_string()));
3543 assert!(lookup_ids.contains(&"nested".to_string()));
3544
3545 let search_ids: Vec<String> =
3546 fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
3547 .unwrap()
3548 .into_iter()
3549 .map(|r| r.symbol.symbol_id)
3550 .collect();
3551 assert!(search_ids.contains(&"local".to_string()));
3552 }
3553}