Module: Rigor::ModuleGraph::Dot

Defined in:
lib/rigor/module_graph/dot.rb

Overview

Renders an array of Edges as a Graphviz DOT document.

Style decisions (per docs/plan.md “グラフモデル”):

  • rankdir=LR for readability of inheritance towers

  • inherits: thick solid

  • include: solid

  • prepend: solid, distinct color

  • extend: dashed

  • const_ref: faded dotted

When ‘collapse:` is given, every node whose fully-qualified name sits under one of the listed prefixes is wrapped in a `subgraph cluster_<prefix>` block, and the prefix is stripped from the visible label. Edges across clusters render normally; Graphviz routes them between the cluster boundaries.

Constant Summary collapse

KIND_STYLE =
{
  "inherits" => 'color="#0f172a", penwidth=2.0',
  "include" => 'color="#1d4ed8"',
  "prepend" => 'color="#9333ea"',
  "extend" => 'color="#0f766e", style="dashed"',
  "const_ref" => 'color="#94a3b8", style="dotted"'
}.freeze
CONFIDENCE_STYLE =
{
  "unresolved" => 'style="dashed", color="#94a3b8"'
}.freeze
HEADER =
<<~DOT
  digraph ruby_modules {
    rankdir=LR;
    graph [compound=true, overlap=false, splines=true];
    node [shape=box, style="rounded,filled", fillcolor="#f8fafc", color="#94a3b8", fontname="Helvetica"];
    edge [color="#64748b", arrowsize=0.7, fontname="Helvetica"];
DOT

Class Method Summary collapse

Class Method Details

.build_groups(nodes, collapse, groups) ⇒ Object

Build the cluster partition. When groups is given we use it verbatim; otherwise fall back to prefix-matching against collapse (the legacy namespace-collapse path).



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/rigor/module_graph/dot.rb', line 89

def build_groups(nodes, collapse, groups)
  if groups && !groups.empty?
    clusters = Hash.new { |h, k| h[k] = [] }
    ungrouped = []
    nodes.each do |name|
      if (label = groups[name])
        clusters[label] << name
      else
        ungrouped << name
      end
    end
    [clusters, ungrouped]
  else
    group_by_prefix(nodes, collapse)
  end
end

.cluster_id(prefix) ⇒ Object

Cluster identifiers in DOT must match ‘[A-Za-z_]*` — package names like `packages/billing` would otherwise break Graphviz’s parser even inside quotes. Squash every non-id character to ‘_` so the prefix `cluster_` still triggers Graphviz’s cluster handling.



142
143
144
# File 'lib/rigor/module_graph/dot.rb', line 142

def cluster_id(prefix)
  prefix.gsub(/[^A-Za-z0-9_]+/, "_")
end

.collect_nodes(edges) ⇒ Object



81
82
83
84
# File 'lib/rigor/module_graph/dot.rb', line 81

def collect_nodes(edges)
  names = edges.flat_map { |edge| [edge.from, edge.to] }
  names.uniq.sort
end

.dedup(edges) ⇒ Object



70
71
72
73
74
75
76
77
78
79
# File 'lib/rigor/module_graph/dot.rb', line 70

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

.group_by_prefix(nodes, collapse) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/rigor/module_graph/dot.rb', line 106

def group_by_prefix(nodes, collapse)
  prefixes = Array(collapse).map(&:to_s).reject(&:empty?)
  return [{}, nodes] if prefixes.empty?

  sorted = prefixes.sort_by { |p| -p.length }
  clusters = Hash.new { |h, k| h[k] = [] }
  ungrouped = []
  nodes.each do |name|
    match = sorted.find { |p| name.start_with?(p + "::") }
    if match
      clusters[match] << name
    else
      ungrouped << name
    end
  end
  [clusters, ungrouped]
end

.quote(name) ⇒ Object



157
158
159
160
161
# File 'lib/rigor/module_graph/dot.rb', line 157

def quote(name)
  # DOT identifiers that contain `::` or quotes must be
  # double-quoted; escape embedded double quotes.
  '"' + name.gsub('"', '\"') + '"'
end

.render(edges, collapse: [], groups: nil) ⇒ Object

Parameters:

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

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

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

    explicit {node_name => cluster_label} mapping. Takes precedence over collapse when given. Used by the --package overlay where the cluster boundary is something other than a :: namespace prefix.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/rigor/module_graph/dot.rb', line 51

def render(edges, collapse: [], groups: nil)
  edges = dedup(edges)
  nodes = collect_nodes(edges)
  clusters, ungrouped = build_groups(nodes, collapse, groups)

  out = +HEADER
  clusters.each do |label, members|
    out << render_cluster(label, members, use_namespace_prefix: groups.nil?)
  end
  ungrouped.each do |name|
    out << "  #{quote(name)};\n"
  end
  out << "\n" unless nodes.empty?
  edges.each do |edge|
    out << render_edge(edge)
  end
  out << "}\n"
end

.render_cluster(label, members, use_namespace_prefix: true) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/rigor/module_graph/dot.rb', line 124

def render_cluster(label, members, use_namespace_prefix: true)
  out = +"  subgraph #{quote("cluster_" + cluster_id(label))} {\n"
  out << "    label=#{quote(label)};\n"
  out << "    style=\"rounded,filled\";\n"
  out << "    color=\"#cbd5e1\";\n"
  out << "    fillcolor=\"#f1f5f9\";\n"
  members.each do |name|
    short = use_namespace_prefix ? name.sub(/\A#{Regexp.escape(label)}::/, "") : name
    out << "    #{quote(name)} [label=#{quote(short)}];\n"
  end
  out << "  }\n"
end

.render_edge(edge) ⇒ Object



146
147
148
149
150
151
152
153
154
155
# File 'lib/rigor/module_graph/dot.rb', line 146

def render_edge(edge)
  attrs = +"label=\"#{edge.kind}\""
  if (style = KIND_STYLE[edge.kind])
    attrs << ", " << style
  end
  if (style = CONFIDENCE_STYLE[edge.confidence])
    attrs << ", " << style
  end
  "  #{quote(edge.from)} -> #{quote(edge.to)} [#{attrs}];\n"
end