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