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