init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s
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:
266
crates/adapters/mermaid/src/mermaid_renderer.rs
Normal file
266
crates/adapters/mermaid/src/mermaid_renderer.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user