1use crate::types::*;
2use anyhow::{Context, Result};
3use regex::Regex;
4use rusqlite::{params, Connection, OptionalExtension};
5use serde_json::Value;
6use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9
10pub struct Database {
11 conn: Connection,
12 path: PathBuf,
13}
14
15impl Database {
16 pub fn initialize(path: impl AsRef<Path>) -> Result<Self> {
17 let db = Self::open_raw(path)?;
18 db.create_schema()?;
19 Ok(db)
20 }
21
22 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
23 let db = Self::open_raw(path)?;
24 db.create_schema()?;
25 Ok(db)
26 }
27
28 fn open_raw(path: impl AsRef<Path>) -> Result<Self> {
29 let path = path.as_ref().to_path_buf();
30 if let Some(parent) = path.parent() {
31 std::fs::create_dir_all(parent)?;
32 }
33 let conn =
34 Connection::open(&path).with_context(|| format!("opening {}", path.display()))?;
35 conn.pragma_update(None, "foreign_keys", "ON")?;
36 conn.pragma_update(None, "journal_mode", "WAL")?;
37 conn.pragma_update(None, "busy_timeout", 120_000)?;
38 Ok(Self { conn, path })
39 }
40
41 fn create_schema(&self) -> Result<()> {
42 self.conn.execute_batch(
43 r#"
44 CREATE TABLE IF NOT EXISTS schema_versions (
45 version INTEGER PRIMARY KEY,
46 applied_at INTEGER NOT NULL,
47 description TEXT
48 );
49 INSERT OR IGNORE INTO schema_versions (version, applied_at, description)
50 VALUES (1, strftime('%s', 'now') * 1000, 'Rust schema');
51
52 CREATE TABLE IF NOT EXISTS nodes (
53 id TEXT PRIMARY KEY,
54 kind TEXT NOT NULL,
55 name TEXT NOT NULL,
56 qualified_name TEXT NOT NULL,
57 file_path TEXT NOT NULL,
58 language TEXT NOT NULL,
59 start_line INTEGER NOT NULL,
60 end_line INTEGER NOT NULL,
61 start_column INTEGER NOT NULL,
62 end_column INTEGER NOT NULL,
63 docstring TEXT,
64 signature TEXT,
65 visibility TEXT,
66 is_exported INTEGER DEFAULT 0,
67 is_async INTEGER DEFAULT 0,
68 is_static INTEGER DEFAULT 0,
69 is_abstract INTEGER DEFAULT 0,
70 decorators TEXT,
71 type_parameters TEXT,
72 updated_at INTEGER NOT NULL
73 );
74
75 CREATE TABLE IF NOT EXISTS edges (
76 id INTEGER PRIMARY KEY AUTOINCREMENT,
77 source TEXT NOT NULL,
78 target TEXT NOT NULL,
79 kind TEXT NOT NULL,
80 metadata TEXT,
81 line INTEGER,
82 col INTEGER,
83 provenance TEXT DEFAULT NULL,
84 FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE,
85 FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE
86 );
87
88 CREATE TABLE IF NOT EXISTS files (
89 path TEXT PRIMARY KEY,
90 content_hash TEXT NOT NULL,
91 language TEXT NOT NULL,
92 size INTEGER NOT NULL,
93 modified_at INTEGER NOT NULL,
94 indexed_at INTEGER NOT NULL,
95 node_count INTEGER DEFAULT 0,
96 errors TEXT
97 );
98
99 CREATE TABLE IF NOT EXISTS unresolved_refs (
100 id INTEGER PRIMARY KEY AUTOINCREMENT,
101 from_node_id TEXT NOT NULL,
102 reference_name TEXT NOT NULL,
103 reference_kind TEXT NOT NULL,
104 line INTEGER NOT NULL,
105 col INTEGER NOT NULL,
106 candidates TEXT,
107 file_path TEXT NOT NULL DEFAULT '',
108 language TEXT NOT NULL DEFAULT 'unknown',
109 FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
110 );
111
112 CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
113 CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
114 CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path);
115 CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language);
116 CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
117 CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source, kind);
118 CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind);
119 CREATE INDEX IF NOT EXISTS idx_files_language ON files(language);
120 CREATE INDEX IF NOT EXISTS idx_unresolved_name ON unresolved_refs(reference_name);
121 "#,
122 )?;
123 Ok(())
124 }
125
126 pub fn clear_all(&self) -> Result<()> {
127 self.conn.execute_batch(
128 "DELETE FROM edges; DELETE FROM unresolved_refs; DELETE FROM nodes; DELETE FROM files;",
129 )?;
130 Ok(())
131 }
132
133 pub fn delete_file_index(&self, path: &str) -> Result<()> {
134 self.conn.execute_batch("BEGIN IMMEDIATE TRANSACTION")?;
135 let result = (|| -> Result<()> {
136 self.delete_file_index_inner(path)?;
137 Ok(())
138 })();
139 match result {
140 Ok(()) => {
141 self.conn.execute_batch("COMMIT")?;
142 Ok(())
143 }
144 Err(err) => {
145 let _ = self.conn.execute_batch("ROLLBACK");
146 Err(err)
147 }
148 }
149 }
150
151 pub fn replace_file_index(
152 &self,
153 file: &FileRecord,
154 nodes: &[Node],
155 edges: &[Edge],
156 refs: &[UnresolvedReference],
157 ) -> Result<()> {
158 self.conn.execute_batch("BEGIN IMMEDIATE TRANSACTION")?;
159 let result = (|| -> Result<()> {
160 self.delete_file_index_inner(&file.path)?;
161 self.insert_file(file)?;
162 self.insert_nodes(nodes)?;
163 self.insert_edges(edges)?;
164 self.insert_unresolved_refs(refs)?;
165 Ok(())
166 })();
167 match result {
168 Ok(()) => {
169 self.conn.execute_batch("COMMIT")?;
170 Ok(())
171 }
172 Err(err) => {
173 let _ = self.conn.execute_batch("ROLLBACK");
174 Err(err)
175 }
176 }
177 }
178
179 pub fn clear_resolved_reference_edges(&self) -> Result<()> {
180 self.conn
181 .execute("DELETE FROM edges WHERE provenance = 'resolver'", [])?;
182 Ok(())
183 }
184
185 fn delete_file_index_inner(&self, path: &str) -> Result<()> {
186 self.conn.execute(
187 "DELETE FROM edges WHERE source IN (SELECT id FROM nodes WHERE file_path = ?1)
188 OR target IN (SELECT id FROM nodes WHERE file_path = ?1)",
189 [path],
190 )?;
191 self.conn
192 .execute("DELETE FROM unresolved_refs WHERE file_path = ?1", [path])?;
193 self.conn
194 .execute("DELETE FROM nodes WHERE file_path = ?1", [path])?;
195 self.conn
196 .execute("DELETE FROM files WHERE path = ?1", [path])?;
197 Ok(())
198 }
199
200 pub fn insert_file(&self, file: &FileRecord) -> Result<()> {
201 self.conn.execute(
202 "INSERT OR REPLACE INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
203 params![file.path, file.content_hash, file.language.as_str(), file.size as i64, file.modified_at, file.indexed_at, file.node_count],
204 )?;
205 Ok(())
206 }
207
208 pub fn insert_nodes(&self, nodes: &[Node]) -> Result<()> {
209 let mut stmt = self.conn.prepare(
210 "INSERT OR REPLACE INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, docstring, signature, visibility, is_exported, is_async, is_static, is_abstract, decorators, type_parameters, updated_at)
211 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, NULL, NULL, ?18)"
212 )?;
213 for n in nodes {
214 stmt.execute(params![
215 n.id,
216 n.kind.as_str(),
217 n.name,
218 n.qualified_name,
219 n.file_path,
220 n.language.as_str(),
221 n.start_line,
222 n.end_line,
223 n.start_column,
224 n.end_column,
225 n.docstring,
226 n.signature,
227 n.visibility,
228 n.is_exported as i64,
229 n.is_async as i64,
230 n.is_static as i64,
231 n.is_abstract as i64,
232 n.updated_at
233 ])?;
234 }
235 Ok(())
236 }
237
238 pub fn insert_edges(&self, edges: &[Edge]) -> Result<()> {
239 let mut stmt = self.conn.prepare("INSERT INTO edges (source, target, kind, line, col, provenance) VALUES (?1, ?2, ?3, ?4, ?5, ?6)")?;
240 for e in edges {
241 stmt.execute(params![
242 e.source,
243 e.target,
244 e.kind.as_str(),
245 e.line,
246 e.col,
247 e.provenance
248 ])?;
249 }
250 Ok(())
251 }
252
253 pub fn insert_unresolved_refs(&self, refs: &[UnresolvedReference]) -> Result<()> {
254 let mut stmt = self.conn.prepare(
255 "INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
256 )?;
257 for r in refs {
258 stmt.execute(params![
259 r.from_node_id,
260 r.reference_name,
261 r.reference_kind.as_str(),
262 r.line,
263 r.column,
264 r.file_path,
265 r.language.as_str()
266 ])?;
267 }
268 Ok(())
269 }
270
271 pub fn resolve_references(&self, project_root: &Path) -> Result<()> {
272 let indexed_files = self.indexed_file_set()?;
273 let aliases = load_project_aliases(project_root).unwrap_or_default();
274 let mut refs = self.conn.prepare("SELECT from_node_id, reference_name, reference_kind, line, col, file_path, language FROM unresolved_refs")?;
275 let rows = refs.query_map([], |row| {
276 Ok((
277 row.get::<_, String>(0)?,
278 row.get::<_, String>(1)?,
279 row.get::<_, String>(2)?,
280 row.get::<_, Option<i64>>(3)?,
281 row.get::<_, Option<i64>>(4)?,
282 row.get::<_, String>(5)?,
283 row.get::<_, String>(6)?,
284 ))
285 })?;
286 for row in rows {
287 let (from, name, kind, line, col, file_path, lang) = row?;
288 let language = Language::from_str(&lang).unwrap_or(Language::Unknown);
289 let mut target =
290 self.resolve_reference_path(&name, &file_path, language, &indexed_files, &aliases)?;
291 if target.is_none() {
292 target = self.resolve_reference_by_name(&from, &name, &lang)?;
293 }
294 if let Some(target) = target {
295 self.conn.execute(
296 "INSERT INTO edges (source, target, kind, line, col, provenance) VALUES (?1, ?2, ?3, ?4, ?5, 'resolver')",
297 params![from, target, kind, line, col],
298 )?;
299 }
300 }
301 Ok(())
302 }
303
304 pub fn resolve_references_by_name(&self) -> Result<()> {
305 self.resolve_references(Path::new("."))
306 }
307
308 fn indexed_file_set(&self) -> Result<BTreeSet<String>> {
309 let mut stmt = self.conn.prepare("SELECT path FROM files")?;
310 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
311 let mut out = BTreeSet::new();
312 for row in rows {
313 out.insert(normalize_path(&row?));
314 }
315 Ok(out)
316 }
317
318 fn resolve_reference_path(
319 &self,
320 reference_name: &str,
321 from_file: &str,
322 language: Language,
323 indexed_files: &BTreeSet<String>,
324 aliases: &[PathAlias],
325 ) -> Result<Option<String>> {
326 let Some(path) =
327 resolve_import_path(reference_name, from_file, language, indexed_files, aliases)
328 else {
329 return Ok(None);
330 };
331 self.conn
332 .query_row(
333 "SELECT id FROM nodes WHERE kind = 'file' AND file_path = ?1 LIMIT 1",
334 [path],
335 |row| row.get(0),
336 )
337 .optional()
338 .map_err(Into::into)
339 }
340
341 fn resolve_reference_by_name(
342 &self,
343 from_node_id: &str,
344 name: &str,
345 language: &str,
346 ) -> Result<Option<String>> {
347 let mut stmt = self.conn.prepare(
348 "SELECT id, kind, file_path FROM nodes WHERE name = ?1 AND language = ?2 AND id != ?3",
349 )?;
350 let rows = stmt.query_map(params![name, language, from_node_id], |row| {
351 Ok((
352 row.get::<_, String>(0)?,
353 row.get::<_, String>(1)?,
354 row.get::<_, String>(2)?,
355 ))
356 })?;
357 let mut candidates = Vec::new();
358 for row in rows {
359 candidates.push(row?);
360 }
361 if candidates.is_empty() {
362 return Ok(None);
363 }
364 candidates.sort_by(|a, b| {
365 node_resolution_rank(&a.1)
366 .cmp(&node_resolution_rank(&b.1))
367 .then_with(|| a.2.cmp(&b.2))
368 .then_with(|| a.0.cmp(&b.0))
369 });
370 let best_rank = node_resolution_rank(&candidates[0].1);
371 let best_count = candidates
372 .iter()
373 .filter(|(_, kind, _)| node_resolution_rank(kind) == best_rank)
374 .count();
375 if best_count == 1 {
376 Ok(Some(candidates[0].0.clone()))
377 } else {
378 Ok(None)
379 }
380 }
381
382 pub fn edge_count(&self) -> Result<i64> {
383 Ok(self
384 .conn
385 .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?)
386 }
387
388 pub fn stats(&self) -> Result<GraphStats> {
389 let file_count = self
390 .conn
391 .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))?;
392 let node_count = self
393 .conn
394 .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
395 let edge_count = self
396 .conn
397 .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
398 let db_size_bytes = std::fs::metadata(&self.path)
399 .map(|m| m.len() as i64)
400 .unwrap_or_default();
401 let oldest_indexed_at =
402 self.conn
403 .query_row("SELECT MIN(indexed_at) FROM files", [], |r| r.get(0))?;
404 let last_indexed_at =
405 self.conn
406 .query_row("SELECT MAX(indexed_at) FROM files", [], |r| r.get(0))?;
407 let newest_modified_at =
408 self.conn
409 .query_row("SELECT MAX(modified_at) FROM files", [], |r| r.get(0))?;
410 let stale_file_count = self.conn.query_row(
411 "SELECT COUNT(*) FROM files WHERE modified_at > indexed_at",
412 [],
413 |r| r.get(0),
414 )?;
415 let files_by_language = grouped_counts(
416 &self.conn,
417 "SELECT language, COUNT(*) FROM files GROUP BY language",
418 )?;
419 let nodes_by_kind =
420 grouped_counts(&self.conn, "SELECT kind, COUNT(*) FROM nodes GROUP BY kind")?;
421 Ok(GraphStats {
422 file_count,
423 node_count,
424 edge_count,
425 db_size_bytes,
426 oldest_indexed_at,
427 last_indexed_at,
428 newest_modified_at,
429 stale_file_count,
430 files_by_language,
431 nodes_by_kind,
432 })
433 }
434
435 pub fn search_nodes(&self, query: &str, options: SearchOptions) -> Result<Vec<SearchResult>> {
436 let limit = if options.limit <= 0 {
437 10
438 } else {
439 options.limit
440 };
441 let fetch_limit = (limit * 5).max(limit).min(500);
442 let pattern = format!("%{}%", query);
443 let exact = query.to_string();
444 let prefix = format!("{}%", query);
445
446 let base = "SELECT id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, docstring, signature, visibility, is_exported, is_async, is_static, is_abstract, updated_at FROM nodes";
447 let order = " ORDER BY CASE WHEN name = ? THEN 0 WHEN name LIKE ? THEN 1 ELSE 2 END, length(name) LIMIT ?";
448
449 let rows = match (options.kind, options.language) {
450 (Some(k), Some(l)) => {
451 let sql = format!("{base} WHERE (name LIKE ? OR qualified_name LIKE ? OR signature LIKE ? OR file_path LIKE ?) AND kind = ? AND language = ?{order}");
452 let mut stmt = self.conn.prepare(&sql)?;
453 let nodes = collect_nodes(stmt.query_map(
454 params![
455 pattern,
456 pattern,
457 pattern,
458 pattern,
459 k.as_str(),
460 l.as_str(),
461 exact,
462 prefix,
463 fetch_limit
464 ],
465 node_from_row,
466 )?)?;
467 nodes
468 }
469 (Some(k), None) => {
470 let sql = format!("{base} WHERE (name LIKE ? OR qualified_name LIKE ? OR signature LIKE ? OR file_path LIKE ?) AND kind = ?{order}");
471 let mut stmt = self.conn.prepare(&sql)?;
472 let nodes = collect_nodes(stmt.query_map(
473 params![
474 pattern,
475 pattern,
476 pattern,
477 pattern,
478 k.as_str(),
479 exact,
480 prefix,
481 fetch_limit
482 ],
483 node_from_row,
484 )?)?;
485 nodes
486 }
487 (None, Some(l)) => {
488 let sql = format!("{base} WHERE (name LIKE ? OR qualified_name LIKE ? OR signature LIKE ? OR file_path LIKE ?) AND language = ?{order}");
489 let mut stmt = self.conn.prepare(&sql)?;
490 let nodes = collect_nodes(stmt.query_map(
491 params![
492 pattern,
493 pattern,
494 pattern,
495 pattern,
496 l.as_str(),
497 exact,
498 prefix,
499 fetch_limit
500 ],
501 node_from_row,
502 )?)?;
503 nodes
504 }
505 (None, None) => {
506 let sql = format!("{base} WHERE (name LIKE ? OR qualified_name LIKE ? OR signature LIKE ? OR file_path LIKE ?){order}");
507 let mut stmt = self.conn.prepare(&sql)?;
508 let nodes = collect_nodes(stmt.query_map(
509 params![
510 pattern,
511 pattern,
512 pattern,
513 pattern,
514 exact,
515 prefix,
516 fetch_limit
517 ],
518 node_from_row,
519 )?)?;
520 nodes
521 }
522 };
523 let mut results = rows
524 .into_iter()
525 .map(|node| SearchResult {
526 score: search_score(query, &node),
527 node,
528 })
529 .collect::<Vec<_>>();
530 results.sort_by(|a, b| {
531 b.score
532 .partial_cmp(&a.score)
533 .unwrap_or(std::cmp::Ordering::Equal)
534 .then_with(|| a.node.name.len().cmp(&b.node.name.len()))
535 .then_with(|| a.node.file_path.cmp(&b.node.file_path))
536 .then_with(|| a.node.start_line.cmp(&b.node.start_line))
537 });
538 results.truncate(limit as usize);
539 Ok(results)
540 }
541
542 pub fn get_node(&self, id: &str) -> Result<Option<Node>> {
543 self.conn
544 .query_row("SELECT id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, docstring, signature, visibility, is_exported, is_async, is_static, is_abstract, updated_at FROM nodes WHERE id = ?1", [id], node_from_row)
545 .optional()
546 .map_err(Into::into)
547 }
548
549 pub fn get_nodes_by_name(&self, name: &str, limit: i64) -> Result<Vec<Node>> {
550 let mut stmt = self.conn.prepare("SELECT id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, docstring, signature, visibility, is_exported, is_async, is_static, is_abstract, updated_at FROM nodes WHERE name = ?1 ORDER BY file_path, start_line LIMIT ?2")?;
551 let nodes = collect_nodes(stmt.query_map(params![name, limit], node_from_row)?)?;
552 Ok(nodes)
553 }
554
555 pub fn get_all_files(&self) -> Result<Vec<FileRecord>> {
556 let mut stmt = self.conn.prepare("SELECT path, content_hash, language, size, modified_at, indexed_at, node_count FROM files ORDER BY path")?;
557 let rows = stmt.query_map([], |row| {
558 let language: String = row.get(2)?;
559 Ok(FileRecord {
560 path: row.get(0)?,
561 content_hash: row.get(1)?,
562 language: Language::from_str(&language).unwrap_or(Language::Unknown),
563 size: row.get::<_, i64>(3)? as u64,
564 modified_at: row.get(4)?,
565 indexed_at: row.get(5)?,
566 node_count: row.get(6)?,
567 })
568 })?;
569 let mut out = Vec::new();
570 for row in rows {
571 out.push(row?);
572 }
573 Ok(out)
574 }
575
576 pub fn get_nodes_in_file(&self, file_path: &str) -> Result<Vec<Node>> {
577 let mut stmt = self.conn.prepare("SELECT id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, docstring, signature, visibility, is_exported, is_async, is_static, is_abstract, updated_at FROM nodes WHERE file_path = ?1 ORDER BY start_line, start_column")?;
578 let nodes = collect_nodes(stmt.query_map([file_path], node_from_row)?)?;
579 Ok(nodes)
580 }
581
582 pub fn get_incoming_edges(
583 &self,
584 node_id: &str,
585 kinds: Option<&[EdgeKind]>,
586 ) -> Result<Vec<Edge>> {
587 self.get_edges(node_id, EdgeDirection::Incoming, kinds)
588 }
589
590 pub fn get_outgoing_edges(
591 &self,
592 node_id: &str,
593 kinds: Option<&[EdgeKind]>,
594 ) -> Result<Vec<Edge>> {
595 self.get_edges(node_id, EdgeDirection::Outgoing, kinds)
596 }
597
598 pub fn get_file_dependents(&self, file_path: &str) -> Result<Vec<String>> {
599 let mut out = std::collections::BTreeSet::new();
600 for node in self.get_nodes_in_file(file_path)? {
601 let edges = self.get_incoming_edges(
602 &node.id,
603 Some(&[
604 EdgeKind::Calls,
605 EdgeKind::References,
606 EdgeKind::Imports,
607 EdgeKind::Extends,
608 EdgeKind::Implements,
609 ]),
610 )?;
611 for edge in edges {
612 if let Some(source) = self.get_node(&edge.source)? {
613 if source.file_path != file_path {
614 out.insert(source.file_path);
615 }
616 }
617 }
618 }
619 Ok(out.into_iter().collect())
620 }
621
622 fn get_edges(
623 &self,
624 node_id: &str,
625 direction: EdgeDirection,
626 kinds: Option<&[EdgeKind]>,
627 ) -> Result<Vec<Edge>> {
628 let column = match direction {
629 EdgeDirection::Incoming => "target",
630 EdgeDirection::Outgoing => "source",
631 };
632 let mut sql = format!(
633 "SELECT id, source, target, kind, line, col, provenance FROM edges WHERE {column} = ?"
634 );
635 if let Some(kinds) = kinds {
636 if !kinds.is_empty() {
637 sql.push_str(" AND kind IN (");
638 sql.push_str(
639 &std::iter::repeat("?")
640 .take(kinds.len())
641 .collect::<Vec<_>>()
642 .join(","),
643 );
644 sql.push(')');
645 }
646 }
647 sql.push_str(" ORDER BY id");
648
649 let mut values = vec![node_id.to_string()];
650 if let Some(kinds) = kinds {
651 values.extend(kinds.iter().map(|k| k.as_str().to_string()));
652 }
653 let mut stmt = self.conn.prepare(&sql)?;
654 let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), edge_from_row)?;
655 let mut out = Vec::new();
656 for row in rows {
657 out.push(row?);
658 }
659 Ok(out)
660 }
661}
662
663enum EdgeDirection {
664 Incoming,
665 Outgoing,
666}
667
668fn collect_nodes(
669 rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<Node>>,
670) -> Result<Vec<Node>> {
671 let mut out = Vec::new();
672 for row in rows {
673 out.push(row?);
674 }
675 Ok(out)
676}
677
678fn grouped_counts(conn: &Connection, sql: &str) -> Result<Vec<(String, i64)>> {
679 let mut stmt = conn.prepare(sql)?;
680 let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
681 let mut out = Vec::new();
682 for row in rows {
683 out.push(row?);
684 }
685 Ok(out)
686}
687
688fn node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Node> {
689 let kind: String = row.get(1)?;
690 let language: String = row.get(5)?;
691 Ok(Node {
692 id: row.get(0)?,
693 kind: parse_kind(&kind),
694 name: row.get(2)?,
695 qualified_name: row.get(3)?,
696 file_path: row.get(4)?,
697 language: Language::from_str(&language).unwrap_or(Language::Unknown),
698 start_line: row.get(6)?,
699 end_line: row.get(7)?,
700 start_column: row.get(8)?,
701 end_column: row.get(9)?,
702 docstring: row.get(10)?,
703 signature: row.get(11)?,
704 visibility: row.get(12)?,
705 is_exported: row.get::<_, i64>(13)? != 0,
706 is_async: row.get::<_, i64>(14)? != 0,
707 is_static: row.get::<_, i64>(15)? != 0,
708 is_abstract: row.get::<_, i64>(16)? != 0,
709 updated_at: row.get(17)?,
710 })
711}
712
713fn edge_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Edge> {
714 let kind: String = row.get(3)?;
715 Ok(Edge {
716 id: row.get(0)?,
717 source: row.get(1)?,
718 target: row.get(2)?,
719 kind: parse_edge_kind(&kind),
720 line: row.get(4)?,
721 col: row.get(5)?,
722 provenance: row.get(6)?,
723 })
724}
725
726fn search_score(query: &str, node: &Node) -> f64 {
727 let query = query.to_ascii_lowercase();
728 let name = node.name.to_ascii_lowercase();
729 let qualified = node.qualified_name.to_ascii_lowercase();
730 let file_path = node.file_path.to_ascii_lowercase();
731 let signature = node
732 .signature
733 .as_deref()
734 .unwrap_or_default()
735 .to_ascii_lowercase();
736
737 if name == query {
738 100.0
739 } else if name.starts_with(&query) {
740 90.0
741 } else if qualified.contains(&query) {
742 80.0
743 } else if file_path.contains(&query) {
744 70.0
745 } else if signature.contains(&query) {
746 60.0
747 } else {
748 10.0
749 }
750}
751
752#[derive(Debug, Clone)]
753struct PathAlias {
754 prefix: String,
755 suffix: String,
756 replacements: Vec<String>,
757 has_wildcard: bool,
758}
759
760fn resolve_import_path(
761 reference_name: &str,
762 from_file: &str,
763 language: Language,
764 indexed_files: &BTreeSet<String>,
765 aliases: &[PathAlias],
766) -> Option<String> {
767 if is_external_import(reference_name, language, aliases) {
768 return None;
769 }
770
771 let mut bases = Vec::new();
772 if reference_name.starts_with('.') {
773 bases.push(join_normalized(parent_dir(from_file), reference_name));
774 } else {
775 bases.extend(apply_path_aliases(reference_name, aliases));
776 for (prefix, replacement) in [
777 ("@/", "src/"),
778 ("~/", "src/"),
779 ("@src/", "src/"),
780 ("src/", "src/"),
781 ("@app/", "app/"),
782 ("app/", "app/"),
783 ] {
784 if reference_name.starts_with(prefix) {
785 bases.push(format!(
786 "{}{}",
787 replacement,
788 reference_name.trim_start_matches(prefix)
789 ));
790 }
791 }
792 bases.push(reference_name.to_string());
793 }
794
795 for base in bases {
796 for candidate in import_candidates(&base, language) {
797 let candidate = normalize_path(&candidate);
798 if indexed_files.contains(&candidate) {
799 return Some(candidate);
800 }
801 }
802 }
803 None
804}
805
806fn is_external_import(reference_name: &str, language: Language, aliases: &[PathAlias]) -> bool {
807 if reference_name.starts_with('.') || reference_name.contains('/') {
808 return false;
809 }
810 if aliases
811 .iter()
812 .any(|alias| alias_matches(reference_name, alias))
813 {
814 return false;
815 }
816 match language {
817 Language::TypeScript | Language::JavaScript | Language::Tsx | Language::Jsx => true,
818 Language::Python => matches!(
819 reference_name.split('.').next().unwrap_or(reference_name),
820 "os" | "sys" | "json" | "re" | "math" | "datetime" | "collections" | "typing"
821 ),
822 _ => false,
823 }
824}
825
826fn import_candidates(base: &str, language: Language) -> Vec<String> {
827 let mut out = Vec::new();
828 out.push(base.to_string());
829 let exts: &[&str] = match language {
830 Language::TypeScript => &[
831 ".ts",
832 ".tsx",
833 ".d.ts",
834 ".js",
835 ".jsx",
836 "/index.ts",
837 "/index.tsx",
838 "/index.js",
839 ],
840 Language::JavaScript => &[".js", ".jsx", ".mjs", ".cjs", "/index.js", "/index.jsx"],
841 Language::Tsx => &[
842 ".tsx",
843 ".ts",
844 ".d.ts",
845 ".js",
846 ".jsx",
847 "/index.tsx",
848 "/index.ts",
849 "/index.js",
850 ],
851 Language::Jsx => &[".jsx", ".js", "/index.jsx", "/index.js"],
852 Language::Vue => &[".vue", ".ts", ".js", "/index.vue", "/index.ts", "/index.js"],
853 Language::Svelte => &[
854 ".svelte",
855 ".ts",
856 ".js",
857 "/index.svelte",
858 "/index.ts",
859 "/index.js",
860 ],
861 Language::Liquid => &[".liquid"],
862 Language::Python => &[".py", "/__init__.py"],
863 Language::Rust => &[".rs", "/mod.rs"],
864 Language::Go => &[".go"],
865 Language::Java => &[".java"],
866 Language::Kotlin => &[".kt", ".kts"],
867 Language::CSharp => &[".cs"],
868 Language::Php => &[".php"],
869 Language::Ruby => &[".rb"],
870 Language::Dart => &[".dart"],
871 Language::Pascal => &[".pas", ".pp"],
872 Language::Scala => &[".scala"],
873 _ => &[],
874 };
875 if !Path::new(base)
876 .extension()
877 .and_then(|ext| ext.to_str())
878 .is_some_and(|ext| !ext.is_empty())
879 {
880 out.extend(exts.iter().map(|ext| format!("{base}{ext}")));
881 }
882 out
883}
884
885fn load_project_aliases(project_root: &Path) -> Result<Vec<PathAlias>> {
886 for name in ["tsconfig.json", "jsconfig.json"] {
887 let path = project_root.join(name);
888 if !path.exists() {
889 continue;
890 }
891 let content = std::fs::read_to_string(path)?;
892 let value: Value = serde_json::from_str(&strip_jsonc(&content))?;
893 let Some(paths) = value
894 .pointer("/compilerOptions/paths")
895 .and_then(|paths| paths.as_object())
896 else {
897 return Ok(Vec::new());
898 };
899 let mut aliases = Vec::new();
900 for (pattern, replacements) in paths {
901 let Some(items) = replacements.as_array() else {
902 continue;
903 };
904 let replacements = items
905 .iter()
906 .filter_map(|item| item.as_str().map(normalize_path))
907 .collect::<Vec<_>>();
908 if replacements.is_empty() {
909 continue;
910 }
911 let (prefix, suffix, has_wildcard) = split_alias_pattern(pattern);
912 aliases.push(PathAlias {
913 prefix,
914 suffix,
915 replacements,
916 has_wildcard,
917 });
918 }
919 aliases.sort_by(|a, b| b.prefix.len().cmp(&a.prefix.len()));
920 return Ok(aliases);
921 }
922 Ok(Vec::new())
923}
924
925fn split_alias_pattern(pattern: &str) -> (String, String, bool) {
926 if let Some(index) = pattern.find('*') {
927 (
928 pattern[..index].to_string(),
929 pattern[index + 1..].to_string(),
930 true,
931 )
932 } else {
933 (pattern.to_string(), String::new(), false)
934 }
935}
936
937fn apply_path_aliases(reference_name: &str, aliases: &[PathAlias]) -> Vec<String> {
938 let mut out = Vec::new();
939 for alias in aliases {
940 if !alias_matches(reference_name, alias) {
941 continue;
942 }
943 let captured = if alias.has_wildcard {
944 &reference_name[alias.prefix.len()..reference_name.len() - alias.suffix.len()]
945 } else {
946 ""
947 };
948 for replacement in &alias.replacements {
949 out.push(if alias.has_wildcard {
950 replacement.replace('*', captured)
951 } else {
952 replacement.clone()
953 });
954 }
955 break;
956 }
957 out
958}
959
960fn alias_matches(reference_name: &str, alias: &PathAlias) -> bool {
961 if !reference_name.starts_with(&alias.prefix) || !reference_name.ends_with(&alias.suffix) {
962 return false;
963 }
964 alias.has_wildcard || reference_name == alias.prefix
965}
966
967fn strip_jsonc(source: &str) -> String {
968 let mut out = String::with_capacity(source.len());
969 let mut chars = source.chars().peekable();
970 let mut in_string = false;
971 while let Some(ch) = chars.next() {
972 if in_string {
973 out.push(ch);
974 if ch == '\\' {
975 if let Some(next) = chars.next() {
976 out.push(next);
977 }
978 } else if ch == '"' {
979 in_string = false;
980 }
981 continue;
982 }
983 if ch == '"' {
984 in_string = true;
985 out.push(ch);
986 continue;
987 }
988 if ch == '/' && chars.peek() == Some(&'/') {
989 for next in chars.by_ref() {
990 if next == '\n' {
991 out.push('\n');
992 break;
993 }
994 }
995 continue;
996 }
997 if ch == '/' && chars.peek() == Some(&'*') {
998 chars.next();
999 while let Some(next) = chars.next() {
1000 if next == '*' && chars.peek() == Some(&'/') {
1001 chars.next();
1002 break;
1003 }
1004 }
1005 continue;
1006 }
1007 out.push(ch);
1008 }
1009 Regex::new(r",(\s*[}\]])")
1010 .unwrap()
1011 .replace_all(&out, "$1")
1012 .to_string()
1013}
1014
1015fn parent_dir(path: &str) -> &str {
1016 path.rsplit_once('/')
1017 .map(|(parent, _)| parent)
1018 .unwrap_or("")
1019}
1020
1021fn join_normalized(parent: &str, child: &str) -> String {
1022 let mut parts = Vec::new();
1023 let joined = format!("{parent}/{child}");
1024 for part in joined.split('/') {
1025 match part {
1026 "" | "." => {}
1027 ".." => {
1028 parts.pop();
1029 }
1030 _ => parts.push(part.to_string()),
1031 }
1032 }
1033 parts.join("/")
1034}
1035
1036fn normalize_path(path: &str) -> String {
1037 join_normalized("", &path.replace('\\', "/"))
1038}
1039
1040fn node_resolution_rank(kind: &str) -> i64 {
1041 match kind {
1042 "function" => 0,
1043 "method" => 1,
1044 "component" => 2,
1045 "class" => 3,
1046 "struct" => 4,
1047 "interface" => 5,
1048 "trait" => 6,
1049 "module" => 7,
1050 "file" => 8,
1051 _ => 20,
1052 }
1053}
1054
1055fn parse_kind(s: &str) -> NodeKind {
1056 match s {
1057 "file" => NodeKind::File,
1058 "module" => NodeKind::Module,
1059 "class" => NodeKind::Class,
1060 "struct" => NodeKind::Struct,
1061 "interface" => NodeKind::Interface,
1062 "trait" => NodeKind::Trait,
1063 "protocol" => NodeKind::Protocol,
1064 "function" => NodeKind::Function,
1065 "method" => NodeKind::Method,
1066 "property" => NodeKind::Property,
1067 "field" => NodeKind::Field,
1068 "variable" => NodeKind::Variable,
1069 "constant" => NodeKind::Constant,
1070 "enum" => NodeKind::Enum,
1071 "enum_member" => NodeKind::EnumMember,
1072 "type_alias" => NodeKind::TypeAlias,
1073 "namespace" => NodeKind::Namespace,
1074 "parameter" => NodeKind::Parameter,
1075 "import" => NodeKind::Import,
1076 "export" => NodeKind::Export,
1077 "route" => NodeKind::Route,
1078 "component" => NodeKind::Component,
1079 _ => NodeKind::Variable,
1080 }
1081}
1082
1083fn parse_edge_kind(s: &str) -> EdgeKind {
1084 match s {
1085 "contains" => EdgeKind::Contains,
1086 "calls" => EdgeKind::Calls,
1087 "imports" => EdgeKind::Imports,
1088 "exports" => EdgeKind::Exports,
1089 "extends" => EdgeKind::Extends,
1090 "implements" => EdgeKind::Implements,
1091 "references" => EdgeKind::References,
1092 "type_of" => EdgeKind::TypeOf,
1093 "returns" => EdgeKind::Returns,
1094 "instantiates" => EdgeKind::Instantiates,
1095 "overrides" => EdgeKind::Overrides,
1096 "decorates" => EdgeKind::Decorates,
1097 _ => EdgeKind::References,
1098 }
1099}