267 lines
8.6 KiB
Rust
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))
|
|
}
|
|
}
|