Files
archlens/crates/adapters/mermaid/src/mermaid_renderer.rs

267 lines
8.6 KiB
Rust

use std::collections::HashSet;
use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
use archlens_rendering_primitives::non_import_rels;
pub struct MermaidRenderer {
level: DiagramLevel,
show_weights: bool,
}
impl Default for MermaidRenderer {
fn default() -> Self {
Self::new()
}
}
impl MermaidRenderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
show_weights: true,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self {
level,
show_weights: true,
}
}
pub fn with_weights(mut self, show: bool) -> Self {
self.show_weights = show;
self
}
fn display_name(qualified: &str) -> &str {
qualified.split("::").last().unwrap_or(qualified)
}
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 (grouped, ungrouped) = graph.elements_by_module();
let has_namespaces = !grouped.is_empty();
let mut seen: HashSet<String> = HashSet::new();
let mut deferred_members: Vec<String> = Vec::new();
for element in &ungrouped {
if seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, &mut deferred_members, element, " ", false);
}
}
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,
&mut deferred_members,
element,
" ",
true,
);
}
}
lines.push(" }".to_string());
}
}
lines.extend(deferred_members);
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in non_import_rels(graph.relationships()) {
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
};
let src = Self::display_name(rel.source());
let tgt = Self::display_name(rel.target());
let key = format!("{} {} {}", src, arrow, tgt);
if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}"));
}
}
lines.join("\n")
}
fn push_class_lines(
lines: &mut Vec<String>,
deferred: &mut Vec<String>,
element: &CodeElement,
indent: &str,
in_namespace: bool,
) {
lines.push(format!(
"{indent}class {}",
Self::format_element_name(element)
));
let member_target = if in_namespace { deferred } else { lines };
if element.visibility() != Visibility::Public {
member_target.push(format!(
" <<{}>> {}",
Self::format_visibility(element.visibility()),
element.name()
));
}
let name = element.name();
for field in element.fields() {
member_target.push(format!(" {name} : {}", sanitize_member(field)));
}
for method in element.methods() {
member_target.push(format!(" {name} : {}", sanitize_member(method)));
}
}
fn render_module_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
for module in graph.modules() {
let m = module.as_str();
lines.push(format!(" {m}[{m}]"));
}
for ((source, target), count) in &graph.module_edges() {
let line = if self.show_weights {
let label = if *count == 1 {
"1 dep".to_string()
} else {
format!("{count} deps")
};
format!(" {source} -->|{label}| {target}")
} else {
format!(" {source} --> {target}")
};
lines.push(line);
}
lines.join("\n")
}
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let (grouped, ungrouped) = graph.elements_by_module();
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(['-', '.'], "_")
}
}
// Sanitize a field/method string for Mermaid classDiagram member syntax.
// Mermaid uses `ClassName : member` — it cannot handle colons inside the
// member (Rust's `field: Type`), nor reference/lifetime syntax (`&`, `'`),
// nor angle-bracket generics (`<`, `>`).
fn sanitize_member(s: &str) -> String {
// Convert `name: Type` → `name Type` (drop the colon)
let no_colon = s.replace(": ", " ").replace(':', " ");
// Strip chars that break the Mermaid grammar
no_colon
.replace(['&', '\''], "")
.replace('<', "[")
.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))
}
fn render_for_module(
&self,
subgraph: &CodeGraph,
module: &ModuleName,
cross_deps: &[(ModuleName, usize)],
) -> Result<RenderOutput, DomainError> {
let base = self.render_class_diagram(subgraph);
let content = if cross_deps.is_empty() {
base
} else {
let src_id = format!(
"{}_module",
module.as_str().to_lowercase().replace('-', "_")
);
let mut extra = format!(
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
module.as_str()
);
for (dep_mod, count) in cross_deps {
let dep_id = format!(
"{}_module",
dep_mod.as_str().to_lowercase().replace('-', "_")
);
extra.push_str(&format!(
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
dep_mod.as_str()
));
let label = if *count == 1 {
"1 dep".to_string()
} else {
format!("{count} deps")
};
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
}
format!("{base}\n{extra}")
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}