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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallMap
4
+ # Resolves a MethodCall to a Definition using the SourceIndex.
5
+ #
6
+ # This handles the common Rails patterns:
7
+ # - bare call within the same class → instance method on `context_owner`
8
+ # - `SomeService.execute` → class method on SomeService
9
+ # - `SomeClass.new(...).execute` → instance method on SomeClass
10
+ # - `self.foo` → class method on `context_owner`
11
+ class Resolver
12
+ # @param index [SourceIndex]
13
+ def initialize(index)
14
+ @index = index
15
+ end
16
+
17
+ # @param call [MethodCall] the call to resolve
18
+ # @param context_owner [String] the class/module the calling method belongs to
19
+ # @return [Definition, nil]
20
+ def resolve(call, context_owner:, context_kind: :instance_method, lexical_nesting: nil)
21
+ return nil if call.dynamic?
22
+
23
+ if call.bare? || call.receiver == "self"
24
+ resolve_bare(call, context_owner, context_kind)
25
+ else
26
+ resolve_receiver(call, context_owner, context_kind, lexical_nesting)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # Bare (and `self.`) calls dispatch through normal method lookup, so walk
33
+ # the owner's superclass chain — inherited helpers like a parent
34
+ # controller's `authenticate_user!` resolve too.
35
+ def resolve_bare(call, context_owner, context_kind)
36
+ finder = context_kind == :class_method ? :find_class_method : :find_instance_method
37
+ find_in_chain(finder, context_owner, call.method_name)
38
+ end
39
+
40
+ # Resolve a call with an explicit receiver.
41
+ def resolve_receiver(call, context_owner, context_kind, lexical_nesting)
42
+ receiver = call.receiver
43
+
44
+ # `SomeClass.new(...)` chain → instance method on SomeClass
45
+ if receiver.match?(/\A([A-Z][A-Za-z0-9:]*?)\.new\z/)
46
+ owner = receiver.sub(/\.new\z/, "")
47
+ return resolve_constant(:instance_method, owner, call, lexical_nesting, context_owner)
48
+ end
49
+
50
+ # bare `new` or `self.new` chain (implicit self.new inside a class method only)
51
+ if %w[new self.new].include?(receiver) && context_kind == :class_method
52
+ return find_in_chain(:find_instance_method, context_owner, call.method_name)
53
+ end
54
+
55
+ # `SomeClass.method` → class method
56
+ return unless receiver.match?(/\A[A-Z]/)
57
+
58
+ resolve_constant(:class_method, receiver, call, lexical_nesting, context_owner)
59
+ end
60
+
61
+ def resolve_constant(kind, owner, call, lexical_nesting, context_owner)
62
+ finder = kind == :class_method ? :find_class_method : :find_instance_method
63
+ if call.absolute?
64
+ find_in_chain(finder, owner, call.method_name)
65
+ else
66
+ find_with_namespace_fallback(finder, owner, call.method_name, lexical_nesting, context_owner)
67
+ end
68
+ end
69
+
70
+ # Try the constant against each lexical scope from innermost outward
71
+ # (mirroring Ruby's constant lookup), then against the context class's
72
+ # superclass chain (constants nested in a parent class are visible from
73
+ # the child), then fall back to top-level. Each scope entry is a full
74
+ # qualified name used as a prefix directly.
75
+ def find_with_namespace_fallback(finder, owner, method_name, lexical_nesting, context_owner)
76
+ scopes = (lexical_nesting || []).reverse + ancestor_scopes(lexical_nesting, context_owner)
77
+ scopes.each do |scope|
78
+ result = find_in_chain(finder, "#{scope}::#{owner}", method_name)
79
+ return result if result
80
+ end
81
+
82
+ find_in_chain(finder, owner, method_name)
83
+ end
84
+
85
+ # Ruby searches the ancestors of the innermost lexically enclosing scope
86
+ # (the cref), not the method's owner. Only when the method is defined
87
+ # inside its own class body do the two coincide — for explicit-receiver
88
+ # definitions like `module Reports; def Generator.build` the owner's
89
+ # ancestors are NOT part of constant lookup.
90
+ def ancestor_scopes(lexical_nesting, context_owner)
91
+ return [] unless (lexical_nesting || []).last == context_owner
92
+
93
+ @index.ancestor_chain(context_owner)
94
+ end
95
+
96
+ # Method dispatch walks the receiver class's superclass chain, so a
97
+ # method defined on a parent (class or instance side) resolves too.
98
+ def find_in_chain(finder, owner, method_name)
99
+ @index.ancestor_chain(owner).each do |candidate|
100
+ result = @index.public_send(finder, candidate, method_name)
101
+ return result if result
102
+ end
103
+
104
+ nil
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "definition_collector"
4
+
5
+ module CallMap
6
+ # Indexes class / module / method definitions found under a directory.
7
+ #
8
+ # AST handling is delegated to DefinitionCollector; this class only stores
9
+ # the resulting Definitions and answers lookups against them.
10
+ class SourceIndex
11
+ DEFAULT_GLOB = "app/**/*.rb"
12
+
13
+ # Build a SourceIndex by indexing every Ruby file under `root`.
14
+ def self.build(root:, glob: DEFAULT_GLOB)
15
+ new.index_directory(root, glob: glob)
16
+ end
17
+
18
+ def initialize
19
+ @definitions = []
20
+ end
21
+
22
+ attr_reader :definitions
23
+
24
+ # @param root [String] directory to search from
25
+ # @param glob [String] glob pattern relative to root
26
+ def index_directory(root, glob: DEFAULT_GLOB)
27
+ Dir.glob(File.join(root, glob)).each { |path| index_file(path) }
28
+ self
29
+ end
30
+
31
+ def index_file(path)
32
+ @definitions.concat(DefinitionCollector.collect(File.read(path), path: path))
33
+ self
34
+ end
35
+
36
+ def find_instance_method(owner, name)
37
+ find_method(:instance_method, owner, name)
38
+ end
39
+
40
+ def find_class_method(owner, name)
41
+ find_method(:class_method, owner, name)
42
+ end
43
+
44
+ # All :class definitions matching the qualified name (a class may be
45
+ # reopened across files), in indexing order.
46
+ def find_class_definitions(qualified_name)
47
+ definitions.select { |d| d.kind == :class && d.name == qualified_name }
48
+ end
49
+
50
+ # The class plus its superclasses (innermost first), resolved against the
51
+ # index. Stops at classes not present in the index or on a cycle.
52
+ def ancestor_chain(owner)
53
+ chain = []
54
+ current = owner
55
+ while current && !chain.include?(current)
56
+ chain << current
57
+ current = superclass_of(current)
58
+ end
59
+ chain
60
+ end
61
+
62
+ def superclass_of(owner)
63
+ definition = find_class_definitions(owner).find(&:superclass)
64
+ return nil unless definition
65
+
66
+ superclass = definition.superclass
67
+ # A "::"-prefixed superclass is an absolute path — no namespace fallback.
68
+ return superclass.delete_prefix("::") if superclass.start_with?("::")
69
+
70
+ resolve_class_name(superclass, definition.lexical_nesting)
71
+ end
72
+
73
+ private
74
+
75
+ def find_method(kind, owner, name)
76
+ definitions.reverse_each.find { |d| d.kind == kind && d.owner == owner && d.name == name.to_s }
77
+ end
78
+
79
+ # Resolve a superclass constant written relative to the subclass, mirroring
80
+ # Ruby's lexical lookup: each enclosing scope (a full qualified name) from
81
+ # innermost outward, then top-level.
82
+ def resolve_class_name(name, nesting)
83
+ (nesting || []).reverse_each do |scope|
84
+ candidate = "#{scope}::#{name}"
85
+ return candidate if find_class_definitions(candidate).any?
86
+ end
87
+
88
+ name
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallMap
4
+ VERSION = "0.1.0"
5
+ end
data/lib/call_map.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "call_map/version"
4
+ require_relative "call_map/definition"
5
+ require_relative "call_map/definition_collector"
6
+ require_relative "call_map/source_index"
7
+ require_relative "call_map/method_call"
8
+ require_relative "call_map/call_extractor"
9
+ require_relative "call_map/callback_extractor"
10
+ require_relative "call_map/call_node"
11
+ require_relative "call_map/resolver"
12
+ require_relative "call_map/analyzer"
13
+ require_relative "call_map/formatters/text_tree"
14
+
15
+ module CallMap
16
+ class Error < StandardError; end
17
+ # Your code goes here...
18
+ end
data/sig/call_map.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module CallMap
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: call_map
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - bumpfuji10
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: prism
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ description: CallMap statically analyzes a Rails application and prints the method
28
+ call chain from a controller action as a stable text tree, including before_action
29
+ callbacks. It is a code-reading aid focused on application code, not a full static
30
+ analyzer.
31
+ email:
32
+ - bumpfuji10@gmail.com
33
+ executables:
34
+ - call_map
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - LICENSE.txt
39
+ - README.md
40
+ - exe/call_map
41
+ - lib/call_map.rb
42
+ - lib/call_map/analyzer.rb
43
+ - lib/call_map/call_extractor.rb
44
+ - lib/call_map/call_node.rb
45
+ - lib/call_map/callback_extractor.rb
46
+ - lib/call_map/cli.rb
47
+ - lib/call_map/definition.rb
48
+ - lib/call_map/definition_collector.rb
49
+ - lib/call_map/formatters/text_tree.rb
50
+ - lib/call_map/method_call.rb
51
+ - lib/call_map/resolver.rb
52
+ - lib/call_map/source_index.rb
53
+ - lib/call_map/version.rb
54
+ - sig/call_map.rbs
55
+ homepage: https://github.com/bumpfuji10/call_map
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://github.com/bumpfuji10/call_map
60
+ source_code_uri: https://github.com/bumpfuji10/call_map
61
+ rubygems_mfa_required: 'true'
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 3.2.0
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.4.19
78
+ signing_key:
79
+ specification_version: 4
80
+ summary: Generate readable call maps from Rails controller actions.
81
+ test_files: []