use std::path::Path;
use tree_sitter::{Node, Parser};
use super::ParseMode;
use crate::payload::{ParsedFilePayload, RawScope, RawSymbol};
pub struct RustAstParser;
impl RustAstParser {
pub fn parse(
relative_path: &Path,
content: &[u8],
content_hash: &str,
mode: ParseMode,
) -> anyhow::Result<ParsedFilePayload> {
let source_str = std::str::from_utf8(content)
.map_err(|e| anyhow::anyhow!("Invalid UTF-8 in {}: {e}", relative_path.display()))?;
let mut parser = Parser::new();
let language = tree_sitter_rust::LANGUAGE;
parser
.set_language(&language.into())
.map_err(|e| anyhow::anyhow!("Failed to set Rust tree-sitter language: {e}"))?;
let tree = parser.parse(content, None).ok_or_else(|| {
anyhow::anyhow!("Tree-sitter failed to parse {}", relative_path.display())
})?;
let base_module = match &mode {
ParseMode::Workspace => module_path_for(relative_path),
ParseMode::DependencyPublicSurface { crate_name } => {
dependency_module_path_for(relative_path, crate_name)
}
};
let mut extractor = AstExtractor::new(source_str, base_module, mode);
extractor.traverse(tree.root_node(), None, &[]);
let line_count = source_str.lines().count() as u32;
Ok(ParsedFilePayload {
relative_path: relative_path.to_path_buf(),
content_hash: content_hash.to_string(),
language: "rust".to_string(),
line_count: if line_count == 0 { 1 } else { line_count },
byte_size: content.len(),
scopes: extractor.scopes,
symbols: extractor.symbols,
references: Vec::new(),
edges: Vec::new(),
})
}
}
const TARGET_DIRS: [&str; 4] = ["src", "tests", "benches", "examples"];
pub fn crate_root_for(rel_path: &Path) -> String {
module_parts(rel_path).0
}
pub fn module_path_for(rel_path: &Path) -> String {
let (root, modules) = module_parts(rel_path);
std::iter::once(root)
.chain(modules)
.collect::<Vec<_>>()
.join("::")
}
pub fn dependency_module_path_for(rel_path: &Path, crate_name: &str) -> String {
let root = crate_name.replace('-', "_");
let parts: Vec<String> = rel_path
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let after_src: &[String] = if let Some(idx) = parts.iter().position(|p| p == "src") {
&parts[idx + 1..]
} else {
&parts
};
let modules: Vec<String> = after_src
.iter()
.filter_map(|part| match part.strip_suffix(".rs") {
Some("mod" | "lib" | "main") => None,
Some(stem) => Some(stem.to_string()),
None => Some(part.clone()),
})
.collect();
if modules.is_empty() {
root
} else {
std::iter::once(root)
.chain(modules)
.collect::<Vec<_>>()
.join("::")
}
}
fn module_parts(rel_path: &Path) -> (String, Vec<String>) {
let parts: Vec<String> = rel_path
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let file_modules = |rest: &[String]| -> Vec<String> {
rest.iter()
.filter_map(|part| match part.strip_suffix(".rs") {
Some("mod" | "lib" | "main") => None,
Some(stem) => Some(stem.to_string()),
None => Some(part.clone()),
})
.collect()
};
let dirs = &parts[..parts.len().saturating_sub(1)];
let Some(idx) = dirs.iter().position(|p| TARGET_DIRS.contains(&p.as_str())) else {
return match parts.split_first() {
Some((first, rest)) => (first.clone(), file_modules(rest)),
None => ("crate".to_string(), Vec::new()),
};
};
let target = parts[idx].as_str();
let root = match idx {
0 if target == "src" => "crate".to_string(),
0 if target == "tests" => "test".to_string(),
0 => target.to_string(),
_ => {
let pkg = parts[idx - 1].replace('-', "_");
if target == "src" {
pkg
} else {
format!("{pkg}::{target}")
}
}
};
(root, file_modules(&parts[idx + 1..]))
}
struct AstExtractor<'a> {
source: &'a str,
base_module: String,
mode: ParseMode,
scopes: Vec<RawScope>,
symbols: Vec<RawSymbol>,
}
impl<'a> AstExtractor<'a> {
fn new(source: &'a str, base_module: String, mode: ParseMode) -> Self {
Self {
source,
base_module,
mode,
scopes: Vec::new(),
symbols: Vec::new(),
}
}
fn add_scope(&mut self, kind: &str, node: Node, parent_scope: Option<usize>) -> usize {
let local_id = self.scopes.len();
self.scopes.push(RawScope {
local_id,
parent_local_id: parent_scope,
scope_kind: kind.to_string(),
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
local_id
}
fn extract_docstring(&self, node: Node) -> Option<String> {
let mut docs = Vec::new();
let start_byte = node.start_byte();
let preceding_text = &self.source[..start_byte];
let lines: Vec<&str> = preceding_text.lines().collect();
let mut doc_lines = Vec::new();
for line in lines.iter().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
if !doc_lines.is_empty() {
break;
}
continue;
}
if trimmed.starts_with("///") {
let content = trimmed
.strip_prefix("///")
.unwrap_or("")
.trim_start_matches(' ');
doc_lines.push(content.to_string());
} else if trimmed.starts_with("#[") || trimmed.starts_with("//") {
continue;
} else {
break;
}
}
doc_lines.reverse();
if !doc_lines.is_empty() {
docs.push(doc_lines.join("\n"));
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "line_comment" {
let text = self.get_node_text(child).trim();
if text.starts_with("//!") {
let content = text
.strip_prefix("//!")
.unwrap_or("")
.trim_start_matches(' ');
docs.push(content.to_string());
}
}
}
if docs.is_empty() {
None
} else {
Some(docs.join("\n").trim().to_string())
}
}
fn get_node_text(&self, node: Node) -> &'a str {
&self.source[node.start_byte()..node.end_byte()]
}
fn extract_visibility(&self, node: Node) -> String {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "visibility_modifier" {
let vis_text = self.get_node_text(child).trim();
if vis_text == "pub" {
return "public".to_string();
} else if vis_text.contains("crate")
|| vis_text.contains("super")
|| vis_text.contains("in ")
{
return "crate".to_string();
} else {
return "public".to_string();
}
}
}
"private".to_string()
}
fn extract_signature(&self, node: Node) -> String {
let text = self.get_node_text(node);
let sig_end = text
.find('{')
.or_else(|| text.find(';'))
.unwrap_or(text.len());
let sig = &text[..sig_end];
let mut lines = Vec::new();
for line in sig.lines() {
let trimmed = line.trim();
if trimmed.starts_with("#[") || trimmed.starts_with("///") || trimmed.starts_with("//")
{
continue;
}
lines.push(trimmed);
}
let condensed = lines.join(" ");
condensed
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
fn make_qualified_name(&self, name: &str, qualifiers: &[String]) -> String {
let mut parts = vec![self.base_module.clone()];
parts.extend_from_slice(qualifiers);
parts.push(name.to_string());
parts.join("::")
}
fn traverse(&mut self, node: Node, current_scope: Option<usize>, qualifiers: &[String]) {
let is_dep_mode = self.mode.is_dependency_public_surface();
match node.kind() {
"function_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("anonymous_fn");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "fn".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
if is_dep_mode {
return;
}
let fn_scope = self.add_scope("function", node, current_scope);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "parameters" || child.kind() == "visibility_modifier" {
continue;
}
self.traverse(child, Some(fn_scope), qualifiers);
}
}
"struct_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("AnonymousStruct");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "struct".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
if is_dep_mode {
return;
}
let struct_scope = self.add_scope("class", node, current_scope);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, Some(struct_scope), qualifiers);
}
}
"enum_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("AnonymousEnum");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "enum".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
if is_dep_mode {
return;
}
let enum_scope = self.add_scope("class", node, current_scope);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, Some(enum_scope), qualifiers);
}
}
"union_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("AnonymousUnion");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "struct".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
if is_dep_mode {
return;
}
let union_scope = self.add_scope("class", node, current_scope);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, Some(union_scope), qualifiers);
}
}
"trait_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("AnonymousTrait");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "trait".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
if is_dep_mode {
return;
}
let trait_scope = self.add_scope("class", node, current_scope);
let mut new_qualifiers = qualifiers.to_vec();
new_qualifiers.push(name.to_string());
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, Some(trait_scope), &new_qualifiers);
}
}
"impl_item" => {
let target_type = node
.child_by_field_name("type")
.map(|n| self.get_node_text(n).trim())
.unwrap_or("Unknown");
let target_type_clean = target_type.split('<').next().unwrap_or(target_type).trim();
let impl_scope = if !is_dep_mode {
Some(self.add_scope("class", node, current_scope))
} else {
None
};
let mut new_qualifiers = qualifiers.to_vec();
if !target_type_clean.is_empty() && target_type_clean != "Unknown" {
new_qualifiers.push(target_type_clean.to_string());
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, impl_scope, &new_qualifiers);
}
}
"mod_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("anonymous_mod");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "mod".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
if let Some(body) = node.child_by_field_name("body") {
let mod_scope = if !is_dep_mode {
Some(self.add_scope("module", node, current_scope))
} else {
None
};
let mut new_qualifiers = qualifiers.to_vec();
new_qualifiers.push(name.to_string());
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
self.traverse(child, mod_scope, &new_qualifiers);
}
}
}
"type_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("AnonymousType");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "type".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
}
"const_item" | "static_item" => {
let name = node
.child_by_field_name("name")
.map(|n| self.get_node_text(n))
.unwrap_or("ANONYMOUS_CONST");
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let qualified_name = self.make_qualified_name(name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: name.to_string(),
qualified_name,
kind: "const".to_string(),
visibility,
signature,
docstring,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
});
}
"use_declaration" => {
let visibility = self.extract_visibility(node);
if is_dep_mode && visibility != "public" {
return;
}
let signature = self.extract_signature(node);
let docstring = self.extract_docstring(node);
let items = extract_use_items(self.source, node);
for item in items {
let qualified_name = self.make_qualified_name(&item.local_name, qualifiers);
self.symbols.push(RawSymbol {
scope_local_id: current_scope,
name: item.local_name,
qualified_name,
kind: "reexport".to_string(),
visibility: visibility.clone(),
signature: signature.clone(),
docstring: docstring.clone(),
start_byte: item.start_byte,
end_byte: item.end_byte,
start_line: item.start_line,
end_line: item.end_line,
});
}
}
"closure_expression" => {
if is_dep_mode {
return;
}
let closure_scope = self.add_scope("closure", node, current_scope);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, Some(closure_scope), qualifiers);
}
}
"block" => {
if is_dep_mode {
return;
}
let is_direct_body = node.parent().is_some_and(|p| {
p.kind() == "function_item" || p.kind() == "closure_expression"
});
if is_direct_body {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, current_scope, qualifiers);
}
} else {
let block_scope = self.add_scope("block", node, current_scope);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, Some(block_scope), qualifiers);
}
}
}
_ => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
self.traverse(child, current_scope, qualifiers);
}
}
}
}
}
pub fn extract_type_members(source: &str, type_name: &str) -> Vec<String> {
let mut parser = Parser::new();
if parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.is_err()
{
return Vec::new();
}
let Some(tree) = parser.parse(source, None) else {
return Vec::new();
};
let text = |node: Node| &source[node.start_byte()..node.end_byte()];
let mut members = Vec::new();
let mut found_decl = false;
let mut impls = Vec::new();
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
match node.kind() {
"struct_item" | "union_item" | "enum_item" | "trait_item"
if !found_decl && node.child_by_field_name("name").map(text) == Some(type_name) =>
{
found_decl = true;
if let Some(body) = node.child_by_field_name("body") {
collect_body_members(body, source, &mut members);
}
}
"impl_item" => {
let impl_type = node.child_by_field_name("type").map(|t| {
let t = text(t);
t.split('<').next().unwrap_or(t).trim()
});
let impl_type = impl_type.map(|t| t.rsplit("::").next().unwrap_or(t));
if impl_type == Some(type_name)
&& let Some(tr) = node.child_by_field_name("trait")
{
impls.push(format!("impl: {}", condense(text(tr))));
}
}
_ => {
let mut cursor = node.walk();
stack.extend(node.children(&mut cursor));
}
}
}
if !found_decl {
return Vec::new();
}
members.extend(impls);
members
}
fn collect_body_members(body: Node, source: &str, members: &mut Vec<String>) {
let text = |node: Node| &source[node.start_byte()..node.end_byte()];
let mut tuple_index = 0;
let mut cursor = body.walk();
for child in body.named_children(&mut cursor) {
let entry = match child.kind() {
"field_declaration" => format!("field: {}", condense(text(child))),
"enum_variant" => format!("variant: {}", condense(text(child))),
"function_signature_item" | "function_item" => {
let sig = text(child);
let sig = sig.split('{').next().unwrap_or(sig).trim_end_matches(';');
format!("method: {}", condense(sig))
}
"associated_type" => format!("type: {}", condense(text(child))),
"const_item" => format!("const: {}", condense(text(child))),
"visibility_modifier" => continue,
kind if body.kind() == "ordered_field_declaration_list" && kind != "attribute_item" => {
let entry = format!("field: {tuple_index}: {}", condense(text(child)));
tuple_index += 1;
entry
}
_ => continue,
};
members.push(entry);
}
}
fn condense(text: &str) -> String {
text.lines()
.map(|line| match line.find("//") {
Some(idx) => &line[..idx],
None => line,
})
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("#["))
.collect::<Vec<_>>()
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace(", }", " }")
.replace(", )", ")")
.trim_end_matches(',')
.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedUseItem {
pub local_name: String,
pub target_path: String,
pub start_byte: usize,
pub end_byte: usize,
pub start_line: u32,
pub end_line: u32,
pub col_number: u32,
}
pub fn extract_use_items(source: &str, use_node: Node) -> Vec<ExtractedUseItem> {
let mut items = Vec::new();
fn collect_items(source: &str, node: Node, prefix: &str, items: &mut Vec<ExtractedUseItem>) {
let text = |n: Node| &source[n.start_byte()..n.end_byte()];
match node.kind() {
"use_declaration" => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "visibility_modifier"
&& child.kind() != "use"
&& child.kind() != ";"
{
collect_items(source, child, prefix, items);
}
}
}
"scoped_use_list" => {
let path_node = node.child_by_field_name("path");
let list_node = node.child_by_field_name("list");
let base = if let Some(p) = path_node {
let p_txt = text(p);
if prefix.is_empty() {
p_txt.to_string()
} else {
format!("{prefix}::{p_txt}")
}
} else {
prefix.to_string()
};
if let Some(list) = list_node {
collect_items(source, list, &base, items);
}
}
"use_list" => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "{" && child.kind() != "}" && child.kind() != "," {
collect_items(source, child, prefix, items);
}
}
}
"use_as_clause" => {
let path_node = node.child_by_field_name("path");
let alias_node = node.child_by_field_name("alias");
if let (Some(path), Some(alias)) = (path_node, alias_node) {
let target_sub = text(path);
let target_path = if prefix.is_empty() {
target_sub.to_string()
} else {
format!("{prefix}::{target_sub}")
};
let local_name = text(alias).to_string();
items.push(ExtractedUseItem {
local_name,
target_path,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
col_number: node.start_position().column as u32,
});
}
}
"scoped_identifier" => {
let target_sub = text(node);
let target_path = if prefix.is_empty() {
target_sub.to_string()
} else {
format!("{prefix}::{target_sub}")
};
let local_name = node
.child_by_field_name("name")
.map(|n| text(n).to_string())
.unwrap_or_else(|| {
target_sub
.rsplit("::")
.next()
.unwrap_or(target_sub)
.to_string()
});
items.push(ExtractedUseItem {
local_name,
target_path,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
col_number: node.start_position().column as u32,
});
}
"identifier" => {
let id_text = text(node).to_string();
let target_path = if prefix.is_empty() {
id_text.clone()
} else {
format!("{prefix}::{id_text}")
};
items.push(ExtractedUseItem {
local_name: id_text,
target_path,
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
col_number: node.start_position().column as u32,
});
}
"self" => {
let local_name = prefix.rsplit("::").next().unwrap_or("self").to_string();
items.push(ExtractedUseItem {
local_name,
target_path: prefix.to_string(),
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_line: (node.start_position().row + 1) as u32,
end_line: (node.end_position().row + 1) as u32,
col_number: node.start_position().column as u32,
});
}
"use_wildcard" => {}
_ => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_items(source, child, prefix, items);
}
}
}
}
collect_items(source, use_node, "", &mut items);
items
}