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