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