call_map 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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/exe/call_map +7 -0
- data/lib/call_map/analyzer.rb +143 -0
- data/lib/call_map/call_extractor.rb +124 -0
- data/lib/call_map/call_node.rb +52 -0
- data/lib/call_map/callback_extractor.rb +180 -0
- data/lib/call_map/cli.rb +68 -0
- data/lib/call_map/definition.rb +70 -0
- data/lib/call_map/definition_collector.rb +348 -0
- data/lib/call_map/formatters/text_tree.rb +69 -0
- data/lib/call_map/method_call.rb +74 -0
- data/lib/call_map/resolver.rb +107 -0
- data/lib/call_map/source_index.rb +91 -0
- data/lib/call_map/version.rb +5 -0
- data/lib/call_map.rb +18 -0
- data/sig/call_map.rbs +4 -0
- metadata +81 -0
data/lib/call_map/cli.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "source_index"
|
|
5
|
+
require_relative "analyzer"
|
|
6
|
+
require_relative "formatters/text_tree"
|
|
7
|
+
|
|
8
|
+
module CallMap
|
|
9
|
+
# Command-line entry point: parses the target and options, builds the
|
|
10
|
+
# index, and prints the call tree.
|
|
11
|
+
#
|
|
12
|
+
# call_map OrdersController#destroy --depth=3 --include-comments
|
|
13
|
+
class CLI
|
|
14
|
+
DEFAULT_DEPTH = 3
|
|
15
|
+
|
|
16
|
+
def self.start(argv)
|
|
17
|
+
new.start(argv)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start(argv)
|
|
21
|
+
options = parse_options!(argv)
|
|
22
|
+
target = argv.first
|
|
23
|
+
validate_target!(target)
|
|
24
|
+
|
|
25
|
+
tree = build_tree(target, options)
|
|
26
|
+
puts Formatters::TextTree.format(tree, include_comments: options.fetch(:include_comments, false))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_tree(target, options)
|
|
32
|
+
owner, method_name = target.split("#", 2)
|
|
33
|
+
index = SourceIndex.build(root: options.fetch(:root, Dir.pwd))
|
|
34
|
+
definition = index.find_instance_method(owner, method_name)
|
|
35
|
+
abort "Error: definition not found for '#{target}'." unless definition
|
|
36
|
+
|
|
37
|
+
Analyzer.new(index).build_call_tree(definition, depth: options.fetch(:depth, DEFAULT_DEPTH))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse_options!(argv)
|
|
41
|
+
options = {}
|
|
42
|
+
option_parser(options).parse!(argv)
|
|
43
|
+
options
|
|
44
|
+
rescue OptionParser::ParseError => e
|
|
45
|
+
warn "Error: #{e.message}"
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def option_parser(options)
|
|
50
|
+
OptionParser.new do |opts|
|
|
51
|
+
opts.on("--depth=N", Integer, "Maximum traversal depth (default: #{DEFAULT_DEPTH})") { |n| options[:depth] = n }
|
|
52
|
+
opts.on("--include-comments", "Show method leading comments in the tree") { options[:include_comments] = true }
|
|
53
|
+
opts.on("--root=PATH", "Application root to index (default: cwd)") { |path| options[:root] = path }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_target!(target)
|
|
58
|
+
return if valid_class_name_method_format?(target)
|
|
59
|
+
|
|
60
|
+
warn "Error: Invalid target format '#{target}'. Expected ClassName#method_name."
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid_class_name_method_format?(target)
|
|
65
|
+
/\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*#[a-z_][A-Za-z0-9_]*[!?=]?\z/.match?(target)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CallMap
|
|
4
|
+
# A single class / module / method definition.
|
|
5
|
+
#
|
|
6
|
+
# This is a plain value object and must NOT depend on the parser (Prism).
|
|
7
|
+
# Building a Definition from an AST is the job of the parser boundary class,
|
|
8
|
+
# so that parser-specific code stays in one place.
|
|
9
|
+
class Definition
|
|
10
|
+
KINDS = %i[class module instance_method class_method].freeze
|
|
11
|
+
|
|
12
|
+
# @param kind [Symbol] one of KINDS
|
|
13
|
+
# @param name [String] method name, or qualified constant name for class/module
|
|
14
|
+
# @param path [String] file path where the definition is written
|
|
15
|
+
# @param line [Integer] starting line number of the definition
|
|
16
|
+
# @param owner [String, nil] qualified constant name of the enclosing class/module (for methods)
|
|
17
|
+
# @param lexical_nesting [Array<String>, nil] lexical scope stack at the definition site, outermost first
|
|
18
|
+
# @param superclass [String, nil] superclass constant name as written (for :class definitions)
|
|
19
|
+
# @param visibility [Symbol] :public / :private / :protected (instance methods)
|
|
20
|
+
def initialize(kind:, name:, path:, line:, owner: nil, lexical_nesting: nil, superclass: nil, visibility: :public)
|
|
21
|
+
raise ArgumentError, "unknown kind: #{kind}" unless KINDS.include?(kind)
|
|
22
|
+
|
|
23
|
+
@kind = kind
|
|
24
|
+
@name = name
|
|
25
|
+
@owner = owner
|
|
26
|
+
@path = path
|
|
27
|
+
@line = line
|
|
28
|
+
@lexical_nesting = lexical_nesting
|
|
29
|
+
@superclass = superclass
|
|
30
|
+
@visibility = visibility
|
|
31
|
+
# Free-form metadata; :comments holds the method's leading comment lines.
|
|
32
|
+
@metadata = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :kind, :name, :owner, :path, :line, :lexical_nesting, :superclass
|
|
36
|
+
# Writable so `private :foo`-style post-declarations can adjust it.
|
|
37
|
+
attr_accessor :visibility
|
|
38
|
+
|
|
39
|
+
def public_method?
|
|
40
|
+
visibility == :public
|
|
41
|
+
end
|
|
42
|
+
attr_accessor :metadata
|
|
43
|
+
|
|
44
|
+
# Leading comment lines attached at collection time ("#" stripped).
|
|
45
|
+
def comments
|
|
46
|
+
metadata[:comments] || []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def class_or_module?
|
|
50
|
+
%i[class module].include?(kind)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def method?
|
|
54
|
+
%i[instance_method class_method].include?(kind)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Human-readable qualified name used for lookups and tree output.
|
|
58
|
+
#
|
|
59
|
+
# - class / module: "Admin::ReportsController"
|
|
60
|
+
# - instance method: "OrdersController#destroy"
|
|
61
|
+
# - class method: "OrderDeleteService.execute"
|
|
62
|
+
def qualified_name
|
|
63
|
+
case kind
|
|
64
|
+
when :instance_method then "#{owner}##{name}"
|
|
65
|
+
when :class_method then "#{owner}.#{name}"
|
|
66
|
+
else name
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "definition"
|
|
5
|
+
|
|
6
|
+
module CallMap
|
|
7
|
+
# The single boundary that touches Prism.
|
|
8
|
+
#
|
|
9
|
+
# Prism node types (ClassNode / ModuleNode / DefNode ...) are referenced only
|
|
10
|
+
# here. Everything else in the codebase works with Definition, so the parser
|
|
11
|
+
# can be swapped out later by replacing just this class.
|
|
12
|
+
class DefinitionCollector < Prism::Visitor
|
|
13
|
+
# Parse `source` and return the list of definitions found in it.
|
|
14
|
+
#
|
|
15
|
+
# @param source [String] Ruby source code
|
|
16
|
+
# @param path [String] file path the source came from (kept on each Definition)
|
|
17
|
+
# @return [Array<Definition>]
|
|
18
|
+
def self.collect(source, path:)
|
|
19
|
+
new(path).collect(source)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(path)
|
|
23
|
+
super() # Prism::Visitor#initialize takes no args
|
|
24
|
+
@path = path
|
|
25
|
+
@namespace = [] # stack of enclosing class/module names (for qualified naming)
|
|
26
|
+
@singletons = [] # per-scope singleton owner (nil / String / :unresolved)
|
|
27
|
+
@lexical_scopes = [] # syntactic scope stack of FULL names (mirrors Module.nesting)
|
|
28
|
+
@visibilities = [:public] # per-scope visibility state toggled by private/protected/public
|
|
29
|
+
@inline_visibility = nil # for the `private def foo` inline form
|
|
30
|
+
@definitions = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def collect(source)
|
|
34
|
+
# Prism.parse returns a ParseResult; .value is the root (ProgramNode).
|
|
35
|
+
# accept(self) starts the visitor traversal from the root.
|
|
36
|
+
result = Prism.parse(source)
|
|
37
|
+
@comment_lines = pure_comment_lines(source, result.comments)
|
|
38
|
+
result.value.accept(self)
|
|
39
|
+
@definitions
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Called when the traversal reaches a `class` definition.
|
|
43
|
+
def visit_class_node(node)
|
|
44
|
+
enter_namespace(node.constant_path) do
|
|
45
|
+
@definitions << build_definition(:class, current_namespace, node, superclass: superclass_name(node))
|
|
46
|
+
super # descend into the class body (its methods etc.)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Called when the traversal reaches a `module` definition.
|
|
51
|
+
def visit_module_node(node)
|
|
52
|
+
enter_namespace(node.constant_path) do
|
|
53
|
+
@definitions << build_definition(:module, current_namespace, node)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Called for `class << self`, `class << SomeConstant`, and `class << obj`.
|
|
59
|
+
def visit_singleton_class_node(node)
|
|
60
|
+
within_singleton(singleton_owner(node.expression)) do
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Called for every `def` — `def foo`, `def self.foo`, and `def Foo.bar`.
|
|
66
|
+
def visit_def_node(node)
|
|
67
|
+
info = method_kind_and_owner(node.receiver)
|
|
68
|
+
# info is nil when the method belongs to an unresolvable receiver
|
|
69
|
+
# (e.g. `class << obj`); such defs are skipped rather than mis-registered.
|
|
70
|
+
# No super in either case — do not recurse into method bodies. Nested
|
|
71
|
+
# defs inside a method are runtime-only and should not appear in the index.
|
|
72
|
+
return unless info
|
|
73
|
+
|
|
74
|
+
definition = build_definition(info[:kind], node.name.to_s, node, owner: info[:owner])
|
|
75
|
+
comments = leading_comments(node.location.start_line)
|
|
76
|
+
definition.metadata[:comments] = comments if comments.any?
|
|
77
|
+
@definitions << definition
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Track `private` / `protected` / `public` visibility directives at the
|
|
81
|
+
# class-body level (bare toggle, `private def foo`, and `private :foo`).
|
|
82
|
+
def visit_call_node(node)
|
|
83
|
+
directive = visibility_directive(node)
|
|
84
|
+
return super unless directive
|
|
85
|
+
|
|
86
|
+
args = node.arguments&.arguments
|
|
87
|
+
return with_inline_visibility(directive) { super } if args&.any? && args.all?(Prism::DefNode)
|
|
88
|
+
|
|
89
|
+
apply_visibility(directive, args)
|
|
90
|
+
super
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Decide the (kind, owner) of a method from its `def` receiver.
|
|
96
|
+
# Returns nil to signal "do not register this def".
|
|
97
|
+
#
|
|
98
|
+
# - no receiver (`def foo`): depends on the enclosing scope
|
|
99
|
+
# - inside `class << self` / `class << Const` -> class method on that owner
|
|
100
|
+
# - inside `class << obj` (unresolvable) -> nil (skip)
|
|
101
|
+
# - otherwise -> instance method on the current namespace
|
|
102
|
+
# - self receiver (`def self.foo`) -> class method on the current namespace
|
|
103
|
+
# - constant receiver (`def Foo.bar`) -> class method owned by that constant
|
|
104
|
+
def method_kind_and_owner(receiver)
|
|
105
|
+
case receiver
|
|
106
|
+
when nil
|
|
107
|
+
singleton_scope_kind_and_owner
|
|
108
|
+
when Prism::SelfNode
|
|
109
|
+
{ kind: :class_method, owner: current_namespace }
|
|
110
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
111
|
+
{ kind: :class_method, owner: qualified_constant(receiver) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Interpret the innermost singleton scope for a bodyless `def`.
|
|
116
|
+
def singleton_scope_kind_and_owner
|
|
117
|
+
owner = current_singleton_owner
|
|
118
|
+
case owner
|
|
119
|
+
when nil then { kind: :instance_method, owner: current_namespace }
|
|
120
|
+
when :unresolved then nil
|
|
121
|
+
else { kind: :class_method, owner: owner }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The superclass constant as written; an absolute path keeps its leading
|
|
126
|
+
# "::" so downstream resolution can skip the namespace fallback.
|
|
127
|
+
def superclass_name(node)
|
|
128
|
+
name = constant_name(node.superclass)
|
|
129
|
+
return name unless name && absolute_constant?(node.superclass)
|
|
130
|
+
|
|
131
|
+
"::#{name}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_definition(kind, name, node, owner: nil, superclass: nil)
|
|
135
|
+
nesting = %i[instance_method class_method].include?(kind) ? lexical_nesting : outer_nesting
|
|
136
|
+
visibility = kind == :instance_method ? current_visibility : :public
|
|
137
|
+
Definition.new(kind: kind, name: name, owner: owner, path: @path, line: node.location.start_line,
|
|
138
|
+
lexical_nesting: nesting, superclass: superclass, visibility: visibility)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Map of line number → comment text, limited to whole-line comments.
|
|
142
|
+
# A trailing comment after code (`x = 1 # setup`) must not be mistaken
|
|
143
|
+
# for a leading comment of the following def.
|
|
144
|
+
def pure_comment_lines(source, comments)
|
|
145
|
+
lines = source.lines
|
|
146
|
+
comments.each_with_object({}) do |comment, map|
|
|
147
|
+
line = comment.location.start_line
|
|
148
|
+
map[line] = comment.slice.sub(/\A#\s?/, "") if lines[line - 1]&.strip&.start_with?("#")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Contiguous comment lines directly above the given line, in source order.
|
|
153
|
+
# A blank line breaks contiguity, so file-top magic comments are not
|
|
154
|
+
# attached to the first method.
|
|
155
|
+
def leading_comments(line)
|
|
156
|
+
collected = []
|
|
157
|
+
cursor = line - 1
|
|
158
|
+
while @comment_lines.key?(cursor)
|
|
159
|
+
collected.unshift(@comment_lines[cursor])
|
|
160
|
+
cursor -= 1
|
|
161
|
+
end
|
|
162
|
+
collected
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def visibility_directive(node)
|
|
166
|
+
return nil unless node.receiver.nil? && %i[public private protected].include?(node.name)
|
|
167
|
+
|
|
168
|
+
node.name
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def apply_visibility(directive, args)
|
|
172
|
+
if args.nil? || args.empty?
|
|
173
|
+
@visibilities[-1] = directive
|
|
174
|
+
else
|
|
175
|
+
mark_named_visibility(directive, args)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def current_visibility
|
|
180
|
+
@inline_visibility || @visibilities.last
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def with_inline_visibility(directive)
|
|
184
|
+
@inline_visibility = directive
|
|
185
|
+
yield
|
|
186
|
+
ensure
|
|
187
|
+
@inline_visibility = nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# `private :foo, :bar` — adjust already-collected definitions by name.
|
|
191
|
+
def mark_named_visibility(directive, args)
|
|
192
|
+
names = args.filter_map { |a| a.value if a.is_a?(Prism::SymbolNode) }
|
|
193
|
+
@definitions.each do |d|
|
|
194
|
+
d.visibility = directive if d.kind == :instance_method && d.owner == current_namespace && names.include?(d.name)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The lexical scope stack at the definition site, outermost first, where
|
|
199
|
+
# each entry is the scope's FULL qualified name (mirroring Module.nesting,
|
|
200
|
+
# which is purely syntactic). E.g. inside `module Admin; class Admin::Foo`
|
|
201
|
+
# the stack is ["Admin", "Admin::Foo"] — resolution tries each entry as a
|
|
202
|
+
# prefix from innermost outward, so both Admin::Foo::X and Admin::X are
|
|
203
|
+
# searched, but never a double-prefixed Admin::Admin::X.
|
|
204
|
+
def lexical_nesting
|
|
205
|
+
return nil if @lexical_scopes.empty?
|
|
206
|
+
|
|
207
|
+
@lexical_scopes.dup
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# For class/module definitions: the scope stack OUTSIDE the definition
|
|
211
|
+
# itself. A superclass expression (`class Foo < Bar`) is evaluated in
|
|
212
|
+
# this outer scope, not inside the class body.
|
|
213
|
+
def outer_nesting
|
|
214
|
+
return nil if @lexical_scopes.size <= 1
|
|
215
|
+
|
|
216
|
+
@lexical_scopes[0..-2]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Route to absolute or relative namespace handling based on the constant node.
|
|
220
|
+
def enter_namespace(constant_node, &)
|
|
221
|
+
name = constant_name(constant_node)
|
|
222
|
+
ns = current_namespace
|
|
223
|
+
|
|
224
|
+
if absolute_constant?(constant_node) || already_qualified?(name, ns)
|
|
225
|
+
within_absolute_namespace(name, &)
|
|
226
|
+
else
|
|
227
|
+
within_namespace(name, &)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A constant path like `Admin::ReportsController` inside `module Admin`
|
|
232
|
+
# already contains the enclosing namespace — pushing it would produce
|
|
233
|
+
# `Admin::Admin::ReportsController`.
|
|
234
|
+
def already_qualified?(name, namespace)
|
|
235
|
+
return false if namespace.empty? || name.nil?
|
|
236
|
+
|
|
237
|
+
name == namespace || name.start_with?("#{namespace}::")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Push `name` while the block runs, then always pop it back off.
|
|
241
|
+
# Entering a named class/module also pushes a nil singleton owner, so a
|
|
242
|
+
# normal class nested inside `class << self` is not mistaken for a singleton.
|
|
243
|
+
def within_namespace(name)
|
|
244
|
+
@namespace.push(name)
|
|
245
|
+
@singletons.push(nil)
|
|
246
|
+
push_scope(current_namespace)
|
|
247
|
+
yield
|
|
248
|
+
ensure
|
|
249
|
+
@namespace.pop
|
|
250
|
+
@singletons.pop
|
|
251
|
+
pop_scope
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# For absolute constant paths (`class ::Foo::Bar`) and already-qualified
|
|
255
|
+
# compact-style names, replace the NAMING stack with just the constant's
|
|
256
|
+
# own segments. The lexical scope stack still gains the new scope while
|
|
257
|
+
# keeping the outer ones — Module.nesting is syntactic, so enclosing
|
|
258
|
+
# modules remain part of constant lookup even for such definitions.
|
|
259
|
+
def within_absolute_namespace(name)
|
|
260
|
+
saved = [@namespace, @singletons]
|
|
261
|
+
@namespace = [name]
|
|
262
|
+
@singletons = [nil]
|
|
263
|
+
push_scope(name)
|
|
264
|
+
yield
|
|
265
|
+
ensure
|
|
266
|
+
@namespace, @singletons = saved
|
|
267
|
+
pop_scope
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Visibility state and the syntactic scope stack move together whenever a
|
|
271
|
+
# class/module scope is entered.
|
|
272
|
+
def push_scope(lexical_name)
|
|
273
|
+
@visibilities.push(:public)
|
|
274
|
+
@lexical_scopes.push(lexical_name)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def pop_scope
|
|
278
|
+
@visibilities.pop
|
|
279
|
+
@lexical_scopes.pop
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Track the class-method owner implied by the current singleton scope.
|
|
283
|
+
# owner is a String ("class << Foo"), or :unresolved ("class << obj").
|
|
284
|
+
def within_singleton(owner)
|
|
285
|
+
@singletons.push(owner)
|
|
286
|
+
@visibilities.push(:public)
|
|
287
|
+
yield
|
|
288
|
+
ensure
|
|
289
|
+
@singletons.pop
|
|
290
|
+
@visibilities.pop
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def current_singleton_owner
|
|
294
|
+
@singletons.last
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Resolve the owner for a `class << expression` header.
|
|
298
|
+
# - self -> the enclosing class/module
|
|
299
|
+
# - a constant -> that (namespace-qualified) constant
|
|
300
|
+
# - anything else -> :unresolved (a runtime object we can't name statically)
|
|
301
|
+
def singleton_owner(expression)
|
|
302
|
+
case expression
|
|
303
|
+
when Prism::SelfNode then current_namespace
|
|
304
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then qualified_constant(expression)
|
|
305
|
+
else :unresolved
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Best-effort qualification of a constant receiver with the current
|
|
310
|
+
# namespace. Full Ruby constant resolution is out of scope for the MVP;
|
|
311
|
+
# a relative constant is simply prefixed with the enclosing namespace.
|
|
312
|
+
def qualified_constant(node)
|
|
313
|
+
name = constant_name(node)
|
|
314
|
+
ns = current_namespace
|
|
315
|
+
return name if name.nil? || ns.empty? || absolute_constant?(node)
|
|
316
|
+
return ns if name == ns || ns.end_with?("::#{name}")
|
|
317
|
+
|
|
318
|
+
"#{ns}::#{name}"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# A ConstantPathNode whose root parent is nil represents an absolute
|
|
322
|
+
# constant path (e.g. `::Reports::Generator`). Such constants should
|
|
323
|
+
# not be prefixed with the enclosing namespace.
|
|
324
|
+
def absolute_constant?(node)
|
|
325
|
+
return false unless node.is_a?(Prism::ConstantPathNode)
|
|
326
|
+
|
|
327
|
+
root = node
|
|
328
|
+
root = root.parent while root.parent.is_a?(Prism::ConstantPathNode)
|
|
329
|
+
root.parent.nil?
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def current_namespace
|
|
333
|
+
@namespace.join("::")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Build a qualified name string from a constant node.
|
|
337
|
+
# - ConstantReadNode (`Foo`) -> "Foo"
|
|
338
|
+
# - ConstantPathNode (`Admin::Foo`) -> "Admin::Foo"
|
|
339
|
+
def constant_name(node)
|
|
340
|
+
case node
|
|
341
|
+
when Prism::ConstantReadNode
|
|
342
|
+
node.name.to_s
|
|
343
|
+
when Prism::ConstantPathNode
|
|
344
|
+
[constant_name(node.parent), node.name.to_s].compact.join("::")
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CallMap
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders a CallNode tree as a text tree using box-drawing characters,
|
|
6
|
+
# suitable for pasting into Notion or pull requests.
|
|
7
|
+
#
|
|
8
|
+
# OrdersController#destroy
|
|
9
|
+
# ├─ before_action set_order
|
|
10
|
+
# │ └─ Order.find
|
|
11
|
+
# └─ OrderDeleteService.execute
|
|
12
|
+
# └─ OrderDeleteService#execute
|
|
13
|
+
#
|
|
14
|
+
# With include_comments: true, a resolved node's leading comment is
|
|
15
|
+
# appended as an inline annotation (opt-in — comments are reading aid,
|
|
16
|
+
# not call-graph structure):
|
|
17
|
+
#
|
|
18
|
+
# └─ OrderDeleteService#execute # Destroys the order.
|
|
19
|
+
class TextTree
|
|
20
|
+
# Only the first comment line is shown, truncated to keep the tree scannable.
|
|
21
|
+
MAX_COMMENT_LENGTH = 60
|
|
22
|
+
|
|
23
|
+
# @param root [CallNode]
|
|
24
|
+
# @param include_comments [Boolean] append leading comments to resolved nodes
|
|
25
|
+
# @return [String]
|
|
26
|
+
def self.format(root, include_comments: false)
|
|
27
|
+
new(include_comments: include_comments).format(root)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(include_comments: false)
|
|
31
|
+
@include_comments = include_comments
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def format(root)
|
|
35
|
+
lines = [node_label(root)]
|
|
36
|
+
append_children(lines, root.children, "")
|
|
37
|
+
"#{lines.join("\n")}\n"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def append_children(lines, children, prefix)
|
|
43
|
+
children.each_with_index do |child, index|
|
|
44
|
+
last = index == children.size - 1
|
|
45
|
+
connector = last ? "└─ " : "├─ "
|
|
46
|
+
lines << "#{prefix}#{connector}#{node_label(child)}"
|
|
47
|
+
child_prefix = prefix + (last ? " " : "│ ")
|
|
48
|
+
append_children(lines, child.children, child_prefix)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def node_label(node)
|
|
53
|
+
label = node.label
|
|
54
|
+
return label unless @include_comments
|
|
55
|
+
|
|
56
|
+
comment = node.definition&.comments&.first
|
|
57
|
+
return label unless comment
|
|
58
|
+
|
|
59
|
+
"#{label} # #{truncate(comment)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def truncate(text)
|
|
63
|
+
return text if text.length <= MAX_COMMENT_LENGTH
|
|
64
|
+
|
|
65
|
+
"#{text[0, MAX_COMMENT_LENGTH]}…"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CallMap
|
|
4
|
+
# A single method call extracted from a method body.
|
|
5
|
+
#
|
|
6
|
+
# This is a plain value object and must NOT depend on the parser (Prism).
|
|
7
|
+
# Building a MethodCall from an AST is the job of CallExtractor.
|
|
8
|
+
class MethodCall
|
|
9
|
+
# Common Rails methods that appear as bare calls inside controllers and
|
|
10
|
+
# models. A bare call that stays unresolved and matches this list is
|
|
11
|
+
# displayed as a framework leaf.
|
|
12
|
+
KNOWN_FRAMEWORK_METHODS = %w[
|
|
13
|
+
redirect_to redirect_back render render_to_string head respond_to respond_with
|
|
14
|
+
params request response session cookies flash logger helpers url_for
|
|
15
|
+
current_user authenticate_user! sign_in sign_out authorize policy_scope
|
|
16
|
+
raise puts pp
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# @param receiver [String, nil] receiver expression ("OrderDeleteService", "self", nil for bare calls)
|
|
20
|
+
# @param method_name [String] name of the called method
|
|
21
|
+
# @param line [Integer] line number of the call site
|
|
22
|
+
# @param dynamic [Boolean] true for send/public_send style calls
|
|
23
|
+
# @param absolute [Boolean] true for ::Foo style absolute constant paths
|
|
24
|
+
# @param callback [String, nil] callback type (e.g. "before_action") if this call originates from a DSL callback
|
|
25
|
+
def initialize(receiver:, method_name:, line:, dynamic: false, absolute: false, callback: nil)
|
|
26
|
+
@receiver = receiver
|
|
27
|
+
@method_name = method_name
|
|
28
|
+
@line = line
|
|
29
|
+
@dynamic = dynamic
|
|
30
|
+
@absolute = absolute
|
|
31
|
+
@callback = callback
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :receiver, :method_name, :line, :callback
|
|
35
|
+
|
|
36
|
+
def dynamic?
|
|
37
|
+
@dynamic
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def absolute?
|
|
41
|
+
@absolute
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def callback?
|
|
45
|
+
!@callback.nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bare?
|
|
49
|
+
receiver.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether this call, IF it stays unresolved, should be shown as a
|
|
53
|
+
# framework leaf. An explicit receiver that did not resolve points
|
|
54
|
+
# outside the indexed app code; a bare call (including a callback filter
|
|
55
|
+
# like Devise's `before_action :authenticate_user!`) is framework-ish
|
|
56
|
+
# only when it matches the known Rails method list — an unlisted bare
|
|
57
|
+
# call may just be an analysis miss, so it gets no suffix instead.
|
|
58
|
+
def framework_leaf?
|
|
59
|
+
return false if dynamic?
|
|
60
|
+
return KNOWN_FRAMEWORK_METHODS.include?(method_name) if bare?
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Human-readable label for tree output.
|
|
66
|
+
def label
|
|
67
|
+
base = receiver ? "#{receiver}.#{method_name}" : method_name
|
|
68
|
+
return "#{callback} #{base}" if callback?
|
|
69
|
+
return "#{base} [dynamic]" if dynamic?
|
|
70
|
+
|
|
71
|
+
base
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|