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.
@@ -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