rigor-module-graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module ModuleGraph
5
+ # Restricts an edge list to the subgraph reachable from a set
6
+ # of root nodes within a hop limit.
7
+ #
8
+ # Used by the +--from+ / +--depth+ CLI flags to make a graph
9
+ # focused on one or a few constants tractable to look at on a
10
+ # large project (where dumping every edge produces 1000+-node
11
+ # Mermaid output that browsers refuse to render).
12
+ #
13
+ # Direction is configurable:
14
+ #
15
+ # +:out+:: follow edges in the natural direction
16
+ # (depends-on)
17
+ # +:in+:: follow edges backwards (depended-on-by)
18
+ # +:both+:: union of the two (the default — usually what
19
+ # "what's around Article?" means)
20
+ module Reachability
21
+ module_function
22
+
23
+ VALID_DIRECTIONS = %i[out in both].freeze
24
+ VALID_EDGE_SCOPES = %i[cluster walk].freeze
25
+
26
+ EMPTY = [].freeze
27
+ private_constant :EMPTY
28
+
29
+ # @param edges [Array<Edge>]
30
+ # @param roots [Array<String>] node names to start from
31
+ # @param depth [Integer, nil] hop limit, nil for unlimited
32
+ # @param direction [Symbol] one of VALID_DIRECTIONS
33
+ # @param edge_scope [Symbol] one of VALID_EDGE_SCOPES.
34
+ # +:cluster+ (default) keeps every edge whose endpoints
35
+ # both fall in the reachable node set — useful when you
36
+ # want the neighbourhood as a cluster. +:walk+ keeps only
37
+ # the edges the BFS itself traverses, so a
38
+ # +depth=1 --direction out+ walk from +Article+ returns
39
+ # exactly the edges whose +from+ is +Article+, never the
40
+ # sibling +inherits ApplicationRecord+ rows from the
41
+ # reached set.
42
+ # @return [Array<Edge>] filtered edges with original order
43
+ # preserved.
44
+ def filter(edges, roots:, depth: nil, direction: :both, edge_scope: :cluster)
45
+ roots = Array(roots).map(&:to_s).reject(&:empty?)
46
+ return edges if roots.empty?
47
+
48
+ unless VALID_DIRECTIONS.include?(direction)
49
+ raise ArgumentError, "unknown direction #{direction.inspect}; expected one of #{VALID_DIRECTIONS.inspect}"
50
+ end
51
+ unless VALID_EDGE_SCOPES.include?(edge_scope)
52
+ raise ArgumentError, "unknown edge_scope #{edge_scope.inspect}; expected one of #{VALID_EDGE_SCOPES.inspect}"
53
+ end
54
+
55
+ case edge_scope
56
+ when :cluster
57
+ reachable = walk(edges, roots, depth, direction)
58
+ edges.select { |e| reachable.key?(e.from) && reachable.key?(e.to) }
59
+ when :walk
60
+ indexes = walked_edge_indexes(edges, roots, depth, direction)
61
+ # `walked` is a Hash<edge_index, true>; filter by
62
+ # membership keeping the original edge order.
63
+ out = []
64
+ edges.each_with_index do |e, i|
65
+ out << e if indexes.key?(i)
66
+ end
67
+ out
68
+ end
69
+ end
70
+
71
+ # BFS over the edge graph; returns a Hash<node_name, true>
72
+ # whose keys are the reachable nodes. We use a Hash instead
73
+ # of Set because Hash key lookup is faster on Ruby 4 and we
74
+ # don't need Set's union/intersection methods here.
75
+ def walk(edges, roots, depth, direction)
76
+ forward, backward = build_adjacency(edges, direction)
77
+
78
+ visited = {}
79
+ frontier = []
80
+ roots.each do |r|
81
+ visited[r] = true
82
+ frontier << r
83
+ end
84
+ hops = 0
85
+
86
+ until frontier.empty?
87
+ break if depth && hops >= depth
88
+
89
+ next_frontier = []
90
+ frontier.each do |node|
91
+ each_neighbour(node, forward, backward, direction) do |n|
92
+ next if visited.key?(n)
93
+
94
+ visited[n] = true
95
+ next_frontier << n
96
+ end
97
+ end
98
+ frontier = next_frontier
99
+ hops += 1
100
+ end
101
+ visited
102
+ end
103
+
104
+ # Returns a Hash<edge_index, true> for the edges actually
105
+ # traversed by the BFS. `direction=both` runs the out-walk
106
+ # and in-walk against separate adjacency tables so the
107
+ # zigzag chain (+A <- X -> Y+ whose +X -> Y+ isn't on any
108
+ # genuine path from the roots) stays excluded.
109
+ def walked_edge_indexes(edges, roots, depth, direction)
110
+ if direction == :both
111
+ forward, backward = build_indexed_adjacencies(edges)
112
+ walked = {}
113
+ run_indexed_walk(forward, roots, depth, walked)
114
+ run_indexed_walk(backward, roots, depth, walked)
115
+ return walked
116
+ end
117
+
118
+ adjacency = build_indexed_adjacency(edges, direction)
119
+ walked = {}
120
+ run_indexed_walk(adjacency, roots, depth, walked)
121
+ walked
122
+ end
123
+
124
+ # @api private
125
+ def build_adjacency(edges, direction)
126
+ # Build only the adjacency tables we actually need. The
127
+ # caller asking for +:out+ never touches +backward+ etc.
128
+ forward = direction == :in ? nil : Hash.new { |h, k| h[k] = [] }
129
+ backward = direction == :out ? nil : Hash.new { |h, k| h[k] = [] }
130
+ edges.each do |edge|
131
+ forward[edge.from] << edge.to if forward
132
+ backward[edge.to] << edge.from if backward
133
+ end
134
+ [forward, backward]
135
+ end
136
+
137
+ def each_neighbour(node, forward, backward, direction, &block)
138
+ case direction
139
+ when :out
140
+ forward.fetch(node, EMPTY).each(&block)
141
+ when :in
142
+ backward.fetch(node, EMPTY).each(&block)
143
+ when :both
144
+ forward.fetch(node, EMPTY).each(&block)
145
+ backward.fetch(node, EMPTY).each(&block)
146
+ end
147
+ end
148
+
149
+ def build_indexed_adjacency(edges, direction)
150
+ adjacency = Hash.new { |h, k| h[k] = [] }
151
+ edges.each_with_index do |edge, i|
152
+ case direction
153
+ when :out then adjacency[edge.from] << [edge.to, i]
154
+ when :in then adjacency[edge.to] << [edge.from, i]
155
+ end
156
+ end
157
+ adjacency
158
+ end
159
+
160
+ def build_indexed_adjacencies(edges)
161
+ forward = Hash.new { |h, k| h[k] = [] }
162
+ backward = Hash.new { |h, k| h[k] = [] }
163
+ edges.each_with_index do |edge, i|
164
+ forward[edge.from] << [edge.to, i]
165
+ backward[edge.to] << [edge.from, i]
166
+ end
167
+ [forward, backward]
168
+ end
169
+
170
+ def run_indexed_walk(adjacency, roots, depth, walked)
171
+ visited = {}
172
+ frontier = []
173
+ roots.each do |r|
174
+ visited[r] = true
175
+ frontier << r
176
+ end
177
+ hops = 0
178
+ until frontier.empty?
179
+ break if depth && hops >= depth
180
+
181
+ next_frontier = []
182
+ frontier.each do |node|
183
+ adjacency.fetch(node, EMPTY).each do |neighbour, edge_index|
184
+ walked[edge_index] = true
185
+ next if visited.key?(neighbour)
186
+
187
+ visited[neighbour] = true
188
+ next_frontier << neighbour
189
+ end
190
+ end
191
+ frontier = next_frontier
192
+ hops += 1
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module ModuleGraph
5
+ # Computes per-namespace dependency metrics over an edge list.
6
+ #
7
+ # Five numbers per namespace:
8
+ #
9
+ # +nodes+:: number of distinct constants in the namespace
10
+ # +fan_out+:: edges whose +from+ is in the namespace and
11
+ # +to+ is outside it
12
+ # +fan_in+:: edges whose +to+ is in the namespace and
13
+ # +from+ is outside it
14
+ # +internal+:: edges where both endpoints sit in the namespace
15
+ # +total+:: +fan_out+ + +internal+ — every edge originating
16
+ # in the namespace
17
+ #
18
+ # Grouping is by top-level namespace by default
19
+ # (+Billing::Invoice+ → +Billing+). Pass +depth: N+ for a
20
+ # deeper split (+Billing::Invoice::Line+ at depth 2 →
21
+ # +Billing::Invoice+). Names without enough segments at the
22
+ # requested depth bucket under the special label
23
+ # +"(top-level)"+ so they stay visible in the report.
24
+ module Stats
25
+ module_function
26
+
27
+ TOP_LEVEL_BUCKET = "(top-level)"
28
+
29
+ # @param edges [Array<Edge>]
30
+ # @param depth [Integer] number of leading +::+ segments to
31
+ # keep when grouping
32
+ # @return [Array<NamespaceMetrics>] sorted by fan_out desc,
33
+ # then namespace asc; deterministic output.
34
+ def compute(edges, depth: 1)
35
+ # Single pass over edges. For each edge we resolve both
36
+ # endpoints to a normalised name (cached), then to a
37
+ # bucket (cached), then update the bucket's mutable
38
+ # `[nodes_set, fan_out, fan_in, internal]` counter array.
39
+ # Allocating one immutable `NamespaceMetrics` per edge via
40
+ # `Data#with` is what made the old implementation slow.
41
+ normalised = {}
42
+ groups = {}
43
+ counters = Hash.new { |h, k| h[k] = [{}, 0, 0, 0] }
44
+
45
+ edges.each do |edge|
46
+ from = (normalised[edge.from] ||= fast_normalise(edge.from))
47
+ to = (normalised[edge.to] ||= fast_normalise(edge.to))
48
+
49
+ from_group = (groups[from] ||= bucket_for_normalised(from, depth))
50
+ to_group = (groups[to] ||= bucket_for_normalised(to, depth))
51
+
52
+ from_counter = counters[from_group]
53
+ to_counter = counters[to_group]
54
+ from_counter[0][from] = true
55
+ to_counter[0][to] = true
56
+
57
+ if from_group == to_group
58
+ from_counter[3] += 1
59
+ else
60
+ from_counter[1] += 1
61
+ to_counter[2] += 1
62
+ end
63
+ end
64
+
65
+ metrics = counters.map do |namespace, counter|
66
+ NamespaceMetrics.new(
67
+ namespace: namespace,
68
+ nodes: counter[0].size,
69
+ fan_out: counter[1],
70
+ fan_in: counter[2],
71
+ internal: counter[3]
72
+ )
73
+ end
74
+ metrics.sort_by { |m| [-m.fan_out, m.namespace] }
75
+ end
76
+
77
+ # Strip leading "::" so absolute and relative names share
78
+ # the same bucket / node identity — they refer to the same
79
+ # constant either way.
80
+ def fast_normalise(name)
81
+ name.start_with?("::") ? name[2..] : name
82
+ end
83
+
84
+ # Like +bucket_for+, but skips the +split+ allocation when
85
+ # the name has more segments than +depth+. Walks +index+
86
+ # once per +::+ separator and slices once at the boundary.
87
+ def bucket_for_normalised(name, depth)
88
+ cursor = -2
89
+ depth.times do
90
+ cursor = name.index("::", cursor + 2)
91
+ return TOP_LEVEL_BUCKET unless cursor
92
+ end
93
+ name[0...cursor]
94
+ end
95
+
96
+ # The five numbers for one namespace, exposed as a Data
97
+ # value so callers can treat the result like a row of a
98
+ # table.
99
+ NamespaceMetrics = Data.define(:namespace, :nodes, :fan_out, :fan_in, :internal) do
100
+ # Sum of edges originating in the namespace
101
+ # (fan_out + internal).
102
+ def total
103
+ fan_out + internal
104
+ end
105
+
106
+ def to_h
107
+ {
108
+ "namespace" => namespace,
109
+ "nodes" => nodes,
110
+ "fan_out" => fan_out,
111
+ "fan_in" => fan_in,
112
+ "internal" => internal,
113
+ "total" => total
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title><%= title %></title>
6
+ <script type="module">
7
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
8
+ mermaid.initialize({
9
+ startOnLoad: true,
10
+ securityLevel: "loose",
11
+ maxTextSize: 5_000_000,
12
+ maxEdges: 50_000,
13
+ flowchart: { useMaxWidth: false, htmlLabels: true }
14
+ });
15
+ </script>
16
+ <style>
17
+ body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 2rem; color: #0f172a; background: #f8fafc; }
18
+ h1 { margin-top: 0; }
19
+ .meta { color: #64748b; margin-bottom: 1.5rem; }
20
+ .card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 2px rgba(15,23,42,0.04); }
21
+ .legend { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
22
+ .legend span { padding: 0.25rem 0.75rem; border-radius: 999px; color: white; font-size: 12px; }
23
+ .legend .inherits { background: #0f172a; }
24
+ .legend .include { background: #1d4ed8; }
25
+ .legend .prepend { background: #9333ea; }
26
+ .legend .extend { background: #0f766e; }
27
+ .legend .const_ref { background: #94a3b8; color: #0f172a; }
28
+ .legend .unresolved { background: #fef3c7; color: #0f172a; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <h1><%= title %></h1>
33
+ <% if subtitle -%>
34
+ <p class="meta"><%= subtitle %></p>
35
+ <% end -%>
36
+ <div class="card">
37
+ <pre class="mermaid">
38
+ <%= indented_mermaid %>
39
+ </pre>
40
+ </div>
41
+ <div class="legend">
42
+ <span class="inherits">inherits</span>
43
+ <span class="include">include</span>
44
+ <span class="prepend">prepend</span>
45
+ <span class="extend">extend</span>
46
+ <span class="const_ref">const_ref</span>
47
+ <span class="unresolved">unresolved</span>
48
+ </div>
49
+ </body>
50
+ </html>
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module ModuleGraph
5
+ module Uml
6
+ # Renders a +classDiagram+ Mermaid document from a list of
7
+ # edges plus a list of node metadata rows (the +nodes.jsonl+
8
+ # the collector writes alongside +edges.jsonl+).
9
+ #
10
+ # Differences from the +flowchart+ renderer:
11
+ #
12
+ # * Each class / module gets a body block listing its
13
+ # instance methods, class methods, and attributes, with the
14
+ # standard UML visibility glyphs (+, -, #).
15
+ # * Modules are annotated +<<module>>+ so a Ruby module is
16
+ # visually distinct from a class.
17
+ # * +inherits+ uses +--|>+, mixin uses +..|>+, +const_ref+
18
+ # uses +..>+, ActiveRecord associations carry cardinality
19
+ # pairs ("1" / "*") as edge endpoints.
20
+ # * Mermaid disallows +::+ in class identifiers; we sanitise
21
+ # to +__+ and keep the original as the label only.
22
+ #
23
+ # Filtering knobs:
24
+ #
25
+ # * +include_methods:+ (default true) — show methods inside
26
+ # class bodies.
27
+ # * +include_attributes:+ (default true) — show attributes.
28
+ # * +visibilities:+ — array subset of +%w[public protected
29
+ # private]+, default all.
30
+ module ClassDiagram
31
+ module_function
32
+
33
+ VISIBILITY_GLYPH = {
34
+ "public" => "+",
35
+ "protected" => "#",
36
+ "private" => "-"
37
+ }.freeze
38
+
39
+ ARROW_FOR_KIND = {
40
+ "inherits" => "<|--",
41
+ "include" => "<|..",
42
+ "prepend" => "<|..",
43
+ "extend" => "<|..",
44
+ "const_ref" => "<.."
45
+ }.freeze
46
+
47
+ CARDINALITY = {
48
+ "has_many" => ['"1"', '"*"'],
49
+ "belongs_to" => ['"*"', '"1"'],
50
+ "has_one" => ['"1"', '"1"'],
51
+ "has_and_belongs_to_many" => ['"*"', '"*"']
52
+ }.freeze
53
+
54
+ def render(edges, nodes,
55
+ include_methods: true,
56
+ include_attributes: true,
57
+ visibilities: %w[public protected private])
58
+ declarations = node_declarations(nodes)
59
+ members = node_members(nodes, include_methods, include_attributes, visibilities)
60
+
61
+ out = +"classDiagram\n"
62
+ render_classes(out, declarations, members, edges)
63
+ render_edges(out, dedup(edges))
64
+ out
65
+ end
66
+
67
+ # +Foo::Bar+ can't be a Mermaid identifier; coerce to a
68
+ # safe form. The label always carries the original.
69
+ def safe_id(name)
70
+ name.gsub(/[^A-Za-z0-9_]+/, "__")
71
+ end
72
+
73
+ def dedup(edges)
74
+ seen = {}
75
+ edges.each_with_object([]) do |edge, acc|
76
+ key = edge.dedup_key
77
+ next if seen[key]
78
+
79
+ seen[key] = true
80
+ acc << edge
81
+ end
82
+ end
83
+
84
+ # Build a +{name => "class"|"module"}+ table from the
85
+ # node-declaration rows.
86
+ def node_declarations(nodes)
87
+ decl = {}
88
+ nodes.each do |row|
89
+ case row.kind
90
+ when "class", "module"
91
+ # Re-opens may set the same row multiple times —
92
+ # whichever wins doesn't matter because the kind is
93
+ # the same.
94
+ decl[row.name] = row.kind
95
+ end
96
+ end
97
+ decl
98
+ end
99
+
100
+ # Build a +{owner_name => [{glyph, name, label}, ...]}+
101
+ # table covering the displayable members for every owner.
102
+ def node_members(nodes, include_methods, include_attributes, visibilities)
103
+ members = Hash.new { |h, k| h[k] = [] }
104
+ nodes.each do |row|
105
+ owner = row.owner
106
+ next if owner.nil?
107
+
108
+ visibility = row.visibility || "public"
109
+ next unless visibilities.include?(visibility)
110
+
111
+ glyph = VISIBILITY_GLYPH.fetch(visibility, "+")
112
+
113
+ case row.kind
114
+ when "instance_method", "class_method"
115
+ next unless include_methods
116
+
117
+ suffix = row.kind == "class_method" ? "$ " : ""
118
+ members[owner] << "#{glyph}#{row.name}() #{suffix}".strip
119
+ when "attribute"
120
+ next unless include_attributes
121
+
122
+ # access (read/write/accessor) hints at getter/setter
123
+ # presence; we annotate it after the name.
124
+ members[owner] << "#{glyph}#{row.name} : #{row.access}"
125
+ end
126
+ end
127
+ members
128
+ end
129
+
130
+ # Emit one +class Foo+ line per node, plus a body block of
131
+ # methods / attributes when we have any.
132
+ #
133
+ # We intentionally do NOT emit the UML +<<module>>+
134
+ # annotation: Mermaid 10.x's classDiagram parser silently
135
+ # rejects the document when an annotation co-exists with
136
+ # the +class Foo["Label"]+ form we need for namespaced
137
+ # constants, and rejecting the namespace label is worse for
138
+ # a Ruby graph than losing the module marker. The module
139
+ # vs class distinction is therefore encoded as a +" (mod)"+
140
+ # label suffix for module nodes — it is rendered inside the
141
+ # box where every Mermaid renderer surfaces it.
142
+ #
143
+ # Any class that appears only in an edge keeps its bare
144
+ # +class Foo+ line so the arrow has a target.
145
+ def render_classes(out, declarations, members, edges)
146
+ known = Set.new(declarations.keys)
147
+ edges.each do |edge|
148
+ known << edge.from << edge.to
149
+ end
150
+ known.sort.each do |name|
151
+ id = safe_id(name)
152
+ kind = declarations[name]
153
+ label = label_for(name, kind)
154
+ label_suffix = (label == id ? "" : "[\"#{label}\"]")
155
+ out << " class #{id}#{label_suffix}\n"
156
+
157
+ owner_members = members[name]
158
+ next if owner_members.nil? || owner_members.empty?
159
+
160
+ out << " class #{id} {\n"
161
+ owner_members.each do |line|
162
+ out << " #{line}\n"
163
+ end
164
+ out << " }\n"
165
+ end
166
+ end
167
+
168
+ def label_for(name, kind)
169
+ if kind == "module"
170
+ "#{name} «module»"
171
+ else
172
+ name
173
+ end
174
+ end
175
+
176
+ def render_edges(out, edges)
177
+ out << "\n" unless edges.empty?
178
+ edges.each do |edge|
179
+ from_id = safe_id(edge.from)
180
+ to_id = safe_id(edge.to)
181
+ if (cardinality = CARDINALITY[edge.kind])
182
+ left, right = cardinality
183
+ # `from --> to : has_many` — Mermaid renders this as
184
+ # an association arrow with the kind label.
185
+ out << " #{to_id} #{left} -- #{right} #{from_id} : #{edge.kind}\n"
186
+ elsif (arrow = ARROW_FOR_KIND[edge.kind])
187
+ out << " #{to_id} #{arrow} #{from_id} : #{edge.kind}\n"
188
+ else
189
+ out << " #{to_id} <-- #{from_id} : #{edge.kind}\n"
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # The +rigor-module-graph+ gem's public API. See the README for
5
+ # an overview and Rigor::ModuleGraph::CLI for the CLI surface.
6
+ module ModuleGraph
7
+ # The installed gem version.
8
+ VERSION = "0.1.0"
9
+ end
10
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module ModuleGraph
7
+ # Pre-walks a Prism tree once and records each +DefNode+'s
8
+ # effective visibility based on the running +public+ /
9
+ # +protected+ / +private+ marker calls inside its enclosing
10
+ # class / module body.
11
+ #
12
+ # The Analyzer's per-node rules can then look up a visibility
13
+ # in O(1) without re-walking the surrounding body. Built once
14
+ # per file by the plugin's +node_file_context+ hook.
15
+ #
16
+ # Limitations (acknowledged):
17
+ #
18
+ # * +private :foo+ (explicit symbol form) is ignored; only the
19
+ # bare keyword that flips the running visibility is honoured.
20
+ # The bare form covers ~90% of Ruby; the symbol form mostly
21
+ # shows up in DSL-generated method blocks.
22
+ # * +private_class_method+ and singleton-class blocks
23
+ # (+class << self+) are not interpreted.
24
+ # * Methods inside a +class+ inside a method body (rare) stay
25
+ # at +public+.
26
+ class VisibilityMap
27
+ VISIBILITY_MARKERS = %i[public protected private].freeze
28
+
29
+ def initialize
30
+ @table = {}.compare_by_identity
31
+ end
32
+
33
+ # @param root [Prism::Node]
34
+ # @return [VisibilityMap]
35
+ def self.build(root)
36
+ map = new
37
+ walk_top_level(root, map) if root
38
+ map
39
+ end
40
+
41
+ def visibility_for(node)
42
+ @table[node]
43
+ end
44
+
45
+ def self.walk_top_level(node, map)
46
+ return unless node.is_a?(Prism::Node)
47
+
48
+ # The top level is a module-like scope; defs there read as
49
+ # public. Modules nested below get their own pass with the
50
+ # visibility reset to public.
51
+ case node
52
+ when Prism::ProgramNode
53
+ walk_top_level(node.statements, map)
54
+ when Prism::StatementsNode
55
+ node.body.each { |child| walk_top_level(child, map) }
56
+ when Prism::ClassNode, Prism::ModuleNode
57
+ walk_body(node, map)
58
+ end
59
+ end
60
+
61
+ def self.walk_body(class_or_module, map)
62
+ body = class_or_module.body
63
+ statements = body.respond_to?(:body) ? Array(body.body) : []
64
+ current = "public"
65
+
66
+ statements.each do |stmt|
67
+ case stmt
68
+ when Prism::CallNode
69
+ if VISIBILITY_MARKERS.include?(stmt.name) && bare_marker?(stmt)
70
+ current = stmt.name.to_s
71
+ end
72
+ when Prism::DefNode
73
+ map.record(stmt, current)
74
+ when Prism::ClassNode, Prism::ModuleNode
75
+ walk_body(stmt, map)
76
+ end
77
+ end
78
+ end
79
+
80
+ def self.bare_marker?(call_node)
81
+ call_node.receiver.nil? &&
82
+ (call_node.arguments.nil? || call_node.arguments.arguments.empty?)
83
+ end
84
+
85
+ def record(node, visibility)
86
+ @table[node] = visibility
87
+ end
88
+ end
89
+ end
90
+ end