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

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

Returns:

  • (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

Parameters:

  • edges (Array<Edge>)
  • collapse (Array<String>) (defaults to: [])

    namespace prefixes to fold into subgraphs (mutually exclusive with groups)

  • groups (Hash{String=>String}, nil) (defaults to: nil)

    explicit {node_name => cluster_label} mapping. Takes precedence over collapse when given.



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