Skip to main content

mimir_graph/
lib.rs

1//! Code graph for Mimir: tree-sitter symbol extraction (no LLM, no type
2//! checker) persisted into the unified node/edge store, plus petgraph
3//! queries (callers/calls/impact/path/hubs/communities).
4//!
5//! Symbols are ordinary nodes — they embed into semantic recall and link
6//! to memories like everything else. Stable identity across re-extraction
7//! lives in meta.stable_id; see store_graph.
8
9// extract/languages live in mimir-syntax (dependency-free from mimir-core,
10// so mimir-core can also use them without a cycle back through this crate);
11// re-exported here so existing `mimir_graph::extract`/`mimir_graph::languages`
12// call sites are unaffected.
13pub use mimir_syntax::{extract, languages};
14
15pub mod queries;
16pub mod store_graph;
17
18use mimir_core::error::{Error, Result};
19use mimir_core::model::Node;
20use mimir_core::store::{self, row_to_node, NODE_COLS};
21use rusqlite::Connection;
22
23pub use queries::CodeGraph;
24pub use store_graph::{update, GraphStats};
25
26/// Resolve a symbol by short id, exact qualified name, or bare name.
27/// Bare-name matches must be unique; ambiguity lists the candidates.
28pub fn resolve_symbol(conn: &Connection, project_id: i64, reference: &str) -> Result<Node> {
29    if let Ok(node) = store::resolve_ref(conn, reference) {
30        if matches!(node.kind, mimir_core::model::Kind::Symbol) {
31            return Ok(node);
32        }
33    }
34    let mut stmt = conn.prepare(&format!(
35        "SELECT {NODE_COLS} FROM node
36         WHERE kind = 'symbol' AND project_id = ?1 AND deleted_at IS NULL
37           AND (title = ?2 OR json_extract(meta, '$.name') = ?2)
38         LIMIT 6"
39    ))?;
40    let matches: Vec<Node> = stmt
41        .query_map(rusqlite::params![project_id, reference], row_to_node)?
42        .collect::<rusqlite::Result<_>>()?;
43    match matches.len() {
44        0 => Err(Error::NotFound(format!("symbol '{reference}'"))),
45        1 => Ok(matches.into_iter().next().unwrap()),
46        n => Err(Error::AmbiguousRef(
47            format!(
48                "{reference} (matches {})",
49                matches
50                    .iter()
51                    .filter_map(|m| m.title.as_deref())
52                    .collect::<Vec<_>>()
53                    .join(", ")
54            ),
55            n,
56        )),
57    }
58}
59
60/// One-line agent format for a symbol: `c:TAIL [method rust] path:12-40 qualified`.
61pub fn symbol_line(node: &Node) -> String {
62    let id = mimir_core::model::short_uid(node.kind, &node.uid);
63    let kind = node.subkind.as_deref().unwrap_or("symbol");
64    let lang = node.lang.as_deref().unwrap_or("?");
65    let span = match (node.span_start, node.span_end) {
66        (Some(a), Some(b)) => format!(":{a}-{b}"),
67        _ => String::new(),
68    };
69    format!(
70        "{id} [{kind} {lang}] {}{span} {}",
71        node.path.as_deref().unwrap_or("?"),
72        node.title.as_deref().unwrap_or("?")
73    )
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use mimir_core::Mimir;
80    use std::path::Path;
81
82    fn write(dir: &Path, rel: &str, content: &str) {
83        let path = dir.join(rel);
84        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
85        std::fs::write(path, content).unwrap();
86    }
87
88    fn project(conn: &Connection, root: &Path) -> Node {
89        store::ensure_project(conn, &root.to_string_lossy(), "test-proj").unwrap()
90    }
91
92    fn setup() -> (tempfile::TempDir, Mimir, Node) {
93        let dir = tempfile::tempdir().unwrap();
94        write(
95            dir.path(),
96            "src/util.rs",
97            "/// Slugs.\npub fn slugify(s: &str) -> String { s.to_lowercase() }\n",
98        );
99        write(
100            dir.path(),
101            "src/main.rs",
102            "use crate::util::slugify;\n\nfn main() { run(); }\n\nfn run() -> String { slugify(\"Hi\") }\n",
103        );
104        let mimir = Mimir::open_in_memory().unwrap();
105        let proj = project(&mimir.conn, dir.path());
106        (dir, mimir, proj)
107    }
108
109    #[test]
110    fn build_persists_symbols_and_resolves_calls() {
111        let (dir, mut mimir, proj) = setup();
112        let stats = update(&mut mimir.conn, &proj, dir.path()).unwrap();
113        assert_eq!(stats.files_indexed, 2);
114        assert_eq!(stats.symbols, 3, "slugify, main, run");
115        assert!(stats.calls_resolved >= 2, "{stats:?}"); // main→run, run→slugify
116
117        let run = resolve_symbol(&mimir.conn, proj.id, "run").unwrap();
118        assert_eq!(run.subkind.as_deref(), Some("function"));
119        assert!(run.body.as_deref().unwrap().contains("fn run()"));
120
121        let slug = resolve_symbol(&mimir.conn, proj.id, "slugify").unwrap();
122        assert!(
123            slug.body.as_deref().unwrap().contains("Slugs."),
124            "doc embedded"
125        );
126
127        let graph = CodeGraph::load(&mimir.conn, proj.id).unwrap();
128        let callers = graph.callers(slug.id, 5);
129        let ids: Vec<i64> = callers.iter().map(|r| r.id).collect();
130        assert!(ids.contains(&run.id), "run calls slugify");
131        let main = resolve_symbol(&mimir.conn, proj.id, "main").unwrap();
132        assert!(ids.contains(&main.id), "main → run → slugify transitively");
133
134        // path main → slugify
135        let p = graph.path(main.id, slug.id).unwrap();
136        assert_eq!(p.first(), Some(&main.id));
137        assert_eq!(p.last(), Some(&slug.id));
138    }
139
140    #[test]
141    fn incremental_update_preserves_ids_and_links() {
142        let (dir, mut mimir, proj) = setup();
143        update(&mut mimir.conn, &proj, dir.path()).unwrap();
144        let slug_before = resolve_symbol(&mimir.conn, proj.id, "slugify").unwrap();
145
146        // Link a memory to the symbol (the survival contract under test).
147        let memory = mimir_core::memory::remember(
148            &mimir.conn,
149            mimir_core::memory::Remember {
150                text: "slugify must stay ASCII-only".into(),
151                mtype: mimir_core::model::MemoryType::Decision,
152                tags: vec![],
153                project_id: Some(proj.id),
154                force: false,
155            },
156        )
157        .unwrap();
158        let mem = match memory {
159            mimir_core::memory::RememberOutcome::Created(n) => n,
160            _ => unreachable!(),
161        };
162        store::link(
163            &mimir.conn,
164            mem.id,
165            slug_before.id,
166            mimir_core::model::Rel::About,
167            1.0,
168        )
169        .unwrap();
170
171        // Shift the symbol down and change its body.
172        write(
173            dir.path(),
174            "src/util.rs",
175            "// new header\n\n/// Slugs v2.\npub fn slugify(s: &str) -> String { s.trim().to_lowercase() }\n",
176        );
177        let stats = update(&mut mimir.conn, &proj, dir.path()).unwrap();
178        assert_eq!(stats.files_indexed, 1);
179        assert_eq!(stats.unchanged, 1);
180
181        let slug_after = resolve_symbol(&mimir.conn, proj.id, "slugify").unwrap();
182        assert_eq!(slug_after.id, slug_before.id, "stable id preserved");
183        assert_eq!(slug_after.uid, slug_before.uid);
184        assert_eq!(slug_after.span_start, Some(4), "span updated");
185        assert!(slug_after.body.unwrap().contains("Slugs v2."));
186
187        let edges = store::edges_of(&mimir.conn, mem.id).unwrap();
188        assert_eq!(edges.len(), 1, "memory→symbol link survived re-extraction");
189        assert_eq!(edges[0].dst, slug_after.id);
190    }
191
192    #[test]
193    fn removed_symbols_and_files_disappear() {
194        let (dir, mut mimir, proj) = setup();
195        update(&mut mimir.conn, &proj, dir.path()).unwrap();
196
197        // Remove `run` from main.rs.
198        write(dir.path(), "src/main.rs", "fn main() {}\n");
199        update(&mut mimir.conn, &proj, dir.path()).unwrap();
200        assert!(resolve_symbol(&mimir.conn, proj.id, "run").is_err());
201
202        // Remove the whole util file.
203        std::fs::remove_file(dir.path().join("src/util.rs")).unwrap();
204        let stats = update(&mut mimir.conn, &proj, dir.path()).unwrap();
205        assert_eq!(stats.removed, 1);
206        assert!(resolve_symbol(&mimir.conn, proj.id, "slugify").is_err());
207    }
208
209    #[test]
210    fn cross_language_project() {
211        let dir = tempfile::tempdir().unwrap();
212        write(
213            dir.path(),
214            "web/api.ts",
215            "export function fetchUser(id: number) { return id; }\n",
216        );
217        write(
218            dir.path(),
219            "web/app.ts",
220            "import { fetchUser } from \"./api\";\nexport function load() { return fetchUser(1); }\n",
221        );
222        write(
223            dir.path(),
224            "tools/gen.py",
225            "def generate():\n    return emit()\n\ndef emit():\n    return 1\n",
226        );
227        let mimir = Mimir::open_in_memory().unwrap();
228        let proj = project(&mimir.conn, dir.path());
229        let mut conn = mimir.conn;
230        let stats = update(&mut conn, &proj, dir.path()).unwrap();
231        assert_eq!(stats.files_indexed, 3);
232        assert!(stats.calls_resolved >= 2, "{stats:?}"); // load→fetchUser (import tier), generate→emit
233        assert!(stats.imports >= 1, "app.ts imports api.ts: {stats:?}");
234
235        let graph = CodeGraph::load(&conn, proj.id).unwrap();
236        let fetch = resolve_symbol(&conn, proj.id, "fetchUser").unwrap();
237        let load = resolve_symbol(&conn, proj.id, "load").unwrap();
238        assert!(graph.callers(fetch.id, 2).iter().any(|r| r.id == load.id));
239    }
240
241    /// Each newer language: a file extracts its symbols and resolves a
242    /// same-file call (tier 1), proving the grammar wiring is correct.
243    fn extracts(file: &str, src: &str, expect_symbols: &[&str], caller: &str, callee: &str) {
244        let dir = tempfile::tempdir().unwrap();
245        write(dir.path(), file, src);
246        let mimir = Mimir::open_in_memory().unwrap();
247        let proj = project(&mimir.conn, dir.path());
248        let mut conn = mimir.conn;
249        update(&mut conn, &proj, dir.path()).unwrap();
250        for sym in expect_symbols {
251            assert!(
252                resolve_symbol(&conn, proj.id, sym).is_ok(),
253                "{file}: expected symbol `{sym}`"
254            );
255        }
256        let graph = CodeGraph::load(&conn, proj.id).unwrap();
257        let callee_sym = resolve_symbol(&conn, proj.id, callee).unwrap();
258        let caller_sym = resolve_symbol(&conn, proj.id, caller).unwrap();
259        assert!(
260            graph
261                .callers(callee_sym.id, 2)
262                .iter()
263                .any(|r| r.id == caller_sym.id),
264            "{file}: expected call {caller} -> {callee}"
265        );
266    }
267
268    #[test]
269    fn java_extraction() {
270        extracts(
271            "Main.java",
272            "class Main {\n  void run() { helper(); }\n  int helper() { return 42; }\n}\n",
273            &["Main", "run", "helper"],
274            "run",
275            "helper",
276        );
277    }
278
279    #[test]
280    fn ruby_extraction() {
281        // `helper()` with parens is an unambiguous call; a bare paren-less
282        // `helper` is indistinguishable from a local variable in Ruby and is
283        // deliberately not captured (it would flood the graph with false
284        // edges). Symbols (class/module/method) always extract.
285        extracts(
286            "app.rb",
287            "class App\n  def run\n    helper()\n  end\n  def helper\n    42\n  end\nend\n",
288            &["App", "run", "helper"],
289            "run",
290            "helper",
291        );
292    }
293
294    #[test]
295    fn c_extraction() {
296        extracts(
297            "main.c",
298            "int helper(void) { return 42; }\nint run(void) { return helper(); }\n",
299            &["helper", "run"],
300            "run",
301            "helper",
302        );
303    }
304
305    #[test]
306    fn csharp_extraction() {
307        extracts(
308            "App.cs",
309            "namespace App {\n  class Worker {\n    void Run() { Help(); }\n    void Help() { }\n  }\n}\n",
310            &["Worker", "Run", "Help"],
311            "Run",
312            "Help",
313        );
314    }
315
316    #[test]
317    fn sql_extraction() {
318        // SQL has no calls; the dependency edge (view → table) rides the same
319        // resolver, so `active_orders` resolves as a "caller" of `orders`.
320        extracts(
321            "schema.sql",
322            "CREATE TABLE orders (id INT PRIMARY KEY);\nCREATE VIEW active_orders AS SELECT id FROM orders;\n",
323            &["orders", "active_orders"],
324            "active_orders",
325            "orders",
326        );
327    }
328}