CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
636 lines
22 KiB
Rust
636 lines
22 KiB
Rust
use std::path::Path;
|
|
|
|
use compliance_core::error::CoreError;
|
|
use compliance_core::models::graph::{CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind};
|
|
use compliance_core::traits::graph_builder::{LanguageParser, ParseOutput};
|
|
use tree_sitter::{Node, Parser};
|
|
|
|
pub struct RustParser;
|
|
|
|
impl Default for RustParser {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl RustParser {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
fn walk_tree(
|
|
&self,
|
|
node: Node<'_>,
|
|
source: &str,
|
|
file_path: &str,
|
|
repo_id: &str,
|
|
graph_build_id: &str,
|
|
parent_qualified: Option<&str>,
|
|
output: &mut ParseOutput,
|
|
) {
|
|
match node.kind() {
|
|
"function_item" | "function_signature_item" => {
|
|
if let Some(name_node) = node.child_by_field_name("name") {
|
|
let name = &source[name_node.byte_range()];
|
|
let qualified = match parent_qualified {
|
|
Some(p) => format!("{p}::{name}"),
|
|
None => format!("{file_path}::{name}"),
|
|
};
|
|
|
|
let is_entry = name == "main"
|
|
|| self.has_attribute(&node, source, "test")
|
|
|| self.has_attribute(&node, source, "tokio::main")
|
|
|| self.has_pub_visibility(&node, source);
|
|
|
|
output.nodes.push(CodeNode {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
qualified_name: qualified.clone(),
|
|
name: name.to_string(),
|
|
kind: CodeNodeKind::Function,
|
|
file_path: file_path.to_string(),
|
|
start_line: node.start_position().row as u32 + 1,
|
|
end_line: node.end_position().row as u32 + 1,
|
|
language: "rust".to_string(),
|
|
community_id: None,
|
|
is_entry_point: is_entry,
|
|
graph_index: None,
|
|
});
|
|
|
|
// Extract function calls within the body
|
|
if let Some(body) = node.child_by_field_name("body") {
|
|
self.extract_calls(
|
|
body,
|
|
source,
|
|
file_path,
|
|
repo_id,
|
|
graph_build_id,
|
|
&qualified,
|
|
output,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
"struct_item" => {
|
|
if let Some(name_node) = node.child_by_field_name("name") {
|
|
let name = &source[name_node.byte_range()];
|
|
let qualified = match parent_qualified {
|
|
Some(p) => format!("{p}::{name}"),
|
|
None => format!("{file_path}::{name}"),
|
|
};
|
|
|
|
output.nodes.push(CodeNode {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
qualified_name: qualified,
|
|
name: name.to_string(),
|
|
kind: CodeNodeKind::Struct,
|
|
file_path: file_path.to_string(),
|
|
start_line: node.start_position().row as u32 + 1,
|
|
end_line: node.end_position().row as u32 + 1,
|
|
language: "rust".to_string(),
|
|
community_id: None,
|
|
is_entry_point: false,
|
|
graph_index: None,
|
|
});
|
|
}
|
|
}
|
|
"enum_item" => {
|
|
if let Some(name_node) = node.child_by_field_name("name") {
|
|
let name = &source[name_node.byte_range()];
|
|
let qualified = match parent_qualified {
|
|
Some(p) => format!("{p}::{name}"),
|
|
None => format!("{file_path}::{name}"),
|
|
};
|
|
|
|
output.nodes.push(CodeNode {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
qualified_name: qualified,
|
|
name: name.to_string(),
|
|
kind: CodeNodeKind::Enum,
|
|
file_path: file_path.to_string(),
|
|
start_line: node.start_position().row as u32 + 1,
|
|
end_line: node.end_position().row as u32 + 1,
|
|
language: "rust".to_string(),
|
|
community_id: None,
|
|
is_entry_point: false,
|
|
graph_index: None,
|
|
});
|
|
}
|
|
}
|
|
"trait_item" => {
|
|
if let Some(name_node) = node.child_by_field_name("name") {
|
|
let name = &source[name_node.byte_range()];
|
|
let qualified = match parent_qualified {
|
|
Some(p) => format!("{p}::{name}"),
|
|
None => format!("{file_path}::{name}"),
|
|
};
|
|
|
|
output.nodes.push(CodeNode {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
qualified_name: qualified.clone(),
|
|
name: name.to_string(),
|
|
kind: CodeNodeKind::Trait,
|
|
file_path: file_path.to_string(),
|
|
start_line: node.start_position().row as u32 + 1,
|
|
end_line: node.end_position().row as u32 + 1,
|
|
language: "rust".to_string(),
|
|
community_id: None,
|
|
is_entry_point: false,
|
|
graph_index: None,
|
|
});
|
|
|
|
// Parse methods inside the trait
|
|
self.walk_children(
|
|
node,
|
|
source,
|
|
file_path,
|
|
repo_id,
|
|
graph_build_id,
|
|
Some(&qualified),
|
|
output,
|
|
);
|
|
return; // Don't walk children again
|
|
}
|
|
}
|
|
"impl_item" => {
|
|
// Extract impl target type for qualified naming
|
|
let impl_name = self.extract_impl_type(&node, source);
|
|
let qualified = match parent_qualified {
|
|
Some(p) => format!("{p}::{impl_name}"),
|
|
None => format!("{file_path}::{impl_name}"),
|
|
};
|
|
|
|
// Check for trait impl (impl Trait for Type)
|
|
if let Some(trait_node) = node.child_by_field_name("trait") {
|
|
let trait_name = &source[trait_node.byte_range()];
|
|
output.edges.push(CodeEdge {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
source: qualified.clone(),
|
|
target: trait_name.to_string(),
|
|
kind: CodeEdgeKind::Implements,
|
|
file_path: file_path.to_string(),
|
|
line_number: Some(node.start_position().row as u32 + 1),
|
|
});
|
|
}
|
|
|
|
// Walk methods inside impl block
|
|
self.walk_children(
|
|
node,
|
|
source,
|
|
file_path,
|
|
repo_id,
|
|
graph_build_id,
|
|
Some(&qualified),
|
|
output,
|
|
);
|
|
return;
|
|
}
|
|
"use_declaration" => {
|
|
let use_text = &source[node.byte_range()];
|
|
// Extract the imported path
|
|
if let Some(path) = self.extract_use_path(use_text) {
|
|
output.edges.push(CodeEdge {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
source: parent_qualified.unwrap_or(file_path).to_string(),
|
|
target: path,
|
|
kind: CodeEdgeKind::Imports,
|
|
file_path: file_path.to_string(),
|
|
line_number: Some(node.start_position().row as u32 + 1),
|
|
});
|
|
}
|
|
}
|
|
"mod_item" => {
|
|
if let Some(name_node) = node.child_by_field_name("name") {
|
|
let name = &source[name_node.byte_range()];
|
|
let qualified = match parent_qualified {
|
|
Some(p) => format!("{p}::{name}"),
|
|
None => format!("{file_path}::{name}"),
|
|
};
|
|
|
|
output.nodes.push(CodeNode {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
qualified_name: qualified.clone(),
|
|
name: name.to_string(),
|
|
kind: CodeNodeKind::Module,
|
|
file_path: file_path.to_string(),
|
|
start_line: node.start_position().row as u32 + 1,
|
|
end_line: node.end_position().row as u32 + 1,
|
|
language: "rust".to_string(),
|
|
community_id: None,
|
|
is_entry_point: false,
|
|
graph_index: None,
|
|
});
|
|
|
|
// If it has a body (inline module), walk it
|
|
if let Some(body) = node.child_by_field_name("body") {
|
|
self.walk_children(
|
|
body,
|
|
source,
|
|
file_path,
|
|
repo_id,
|
|
graph_build_id,
|
|
Some(&qualified),
|
|
output,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Default: walk children
|
|
self.walk_children(
|
|
node,
|
|
source,
|
|
file_path,
|
|
repo_id,
|
|
graph_build_id,
|
|
parent_qualified,
|
|
output,
|
|
);
|
|
}
|
|
|
|
fn walk_children(
|
|
&self,
|
|
node: Node<'_>,
|
|
source: &str,
|
|
file_path: &str,
|
|
repo_id: &str,
|
|
graph_build_id: &str,
|
|
parent_qualified: Option<&str>,
|
|
output: &mut ParseOutput,
|
|
) {
|
|
let mut cursor = node.walk();
|
|
for child in node.children(&mut cursor) {
|
|
self.walk_tree(
|
|
child,
|
|
source,
|
|
file_path,
|
|
repo_id,
|
|
graph_build_id,
|
|
parent_qualified,
|
|
output,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn extract_calls(
|
|
&self,
|
|
node: Node<'_>,
|
|
source: &str,
|
|
file_path: &str,
|
|
repo_id: &str,
|
|
graph_build_id: &str,
|
|
caller_qualified: &str,
|
|
output: &mut ParseOutput,
|
|
) {
|
|
if node.kind() == "call_expression" {
|
|
if let Some(func_node) = node.child_by_field_name("function") {
|
|
let callee = &source[func_node.byte_range()];
|
|
output.edges.push(CodeEdge {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
source: caller_qualified.to_string(),
|
|
target: callee.to_string(),
|
|
kind: CodeEdgeKind::Calls,
|
|
file_path: file_path.to_string(),
|
|
line_number: Some(node.start_position().row as u32 + 1),
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut cursor = node.walk();
|
|
for child in node.children(&mut cursor) {
|
|
self.extract_calls(
|
|
child,
|
|
source,
|
|
file_path,
|
|
repo_id,
|
|
graph_build_id,
|
|
caller_qualified,
|
|
output,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn has_attribute(&self, node: &Node<'_>, source: &str, attr_name: &str) -> bool {
|
|
if let Some(prev) = node.prev_sibling() {
|
|
if prev.kind() == "attribute_item" || prev.kind() == "attribute" {
|
|
let text = &source[prev.byte_range()];
|
|
return text.contains(attr_name);
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn has_pub_visibility(&self, node: &Node<'_>, source: &str) -> bool {
|
|
let mut cursor = node.walk();
|
|
for child in node.children(&mut cursor) {
|
|
if child.kind() == "visibility_modifier" {
|
|
let text = &source[child.byte_range()];
|
|
return text == "pub";
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn extract_impl_type(&self, node: &Node<'_>, source: &str) -> String {
|
|
if let Some(type_node) = node.child_by_field_name("type") {
|
|
return source[type_node.byte_range()].to_string();
|
|
}
|
|
"unknown".to_string()
|
|
}
|
|
|
|
fn extract_use_path(&self, use_text: &str) -> Option<String> {
|
|
// "use foo::bar::baz;" -> "foo::bar::baz"
|
|
let trimmed = use_text.strip_prefix("use ")?.trim_end_matches(';').trim();
|
|
Some(trimmed.to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use compliance_core::traits::graph_builder::LanguageParser;
|
|
use std::path::PathBuf;
|
|
|
|
fn parse_rust(source: &str) -> ParseOutput {
|
|
let parser = RustParser::new();
|
|
parser
|
|
.parse_file(&PathBuf::from("test.rs"), source, "repo1", "build1")
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_use_path_simple() {
|
|
let parser = RustParser::new();
|
|
assert_eq!(
|
|
parser.extract_use_path("use std::collections::HashMap;"),
|
|
Some("std::collections::HashMap".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_use_path_nested() {
|
|
let parser = RustParser::new();
|
|
assert_eq!(
|
|
parser.extract_use_path("use crate::models::graph::CodeNode;"),
|
|
Some("crate::models::graph::CodeNode".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_use_path_no_prefix() {
|
|
let parser = RustParser::new();
|
|
assert_eq!(parser.extract_use_path("let x = 5;"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_use_path_empty() {
|
|
let parser = RustParser::new();
|
|
assert_eq!(parser.extract_use_path(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_function() {
|
|
let output = parse_rust("fn hello() {\n let x = 1;\n}\n");
|
|
let fn_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::Function)
|
|
.collect();
|
|
assert_eq!(fn_nodes.len(), 1);
|
|
assert_eq!(fn_nodes[0].name, "hello");
|
|
assert!(fn_nodes[0].qualified_name.contains("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_struct() {
|
|
let output = parse_rust("struct Foo {\n x: i32,\n}\n");
|
|
let struct_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::Struct)
|
|
.collect();
|
|
assert_eq!(struct_nodes.len(), 1);
|
|
assert_eq!(struct_nodes[0].name, "Foo");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_enum() {
|
|
let output = parse_rust("enum Color {\n Red,\n Blue,\n}\n");
|
|
let enum_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::Enum)
|
|
.collect();
|
|
assert_eq!(enum_nodes.len(), 1);
|
|
assert_eq!(enum_nodes[0].name, "Color");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_trait() {
|
|
let output = parse_rust("trait Drawable {\n fn draw(&self);\n}\n");
|
|
let trait_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::Trait)
|
|
.collect();
|
|
assert_eq!(trait_nodes.len(), 1);
|
|
assert_eq!(trait_nodes[0].name, "Drawable");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_file_node_always_created() {
|
|
let output = parse_rust("");
|
|
let file_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::File)
|
|
.collect();
|
|
assert_eq!(file_nodes.len(), 1);
|
|
assert_eq!(file_nodes[0].language, "rust");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_multiple_functions() {
|
|
let source = "fn foo() {}\nfn bar() {}\nfn baz() {}\n";
|
|
let output = parse_rust(source);
|
|
let fn_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::Function)
|
|
.collect();
|
|
assert_eq!(fn_nodes.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_main_is_entry_point() {
|
|
let output = parse_rust("fn main() {\n println!(\"hi\");\n}\n");
|
|
let main_node = output.nodes.iter().find(|n| n.name == "main").unwrap();
|
|
assert!(main_node.is_entry_point);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_pub_fn_is_entry_point() {
|
|
let output = parse_rust("pub fn handler() {}\n");
|
|
let node = output.nodes.iter().find(|n| n.name == "handler").unwrap();
|
|
assert!(node.is_entry_point);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_private_fn_is_not_entry_point() {
|
|
let output = parse_rust("fn helper() {}\n");
|
|
let node = output.nodes.iter().find(|n| n.name == "helper").unwrap();
|
|
assert!(!node.is_entry_point);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_function_calls_create_edges() {
|
|
let source = "fn caller() {\n callee();\n}\nfn callee() {}\n";
|
|
let output = parse_rust(source);
|
|
let call_edges: Vec<_> = output
|
|
.edges
|
|
.iter()
|
|
.filter(|e| e.kind == CodeEdgeKind::Calls)
|
|
.collect();
|
|
assert!(!call_edges.is_empty());
|
|
assert!(call_edges.iter().any(|e| e.target.contains("callee")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_use_declaration_creates_import_edge() {
|
|
let source = "use std::collections::HashMap;\nfn foo() {}\n";
|
|
let output = parse_rust(source);
|
|
let import_edges: Vec<_> = output
|
|
.edges
|
|
.iter()
|
|
.filter(|e| e.kind == CodeEdgeKind::Imports)
|
|
.collect();
|
|
assert!(!import_edges.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_impl_methods() {
|
|
let source = "struct Foo {}\nimpl Foo {\n fn do_thing(&self) {}\n}\n";
|
|
let output = parse_rust(source);
|
|
let fn_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::Function)
|
|
.collect();
|
|
assert_eq!(fn_nodes.len(), 1);
|
|
assert_eq!(fn_nodes[0].name, "do_thing");
|
|
// Method should be qualified under the impl type
|
|
assert!(fn_nodes[0].qualified_name.contains("Foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_mod_item() {
|
|
let source = "mod inner {\n fn nested() {}\n}\n";
|
|
let output = parse_rust(source);
|
|
let mod_nodes: Vec<_> = output
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.kind == CodeNodeKind::Module)
|
|
.collect();
|
|
assert_eq!(mod_nodes.len(), 1);
|
|
assert_eq!(mod_nodes[0].name, "inner");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_line_numbers() {
|
|
let source = "fn first() {}\n\n\nfn second() {}\n";
|
|
let output = parse_rust(source);
|
|
let first = output.nodes.iter().find(|n| n.name == "first").unwrap();
|
|
let second = output.nodes.iter().find(|n| n.name == "second").unwrap();
|
|
assert_eq!(first.start_line, 1);
|
|
assert!(second.start_line > first.start_line);
|
|
}
|
|
|
|
#[test]
|
|
fn test_language_and_extensions() {
|
|
let parser = RustParser::new();
|
|
assert_eq!(parser.language(), "rust");
|
|
assert_eq!(parser.extensions(), &["rs"]);
|
|
}
|
|
}
|
|
|
|
impl LanguageParser for RustParser {
|
|
fn language(&self) -> &str {
|
|
"rust"
|
|
}
|
|
|
|
fn extensions(&self) -> &[&str] {
|
|
&["rs"]
|
|
}
|
|
|
|
fn parse_file(
|
|
&self,
|
|
file_path: &Path,
|
|
source: &str,
|
|
repo_id: &str,
|
|
graph_build_id: &str,
|
|
) -> Result<ParseOutput, CoreError> {
|
|
let mut parser = Parser::new();
|
|
let language = tree_sitter_rust::LANGUAGE;
|
|
parser
|
|
.set_language(&language.into())
|
|
.map_err(|e| CoreError::Graph(format!("Failed to set Rust language: {e}")))?;
|
|
|
|
let tree = parser
|
|
.parse(source, None)
|
|
.ok_or_else(|| CoreError::Graph("Failed to parse Rust file".to_string()))?;
|
|
|
|
let file_path_str = file_path.to_string_lossy().to_string();
|
|
let mut output = ParseOutput::default();
|
|
|
|
// Add file node
|
|
output.nodes.push(CodeNode {
|
|
id: None,
|
|
repo_id: repo_id.to_string(),
|
|
graph_build_id: graph_build_id.to_string(),
|
|
qualified_name: file_path_str.clone(),
|
|
name: file_path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_default(),
|
|
kind: CodeNodeKind::File,
|
|
file_path: file_path_str.clone(),
|
|
start_line: 1,
|
|
end_line: source.lines().count() as u32,
|
|
language: "rust".to_string(),
|
|
community_id: None,
|
|
is_entry_point: false,
|
|
graph_index: None,
|
|
});
|
|
|
|
self.walk_tree(
|
|
tree.root_node(),
|
|
source,
|
|
&file_path_str,
|
|
repo_id,
|
|
graph_build_id,
|
|
None,
|
|
&mut output,
|
|
);
|
|
|
|
Ok(output)
|
|
}
|
|
}
|