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 NOT EXISTS (
1076 WITH RECURSIVE ancestor(symbol_id, depth) AS (
1077 SELECT {target}.parent_symbol_id, 0
1078 UNION ALL
1079 SELECT s.parent_symbol_id, ancestor.depth + 1
1080 FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
1081 WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
1082 )
1083 SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
1084 WHERE a.name = value
1085 )
1086 AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1087 AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
1088 )
1089 )
1090 OR (
1091 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1092 AND (p.target_receiver IS NULL OR p.target_receiver = '')
1093 AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
1094 AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
1095 SELECT 1 FROM symbols closer
1096 WHERE closer.name = {target}.name
1097 AND closer.symbol_id != {target}.symbol_id
1098 AND closer.parent_symbol_id IS NULL
1099 AND closer.kind = {target}.kind
1100 AND {closer_rank} > {target_rank}
1101 ))
1102 )
1103 OR (
1104 {target}.parent_symbol_id IS NULL
1105 AND EXISTS (
1106 SELECT 1 FROM {ns}
1107 WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
1108 AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1109 )
1110 )
1111 )"
1112 )
1113}
1114
1115fn not_documentation(conn: &Connection, alias: &str) -> String {
1118 let has_content_type: bool = conn
1119 .query_row(
1120 "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
1121 [],
1122 |_| Ok(true),
1123 )
1124 .unwrap_or(false);
1125 if has_content_type {
1126 format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
1127 } else {
1128 "1 = 1".to_string()
1129 }
1130}
1131
1132fn has_table(conn: &Connection, name: &str) -> bool {
1133 conn.query_row(
1134 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
1135 [name],
1136 |_| Ok(true),
1137 )
1138 .unwrap_or(false)
1139}
1140
1141fn has_pending_namespace_column(conn: &Connection) -> bool {
1142 let has_ns: bool = conn
1143 .query_row(
1144 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
1145 [],
1146 |_| Ok(true),
1147 )
1148 .unwrap_or(false);
1149 let has_display: bool = conn
1150 .query_row(
1151 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
1152 [],
1153 |_| Ok(true),
1154 )
1155 .unwrap_or(false);
1156 let has_receiver: bool = conn
1157 .query_row(
1158 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
1159 [],
1160 |_| Ok(true),
1161 )
1162 .unwrap_or(false);
1163 has_ns && has_display && has_receiver
1164}
1165
1166fn find_references_internal(
1167 conn: &Connection,
1168 symbol_name: &str,
1169 direction: &str,
1170 limit: usize,
1171 symbol_id: Option<&str>,
1172 include_external: bool,
1173) -> Result<Vec<ReferenceSite>, QueryError> {
1174 let mut results = Vec::new();
1175
1176 if direction == "callers" {
1177 let mut stmt = conn.prepare(
1179 "SELECT s_from.name AS from_name,
1180 r.from_symbol_id,
1181 s_to.name AS to_name,
1182 r.kind,
1183 r.path,
1184 r.start_line,
1185 r.start_column
1186 FROM relationships r
1187 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1188 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1189 WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
1190 LIMIT ?2",
1191 )?;
1192
1193 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1194 Ok(ReferenceSite {
1195 from_symbol_name: row.get(0)?,
1196 from_symbol_id: row.get(1)?,
1197 to_symbol_name: row.get(2)?,
1198 kind: row.get(3)?,
1199 path: row.get::<_, String>(4)?.replace('\\', "/"),
1200 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1201 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1202 })
1203 })?;
1204
1205 for r in rows {
1206 results.push(r?);
1207 }
1208
1209 if results.len() < limit {
1211 let remaining = limit - results.len();
1212 if has_pending_namespace_column(conn) {
1213 if let Some(sid) = symbol_id {
1214 let mut pending_stmt = conn.prepare(
1215 &format!("SELECT s_from.name AS from_name,
1216 p.from_symbol_id,
1217 p.target_terminal_name AS to_name,
1218 p.kind,
1219 p.path,
1220 p.start_line,
1221 p.start_column
1222 FROM pending_relationships p
1223 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1224 JOIN symbols s_target ON s_target.symbol_id = ?3
1225 LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1226 WHERE p.target_terminal_name = ?1
1227 AND {pred}
1228 LIMIT ?2", pred = pending_target_predicate("s_target", "s_target_parent")),
1229 )?;
1230
1231 let p_rows = pending_stmt.query_map(
1232 params![symbol_name, remaining as i64, sid],
1233 |row| {
1234 Ok(ReferenceSite {
1235 from_symbol_name: row.get(0)?,
1236 from_symbol_id: row.get(1)?,
1237 to_symbol_name: row.get(2)?,
1238 kind: row.get(3)?,
1239 path: row.get::<_, String>(4)?.replace('\\', "/"),
1240 start_line: Some(row.get::<_, i64>(5)? as usize),
1241 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1242 })
1243 },
1244 )?;
1245 for r in p_rows {
1246 results.push(r?);
1247 }
1248 } else {
1249 let mut pending_stmt = conn.prepare(
1250 "SELECT s_from.name AS from_name,
1251 p.from_symbol_id,
1252 p.target_terminal_name AS to_name,
1253 p.kind,
1254 p.path,
1255 p.start_line,
1256 p.start_column
1257 FROM pending_relationships p
1258 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1259 WHERE p.target_terminal_name = ?1
1260 AND (
1261 (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1262 OR EXISTS (
1263 SELECT 1 FROM symbols s_any
1264 JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
1265 WHERE s_any.name = p.target_terminal_name
1266 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)
1267 )
1268 )
1269 LIMIT ?2",
1270 )?;
1271
1272 let p_rows =
1273 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1274 Ok(ReferenceSite {
1275 from_symbol_name: row.get(0)?,
1276 from_symbol_id: row.get(1)?,
1277 to_symbol_name: row.get(2)?,
1278 kind: row.get(3)?,
1279 path: row.get::<_, String>(4)?.replace('\\', "/"),
1280 start_line: Some(row.get::<_, i64>(5)? as usize),
1281 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1282 })
1283 })?;
1284 for r in p_rows {
1285 results.push(r?);
1286 }
1287 }
1288 } else {
1289 let is_nested = if let Some(sid) = symbol_id {
1290 conn.query_row(
1291 "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
1292 params![sid],
1293 |_| Ok(true),
1294 )
1295 .unwrap_or(false)
1296 } else {
1297 false
1298 };
1299
1300 if !is_nested {
1301 let mut pending_stmt = conn.prepare(
1302 "SELECT s_from.name AS from_name,
1303 p.from_symbol_id,
1304 p.target_terminal_name AS to_name,
1305 p.kind,
1306 p.path,
1307 p.start_line,
1308 p.start_column
1309 FROM pending_relationships p
1310 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1311 WHERE p.target_terminal_name = ?1
1312 LIMIT ?2",
1313 )?;
1314
1315 let p_rows =
1316 pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1317 Ok(ReferenceSite {
1318 from_symbol_name: row.get(0)?,
1319 from_symbol_id: row.get(1)?,
1320 to_symbol_name: row.get(2)?,
1321 kind: row.get(3)?,
1322 path: row.get::<_, String>(4)?.replace('\\', "/"),
1323 start_line: Some(row.get::<_, i64>(5)? as usize),
1324 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1325 })
1326 })?;
1327
1328 for r in p_rows {
1329 results.push(r?);
1330 }
1331 }
1332 }
1333 }
1334
1335 if results.len() < limit && has_table(conn, "identifiers") {
1336 let remaining = limit - results.len();
1337 let mut ident_stmt = conn.prepare(
1338 "SELECT COALESCE(s.name, ''),
1339 COALESCE(i.containing_symbol_id, ''),
1340 i.name,
1341 i.kind,
1342 i.path,
1343 i.start_line,
1344 i.start_column
1345 FROM identifiers i
1346 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
1347 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
1348 AND COALESCE(s.kind, '') != 'import'
1349 AND (?3 IS NULL OR NOT EXISTS (
1350 SELECT 1 FROM symbols owner
1351 JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
1352 WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
1353 AND member.name = i.name
1354 AND owner.name IS NOT (SELECT parent.name FROM symbols target
1355 JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
1356 WHERE target.symbol_id = ?3)
1357 ))
1358 ORDER BY i.path, i.start_line
1359 LIMIT ?2",
1360 )?;
1361 let rows =
1362 ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
1363 Ok(ReferenceSite {
1364 from_symbol_name: row.get(0)?,
1365 from_symbol_id: row.get(1)?,
1366 to_symbol_name: row.get(2)?,
1367 kind: row.get(3)?,
1368 path: row.get::<_, String>(4)?.replace('\\', "/"),
1369 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1370 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1371 })
1372 })?;
1373 for r in rows {
1374 results.push(r?);
1375 }
1376 }
1377 } else {
1378 let mut stmt = conn.prepare(
1380 "SELECT s_from.name AS from_name,
1381 r.from_symbol_id,
1382 s_to.name AS to_name,
1383 r.kind,
1384 r.path,
1385 r.start_line,
1386 r.start_column
1387 FROM relationships r
1388 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1389 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1390 WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
1391 LIMIT ?2",
1392 )?;
1393
1394 let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1395 Ok(ReferenceSite {
1396 from_symbol_name: row.get(0)?,
1397 from_symbol_id: row.get(1)?,
1398 to_symbol_name: row.get(2)?,
1399 kind: row.get(3)?,
1400 path: row.get::<_, String>(4)?.replace('\\', "/"),
1401 start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1402 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1403 })
1404 })?;
1405
1406 for r in rows {
1407 results.push(r?);
1408 }
1409
1410 if results.len() < limit {
1412 let remaining = limit - results.len();
1413 let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
1414 let sql = if include_external {
1415 String::from("SELECT DISTINCT s_from.name AS from_name,
1416 p.from_symbol_id,
1417 COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
1418 p.kind,
1419 p.path,
1420 p.start_line,
1421 p.start_column
1422 FROM pending_relationships p
1423 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1424 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1425 LIMIT ?2")
1426 } else {
1427 format!("SELECT DISTINCT s_from.name AS from_name,
1428 p.from_symbol_id,
1429 p.target_terminal_name AS to_name,
1430 p.kind,
1431 p.path,
1432 p.start_line,
1433 p.start_column
1434 FROM pending_relationships p
1435 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1436 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1437 AND EXISTS (
1438 SELECT 1 FROM symbols s_to
1439 LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
1440 WHERE s_to.name = p.target_terminal_name
1441 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1442 AND {pred}
1443 )
1444 LIMIT ?2", pred = pending_target_predicate("s_to", "s_to_parent"))
1445 };
1446 let mut pending_stmt = conn.prepare(&sql)?;
1447 let rows = pending_stmt.query_map(
1448 params![symbol_name, remaining as i64, symbol_id],
1449 |row| {
1450 Ok(ReferenceSite {
1451 from_symbol_name: row.get(0)?,
1452 from_symbol_id: row.get(1)?,
1453 to_symbol_name: row.get(2)?,
1454 kind: row.get(3)?,
1455 path: row.get::<_, String>(4)?.replace('\\', "/"),
1456 start_line: Some(row.get::<_, i64>(5)? as usize),
1457 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1458 })
1459 },
1460 )?;
1461 let mut out = Vec::new();
1462 for r in rows {
1463 out.push(r?);
1464 }
1465 out
1466 } else {
1467 let sql = if include_external {
1468 "SELECT DISTINCT s_from.name AS from_name,
1469 p.from_symbol_id,
1470 p.target_terminal_name AS to_name,
1471 p.kind,
1472 p.path,
1473 p.start_line,
1474 p.start_column
1475 FROM pending_relationships p
1476 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1477 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1478 LIMIT ?2"
1479 } else {
1480 "SELECT DISTINCT s_from.name AS from_name,
1481 p.from_symbol_id,
1482 p.target_terminal_name AS to_name,
1483 p.kind,
1484 p.path,
1485 p.start_line,
1486 p.start_column
1487 FROM pending_relationships p
1488 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1489 WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1490 AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
1491 LIMIT ?2"
1492 };
1493 let mut pending_stmt = conn.prepare(sql)?;
1494
1495 let rows = pending_stmt.query_map(
1496 params![symbol_name, remaining as i64, symbol_id],
1497 |row| {
1498 Ok(ReferenceSite {
1499 from_symbol_name: row.get(0)?,
1500 from_symbol_id: row.get(1)?,
1501 to_symbol_name: row.get(2)?,
1502 kind: row.get(3)?,
1503 path: row.get::<_, String>(4)?.replace('\\', "/"),
1504 start_line: Some(row.get::<_, i64>(5)? as usize),
1505 start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1506 })
1507 },
1508 )?;
1509 let mut out = Vec::new();
1510 for r in rows {
1511 out.push(r?);
1512 }
1513 out
1514 };
1515
1516 for r in p_rows {
1517 results.push(r);
1518 }
1519 }
1520 }
1521
1522 Ok(results)
1523}
1524
1525pub fn find_callee_signatures(
1528 conn: &Connection,
1529 symbol_name: &str,
1530 symbol_id: &str,
1531 limit: usize,
1532 include_external: bool,
1533) -> Result<Vec<String>, QueryError> {
1534 let mut stmt = conn.prepare(
1535 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1536 FROM relationships r
1537 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1538 JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1539 WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
1540 LIMIT ?3",
1541 )?;
1542
1543 let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
1544 Ok((
1545 row.get::<_, String>(0)?,
1546 row.get::<_, Option<String>>(1)?,
1547 row.get::<_, String>(2)?.replace('\\', "/"),
1548 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1549 row.get::<_, String>(4)?,
1550 ))
1551 })?;
1552
1553 let mut signatures = Vec::new();
1554 let mut variants = Vec::new();
1555
1556 for r in rows.flatten() {
1557 let (name, sig_opt, path, line, kind) = r;
1558 let sig = sig_opt.unwrap_or(name);
1559 let entry = format!("{sig} ({path}:{line})");
1560 if kind == "variant" {
1561 if !variants.contains(&entry) {
1562 variants.push(entry);
1563 }
1564 } else if !signatures.contains(&entry) {
1565 signatures.push(entry);
1566 }
1567 }
1568
1569 if signatures.len() < limit {
1570 let remaining = (limit - signatures.len()) * 2;
1571 let p_rows: Vec<(String, Option<String>, String, usize, String)> =
1572 if has_pending_namespace_column(conn) {
1573 let mut p_stmt = conn.prepare(
1574 &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1575 FROM pending_relationships p
1576 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1577 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1578 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1579 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1580 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1581 AND {pred}
1582 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1583 )?;
1584
1585 let rows =
1586 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1587 Ok((
1588 row.get::<_, String>(0)?,
1589 row.get::<_, Option<String>>(1)?,
1590 row.get::<_, String>(2)?.replace('\\', "/"),
1591 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1592 row.get::<_, String>(4)?,
1593 ))
1594 })?;
1595 rows.flatten().collect()
1596 } else {
1597 let mut p_stmt = conn.prepare(
1598 "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1599 FROM pending_relationships p
1600 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1601 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1602 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1603 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1604 LIMIT ?3",
1605 )?;
1606
1607 let rows =
1608 p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1609 Ok((
1610 row.get::<_, String>(0)?,
1611 row.get::<_, Option<String>>(1)?,
1612 row.get::<_, String>(2)?.replace('\\', "/"),
1613 row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1614 row.get::<_, String>(4)?,
1615 ))
1616 })?;
1617 rows.flatten().collect()
1618 };
1619
1620 for r in p_rows {
1621 let (name, sig_opt, path, line, kind) = r;
1622 let sig = sig_opt.unwrap_or(name);
1623 let entry = format!("{sig} ({path}:{line})");
1624 if kind == "variant" {
1625 if !variants.contains(&entry) {
1626 variants.push(entry);
1627 }
1628 } else if !signatures.contains(&entry) {
1629 signatures.push(entry);
1630 }
1631 }
1632 }
1633
1634 if include_external && signatures.len() < limit {
1635 let remaining = (limit - signatures.len()) * 2;
1636 let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
1637 let mut ext_stmt = conn.prepare(
1638 &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
1639 FROM pending_relationships p
1640 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1641 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1642 AND NOT EXISTS (
1643 SELECT 1 FROM symbols s_to
1644 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1645 WHERE s_to.name = p.target_terminal_name
1646 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1647 AND {pred}
1648 )
1649 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1650 )?;
1651
1652 let rows =
1653 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1654 Ok((
1655 row.get::<_, String>(0)?,
1656 row.get::<_, String>(1)?.replace('\\', "/"),
1657 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1658 ))
1659 })?;
1660 rows.flatten().collect()
1661 } else {
1662 let mut ext_stmt = conn.prepare(
1663 "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
1664 FROM pending_relationships p
1665 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1666 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1667 AND NOT EXISTS (
1668 SELECT 1 FROM symbols s_to
1669 WHERE s_to.name = p.target_terminal_name
1670 AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1671 )
1672 LIMIT ?3",
1673 )?;
1674
1675 let rows =
1676 ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1677 Ok((
1678 row.get::<_, String>(0)?,
1679 row.get::<_, String>(1)?.replace('\\', "/"),
1680 row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1681 ))
1682 })?;
1683 rows.flatten().collect()
1684 };
1685
1686 for r in ext_rows {
1687 let (name, path, line) = r;
1688 let entry = format!("{name} ({path}:{line})");
1689 if !signatures.contains(&entry) {
1690 signatures.push(entry);
1691 }
1692 }
1693 }
1694
1695 for v in variants {
1696 if signatures.len() >= limit {
1697 break;
1698 }
1699 if !signatures.contains(&v) {
1700 signatures.push(v);
1701 }
1702 }
1703
1704 signatures.truncate(limit);
1705 Ok(signatures)
1706}
1707
1708pub fn find_structural_facts_scoped(
1710 conn: &Connection,
1711 category: &str,
1712 path_filter: Option<&str>,
1713 limit: usize,
1714) -> Result<Vec<StructuralFact>, QueryError> {
1715 let norm_path = path_filter
1716 .map(|p| {
1717 p.replace('\\', "/")
1718 .trim_start_matches("./")
1719 .trim_matches('/')
1720 .to_string()
1721 })
1722 .filter(|p| !p.is_empty());
1723 let dir_prefix = norm_path
1724 .as_deref()
1725 .map(|p| format!("{}/%", escape_like(p)));
1726 let cat_pattern = format!("%{}%", escape_like(category));
1727
1728 let cat_lower = category.trim().to_ascii_lowercase();
1729 let cat_clause = match cat_lower.as_str() {
1730 "config" => {
1731 "(sf.pattern_id LIKE '%.key_value.%' OR sf.pattern_id LIKE '%config%' OR sf.capture_name LIKE '%config%' OR sf.node_kind LIKE '%config%')"
1732 }
1733 "route" | "routes" => {
1734 "(sf.pattern_id LIKE '%.route%' OR sf.pattern_id LIKE '%route%' OR sf.capture_name LIKE '%route%')"
1735 }
1736 "query" | "queries" | "sql" => {
1737 "(sf.pattern_id LIKE '%.sql.%' OR sf.pattern_id LIKE '%query%')"
1738 }
1739 "model" | "models" => "sf.pattern_id LIKE '%.model%'",
1740 _ => {
1741 "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
1742 }
1743 };
1744
1745 let sql = format!(
1746 "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
1747 sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
1748 sf.start_line, sf.end_line, sf.confidence,
1749 COALESCE(
1750 CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
1751 THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
1752 ELSE json_extract(sf.metadata_json, '$.key_path') END,
1753 json_extract(sf.metadata_json, '$.key'),
1754 json_extract(sf.metadata_json, '$.normalized_route_template')
1755 ) AS display_key
1756 FROM structural_facts sf
1757 LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
1758 WHERE (:cat IS NOT NULL AND {cat_clause})
1759 AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1760 ORDER BY sf.path ASC, sf.start_line ASC
1761 LIMIT :limit"
1762 );
1763
1764 let mut stmt = conn.prepare(&sql)?;
1765 let rows = stmt.query_map(
1766 rusqlite::named_params! {
1767 ":cat": cat_pattern,
1768 ":path": norm_path.as_deref(),
1769 ":dir_prefix": dir_prefix.as_deref(),
1770 ":limit": limit as i64,
1771 },
1772 |row| {
1773 Ok(StructuralFact {
1774 structural_fact_id: row.get(0)?,
1775 path: row.get::<_, String>(1)?.replace('\\', "/"),
1776 language: row.get(2)?,
1777 pattern_id: row.get(3)?,
1778 capture_name: row.get(4)?,
1779 node_kind: row.get(5)?,
1780 key: row.get(10)?,
1781 containing_symbol_name: row.get(6)?,
1782 start_line: row.get::<_, i64>(7)? as usize,
1783 end_line: row.get::<_, i64>(8)? as usize,
1784 confidence: row.get(9)?,
1785 })
1786 },
1787 )?;
1788
1789 let mut results = Vec::new();
1790 for r in rows {
1791 results.push(r?);
1792 }
1793 Ok(results)
1794}
1795
1796pub fn find_structural_facts(
1798 conn: &Connection,
1799 category: &str,
1800 limit: usize,
1801) -> Result<Vec<StructuralFact>, QueryError> {
1802 find_structural_facts_scoped(conn, category, None, limit)
1803}
1804
1805pub fn find_literals_scoped(
1807 conn: &Connection,
1808 category: &str,
1809 path_filter: Option<&str>,
1810 limit: usize,
1811) -> Result<Vec<LiteralFact>, QueryError> {
1812 let norm_path = path_filter
1813 .map(|p| {
1814 p.replace('\\', "/")
1815 .trim_start_matches("./")
1816 .trim_matches('/')
1817 .to_string()
1818 })
1819 .filter(|p| !p.is_empty());
1820 let dir_prefix = norm_path
1821 .as_deref()
1822 .map(|p| format!("{}/%", escape_like(p)));
1823 let cat_pattern = format!("%{}%", escape_like(category));
1824
1825 let cat_lower = category.trim().to_ascii_lowercase();
1826 let cat_clause = match cat_lower.as_str() {
1827 "config" => {
1828 "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
1829 }
1830 "route" | "routes" => "l.kind LIKE '%route%'",
1831 "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
1832 "model" | "models" => "l.kind LIKE '%model%'",
1833 _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
1834 };
1835
1836 let sql = format!(
1837 "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
1838 l.start_line, s.name AS containing_symbol_name
1839 FROM literals l
1840 LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
1841 WHERE (:cat IS NOT NULL AND {cat_clause})
1842 AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1843 ORDER BY l.path ASC, l.start_line ASC
1844 LIMIT :limit"
1845 );
1846
1847 let mut stmt = conn.prepare(&sql)?;
1848 let rows = stmt.query_map(
1849 rusqlite::named_params! {
1850 ":cat": cat_pattern,
1851 ":path": norm_path.as_deref(),
1852 ":dir_prefix": dir_prefix.as_deref(),
1853 ":limit": limit as i64,
1854 },
1855 |row| {
1856 Ok(LiteralFact {
1857 literal_id: row.get(0)?,
1858 path: row.get::<_, String>(1)?.replace('\\', "/"),
1859 literal_text: row.get(2)?,
1860 kind: row.get(3)?,
1861 carrier: row.get(4)?,
1862 start_line: row.get::<_, i64>(5)? as usize,
1863 containing_symbol_name: row.get(6)?,
1864 })
1865 },
1866 )?;
1867
1868 let mut results = Vec::new();
1869 for r in rows {
1870 results.push(r?);
1871 }
1872 Ok(results)
1873}
1874
1875pub fn find_literals(
1877 conn: &Connection,
1878 category: &str,
1879 limit: usize,
1880) -> Result<Vec<LiteralFact>, QueryError> {
1881 find_literals_scoped(conn, category, None, limit)
1882}
1883
1884pub fn list_structural_fact_categories_scoped(
1886 conn: &Connection,
1887 path_filter: Option<&str>,
1888) -> Result<Vec<(String, usize)>, QueryError> {
1889 let norm_path = path_filter
1890 .map(|p| {
1891 p.replace('\\', "/")
1892 .trim_start_matches("./")
1893 .trim_matches('/')
1894 .to_string()
1895 })
1896 .filter(|p| !p.is_empty());
1897 let dir_prefix = norm_path
1898 .as_deref()
1899 .map(|p| format!("{}/%", escape_like(p)));
1900
1901 let mut categories = Vec::new();
1902
1903 let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
1904 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1905 GROUP BY pattern_id ORDER BY cnt DESC";
1906 let mut stmt = conn.prepare(sql)?;
1907 let rows = stmt.query_map(
1908 rusqlite::named_params! {
1909 ":path": norm_path.as_deref(),
1910 ":dir_prefix": dir_prefix.as_deref(),
1911 },
1912 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
1913 )?;
1914 for r in rows {
1915 categories.push(r?);
1916 }
1917
1918 let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
1919 WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1920 GROUP BY kind ORDER BY cnt DESC";
1921 let mut lit_stmt = conn.prepare(lit_sql)?;
1922 let lit_rows = lit_stmt.query_map(
1923 rusqlite::named_params! {
1924 ":path": norm_path.as_deref(),
1925 ":dir_prefix": dir_prefix.as_deref(),
1926 },
1927 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
1928 )?;
1929 for r in lit_rows {
1930 categories.push(r?);
1931 }
1932
1933 Ok(categories)
1934}
1935
1936pub fn list_structural_fact_categories(
1938 conn: &Connection,
1939) -> Result<Vec<(String, usize)>, QueryError> {
1940 list_structural_fact_categories_scoped(conn, None)
1941}
1942
1943pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
1945 let has_table: bool = conn
1946 .query_row(
1947 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
1948 [],
1949 |_| Ok(true),
1950 )
1951 .unwrap_or(false);
1952 if !has_table {
1953 return Ok(Vec::new());
1954 }
1955
1956 let mut stmt = conn.prepare(
1957 "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
1958 FROM type_facts
1959 WHERE symbol_id = ?1",
1960 )?;
1961
1962 let rows = stmt.query_map(params![symbol_id], |row| {
1963 Ok(TypeFact {
1964 type_fact_id: row.get(0)?,
1965 symbol_id: row.get(1)?,
1966 language: row.get(2)?,
1967 resolved_type: row.get(3)?,
1968 generic_params: row.get(4)?,
1969 })
1970 })?;
1971
1972 let mut results = Vec::new();
1973 for r in rows {
1974 results.push(r?);
1975 }
1976 Ok(results)
1977}
1978
1979pub fn is_test_path(path: &str) -> bool {
1981 let p = path.to_lowercase().replace('\\', "/");
1982 p.contains("/test/")
1983 || p.contains("/tests/")
1984 || p.contains("/__tests__/")
1985 || p.contains("_test.")
1986 || p.contains(".test.")
1987 || p.contains(".spec.")
1988 || p.ends_with("test.rs")
1989 || p.ends_with("tests.rs")
1990 || p.ends_with("tests.cs")
1991 || p.ends_with("test.go")
1992 || p.starts_with("test_")
1993}
1994
1995pub fn compute_blast_radius_scoped(
1998 conn: &Connection,
1999 seed_symbols: &[&str],
2000 symbol_path_filter: Option<&str>,
2001 seed_paths: &[&str],
2002 max_depth: usize,
2003 limit: usize,
2004) -> Result<BlastRadiusResult, QueryError> {
2005 let max_depth = max_depth.min(5);
2006 let resolved_seed_symbols = seed_symbols
2007 .iter()
2008 .map(|name| {
2009 get_symbol_by_name(conn, name, symbol_path_filter)?
2010 .ok_or_else(|| QueryError::SymbolNotFound((*name).to_string()))
2011 })
2012 .collect::<Result<Vec<_>, _>>()?;
2013 let mut seeds = Vec::new();
2014 let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
2015 for s in seed_symbols {
2016 seeds.push(s.to_string());
2017 }
2018 for p in seed_paths {
2019 seeds.push(p.to_string());
2020 }
2021 "mixed".to_string()
2022 } else if !seed_symbols.is_empty() {
2023 for s in seed_symbols {
2024 seeds.push(s.to_string());
2025 }
2026 "symbol".to_string()
2027 } else if !seed_paths.is_empty() {
2028 for p in seed_paths {
2029 seeds.push(p.to_string());
2030 }
2031 "file".to_string()
2032 } else {
2033 return Ok(BlastRadiusResult {
2034 seed_type: "none".to_string(),
2035 seeds: Vec::new(),
2036 likely_tests: Vec::new(),
2037 impacted_symbols: Vec::new(),
2038 traversal_ceiling_reached: false,
2039 });
2040 };
2041
2042 let mut where_clauses = Vec::new();
2043 let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
2044
2045 if !resolved_seed_symbols.is_empty() {
2046 let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
2047 .map(|i| format!("?{}", i))
2048 .collect();
2049 where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
2050 for symbol in &resolved_seed_symbols {
2051 params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
2052 }
2053 }
2054
2055 if !seed_paths.is_empty() {
2056 let mut path_conds = Vec::new();
2057 for p in seed_paths.iter() {
2058 let raw = p
2059 .replace('\\', "/")
2060 .trim_start_matches("./")
2061 .trim_matches('/')
2062 .to_string();
2063 let exact_idx = params_vec.len() + 1;
2064 params_vec.push(rusqlite::types::Value::Text(raw.clone()));
2065 let dir_pattern = format!("{}/%", escape_like(&raw));
2066 let like_idx = params_vec.len() + 1;
2067 params_vec.push(rusqlite::types::Value::Text(dir_pattern));
2068 path_conds.push(format!(
2069 "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
2070 ));
2071 }
2072 where_clauses.push(format!("({})", path_conds.join(" OR ")));
2073 }
2074
2075 let seed_condition = where_clauses.join(" OR ");
2076 let max_depth_idx = params_vec.len() + 1;
2077 params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
2078
2079 let mut traversal_ceiling_reached = false;
2080
2081 let has_relationships: bool = conn
2082 .query_row(
2083 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
2084 [],
2085 |_| Ok(true),
2086 )
2087 .unwrap_or(false);
2088
2089 let has_pending: bool = conn
2090 .query_row(
2091 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
2092 [],
2093 |_| Ok(true),
2094 )
2095 .unwrap_or(false);
2096
2097 let mut likely_tests = Vec::new();
2098 let mut impacted_symbols = Vec::new();
2099 let mut seen_test_keys = HashSet::new();
2100
2101 let mut recursive_branches = Vec::new();
2102
2103 if has_relationships {
2104 recursive_branches.push(format!(
2105 "SELECT r.from_symbol_id, iw.depth + 1
2106 FROM relationships r
2107 JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
2108 JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2109 WHERE iw.depth < ?{max_depth_idx}
2110 AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
2111 ));
2112 }
2113
2114 if has_pending {
2115 let (parent_join, ns_condition) = if conn
2116 .query_row(
2117 "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
2118 [],
2119 |_| Ok(true),
2120 )
2121 .unwrap_or(false)
2122 {
2123 (
2124 "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2125 LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
2126 format!("AND {pred}", pred = pending_target_predicate("s_target", "s_target_parent")),
2127 )
2128 } else {
2129 ("", String::new())
2130 };
2131
2132 recursive_branches.push(format!(
2133 "SELECT p.from_symbol_id, iw.depth + 1
2134 FROM pending_relationships p
2135 JOIN symbols s_target ON p.target_terminal_name = s_target.name
2136 JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
2137 {parent_join}
2138 WHERE iw.depth < ?{max_depth_idx}
2139 AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2140 {ns_condition}"
2141 ));
2142 }
2143
2144 if !recursive_branches.is_empty() {
2145 let recursive_sql = recursive_branches.join("\n UNION \n");
2146 let not_documentation = not_documentation(conn, "s");
2147 let sql = format!(
2148 "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
2149 SELECT symbol_id, 0
2150 FROM symbols
2151 WHERE ({seed_condition})
2152 AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2153
2154 UNION
2155
2156 {recursive_sql}
2157 )
2158 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
2159 FROM impact_walk iw
2160 CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
2161 WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2162 AND {not_documentation}
2163 GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
2164 HAVING MIN(iw.depth) > 0
2165 ORDER BY min_depth ASC, s.path ASC, s.name ASC
2166 LIMIT 200"
2167 );
2168
2169 let mut stmt = conn.prepare(&sql)?;
2170 let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
2171 .iter()
2172 .map(|v| v as &dyn rusqlite::ToSql)
2173 .collect();
2174
2175 let rows = stmt.query_map(param_refs.as_slice(), |row| {
2176 Ok((
2177 row.get::<_, String>(0)?,
2178 row.get::<_, String>(1)?,
2179 row.get::<_, String>(2)?,
2180 row.get::<_, String>(3)?,
2181 row.get::<_, i64>(4)? as usize,
2182 row.get::<_, bool>(5)?,
2183 row.get::<_, bool>(6)?,
2184 row.get::<_, i64>(7)? as usize,
2185 ))
2186 })?;
2187
2188 let mut row_count = 0;
2189 for r in rows {
2190 row_count += 1;
2191 let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
2192 let path = raw_path.replace('\\', "/");
2193 let is_test_target = is_test || test_container || is_test_path(&path);
2194
2195 if is_test_target {
2196 let key = format!("{}:{}", path, line);
2197 if seen_test_keys.insert(key) {
2198 likely_tests.push(TestTarget {
2199 name,
2200 path,
2201 line,
2202 reason: format!("transitive caller [depth {depth}]"),
2203 });
2204 }
2205 } else {
2206 impacted_symbols.push(ImpactedSymbol {
2207 name,
2208 kind,
2209 path,
2210 line,
2211 depth,
2212 });
2213 }
2214 }
2215 traversal_ceiling_reached = row_count >= 200;
2216 }
2217
2218 let mut file_stems = Vec::new();
2220 for p in seed_paths {
2221 if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
2222 && stem.len() >= 3
2223 && !file_stems.contains(&stem.to_string())
2224 {
2225 file_stems.push(stem.to_string());
2226 }
2227 }
2228 for symbol in &resolved_seed_symbols {
2229 if let Some(stem) = std::path::Path::new(&symbol.path)
2230 .file_stem()
2231 .and_then(|s| s.to_str())
2232 && stem.len() >= 3
2233 && !file_stems.contains(&stem.to_string())
2234 {
2235 file_stems.push(stem.to_string());
2236 }
2237 }
2238
2239 let has_files: bool = conn
2240 .query_row(
2241 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
2242 [],
2243 |_| Ok(true),
2244 )
2245 .unwrap_or(false);
2246
2247 if has_files {
2248 let doc_file = format!(
2249 "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
2250 not_documentation(conn, "d")
2251 );
2252 let mut test_files_stmt = conn.prepare(&format!(
2253 "SELECT DISTINCT path FROM files
2254 WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
2255 AND NOT {doc_file}
2256 LIMIT 10"
2257 ))?;
2258 for stem in file_stems {
2259 let stem_pattern = format!("%{}%", escape_like(&stem));
2260 let t_rows =
2261 test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
2262 for p in t_rows.flatten() {
2263 let p = p.replace('\\', "/");
2264 let key = format!("{}:1", p);
2265 if seen_test_keys.insert(key) {
2266 likely_tests.push(TestTarget {
2267 name: p.clone(),
2268 path: p,
2269 line: 1,
2270 reason: "stem-matched test file".to_string(),
2271 });
2272 }
2273 }
2274 }
2275 }
2276
2277 if likely_tests.len() > limit {
2279 likely_tests.truncate(limit);
2280 }
2281 if impacted_symbols.len() > limit {
2282 impacted_symbols.truncate(limit);
2283 }
2284
2285 Ok(BlastRadiusResult {
2286 seed_type,
2287 seeds,
2288 likely_tests,
2289 impacted_symbols,
2290 traversal_ceiling_reached,
2291 })
2292}
2293
2294pub fn compute_blast_radius(
2296 conn: &Connection,
2297 seed_symbols: &[&str],
2298 seed_paths: &[&str],
2299 max_depth: usize,
2300 limit: usize,
2301) -> Result<BlastRadiusResult, QueryError> {
2302 compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
2303}
2304
2305#[cfg(test)]
2306mod tests {
2307 use super::*;
2308 use crate::db::{ensure_fts_index, open_read_write};
2309
2310 #[test]
2311 fn count_parse_diagnostics_counts_rows_for_one_file() {
2312 let dir = crate::safe_tempdir();
2313 let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
2314
2315 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
2316
2317 conn.execute_batch(
2318 "CREATE TABLE parse_diagnostics (
2319 diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
2320 );
2321 INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
2322 INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
2323 INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
2324 )
2325 .unwrap();
2326
2327 assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
2328 assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
2329 assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
2330 }
2331
2332 #[test]
2333 fn test_sanitize_fts5_query() {
2334 let (and_q, or_q) = sanitize_fts5_query("parse tokens");
2335 assert_eq!(and_q, "\"parse\"* \"tokens\"*");
2336 assert_eq!(or_q, "\"parse\"* OR \"tokens\"*");
2337
2338 let (and_q, or_q) = sanitize_fts5_query(" Option<T> ");
2339 assert_eq!(and_q, "\"Option\"* \"T\"*");
2340 assert_eq!(or_q, "\"Option\"* OR \"T\"*");
2341
2342 let (and_q, or_q) = sanitize_fts5_query(" ");
2343 assert!(and_q.is_empty());
2344 assert!(or_q.is_empty());
2345 }
2346
2347 #[test]
2348 fn search_symbols_treats_like_wildcards_as_literals() {
2349 let dir = crate::safe_tempdir();
2350 let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
2351 let conn = open_read_write(&db_path).unwrap();
2352 conn.execute_batch(
2353 "CREATE TABLE symbols (
2354 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2355 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2356 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2357 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2358 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2359 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2360 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2361 );
2362 INSERT INTO symbols VALUES (
2363 's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
2364 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2365 );
2366 INSERT INTO symbols VALUES (
2367 'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
2368 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2369 );
2370 INSERT INTO symbols VALUES (
2371 'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
2372 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2373 );
2374 CREATE TABLE files (
2375 file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
2376 content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
2377 );
2378 INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
2379 INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
2380 );",
2381 )
2382 .unwrap();
2383
2384 assert_eq!(
2385 search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
2386 "literal%name"
2387 );
2388 assert_eq!(
2389 search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
2390 "literal_name"
2391 );
2392 assert_eq!(
2393 load_scoped_files(&conn, Some("src/literal_path"))
2394 .unwrap()
2395 .len(),
2396 1
2397 );
2398 }
2399
2400 #[test]
2401 fn find_references_for_symbol_limits_callees_by_symbol_id() {
2402 let dir = crate::safe_tempdir();
2403 let db_path = dir.path().join("find_references_for_symbol.db");
2404 let conn = open_read_write(&db_path).unwrap();
2405 conn.execute_batch(
2406 "CREATE TABLE symbols (
2407 symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2408 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2409 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2410 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2411 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2412 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2413 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2414 );
2415 CREATE TABLE relationships (
2416 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2417 start_line INTEGER, start_column INTEGER
2418 );
2419 CREATE TABLE pending_relationships (
2420 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2421 start_line INTEGER, start_column INTEGER
2422 );
2423 INSERT INTO symbols VALUES
2424 ('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),
2425 ('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),
2426 ('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),
2427 ('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);
2428 INSERT INTO relationships VALUES
2429 ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
2430 ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
2431 )
2432 .unwrap();
2433
2434 let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
2435 assert_eq!(references.len(), 1);
2436 assert_eq!(references[0].to_symbol_name, "wanted_dep");
2437 }
2438
2439 #[test]
2440 fn test_fts_search_symbols_and_porter_stemming() {
2441 let dir = crate::safe_tempdir();
2442 let db_path = dir.path().join("fts_search_symbols.db");
2443 let conn = open_read_write(&db_path).unwrap();
2444
2445 conn.execute_batch(
2446 "CREATE TABLE symbols (
2447 symbol_id TEXT PRIMARY KEY,
2448 file_id TEXT,
2449 path TEXT,
2450 language TEXT,
2451 name TEXT,
2452 kind TEXT,
2453 signature TEXT,
2454 doc_comment TEXT,
2455 visibility TEXT,
2456 parent_symbol_id TEXT,
2457 start_line INTEGER,
2458 start_column INTEGER,
2459 end_line INTEGER,
2460 end_column INTEGER,
2461 start_byte INTEGER,
2462 end_byte INTEGER,
2463 body_start_line INTEGER,
2464 body_start_column INTEGER,
2465 body_end_line INTEGER,
2466 body_end_column INTEGER,
2467 body_start_byte INTEGER,
2468 body_end_byte INTEGER,
2469 body_hash TEXT,
2470 semantic_group TEXT,
2471 is_test INTEGER,
2472 test_container INTEGER
2473 );
2474 INSERT INTO symbols VALUES (
2475 's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
2476 'pub trait PaymentGateway', 'Core payment provider interface for transactions',
2477 'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
2478 );
2479 INSERT INTO symbols VALUES (
2480 's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
2481 'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
2482 'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
2483 );
2484 INSERT INTO symbols VALUES (
2485 's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
2486 'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
2487 'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
2488 );
2489 INSERT INTO symbols VALUES (
2490 's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
2491 'fn test_payment_flow()', 'Tests payment charge workflow',
2492 NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
2493 );",
2494 )
2495 .unwrap();
2496
2497 ensure_fts_index(&conn).unwrap();
2498
2499 let results =
2501 fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
2502 assert_eq!(results.len(), 1);
2503 assert_eq!(results[0].symbol.name, "parse_tokens");
2504 assert!(results[0].snippet.is_some());
2505
2506 let results =
2508 fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
2509 assert_eq!(results.len(), 1);
2510 assert_eq!(results[0].symbol.name, "PaymentGateway");
2511
2512 let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
2514 assert_eq!(results.len(), 2);
2515 assert!(results.iter().all(|r| !r.symbol.is_test));
2516
2517 let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
2519 assert_eq!(results.len(), 3);
2520
2521 let results =
2523 fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
2524 assert_eq!(results.len(), 1);
2525 assert_eq!(results[0].symbol.name, "StripeClient");
2526 }
2527
2528 #[test]
2529 fn find_related_tests_returns_each_test_once_under_the_limit() {
2530 let dir = crate::safe_tempdir();
2531 let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
2532 conn.execute_batch(
2533 "CREATE TABLE symbols (
2534 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
2535 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
2536 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
2537 start_column INTEGER, end_line INTEGER, end_column INTEGER,
2538 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2539 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2540 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2541 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2542 );
2543 CREATE TABLE relationships (
2544 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2545 start_line INTEGER, start_column INTEGER
2546 );
2547 CREATE TABLE pending_relationships (
2548 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2549 start_line INTEGER, start_column INTEGER,
2550 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
2551 );
2552 CREATE TABLE type_facts (
2553 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
2554 );
2555 INSERT INTO symbols VALUES
2556 ('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),
2557 ('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),
2558 ('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);
2559 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
2560 ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
2561 ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
2562 ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
2563 ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
2564 ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
2565 ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
2566 )
2567 .unwrap();
2568 let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
2569
2570 let tests = find_related_tests(&conn, &target, 5).unwrap();
2571
2572 let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2573 names.sort();
2574 assert_eq!(names, vec!["first_case", "second_case"]);
2575 }
2576
2577 #[test]
2578 fn documentation_rows_rank_after_code_in_search() {
2579 let dir = crate::safe_tempdir();
2580 let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
2581 conn.execute_batch(
2582 "CREATE TABLE symbols (
2583 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
2584 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2585 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2586 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2587 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2588 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2589 semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
2590 );
2591 INSERT INTO symbols VALUES
2592 ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
2593 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
2594 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
2595 ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
2596 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
2597 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
2598 )
2599 .unwrap();
2600 ensure_fts_index(&conn).unwrap();
2601
2602 let results =
2603 fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
2604 .unwrap();
2605
2606 assert_eq!(results.len(), 2);
2607 assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
2608 assert_eq!(results[1].symbol.name, "Reconcile offline edits");
2609 }
2610
2611 #[test]
2612 fn test_queries_nocase_and_path_normalization() {
2613 let conn = Connection::open_in_memory().unwrap();
2614 conn.execute_batch(
2615 "CREATE TABLE files (
2616 file_id TEXT PRIMARY KEY,
2617 path TEXT NOT NULL,
2618 language TEXT,
2619 content_hash TEXT,
2620 content_bytes INTEGER,
2621 line_count INTEGER,
2622 indexed_at INTEGER
2623 );
2624 CREATE TABLE symbols (
2625 symbol_id TEXT PRIMARY KEY,
2626 file_id TEXT,
2627 path TEXT NOT NULL,
2628 language TEXT,
2629 name TEXT,
2630 kind TEXT,
2631 signature TEXT,
2632 doc_comment TEXT,
2633 visibility TEXT,
2634 parent_symbol_id TEXT,
2635 start_line INTEGER,
2636 start_column INTEGER,
2637 end_line INTEGER,
2638 end_column INTEGER,
2639 start_byte INTEGER,
2640 end_byte INTEGER,
2641 body_start_line INTEGER,
2642 body_start_column INTEGER,
2643 body_end_line INTEGER,
2644 body_end_column INTEGER,
2645 body_start_byte INTEGER,
2646 body_end_byte INTEGER,
2647 body_hash TEXT,
2648 semantic_group TEXT,
2649 is_test INTEGER,
2650 test_container INTEGER
2651 );
2652 -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
2653 INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
2654 INSERT INTO symbols VALUES (
2655 's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
2656 'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2657 2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
2658 );",
2659 )
2660 .unwrap();
2661
2662 let file = get_file(&conn, "SRC/PAYMENT.RS")
2664 .unwrap()
2665 .expect("File should be found");
2666 assert_eq!(
2667 file.path, "src/Payment.rs",
2668 "Path should be normalized to forward slashes"
2669 );
2670
2671 let file2 = get_file(&conn, "src/payment.rs")
2672 .unwrap()
2673 .expect("File should be found");
2674 assert_eq!(file2.path, "src/Payment.rs");
2675
2676 let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
2678 assert_eq!(syms.len(), 1);
2679 assert_eq!(
2680 syms[0].path, "src/Payment.rs",
2681 "Symbol path should be normalized to forward slashes"
2682 );
2683
2684 let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
2686 .unwrap()
2687 .expect("Symbol should be found with case-insensitive path filter");
2688 assert_eq!(sym.path, "src/Payment.rs");
2689 }
2690
2691 #[test]
2692 fn test_exact_case_prioritized_over_nocase() {
2693 let conn = Connection::open_in_memory().unwrap();
2694 conn.execute_batch(
2695 "CREATE TABLE files (
2696 file_id TEXT PRIMARY KEY,
2697 path TEXT NOT NULL,
2698 language TEXT,
2699 content_hash TEXT,
2700 content_bytes INTEGER,
2701 line_count INTEGER,
2702 indexed_at TEXT
2703 );
2704 CREATE TABLE symbols (
2705 symbol_id TEXT PRIMARY KEY,
2706 file_id TEXT,
2707 path TEXT NOT NULL,
2708 language TEXT,
2709 name TEXT NOT NULL,
2710 kind TEXT NOT NULL,
2711 signature TEXT,
2712 doc_comment TEXT,
2713 visibility TEXT,
2714 parent_symbol_id TEXT,
2715 start_line INTEGER,
2716 start_column INTEGER,
2717 end_line INTEGER,
2718 end_column INTEGER,
2719 start_byte INTEGER,
2720 end_byte INTEGER,
2721 body_start_line INTEGER,
2722 body_start_column INTEGER,
2723 body_end_line INTEGER,
2724 body_end_column INTEGER,
2725 body_start_byte INTEGER,
2726 body_end_byte INTEGER,
2727 body_hash TEXT,
2728 semantic_group TEXT,
2729 is_test INTEGER,
2730 test_container INTEGER
2731 );
2732 INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
2733 INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
2734 INSERT INTO symbols VALUES (
2735 's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
2736 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2737 2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
2738 );
2739 INSERT INTO symbols VALUES (
2740 's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
2741 'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2742 2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
2743 );",
2744 )
2745 .unwrap();
2746
2747 let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
2749 assert_eq!(f_lower.path, "src/payment.rs");
2750 assert_eq!(f_lower.file_id, "f2");
2751
2752 let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
2753 assert_eq!(f_upper.path, "src/Payment.rs");
2754 assert_eq!(f_upper.file_id, "f1");
2755
2756 let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
2757 assert_eq!(syms_lower.len(), 1);
2758 assert_eq!(syms_lower[0].file_id, "f2");
2759
2760 let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
2761 assert_eq!(syms_upper.len(), 1);
2762 assert_eq!(syms_upper[0].file_id, "f1");
2763 }
2764
2765 #[test]
2766 fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
2767 let dir = crate::safe_tempdir();
2768 let db_path = dir.path().join("conservative_resolution.db");
2769 let conn = open_read_write(&db_path).unwrap();
2770
2771 conn.execute_batch(
2772 "CREATE TABLE symbols (
2773 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
2774 name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
2775 visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
2776 start_column INTEGER, end_line INTEGER, end_column INTEGER,
2777 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2778 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2779 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2780 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2781 );
2782 CREATE TABLE relationships (
2783 from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2784 start_line INTEGER, start_column INTEGER
2785 );
2786 CREATE TABLE pending_relationships (
2787 from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2788 start_line INTEGER, start_column INTEGER,
2789 target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
2790 );
2791 CREATE TABLE type_facts (
2792 type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
2793 );
2794 -- Workspace struct Workspace and method Workspace::new
2795 INSERT INTO symbols VALUES
2796 ('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),
2797 ('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),
2798 ('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);
2799
2800 -- my_func calls Vec::new() (external namespace 'Vec')
2801 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
2802 ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
2803 )
2804 .unwrap();
2805
2806 let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
2808 assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
2809
2810 let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
2811 assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
2812
2813 let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
2815 assert!(
2816 callers.is_empty(),
2817 "Expected 0 callers for Workspace::new, got: {:?}",
2818 callers
2819 );
2820
2821 let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
2823 assert!(
2824 !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
2825 "my_func should not be impacted before calling Workspace::new: {:?}",
2826 blast.impacted_symbols
2827 );
2828
2829 conn.execute(
2831 "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')",
2832 [],
2833 )
2834 .unwrap();
2835
2836 let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
2837 assert_eq!(
2838 sigs2.len(),
2839 1,
2840 "Expected 1 signature for Workspace::new, got: {:?}",
2841 sigs2
2842 );
2843 assert!(sigs2[0].contains("pub fn new() -> Workspace"));
2844
2845 let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
2847 assert!(
2848 blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
2849 "my_func should be impacted after calling Workspace::new: {:?}",
2850 blast2.impacted_symbols
2851 );
2852
2853 conn.execute(
2855 "INSERT INTO symbols VALUES
2856 ('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);",
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_other', 'new', 'calls', 'src/other.rs', 2, 8, NULL, NULL, 'new')",
2862 [],
2863 )
2864 .unwrap();
2865
2866 let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
2868 assert!(
2869 sigs_other.is_empty(),
2870 "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
2871 sigs_other
2872 );
2873
2874 conn.execute(
2876 "INSERT INTO symbols VALUES
2877 ('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);",
2878 [],
2879 )
2880 .unwrap();
2881 conn.execute(
2882 "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')",
2883 [],
2884 )
2885 .unwrap();
2886
2887 let sigs_sibling =
2888 find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
2889 assert_eq!(
2890 sigs_sibling.len(),
2891 1,
2892 "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
2893 sigs_sibling
2894 );
2895
2896 let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
2898 assert!(
2899 ext_sigs.iter().any(|s| s.contains("Vec")),
2900 "include_external: true should include external Vec::new: {:?}",
2901 ext_sigs
2902 );
2903 }
2904
2905 #[test]
2906 fn test_find_structural_facts_and_literals_scoped() {
2907 let dir = crate::safe_tempdir();
2908 let db_path = dir.path().join("facts_test.db");
2909 let conn = open_read_write(&db_path).unwrap();
2910 conn.execute_batch(
2911 "CREATE TABLE symbols (
2912 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2913 signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2914 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2915 start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2916 body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2917 body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2918 semantic_group TEXT, is_test INTEGER, test_container INTEGER
2919 );
2920 CREATE TABLE structural_facts (
2921 structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
2922 pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
2923 start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
2924 );
2925 CREATE TABLE literals (
2926 literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
2927 kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
2928 start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2929 start_byte INTEGER, end_byte INTEGER
2930 );
2931 INSERT INTO structural_facts VALUES
2932 ('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\"}'),
2933 ('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\"}'),
2934 ('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\"}'),
2935 ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'db.sql.select', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
2936 ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'orm.model.entity', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
2937 ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
2938 INSERT INTO literals VALUES
2939 ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
2940 ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
2941 ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
2942 ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
2943 )
2944 .unwrap();
2945
2946 let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
2948 assert_eq!(facts_config.len(), 2);
2949 assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
2950 assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
2951 assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
2952 assert_eq!(
2953 facts_config[1].key.as_deref(),
2954 Some("mcp_servers.code-kb.command")
2955 );
2956 let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
2957 assert_eq!(lits_config.len(), 1);
2958 assert_eq!(lits_config[0].kind, "toml_key");
2959
2960 let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
2962 assert_eq!(facts_route.len(), 1);
2963 assert_eq!(facts_route[0].pattern_id, "http.route.v1");
2964 assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
2965 let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
2966 assert_eq!(facts_routes.len(), 1);
2967 let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
2968 assert_eq!(lits_route.len(), 1);
2969 assert_eq!(lits_route[0].kind, "http_route");
2970
2971 for q in &["query", "queries", "sql"] {
2973 let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
2974 assert_eq!(facts.len(), 1, "Failed for {}", q);
2975 assert_eq!(facts[0].pattern_id, "db.sql.select");
2976 let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
2977 assert_eq!(lits.len(), 1, "Failed for {}", q);
2978 assert_eq!(lits[0].kind, "sql_query");
2979 }
2980
2981 for m in &["model", "models"] {
2983 let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
2984 assert_eq!(facts.len(), 1, "Failed for {}", m);
2985 assert_eq!(facts[0].pattern_id, "orm.model.entity");
2986 let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
2987 assert_eq!(lits.len(), 1, "Failed for {}", m);
2988 assert_eq!(lits[0].kind, "model_table");
2989 }
2990
2991 let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
2993 assert_eq!(facts_custom.len(), 1);
2994 assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
2995 assert_eq!(facts_custom[0].key, None);
2996
2997 let facts_exact =
2999 find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
3000 assert_eq!(facts_exact.len(), 1);
3001 let facts_miss =
3002 find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
3003 assert_eq!(facts_miss.len(), 0);
3004
3005 let facts_dir =
3007 find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
3008 assert_eq!(facts_dir.len(), 1);
3009 let facts_dir_miss =
3010 find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
3011 assert_eq!(facts_dir_miss.len(), 0);
3012
3013 let f_del = find_structural_facts(&conn, "config", 10).unwrap();
3015 assert_eq!(f_del.len(), 2);
3016 let l_del = find_literals(&conn, "config", 10).unwrap();
3017 assert_eq!(l_del.len(), 1);
3018 }
3019
3020 fn local_variable_fixture() -> Connection {
3021 let conn = Connection::open_in_memory().unwrap();
3022 conn.execute_batch(
3023 "CREATE TABLE symbols (
3024 symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3025 kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
3026 parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
3027 end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
3028 body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
3029 body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
3030 body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
3031 );
3032 INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
3033 parent_symbol_id, start_line, start_column, end_line, end_column,
3034 start_byte, end_byte, is_test, test_container)
3035 VALUES
3036 ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
3037 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
3038 ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3039 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
3040 ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
3041 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
3042 ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3043 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
3044 ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3045 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
3046 ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
3047 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
3048 ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3049 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
3050 )
3051 .unwrap();
3052 conn
3053 }
3054
3055 fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
3056 let mut stmt = conn
3057 .prepare(
3058 "SELECT s.symbol_id FROM symbols_fts f
3059 JOIN symbols s ON s.rowid = f.rowid
3060 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
3061 )
3062 .unwrap();
3063 let mut ids = stmt
3064 .query_map(params![query], |row| row.get::<_, String>(0))
3065 .unwrap()
3066 .collect::<Result<Vec<_>, _>>()
3067 .unwrap();
3068 ids.sort();
3069 ids
3070 }
3071
3072 #[test]
3073 fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
3074 let conn = local_variable_fixture();
3075 conn.execute_batch(
3076 "CREATE VIRTUAL TABLE symbols_fts USING fts5(
3077 name, signature, doc_comment,
3078 content='symbols', content_rowid='rowid', tokenize='porter unicode61'
3079 );
3080 INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
3081 SELECT rowid, name, signature, doc_comment FROM symbols;",
3082 )
3083 .unwrap();
3084
3085 ensure_fts_index(&conn).unwrap();
3086
3087 assert_eq!(
3088 matched_symbol_ids(&conn, "sqlite"),
3089 vec!["field", "func", "global", "pool"]
3090 );
3091 }
3092
3093 #[test]
3094 fn lookup_excludes_locals_and_parameters() {
3095 let conn = local_variable_fixture();
3096
3097 let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
3098 .unwrap()
3099 .into_iter()
3100 .map(|s| s.symbol_id)
3101 .collect();
3102
3103 assert!(!ids.contains(&"local".to_string()));
3104 assert!(!ids.contains(&"nested".to_string()));
3105 assert!(ids.contains(&"field".to_string()));
3106 assert!(ids.contains(&"global".to_string()));
3107 }
3108
3109 #[test]
3110 fn search_excludes_locals_and_parameters() {
3111 let conn = local_variable_fixture();
3112 ensure_fts_index(&conn).unwrap();
3113
3114 let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
3115 .unwrap()
3116 .into_iter()
3117 .map(|r| r.symbol.symbol_id)
3118 .collect();
3119
3120 assert!(!ids.contains(&"local".to_string()));
3121 assert!(ids.contains(&"func".to_string()));
3122 }
3123
3124 #[test]
3125 fn variable_kind_search_keeps_full_text_matching() {
3126 let conn = local_variable_fixture();
3127 ensure_fts_index(&conn).unwrap();
3128
3129 let ids: Vec<String> = fts_search_symbols_scoped(
3130 &conn,
3131 "sqlite connection",
3132 Some("variable"),
3133 None,
3134 false,
3135 10,
3136 )
3137 .unwrap()
3138 .into_iter()
3139 .map(|r| r.symbol.symbol_id)
3140 .collect();
3141
3142 assert!(ids.contains(&"global".to_string()));
3143 assert!(ids.contains(&"field".to_string()));
3144 }
3145
3146 #[test]
3147 fn qualified_lookup_returns_the_named_local_variable() {
3148 let conn = local_variable_fixture();
3149
3150 let ids: Vec<String> =
3151 search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
3152 .unwrap()
3153 .into_iter()
3154 .map(|s| s.symbol_id)
3155 .collect();
3156
3157 assert_eq!(ids, vec!["local".to_string()]);
3158 }
3159
3160 #[test]
3161 fn variable_kind_filter_returns_locals_and_parameters() {
3162 let conn = local_variable_fixture();
3163 ensure_fts_index(&conn).unwrap();
3164
3165 let lookup_ids: Vec<String> =
3166 search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
3167 .unwrap()
3168 .into_iter()
3169 .map(|s| s.symbol_id)
3170 .collect();
3171 assert!(lookup_ids.contains(&"local".to_string()));
3172 assert!(lookup_ids.contains(&"nested".to_string()));
3173
3174 let search_ids: Vec<String> =
3175 fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
3176 .unwrap()
3177 .into_iter()
3178 .map(|r| r.symbol.symbol_id)
3179 .collect();
3180 assert!(search_ids.contains(&"local".to_string()));
3181 }
3182}