Skip to main content

codegraph/
db.rs

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}