1use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Span};
16
17pub trait Extractor {
19 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
24}
25
26#[derive(Debug, Clone, Copy, Default)]
29pub struct Registry;
30
31impl Extractor for Registry {
32 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
33 match extension(path) {
34 Some("rs") => RustExtractor.extract(path, blob_id, bytes),
35 _ => FileNodeExtractor.extract(path, blob_id, bytes),
36 }
37 }
38}
39
40fn extension(path: &str) -> Option<&str> {
42 let name = path.rsplit('/').next().unwrap_or(path);
43 name.rsplit_once('.').map(|(_, ext)| ext)
44}
45
46fn file_key(path: &str) -> String {
48 format!("file:{path}")
49}
50
51fn file_node(path: &str, blob_id: &str, bytes: &[u8], lang: Option<&str>) -> Node {
53 let name = path.rsplit('/').next().unwrap_or(path).to_owned();
54 let lines = bytes
55 .iter()
56 .fold(0usize, |n, &b| n + usize::from(b == b'\n'));
57 let end = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
58 Node {
59 key: file_key(path),
60 kind: NodeKind::File,
61 name,
62 path: Some(path.to_owned()),
63 lang: lang.map(ToOwned::to_owned),
64 blob_hash: Some(blob_id.to_owned()),
65 span: Some(Span::new(0, end)),
66 meta: serde_json::json!({ "bytes": bytes.len(), "lines": lines }),
67 }
68}
69
70#[derive(Debug, Clone, Copy, Default)]
74pub struct FileNodeExtractor;
75
76impl Extractor for FileNodeExtractor {
77 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
78 FactSet::new().with_node(file_node(path, blob_id, bytes, None))
79 }
80}
81
82#[derive(Debug, Clone, Copy, Default)]
88pub struct RustExtractor;
89
90impl Extractor for RustExtractor {
91 fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
92 let mut parser = tree_sitter::Parser::new();
93 if parser
96 .set_language(&tree_sitter_rust::LANGUAGE.into())
97 .is_err()
98 {
99 return FileNodeExtractor.extract(path, blob_id, bytes);
100 }
101 let Some(tree) = parser.parse(bytes, None) else {
102 return FileNodeExtractor.extract(path, blob_id, bytes);
103 };
104
105 let mut walk = RustWalk {
106 path,
107 blob_id,
108 src: bytes,
109 nodes: vec![file_node(path, blob_id, bytes, Some("rust"))],
110 edges: Vec::new(),
111 };
112 let root = tree.root_node();
113 let mut cursor = root.walk();
114 let children: Vec<_> = root.children(&mut cursor).collect();
115 for child in children {
116 walk.visit(child, &[]);
117 }
118
119 walk.nodes.sort_by(|a, b| a.key.cmp(&b.key));
122 walk.edges.sort_by(|a, b| {
123 (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst))
124 });
125 FactSet {
126 nodes: walk.nodes,
127 edges: walk.edges,
128 }
129 }
130}
131
132struct Scope {
136 seg: String,
137 key: Option<String>,
138}
139
140struct RustWalk<'a> {
142 path: &'a str,
143 blob_id: &'a str,
144 src: &'a [u8],
145 nodes: Vec<Node>,
146 edges: Vec<Edge>,
147}
148
149impl RustWalk<'_> {
150 fn visit(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
152 match node.kind() {
153 "function_item" => self.visit_symbol(node, scope, NodeKind::Fn, true),
154 "struct_item" | "union_item" => self.visit_symbol(node, scope, NodeKind::Struct, false),
155 "enum_item" => self.visit_symbol(node, scope, NodeKind::Enum, false),
156 "trait_item" => self.visit_symbol(node, scope, NodeKind::Trait, false),
157 "mod_item" => self.visit_symbol(node, scope, NodeKind::Module, false),
158 "type_item" => self.visit_symbol(node, scope, NodeKind::Other("type".into()), false),
159 "macro_definition" => {
160 self.visit_symbol(node, scope, NodeKind::Other("macro".into()), false);
161 }
162 "impl_item" => self.visit_impl(node, scope),
163 "use_declaration" => self.visit_use(node),
164 _ => self.visit_children(node, scope),
167 }
168 }
169
170 fn visit_children(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
172 let mut cursor = node.walk();
173 let children: Vec<_> = node.named_children(&mut cursor).collect();
174 for child in children {
175 self.visit(child, scope);
176 }
177 }
178
179 fn visit_symbol(
182 &mut self,
183 node: tree_sitter::Node,
184 scope: &[Scope],
185 kind: NodeKind,
186 collect_calls: bool,
187 ) {
188 let Some(name) = self.field_text(node, "name") else {
189 return self.visit_children(node, scope);
190 };
191 let qualified = qualify(scope, &name);
192 let key = format!("sym:rust:{}#{qualified}", self.path);
193
194 let mut meta = serde_json::Map::new();
195 if collect_calls {
196 let mut calls = Vec::new();
197 self.collect_calls(node, &mut calls);
198 calls.sort();
199 calls.dedup();
200 if !calls.is_empty() {
201 meta.insert("calls".into(), serde_json::Value::from(calls));
202 }
203 }
204
205 self.nodes.push(Node {
206 key: key.clone(),
207 kind,
208 name,
209 path: Some(self.path.to_owned()),
210 lang: Some("rust".to_owned()),
211 blob_hash: Some(self.blob_id.to_owned()),
212 span: Some(span(node)),
213 meta: serde_json::Value::Object(meta),
214 });
215 self.link_parent(&key, scope);
216
217 let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
220 self.recurse_body(node, &child_scope);
221 }
222
223 fn visit_impl(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
226 let type_name = self
227 .field_text(node, "type")
228 .unwrap_or_else(|| "impl".to_owned());
229 let child_scope = extend(scope, &type_name, None);
230 self.recurse_body(node, &child_scope);
231 }
232
233 fn visit_use(&mut self, node: tree_sitter::Node) {
236 let Some(arg) = node.child_by_field_name("argument") else {
237 return;
238 };
239 let text: String = self
240 .text(arg)
241 .chars()
242 .filter(|c| !c.is_whitespace())
243 .collect();
244 if text.is_empty() {
245 return;
246 }
247 let key = format!("import:rust:{text}");
248 self.nodes.push(Node {
249 key: key.clone(),
250 kind: NodeKind::Other("import".into()),
251 name: text,
252 path: None,
253 lang: Some("rust".to_owned()),
254 blob_hash: None,
255 span: None,
256 meta: serde_json::Value::Null,
257 });
258 self.edges
259 .push(Edge::derived(file_key(self.path), key, EdgeKind::Imports));
260 }
261
262 fn link_parent(&mut self, key: &str, scope: &[Scope]) {
265 if let Some(parent) = scope.iter().rev().find_map(|s| s.key.as_deref()) {
266 self.edges.push(Edge::derived(
267 parent.to_owned(),
268 key.to_owned(),
269 EdgeKind::Contains,
270 ));
271 } else {
272 self.edges.push(Edge::derived(
273 file_key(self.path),
274 key.to_owned(),
275 EdgeKind::Defines,
276 ));
277 }
278 }
279
280 fn recurse_body(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
282 let mut cursor = node.walk();
283 let children: Vec<_> = node.named_children(&mut cursor).collect();
284 for child in children {
285 match child.kind() {
286 "declaration_list" | "field_declaration_list" | "trait_body" => {
287 self.visit_children(child, scope);
288 }
289 _ => {}
290 }
291 }
292 }
293
294 fn collect_calls(&self, node: tree_sitter::Node, out: &mut Vec<String>) {
297 let mut cursor = node.walk();
298 for child in node.named_children(&mut cursor) {
299 if child.kind() == "call_expression"
300 && let Some(func) = child.child_by_field_name("function")
301 && let Some(name) = self.callee_name(func)
302 {
303 out.push(name);
304 }
305 self.collect_calls(child, out);
306 }
307 }
308
309 fn callee_name(&self, func: tree_sitter::Node) -> Option<String> {
312 match func.kind() {
313 "identifier" => Some(self.text(func).to_owned()),
314 "scoped_identifier" => func
315 .child_by_field_name("name")
316 .map(|n| self.text(n).to_owned()),
317 "field_expression" => func
318 .child_by_field_name("field")
319 .map(|n| self.text(n).to_owned()),
320 _ => None,
321 }
322 }
323
324 fn text(&self, node: tree_sitter::Node) -> &str {
325 node.utf8_text(self.src).unwrap_or("")
326 }
327
328 fn field_text(&self, node: tree_sitter::Node, field: &str) -> Option<String> {
329 node.child_by_field_name(field)
330 .map(|n| self.text(n).to_owned())
331 }
332
333 fn simple(&self, node: tree_sitter::Node, field: &str) -> String {
334 self.field_text(node, field).unwrap_or_default()
335 }
336}
337
338fn span(node: tree_sitter::Node) -> Span {
340 let start = u32::try_from(node.start_byte()).unwrap_or(u32::MAX);
341 let end = u32::try_from(node.end_byte()).unwrap_or(u32::MAX);
342 Span::new(start, end)
343}
344
345fn qualify(scope: &[Scope], name: &str) -> String {
347 let mut parts: Vec<&str> = scope.iter().map(|s| s.seg.as_str()).collect();
348 parts.push(name);
349 parts.join("::")
350}
351
352fn extend(scope: &[Scope], seg: &str, key: Option<String>) -> Vec<Scope> {
354 let mut next: Vec<Scope> = scope
355 .iter()
356 .map(|s| Scope {
357 seg: s.seg.clone(),
358 key: s.key.clone(),
359 })
360 .collect();
361 next.push(Scope {
362 seg: seg.to_owned(),
363 key,
364 });
365 next
366}
367
368#[cfg(test)]
369mod tests {
370 use super::{Extractor, FileNodeExtractor, Registry, RustExtractor};
371 use crate::{EdgeKind, NodeKind};
372
373 #[test]
374 fn file_node_extractor_is_deterministic_and_tagged() {
375 let ex = FileNodeExtractor;
376 let a = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
377 let b = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
378 assert_eq!(a, b, "extraction must be deterministic");
379
380 assert_eq!(a.nodes.len(), 1);
381 assert!(a.edges.is_empty());
382 let node = &a.nodes[0];
383 assert_eq!(node.key, "file:src/lib.rs");
384 assert_eq!(node.kind, NodeKind::File);
385 assert_eq!(node.name, "lib.rs");
386 assert_eq!(node.blob_hash.as_deref(), Some("abc123"));
387 assert_eq!(node.meta["lines"], 2);
388 assert_eq!(node.meta["bytes"], 8);
389 }
390
391 const SAMPLE: &str = r"
392use std::path::Path;
393
394pub struct Store;
395
396impl Store {
397 pub fn open() -> Store {
398 helper();
399 Store
400 }
401}
402
403fn helper() {}
404
405mod inner {
406 pub fn nested() {}
407}
408";
409
410 fn keys(fs: &crate::FactSet) -> Vec<String> {
411 let mut k: Vec<_> = fs.nodes.iter().map(|n| n.key.clone()).collect();
412 k.sort();
413 k
414 }
415
416 #[test]
417 fn rust_extractor_emits_symbols_and_edges() {
418 let fs = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
419 let ks = keys(&fs);
420 assert!(ks.contains(&"file:src/lib.rs".to_owned()));
421 assert!(ks.contains(&"sym:rust:src/lib.rs#Store".to_owned()));
422 assert!(ks.contains(&"sym:rust:src/lib.rs#Store::open".to_owned()));
423 assert!(ks.contains(&"sym:rust:src/lib.rs#helper".to_owned()));
424 assert!(ks.contains(&"sym:rust:src/lib.rs#inner".to_owned()));
425 assert!(ks.contains(&"sym:rust:src/lib.rs#inner::nested".to_owned()));
426
427 let open = fs
429 .nodes
430 .iter()
431 .find(|n| n.key == "sym:rust:src/lib.rs#Store::open")
432 .expect("open node");
433 assert_eq!(open.meta["calls"], serde_json::json!(["helper"]));
434
435 let defines: Vec<_> = fs
437 .edges
438 .iter()
439 .filter(|e| e.kind == EdgeKind::Defines && e.dst == "sym:rust:src/lib.rs#helper")
440 .collect();
441 assert_eq!(defines.len(), 1);
442 assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Contains
443 && e.src == "sym:rust:src/lib.rs#inner"
444 && e.dst == "sym:rust:src/lib.rs#inner::nested"));
445
446 assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
448 && e.src == "file:src/lib.rs"
449 && e.dst == "import:rust:std::path::Path"));
450 }
451
452 #[test]
453 fn rust_extraction_is_deterministic() {
454 let a = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
455 let b = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
456 assert_eq!(a, b);
457 }
458
459 #[test]
460 fn registry_dispatches_by_extension() {
461 let rs = Registry.extract("src/lib.rs", "b", SAMPLE.as_bytes());
462 assert!(rs.nodes.len() > 1, "rust file yields symbols");
463 let txt = Registry.extract("notes.txt", "b", b"hello\n");
464 assert_eq!(
465 txt.nodes.len(),
466 1,
467 "non-code file falls back to a file node"
468 );
469 assert_eq!(txt.nodes[0].kind, NodeKind::File);
470 }
471}