init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s

Hex arch + DDD, tree-sitter parsing, Mermaid/ASCII output.
Supports Rust + Python. 92 tests. CI, diff, --check for staleness detection.
This commit is contained in:
2026-06-16 16:13:04 +02:00
commit 35f27d00b0
106 changed files with 6744 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
mod python;
mod rust;
mod tree_sitter_analyzer;
pub use tree_sitter_analyzer::TreeSitterAnalyzer;

View File

@@ -0,0 +1,337 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_classes(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut relationships,
&mut warnings,
);
collect_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_classes(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "class_definition" {
continue;
}
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
Ok(element) => {
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
continue;
}
}
if let Some(superclasses) = child.child_by_field_name("superclasses") {
collect_inheritance(&superclasses, source, name, type_names, relationships);
}
if let Some(body) = child.child_by_field_name("body") {
collect_typed_fields(&body, source, name, type_names, relationships);
collect_constructor_params(&body, source, name, type_names, relationships);
}
}
}
fn collect_inheritance(
superclasses: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = superclasses.walk();
for child in superclasses.children(&mut cursor) {
if child.kind() == "identifier" {
let base_name = &source[child.byte_range()];
if !is_python_builtin(base_name)
&& let Ok(rel) =
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
}
const PYTHON_BUILTINS: &[&str] = &[
"str",
"int",
"float",
"bool",
"bytes",
"list",
"dict",
"set",
"tuple",
"None",
"type",
"object",
"Exception",
"BaseException",
"Optional",
"Any",
"Union",
"List",
"Dict",
"Set",
"Tuple",
"Callable",
"Sequence",
"Mapping",
"Iterable",
"Iterator",
"Generator",
"Coroutine",
"AsyncGenerator",
"ClassVar",
"Final",
"Literal",
"TypeVar",
"Generic",
"Protocol",
"runtime_checkable",
"Self",
];
fn is_python_builtin(name: &str) -> bool {
PYTHON_BUILTINS.contains(&name)
}
const STDLIB_MODULES: &[&str] = &[
"os",
"sys",
"typing",
"logging",
"json",
"re",
"io",
"abc",
"collections",
"datetime",
"enum",
"functools",
"hashlib",
"http",
"importlib",
"inspect",
"itertools",
"math",
"pathlib",
"pickle",
"random",
"shutil",
"signal",
"socket",
"string",
"subprocess",
"tempfile",
"threading",
"time",
"traceback",
"unittest",
"urllib",
"uuid",
"warnings",
"contextlib",
"dataclasses",
"copy",
"struct",
"base64",
"csv",
"glob",
"operator",
"textwrap",
"asyncio",
"concurrent",
"multiprocessing",
];
fn is_external_import(module: &str) -> bool {
let top = module.split('.').next().unwrap_or(module);
if STDLIB_MODULES.contains(&top) {
return true;
}
if top.starts_with('_') {
return true;
}
false
}
fn collect_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"import_statement" => {
let mut name_cursor = child.walk();
for name_child in child.children(&mut name_cursor) {
if name_child.kind() == "dotted_name" {
let module = &source[name_child.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
"import_from_statement" => {
if let Some(module_node) = child.child_by_field_name("module_name") {
let module = &source[module_node.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
_ => {}
}
}
}
fn collect_constructor_params(
body: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
continue;
}
let Some(fn_name) = child.child_by_field_name("name") else {
continue;
};
if &source[fn_name.byte_range()] != "__init__" {
continue;
}
let Some(params) = child.child_by_field_name("parameters") else {
continue;
};
let mut param_cursor = params.walk();
for param in params.children(&mut param_cursor) {
if param.kind() == "typed_parameter"
&& let Some(type_node) = param.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_typed_fields(
body: &Node,
source: &str,
class_name: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
collect_typed_fields_recursive(body, source, class_name, type_names, relationships);
}
fn collect_typed_fields_recursive(
node: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
&& let Some(type_node) = child.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships);
}
}

View File

@@ -0,0 +1,349 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
const RUST_PRIMITIVES: &[&str] = &[
"bool",
"char",
"str",
"String",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"PhantomData",
"Pin",
"Cow",
"Self",
];
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind, Visibility,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_types(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut warnings,
);
collect_relationships(
&root,
source,
&type_names,
&mut relationships,
&mut warnings,
);
collect_mod_declarations(&root, source, file_path, &mut relationships);
collect_use_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_types(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let (kind, name_field) = match child.kind() {
"struct_item" => (CodeElementKind::Struct, "name"),
"enum_item" => (CodeElementKind::Enum, "name"),
"trait_item" => (CodeElementKind::Trait, "name"),
_ => continue,
};
if let Some(name_node) = child.child_by_field_name(name_field) {
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
let visibility = detect_visibility(&child, source);
match CodeElement::new(name, kind, file_path.clone(), line) {
Ok(element) => {
let fields = extract_fields(&child, source);
let methods = extract_methods(node, source, name);
let element = element
.with_visibility(visibility)
.with_fields(fields)
.with_methods(methods);
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
}
}
}
}
}
fn collect_relationships(
node: &Node,
source: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"struct_item" => {
if let Some(name_node) = child.child_by_field_name("name") {
let struct_name = source[name_node.byte_range()].to_string();
collect_field_compositions(
&child,
source,
&struct_name,
type_names,
relationships,
);
}
}
"impl_item" => {
collect_trait_impl(&child, source, type_names, relationships, warnings);
}
_ => {}
}
}
}
fn collect_field_compositions(
struct_node: &Node,
source: &str,
struct_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
if let Some(body) = struct_node.child_by_field_name("body") {
let mut cursor = body.walk();
for field in body.children(&mut cursor) {
if field.kind() == "field_declaration"
&& let Some(type_node) = field.child_by_field_name("type")
{
let type_text = extract_base_type(&type_node, source);
if type_text != struct_name
&& !type_text.is_empty()
&& !RUST_PRIMITIVES.contains(&type_text.as_str())
&& let Ok(rel) =
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_trait_impl(
impl_node: &Node,
source: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
_warnings: &mut Vec<AnalysisWarning>,
) {
let trait_node = impl_node.child_by_field_name("trait");
let type_node = impl_node.child_by_field_name("type");
if let (Some(trait_n), Some(type_n)) = (trait_node, type_node) {
let trait_name = extract_base_type(&trait_n, source);
let type_name = extract_base_type(&type_n, source);
if !trait_name.is_empty()
&& !type_name.is_empty()
&& !RUST_PRIMITIVES.contains(&trait_name.as_str())
&& !RUST_PRIMITIVES.contains(&type_name.as_str())
&& let Ok(rel) =
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
fn extract_fields(node: &Node, source: &str) -> Vec<String> {
let mut fields = Vec::new();
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() == "field_declaration" {
let name = child
.child_by_field_name("name")
.map(|n| &source[n.byte_range()]);
let ty = child
.child_by_field_name("type")
.map(|n| extract_base_type(&n, source));
if let (Some(name), Some(ty)) = (name, ty) {
fields.push(format!("{name}: {ty}"));
}
}
}
}
fields
}
fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() != "impl_item" {
continue;
}
if child.child_by_field_name("trait").is_some() {
continue;
}
let type_node = child.child_by_field_name("type");
if let Some(tn) = type_node {
let impl_name = extract_base_type(&tn, source);
if impl_name != type_name {
continue;
}
}
if let Some(body) = child.child_by_field_name("body") {
let mut body_cursor = body.walk();
for item in body.children(&mut body_cursor) {
if item.kind() == "function_item"
&& let Some(name_node) = item.child_by_field_name("name")
{
let fn_name = &source[name_node.byte_range()];
let vis = if detect_visibility(&item, source) == Visibility::Public {
"+"
} else {
"-"
};
methods.push(format!("{vis}{fn_name}()"));
}
}
}
}
methods
}
fn collect_mod_declarations(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "mod_item" {
continue;
}
if child.child_by_field_name("body").is_some() {
continue;
}
if let Some(name_node) = child.child_by_field_name("name") {
let mod_name = &source[name_node.byte_range()];
let target = format!("crate::{mod_name}");
if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) {
relationships.push(rel);
}
}
}
}
fn collect_use_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "use_declaration" {
continue;
}
if let Some(arg) = child.child_by_field_name("argument") {
let text = &source[arg.byte_range()];
let path = text
.split('{')
.next()
.unwrap_or(text)
.trim_end_matches("::");
if (path.starts_with("crate::") || path.starts_with("super::"))
&& let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
fn extract_base_type(node: &Node, source: &str) -> String {
let text = &source[node.byte_range()];
text.split('<').next().unwrap_or(text).trim().to_string()
}
fn detect_visibility(node: &Node, source: &str) -> Visibility {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "visibility_modifier" {
let text = &source[child.byte_range()];
if text.contains("pub") {
return Visibility::Public;
}
}
}
Visibility::Private
}

View File

@@ -0,0 +1,30 @@
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
use crate::{python, rust};
pub struct TreeSitterAnalyzer;
impl Default for TreeSitterAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl TreeSitterAnalyzer {
pub fn new() -> Self {
Self
}
}
impl SourceAnalyzer for TreeSitterAnalyzer {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
let source = std::fs::read_to_string(file.path().as_str())
.map_err(|e| DomainError::IoError(e.to_string()))?;
match file.language() {
Language::Rust => rust::analyze(&source, file.path()),
Language::Python => python::analyze(&source, file.path()),
Language::CSharp => Ok(AnalysisResult::empty()),
}
}
}