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 = HashSet::new(); let mut deferred_members: Vec = 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 = 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 = 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, deferred: &mut Vec, 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 { 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 { 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 <>\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 <>\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)) } }