1pub 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
26pub 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
60pub 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:?}"); 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 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 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 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 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 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:?}"); 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 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 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 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}