Module: Rigor::ModuleGraph::Mermaid
- Defined in:
- lib/rigor/module_graph/mermaid.rb
Overview
Renders edges as a Mermaid flowchart.
Mermaid does not have per-edge style classes the way DOT does; we use distinct arrow heads per kind (‘==>`, `–>`, `-.->`) plus an `:::kind` classDef on the target node so the legend is readable in any Mermaid renderer.
When ‘collapse:` is given, every node whose name sits under one of the listed prefixes is wrapped in a `subgraph <prefix>` block, with the prefix stripped from the visible label.
Constant Summary collapse
- ARROW_FOR_KIND =
{ "inherits" => "==>", "include" => "-->", "prepend" => "-->", "extend" => "-.->", "const_ref" => "-.->" }.freeze
- CLASS_DEFS =
<<~MERMAID classDef inherits fill:#0f172a,color:#fff,stroke:#0f172a; classDef include fill:#1d4ed8,color:#fff,stroke:#1d4ed8; classDef prepend fill:#9333ea,color:#fff,stroke:#9333ea; classDef extend fill:#0f766e,color:#fff,stroke:#0f766e; classDef const_ref fill:#cbd5e1,color:#0f172a,stroke:#94a3b8; classDef unresolved fill:#fef3c7,color:#0f172a,stroke:#d97706,stroke-dasharray: 4 4; MERMAID
- KIND_PRIORITY =
{ "inherits" => 0, "include" => 1, "prepend" => 2, "extend" => 3, "const_ref" => 4 }.freeze
Class Method Summary collapse
- .assign_node_ids(edges) ⇒ Object
- .better_tag?(candidate, current) ⇒ Boolean
- .build_groups(names, collapse, groups) ⇒ Object
-
.cluster_id(prefix) ⇒ Object
Mermaid subgraph ids must be plain identifiers; anything else breaks the parser silently.
- .dedup(edges) ⇒ Object
- .escape_label(name) ⇒ Object
- .group_by_prefix(names, collapse) ⇒ Object
- .render(edges, collapse: [], groups: nil) ⇒ Object
- .render_class_assignments(edges, node_ids) ⇒ Object
- .render_cluster(label, members, node_ids, use_namespace_prefix: true) ⇒ Object
Class Method Details
.assign_node_ids(edges) ⇒ Object
109 110 111 112 |
# File 'lib/rigor/module_graph/mermaid.rb', line 109 def assign_node_ids(edges) names = edges.flat_map { |edge| [edge.from, edge.to] }.uniq.sort names.each_with_index.to_h { |name, idx| [name, "n#{idx}"] } end |
.better_tag?(candidate, current) ⇒ Boolean
91 92 93 94 95 96 |
# File 'lib/rigor/module_graph/mermaid.rb', line 91 def better_tag?(candidate, current) return false if current == "unresolved" return true if candidate == "unresolved" (KIND_PRIORITY[candidate] || 99) < (KIND_PRIORITY[current] || 99) end |
.build_groups(names, collapse, groups) ⇒ Object
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/rigor/module_graph/mermaid.rb', line 114 def build_groups(names, collapse, groups) if groups && !groups.empty? clusters = Hash.new { |h, k| h[k] = [] } ungrouped = [] names.each do |name| if (label = groups[name]) clusters[label] << name else ungrouped << name end end [clusters, ungrouped] else group_by_prefix(names, collapse) end end |
.cluster_id(prefix) ⇒ Object
Mermaid subgraph ids must be plain identifiers; anything else breaks the parser silently. Coerce non-alnum characters to ‘_` so `packages/billing` ends up as `sg_packages_billing` and stays unambiguous.
162 163 164 |
# File 'lib/rigor/module_graph/mermaid.rb', line 162 def cluster_id(prefix) "sg_#{prefix.gsub(/[^A-Za-z0-9_]+/, "_")}" end |
.dedup(edges) ⇒ Object
98 99 100 101 102 103 104 105 106 107 |
# File 'lib/rigor/module_graph/mermaid.rb', line 98 def dedup(edges) seen = {} edges.each_with_object([]) do |edge, acc| key = edge.dedup_key next if seen[key] seen[key] = true acc << edge end end |
.escape_label(name) ⇒ Object
166 167 168 |
# File 'lib/rigor/module_graph/mermaid.rb', line 166 def escape_label(name) name.gsub('"', "#quot;") end |
.group_by_prefix(names, collapse) ⇒ Object
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/rigor/module_graph/mermaid.rb', line 131 def group_by_prefix(names, collapse) prefixes = Array(collapse).map(&:to_s).reject(&:empty?) return [{}, names] if prefixes.empty? sorted = prefixes.sort_by { |p| -p.length } clusters = Hash.new { |h, k| h[k] = [] } ungrouped = [] names.each do |name| match = sorted.find { |p| name.start_with?(p + "::") } if match clusters[match] << name else ungrouped << name end end [clusters, ungrouped] end |
.render(edges, collapse: [], groups: nil) ⇒ Object
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/rigor/module_graph/mermaid.rb', line 41 def render(edges, collapse: [], groups: nil) edges = dedup(edges) node_ids = assign_node_ids(edges) clusters, ungrouped = build_groups(node_ids.keys.sort, collapse, groups) out = +"flowchart LR\n" clusters.each do |label, members| out << render_cluster(label, members, node_ids, use_namespace_prefix: groups.nil?) end ungrouped.each do |name| out << " #{node_ids[name]}[\"#{escape_label(name)}\"]\n" end out << "\n" unless node_ids.empty? edges.each do |edge| arrow = ARROW_FOR_KIND.fetch(edge.kind, "-->") out << " #{node_ids[edge.from]} #{arrow}|#{edge.kind}| #{node_ids[edge.to]}\n" end out << "\n" out << CLASS_DEFS # One class assignment per node id. Mermaid silently keeps # the last assignment but starts to error out when the same # `class N kind;` line repeats many hundreds of times in a # large graph, so we dedupe and pick the most structural # kind per node (inherits > include > prepend > extend > # const_ref) so the resulting colour conveys intent. out << render_class_assignments(edges, node_ids) out end |
.render_class_assignments(edges, node_ids) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/rigor/module_graph/mermaid.rb', line 78 def render_class_assignments(edges, node_ids) per_node = {} edges.each do |edge| id = node_ids[edge.to] tag = edge.confidence == "unresolved" ? "unresolved" : edge.kind current = per_node[id] if current.nil? || better_tag?(tag, current) per_node[id] = tag end end per_node.map { |id, tag| " class #{id} #{tag};\n" }.join end |
.render_cluster(label, members, node_ids, use_namespace_prefix: true) ⇒ Object
149 150 151 152 153 154 155 156 |
# File 'lib/rigor/module_graph/mermaid.rb', line 149 def render_cluster(label, members, node_ids, use_namespace_prefix: true) out = +" subgraph #{cluster_id(label)} [\"#{escape_label(label)}\"]\n" members.each do |name| short = use_namespace_prefix ? name.sub(/\A#{Regexp.escape(label)}::/, "") : name out << " #{node_ids[name]}[\"#{escape_label(short)}\"]\n" end out << " end\n" end |