use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Span};
pub trait Extractor {
fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Registry;
impl Extractor for Registry {
fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
match extension(path) {
Some("rs") => RustExtractor.extract(path, blob_id, bytes),
_ => FileNodeExtractor.extract(path, blob_id, bytes),
}
}
}
fn extension(path: &str) -> Option<&str> {
let name = path.rsplit('/').next().unwrap_or(path);
name.rsplit_once('.').map(|(_, ext)| ext)
}
fn file_key(path: &str) -> String {
format!("file:{path}")
}
fn file_node(path: &str, blob_id: &str, bytes: &[u8], lang: Option<&str>) -> Node {
let name = path.rsplit('/').next().unwrap_or(path).to_owned();
let lines = bytes
.iter()
.fold(0usize, |n, &b| n + usize::from(b == b'\n'));
let end = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
Node {
key: file_key(path),
kind: NodeKind::File,
name,
path: Some(path.to_owned()),
lang: lang.map(ToOwned::to_owned),
blob_hash: Some(blob_id.to_owned()),
span: Some(Span::new(0, end)),
meta: serde_json::json!({ "bytes": bytes.len(), "lines": lines }),
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FileNodeExtractor;
impl Extractor for FileNodeExtractor {
fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
FactSet::new().with_node(file_node(path, blob_id, bytes, None))
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RustExtractor;
impl Extractor for RustExtractor {
fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
let mut parser = tree_sitter::Parser::new();
if parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.is_err()
{
return FileNodeExtractor.extract(path, blob_id, bytes);
}
let Some(tree) = parser.parse(bytes, None) else {
return FileNodeExtractor.extract(path, blob_id, bytes);
};
let mut walk = RustWalk {
path,
blob_id,
src: bytes,
nodes: vec![file_node(path, blob_id, bytes, Some("rust"))],
edges: Vec::new(),
};
let root = tree.root_node();
let mut cursor = root.walk();
let children: Vec<_> = root.children(&mut cursor).collect();
for child in children {
walk.visit(child, &[]);
}
walk.nodes.sort_by(|a, b| a.key.cmp(&b.key));
walk.edges.sort_by(|a, b| {
(a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst))
});
FactSet {
nodes: walk.nodes,
edges: walk.edges,
}
}
}
struct Scope {
seg: String,
key: Option<String>,
}
struct RustWalk<'a> {
path: &'a str,
blob_id: &'a str,
src: &'a [u8],
nodes: Vec<Node>,
edges: Vec<Edge>,
}
impl RustWalk<'_> {
fn visit(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
match node.kind() {
"function_item" => self.visit_symbol(node, scope, NodeKind::Fn, true),
"struct_item" | "union_item" => self.visit_symbol(node, scope, NodeKind::Struct, false),
"enum_item" => self.visit_symbol(node, scope, NodeKind::Enum, false),
"trait_item" => self.visit_symbol(node, scope, NodeKind::Trait, false),
"mod_item" => self.visit_symbol(node, scope, NodeKind::Module, false),
"type_item" => self.visit_symbol(node, scope, NodeKind::Other("type".into()), false),
"macro_definition" => {
self.visit_symbol(node, scope, NodeKind::Other("macro".into()), false);
}
"impl_item" => self.visit_impl(node, scope),
"use_declaration" => self.visit_use(node),
_ => self.visit_children(node, scope),
}
}
fn visit_children(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
let mut cursor = node.walk();
let children: Vec<_> = node.named_children(&mut cursor).collect();
for child in children {
self.visit(child, scope);
}
}
fn visit_symbol(
&mut self,
node: tree_sitter::Node,
scope: &[Scope],
kind: NodeKind,
collect_calls: bool,
) {
let Some(name) = self.field_text(node, "name") else {
return self.visit_children(node, scope);
};
let qualified = qualify(scope, &name);
let key = format!("sym:rust:{}#{qualified}", self.path);
let mut meta = serde_json::Map::new();
if collect_calls {
let mut calls = Vec::new();
self.collect_calls(node, &mut calls);
calls.sort();
calls.dedup();
if !calls.is_empty() {
meta.insert("calls".into(), serde_json::Value::from(calls));
}
}
self.nodes.push(Node {
key: key.clone(),
kind,
name,
path: Some(self.path.to_owned()),
lang: Some("rust".to_owned()),
blob_hash: Some(self.blob_id.to_owned()),
span: Some(span(node)),
meta: serde_json::Value::Object(meta),
});
self.link_parent(&key, scope);
let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
self.recurse_body(node, &child_scope);
}
fn visit_impl(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
let type_name = self
.field_text(node, "type")
.unwrap_or_else(|| "impl".to_owned());
let child_scope = extend(scope, &type_name, None);
self.recurse_body(node, &child_scope);
}
fn visit_use(&mut self, node: tree_sitter::Node) {
let Some(arg) = node.child_by_field_name("argument") else {
return;
};
let text: String = self
.text(arg)
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if text.is_empty() {
return;
}
let key = format!("import:rust:{text}");
self.nodes.push(Node {
key: key.clone(),
kind: NodeKind::Other("import".into()),
name: text,
path: None,
lang: Some("rust".to_owned()),
blob_hash: None,
span: None,
meta: serde_json::Value::Null,
});
self.edges
.push(Edge::derived(file_key(self.path), key, EdgeKind::Imports));
}
fn link_parent(&mut self, key: &str, scope: &[Scope]) {
if let Some(parent) = scope.iter().rev().find_map(|s| s.key.as_deref()) {
self.edges.push(Edge::derived(
parent.to_owned(),
key.to_owned(),
EdgeKind::Contains,
));
} else {
self.edges.push(Edge::derived(
file_key(self.path),
key.to_owned(),
EdgeKind::Defines,
));
}
}
fn recurse_body(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
let mut cursor = node.walk();
let children: Vec<_> = node.named_children(&mut cursor).collect();
for child in children {
match child.kind() {
"declaration_list" | "field_declaration_list" | "trait_body" => {
self.visit_children(child, scope);
}
_ => {}
}
}
}
fn collect_calls(&self, node: tree_sitter::Node, out: &mut Vec<String>) {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if child.kind() == "call_expression"
&& let Some(func) = child.child_by_field_name("function")
&& let Some(name) = self.callee_name(func)
{
out.push(name);
}
self.collect_calls(child, out);
}
}
fn callee_name(&self, func: tree_sitter::Node) -> Option<String> {
match func.kind() {
"identifier" => Some(self.text(func).to_owned()),
"scoped_identifier" => func
.child_by_field_name("name")
.map(|n| self.text(n).to_owned()),
"field_expression" => func
.child_by_field_name("field")
.map(|n| self.text(n).to_owned()),
_ => None,
}
}
fn text(&self, node: tree_sitter::Node) -> &str {
node.utf8_text(self.src).unwrap_or("")
}
fn field_text(&self, node: tree_sitter::Node, field: &str) -> Option<String> {
node.child_by_field_name(field)
.map(|n| self.text(n).to_owned())
}
fn simple(&self, node: tree_sitter::Node, field: &str) -> String {
self.field_text(node, field).unwrap_or_default()
}
}
fn span(node: tree_sitter::Node) -> Span {
let start = u32::try_from(node.start_byte()).unwrap_or(u32::MAX);
let end = u32::try_from(node.end_byte()).unwrap_or(u32::MAX);
Span::new(start, end)
}
fn qualify(scope: &[Scope], name: &str) -> String {
let mut parts: Vec<&str> = scope.iter().map(|s| s.seg.as_str()).collect();
parts.push(name);
parts.join("::")
}
fn extend(scope: &[Scope], seg: &str, key: Option<String>) -> Vec<Scope> {
let mut next: Vec<Scope> = scope
.iter()
.map(|s| Scope {
seg: s.seg.clone(),
key: s.key.clone(),
})
.collect();
next.push(Scope {
seg: seg.to_owned(),
key,
});
next
}
#[cfg(test)]
mod tests {
use super::{Extractor, FileNodeExtractor, Registry, RustExtractor};
use crate::{EdgeKind, NodeKind};
#[test]
fn file_node_extractor_is_deterministic_and_tagged() {
let ex = FileNodeExtractor;
let a = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
let b = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
assert_eq!(a, b, "extraction must be deterministic");
assert_eq!(a.nodes.len(), 1);
assert!(a.edges.is_empty());
let node = &a.nodes[0];
assert_eq!(node.key, "file:src/lib.rs");
assert_eq!(node.kind, NodeKind::File);
assert_eq!(node.name, "lib.rs");
assert_eq!(node.blob_hash.as_deref(), Some("abc123"));
assert_eq!(node.meta["lines"], 2);
assert_eq!(node.meta["bytes"], 8);
}
const SAMPLE: &str = r"
use std::path::Path;
pub struct Store;
impl Store {
pub fn open() -> Store {
helper();
Store
}
}
fn helper() {}
mod inner {
pub fn nested() {}
}
";
fn keys(fs: &crate::FactSet) -> Vec<String> {
let mut k: Vec<_> = fs.nodes.iter().map(|n| n.key.clone()).collect();
k.sort();
k
}
#[test]
fn rust_extractor_emits_symbols_and_edges() {
let fs = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
let ks = keys(&fs);
assert!(ks.contains(&"file:src/lib.rs".to_owned()));
assert!(ks.contains(&"sym:rust:src/lib.rs#Store".to_owned()));
assert!(ks.contains(&"sym:rust:src/lib.rs#Store::open".to_owned()));
assert!(ks.contains(&"sym:rust:src/lib.rs#helper".to_owned()));
assert!(ks.contains(&"sym:rust:src/lib.rs#inner".to_owned()));
assert!(ks.contains(&"sym:rust:src/lib.rs#inner::nested".to_owned()));
let open = fs
.nodes
.iter()
.find(|n| n.key == "sym:rust:src/lib.rs#Store::open")
.expect("open node");
assert_eq!(open.meta["calls"], serde_json::json!(["helper"]));
let defines: Vec<_> = fs
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Defines && e.dst == "sym:rust:src/lib.rs#helper")
.collect();
assert_eq!(defines.len(), 1);
assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Contains
&& e.src == "sym:rust:src/lib.rs#inner"
&& e.dst == "sym:rust:src/lib.rs#inner::nested"));
assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
&& e.src == "file:src/lib.rs"
&& e.dst == "import:rust:std::path::Path"));
}
#[test]
fn rust_extraction_is_deterministic() {
let a = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
let b = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
assert_eq!(a, b);
}
#[test]
fn registry_dispatches_by_extension() {
let rs = Registry.extract("src/lib.rs", "b", SAMPLE.as_bytes());
assert!(rs.nodes.len() > 1, "rust file yields symbols");
let txt = Registry.extract("notes.txt", "b", b"hello\n");
assert_eq!(
txt.nodes.len(),
1,
"non-code file falls back to a file node"
);
assert_eq!(txt.nodes[0].kind, NodeKind::File);
}
}