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,3 @@
mod mermaid_renderer;
pub use mermaid_renderer::MermaidRenderer;

View File

@@ -0,0 +1,266 @@
use std::collections::{HashMap, HashSet};
use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
pub struct MermaidRenderer {
level: DiagramLevel,
}
impl Default for MermaidRenderer {
fn default() -> Self {
Self::new()
}
}
impl MermaidRenderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
}
fn format_element_name(element: &CodeElement) -> String {
let name = element.name();
if element.generics().is_empty() {
name.to_string()
} else {
format!("{}~{}~", name, element.generics().join(", "))
}
}
fn format_visibility(visibility: Visibility) -> &'static str {
match visibility {
Visibility::Public => "public",
Visibility::Private => "private",
Visibility::Internal => "internal",
}
}
fn render_class_diagram(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["classDiagram".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
let has_namespaces = !grouped.is_empty();
let mut seen: HashSet<String> = HashSet::new();
for element in &ungrouped {
if seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
if has_namespaces {
for (namespace, elements) in &grouped {
lines.push(format!(" namespace {namespace} {{"));
let mut ns_seen: HashSet<String> = HashSet::new();
for element in elements {
if ns_seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
lines.push(" }".to_string());
}
}
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
};
let key = format!("{} {} {}", rel.source(), arrow, rel.target());
if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}"));
}
}
lines.join("\n")
}
fn push_class_lines(lines: &mut Vec<String>, element: &CodeElement, indent: &str) {
lines.push(format!(
"{indent}class {}",
Self::format_element_name(element)
));
if element.visibility() != Visibility::Public {
lines.push(format!(
"{indent}<<{}>> {}",
Self::format_visibility(element.visibility()),
element.name()
));
}
let name = element.name();
for field in element.fields() {
lines.push(format!("{indent}{name} : {field}"));
}
for method in element.methods() {
lines.push(format!("{indent}{name} : {method}"));
}
}
fn render_module_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut name_to_modules: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut file_to_module: HashMap<String, String> = HashMap::new();
let mut modules: HashSet<String> = HashSet::new();
for element in graph.elements() {
if let Some(module) = element.module() {
name_to_modules
.entry(element.name())
.or_default()
.insert(module.as_str());
modules.insert(module.as_str().to_string());
let file_stem = std::path::Path::new(element.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if !file_stem.is_empty() {
file_to_module.insert(file_stem.to_string(), module.as_str().to_string());
}
}
}
for module in &modules {
lines.push(format!(" {module}[{module}]"));
}
let mut module_edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
let source_mod = file_to_module.get(rel.source());
let target_top = rel.target().split('.').next().unwrap_or("");
let target_mod = Self::capitalize(target_top);
if let Some(src) = source_mod
&& modules.contains(&target_mod)
&& *src != target_mod
{
module_edges.insert((src.clone(), target_mod));
}
}
_ => {
if modules.contains(rel.source())
&& modules.contains(rel.target())
&& rel.source() != rel.target()
{
module_edges.insert((rel.source().to_string(), rel.target().to_string()));
continue;
}
let src_mods = name_to_modules.get(rel.source());
let tgt_mods = name_to_modules.get(rel.target());
if let (Some(src_set), Some(tgt_set)) = (src_mods, tgt_mods) {
for src_mod in src_set {
if tgt_set.contains(src_mod) {
continue;
}
for tgt_mod in tgt_set {
if src_mod != tgt_mod {
module_edges.insert((src_mod.to_string(), tgt_mod.to_string()));
}
}
}
}
}
}
}
for (source, target) in &module_edges {
lines.push(format!(" {source} --> {target}"));
}
lines.join("\n")
}
fn capitalize(s: &str) -> String {
if s.is_empty() {
return String::new();
}
format!("{}{}", s[..1].to_uppercase(), &s[1..])
}
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
for element in &ungrouped {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
for (group, elements) in &grouped {
lines.push(format!(" subgraph {group}"));
for element in elements {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
lines.push(" end".to_string());
}
for rel in graph.relationships() {
let source_id = Self::sanitize_id(rel.source());
let target_id = Self::sanitize_id(rel.target());
lines.push(format!(" {source_id} --> {target_id}"));
}
lines.join("\n")
}
fn sanitize_id(name: &str) -> String {
name.replace(['-', '.'], "_")
}
}
impl DiagramRenderer for MermaidRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = match self.level {
DiagramLevel::Type => self.render_class_diagram(graph),
DiagramLevel::Module => self.render_module_flowchart(graph),
DiagramLevel::Project => self.render_project_flowchart(graph),
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}