Skip to main content

rto_graph/
extract.rs

1//! Extraction: turning the bytes of a source blob into a [`FactSet`].
2//!
3//! Extraction must be a deterministic pure function of `(path, blob_id, bytes)`
4//! so its output can be cached; because the facts are path-dependent (node keys
5//! are path-scoped), the cache is keyed by both path and blob id (see
6//! [`crate::sync`]). [`Registry`] dispatches by file extension to a
7//! language-aware extractor ([`RustExtractor`]), falling back to
8//! [`FileNodeExtractor`] for files with no registered language.
9//!
10//! Language extractors emit `defines`/`contains`/`imports` edges directly, and
11//! record each function's callee names in the caller node's `meta.calls`. Call
12//! *edges* are resolved later, at assembly time, once every file's symbols are
13//! known (see [`crate::sync`]) — a single blob cannot resolve cross-file calls.
14
15use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Span};
16
17/// Turns one source blob into the nodes and edges derived from it.
18pub trait Extractor {
19    /// Extract a [`FactSet`] from a blob's `path`, git `blob_id`, and `bytes`.
20    ///
21    /// Implementations must be deterministic: identical inputs must always
22    /// produce an identical fact set.
23    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
24}
25
26/// Dispatches extraction to a language-aware extractor by file extension,
27/// falling back to [`FileNodeExtractor`] when no language is registered.
28#[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
40/// Lowercase file extension of `path`, if any.
41fn extension(path: &str) -> Option<&str> {
42    let name = path.rsplit('/').next().unwrap_or(path);
43    name.rsplit_once('.').map(|(_, ext)| ext)
44}
45
46/// The natural key of the `file` node for `path`.
47fn file_key(path: &str) -> String {
48    format!("file:{path}")
49}
50
51/// Build the shared `file` node for a source blob.
52fn 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/// Fallback extractor: emits a single `file` node per blob, tagged with its blob
71/// hash and basic size metadata. Produces no edges. Used for files with no
72/// registered language.
73#[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/// Derived extractor for Rust source, backed by tree-sitter. Emits a `file`
83/// node, one symbol node per `fn`/`struct`/`enum`/`trait`/`mod` (and a few
84/// others) with `defines`/`contains` edges reflecting lexical nesting, and
85/// `imports` edges for `use` declarations. Each function records the simple
86/// names it calls in `meta.calls` for later cross-file resolution.
87#[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        // The Rust grammar is compiled in, so this only fails on a version
94        // mismatch — a build-time invariant, not a runtime input error.
95        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        // Deterministic ordering so the cached fact set is byte-stable
120        // regardless of traversal incidentals.
121        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
132/// One entry on the lexical scope stack: a name segment and, when the scope is
133/// itself an emitted symbol, that symbol's key (impl blocks contribute a segment
134/// but no node, so their `key` is `None`).
135struct Scope {
136    seg: String,
137    key: Option<String>,
138}
139
140/// Accumulating state for a single Rust file walk.
141struct 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    /// Visit one AST node under the given lexical scope stack.
151    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            // Recurse through unnamed structural wrappers (e.g. the top-level
165            // `declaration_list` of a module handled in `visit_symbol`).
166            _ => self.visit_children(node, scope),
167        }
168    }
169
170    /// Visit every named child of `node` under the same scope.
171    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    /// Emit a symbol node for a named definition, link it to its containing
180    /// scope, and recurse into its body for nested definitions.
181    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        // Recurse into the body so nested items (a fn in a mod, etc.) are found,
218        // pushing this symbol onto the scope stack.
219        let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
220        self.recurse_body(node, &child_scope);
221    }
222
223    /// An `impl` block emits no node but contributes its type name as a scope
224    /// segment, so methods qualify as `Type::method`.
225    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    /// Record a `use` declaration as an `imports` edge from the file to an
234    /// import-target node keyed by the (whitespace-normalised) import path.
235    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    /// Link a freshly-emitted symbol to its nearest enclosing emitted scope:
263    /// `contains` from that symbol, or `defines` from the file at top level.
264    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    /// Recurse into the `declaration_list` / body of a definition.
281    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    /// Collect the simple names of functions called anywhere within `node`'s
295    /// subtree (used for later call resolution).
296    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    /// The simple callee name for a `call_expression`'s function child:
310    /// `foo()` → `foo`, `a::b::foo()` → `foo`, `x.foo()` → `foo`.
311    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
338/// Byte span of an AST node, clamped to `u32`.
339fn 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
345/// Qualified name for a new symbol: all enclosing scope segments plus `name`.
346fn 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
352/// Push a scope entry, returning the extended stack.
353fn 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        // `open` records that it calls `helper`.
428        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        // file defines top-level items; a module contains its nested fn.
436        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        // the `use` becomes an imports edge.
447        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}