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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '07679b146de8490eecbaab5863ba5658cb8a5f94deb9509573dec80012f15a42'
4
+ data.tar.gz: c8ffb63ed36c006eb6bfe736682e83279ea48e74e94037e332b04f249412993b
5
+ SHA512:
6
+ metadata.gz: b3ed3a5485192ef21cd7f42bbc1c86401ec918c9caac0553ee38c6e1c906f355e2e4531923ae14568116e53655a1b0d06d97d59ed3755c74cccc871d810a4411
7
+ data.tar.gz: d462c5c4b7671caa14617056bd8821cd7904c201eb035abb5a31d9edf0de235ac12d7b6f40b0c239224ef5724c249d85856b3f20b8746481dfd2424c091880c9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 bumpfuji10
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # CallMap
2
+
3
+ Rails アプリケーションのメソッド呼び出しチェーンを静的解析して、text tree として出力する gem です。
4
+
5
+ controller action を起点に「この action は何を呼び、その先で何が起きるのか」を、Notion や PR にそのまま貼れる形で可視化します。コードリーディングの補助ツールであり、完全な静的解析器ではありません。
6
+
7
+ ## 目的
8
+
9
+ Rails アプリで処理の流れを追うとき、`destroy` action からサービス、モデル、notifier へと手でコードを辿りながらメモを書くことがあります。CallMap はその「呼び出しツリーのメモ」を自動生成します。
10
+
11
+ - framework 内部ではなく、**自分たちのアプリケーションコード**に集中する
12
+ - 出力は安定していて、ドキュメントや PR に貼れる
13
+ - `before_action` などの Rails DSL も処理経路として表示する
14
+
15
+ ## インストール
16
+
17
+ 現時点では RubyGems には公開していません。GitHub から直接インストールしてください。
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "call_map", github: "bumpfuji10/call_map"
22
+ ```
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ ## 使い方
29
+
30
+ Rails アプリのルートディレクトリで、`ClassName#method_name` 形式の起点を指定して実行します。
31
+
32
+ ```bash
33
+ bundle exec call_map OrdersController#destroy --depth=3
34
+ ```
35
+
36
+ ### 出力例
37
+
38
+ ```text
39
+ OrdersController#destroy
40
+ ├─ before_action authenticate_user!
41
+ ├─ before_action set_order
42
+ │ ├─ Order.find [framework]
43
+ │ └─ params.[] [framework]
44
+ ├─ authorize [framework]
45
+ ├─ OrderDeleteService.execute
46
+ │ └─ OrderDeleteService#execute
47
+ │ └─ @order.destroy! [framework]
48
+ └─ OrderNotifier.notify_deletion
49
+ └─ OrderNotifier#send_notification
50
+ ├─ OrderNotifier#deliver
51
+ └─ @order.user [framework]
52
+ ```
53
+
54
+ ### オプション
55
+
56
+ | オプション | 説明 |
57
+ |---|---|
58
+ | `--depth=N` | 探索の深さ制限(デフォルト: 3) |
59
+ | `--include-comments` | メソッド直上のコメントをツリーに表示する |
60
+ | `--root=PATH` | 解析対象のアプリケーションルート(デフォルト: カレントディレクトリ。`app/**/*.rb` を索引します) |
61
+
62
+ ```bash
63
+ bundle exec call_map OrdersController#destroy --depth=2 --include-comments
64
+ ```
65
+
66
+ ### ラベルの意味
67
+
68
+ | ラベル | 意味 |
69
+ |---|---|
70
+ | `before_action foo` | `before_action` 由来の呼び出し(action 本体より前に表示) |
71
+ | `foo [framework]` | 自分のコード内に定義が見つからず、Rails / gem 側と思われる呼び出し。ここで探索を止めます |
72
+ | `foo [dynamic]` | `send` / `public_send` などの動的呼び出し。解決せず葉として表示します |
73
+ | `Foo#bar [circular]` | 探索パス上で同じメソッドに再訪した(循環)。ここで探索を止めます |
74
+ | suffix なしの未解決呼び出し | 自クラス等への呼び出しに見えるが定義を発見できなかったもの。解析漏れの可能性があるため、誤ったラベルを付けません |
75
+
76
+ ## 正確性についてのスタンス
77
+
78
+ **CallMap は完全な静的解析器ではありません。** Ruby は動的な言語であり、静的解析で実行時の挙動を完全に再現することはできません。CallMap は「コードを読む人が手で書くメモ」の精度を目標にしており、以下を意図的に選択しています。
79
+
80
+ - 解決できない呼び出しは、誤って潜るのではなく**葉として止める**
81
+ - 確信の持てない呼び出しに誤ったラベルを付けない
82
+ - Rails 内部・Ruby 標準ライブラリ・gem 内部には**潜らない**(`[framework]` として表示して止まります)
83
+
84
+ ## MVP で対応している呼び出しパターン
85
+
86
+ - 同一クラス内の bare call / private method(継承チェーン込み)
87
+ - `SomeService.execute` — class method
88
+ - `SomeClass.new(...).execute` / `self.new.perform` — instance method
89
+ - `self.foo` — 呼び出し元のコンテキスト(instance / class method)に応じて解決
90
+ - namespace 内の相対定数(`Reports::Runner` から `Generator.build` → `Reports::Generator.build`)、絶対定数(`::Foo`)
91
+ - 継承: 親クラスのメソッド・親クラス配下のネスト定数・superclass の lexical 解決
92
+ - `before_action` / `skip_before_action`(`only:` / `except:`(値は symbol / 文字列 / 配列)、複数指定、継承、reopen、skip 後の再追加。callback 名は symbol 指定のみ対応 — `before_action "audit"` のような文字列指定の filter 名は未対応)
93
+ - メソッド直上コメントの保持と表示(`--include-comments`)
94
+
95
+ ## Known limitations
96
+
97
+ - `around_action` / `after_action` には対応していません
98
+ - `include` / `extend` された concern / module のメソッド解決には対応していません
99
+ - メタプログラミング(`define_method`、`method_missing`、動的な定数参照など)は解決できません
100
+ - 同名メソッドが複数ある場合、Ruby の実行時ディスパッチと異なる解決をすることがあります
101
+ - 索引対象は `app/**/*.rb` のみです(`lib/` などは対象外)
102
+ - `[framework]` 判定はヒューリスティックです(receiver 付き未解決、または既知の Rails メソッド名リスト)
103
+
104
+ 設計上のトレードオフの詳細は [docs/initial_design.md](docs/initial_design.md) を参照してください。
105
+
106
+ ## バージョン方針
107
+
108
+ [Semantic Versioning](https://semver.org/) に従います。現在は `0.1.0`(MVP)で、`0.x` の間は出力形式や API に破壊的変更が入る可能性があります。RubyGems への公開は `1.0.0` を目安に検討します。
109
+
110
+ ## License
111
+
112
+ [MIT License](LICENSE.txt)
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ bin/setup # 依存のインストール
118
+ bundle exec rspec # テスト
119
+ bundle exec rubocop
120
+ bin/console # 対話プロンプト
121
+ ```
122
+
123
+ fixture の Rails アプリ(`spec/fixtures/rails_app`)に対して手元で実行できます:
124
+
125
+ ```bash
126
+ ruby -Ilib exe/call_map "OrdersController#destroy" --root=spec/fixtures/rails_app
127
+ ```
128
+
129
+ ## Contributing
130
+
131
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bumpfuji10/call_map.
data/exe/call_map ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "call_map"
5
+ require "call_map/cli"
6
+
7
+ CallMap::CLI.start(ARGV)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "set" # rubocop:disable Lint/RedundantRequireStatement -- explicit for clarity; Set autoloads only on 3.2+
5
+ require_relative "call_extractor"
6
+ require_relative "callback_extractor"
7
+ require_relative "resolver"
8
+ require_relative "call_node"
9
+
10
+ module CallMap
11
+ # Builds a call tree from a starting method definition by recursively
12
+ # extracting calls and resolving them against the SourceIndex.
13
+ class Analyzer
14
+ # @param index [SourceIndex]
15
+ def initialize(index)
16
+ @index = index
17
+ @resolver = Resolver.new(index)
18
+ end
19
+
20
+ # Build a call tree rooted at the given definition.
21
+ #
22
+ # @param definition [Definition] the starting method
23
+ # @param depth [Integer] maximum recursion depth (0 = no children)
24
+ # @return [CallNode]
25
+ def build_call_tree(definition, depth: 3)
26
+ visited = Set.new
27
+ build_node(definition, nil, depth, visited, entry: true)
28
+ end
29
+
30
+ private
31
+
32
+ def build_node(definition, method_call, remaining_depth, visited, entry: false)
33
+ key = node_key(definition)
34
+ circular = definition.method? && visited.include?(key)
35
+ children = if remaining_depth.positive? && definition.method? && !circular
36
+ build_children(definition, remaining_depth, visited | [key], entry: entry)
37
+ else
38
+ []
39
+ end
40
+
41
+ CallNode.new(definition: definition, method_call: method_call, children: children, circular: circular)
42
+ end
43
+
44
+ def build_children(definition, remaining_depth, visited, entry: false)
45
+ callback_nodes = if entry && action_entry?(definition)
46
+ build_callback_nodes(definition, remaining_depth, visited)
47
+ else
48
+ []
49
+ end
50
+ call_nodes = extract_calls(definition).map do |call|
51
+ resolve_and_build(call, definition, remaining_depth, visited)
52
+ end
53
+ callback_nodes + call_nodes
54
+ end
55
+
56
+ # before_action only runs before a controller action — a public instance
57
+ # method. Private/protected helpers used as an entry point get no callbacks.
58
+ def action_entry?(definition)
59
+ definition.kind == :instance_method && definition.public_method?
60
+ end
61
+
62
+ # Callback filter symbols are invoked via normal method lookup on the
63
+ # controller instance — Resolver walks the superclass chain from the
64
+ # action's class, so a child override wins over the parent's method.
65
+ def build_callback_nodes(definition, remaining_depth, visited)
66
+ extract_callbacks(definition).map do |call|
67
+ resolved = @resolver.resolve(call, context_owner: definition.owner)
68
+ build_resolved_node(resolved, call, remaining_depth, visited)
69
+ end
70
+ end
71
+
72
+ def resolve_and_build(call, parent_definition, remaining_depth, visited)
73
+ resolved = @resolver.resolve(call,
74
+ context_owner: parent_definition.owner,
75
+ context_kind: parent_definition.kind,
76
+ lexical_nesting: parent_definition.lexical_nesting)
77
+ build_resolved_node(resolved, call, remaining_depth, visited)
78
+ end
79
+
80
+ def build_resolved_node(resolved, call, remaining_depth, visited)
81
+ if resolved
82
+ build_node(resolved, call, remaining_depth - 1, visited)
83
+ else
84
+ CallNode.new(method_call: call)
85
+ end
86
+ end
87
+
88
+ # Collect callbacks for the action by replaying before_action /
89
+ # skip_before_action declarations in inheritance order (parent first) and
90
+ # declaration order, as Rails does. A skip removes only the callbacks
91
+ # accumulated so far — a callback re-added afterwards runs again.
92
+ def extract_callbacks(definition)
93
+ return [] unless definition.kind == :instance_method
94
+
95
+ events = @index.ancestor_chain(definition.owner).reverse.flat_map do |owner|
96
+ callback_events_on(owner, definition.name)
97
+ end
98
+ replay_callback_events(events)
99
+ end
100
+
101
+ def replay_callback_events(events)
102
+ events.each_with_object([]) do |event, active|
103
+ case event[:type]
104
+ when :add then active << event[:call]
105
+ when :skip then active.reject! { |call| call.method_name == event[:name] }
106
+ end
107
+ end
108
+ end
109
+
110
+ def callback_events_on(owner, action_name)
111
+ @index.find_class_definitions(owner).map(&:path).uniq.flat_map do |path|
112
+ CallbackExtractor.extract(File.read(path), action_name, owner: owner)
113
+ end
114
+ end
115
+
116
+ def extract_calls(definition)
117
+ source = File.read(definition.path)
118
+ def_node = find_def_node(source, definition)
119
+ return [] unless def_node
120
+
121
+ CallExtractor.extract(def_node)
122
+ end
123
+
124
+ def find_def_node(source, definition)
125
+ root = Prism.parse(source).value
126
+ find_def_at_line(root, definition.name, definition.line)
127
+ end
128
+
129
+ def find_def_at_line(node, name, line)
130
+ return node if node.is_a?(Prism::DefNode) && node.name.to_s == name && node.location.start_line == line
131
+
132
+ node.child_nodes.compact.each do |child|
133
+ result = find_def_at_line(child, name, line)
134
+ return result if result
135
+ end
136
+ nil
137
+ end
138
+
139
+ def node_key(definition)
140
+ "#{definition.path}:#{definition.line}:#{definition.qualified_name}"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "method_call"
5
+
6
+ module CallMap
7
+ # Extracts method calls from a method body's AST.
8
+ #
9
+ # Like DefinitionCollector, this is a Prism boundary class — Prism node
10
+ # types are referenced only here.
11
+ class CallExtractor < Prism::Visitor
12
+ DYNAMIC_METHODS = %w[send public_send __send__].freeze
13
+
14
+ # Extract calls from a DefNode's body.
15
+ #
16
+ # @param def_node [Prism::DefNode] the method definition node
17
+ # @return [Array<MethodCall>]
18
+ def self.extract(def_node)
19
+ extractor = new
20
+ def_node.body&.accept(extractor)
21
+ extractor.calls
22
+ end
23
+
24
+ def initialize
25
+ super
26
+ @calls = []
27
+ end
28
+
29
+ attr_reader :calls
30
+
31
+ def visit_call_node(node)
32
+ @calls << build_call(node)
33
+ # Walk arguments and block, but not the receiver itself (it is already
34
+ # captured in the receiver label). However, walk the receiver chain's
35
+ # arguments/blocks so that calls like `SomeClass.new(build_order).execute`
36
+ # still extract `build_order`.
37
+ node.arguments&.accept(self)
38
+ node.block&.accept(self)
39
+ walk_receiver_args(node.receiver)
40
+ end
41
+
42
+ # Do not recurse into nested def bodies — they are not executed
43
+ # as part of the enclosing method's call path.
44
+ def visit_def_node(_node); end
45
+
46
+ private
47
+
48
+ # Walk the receiver chain's arguments/blocks without registering the
49
+ # receiver CallNodes themselves as separate calls.
50
+ def walk_receiver_args(receiver)
51
+ return unless receiver.is_a?(Prism::CallNode)
52
+
53
+ receiver.arguments&.accept(self)
54
+ receiver.block&.accept(self)
55
+ walk_receiver_args(receiver.receiver)
56
+ end
57
+
58
+ def build_call(node)
59
+ receiver_str = receiver_label(node.receiver)
60
+ method_name = node.name.to_s
61
+ dynamic = DYNAMIC_METHODS.include?(method_name)
62
+
63
+ MethodCall.new(
64
+ receiver: receiver_str,
65
+ method_name: dynamic ? dynamic_target(node) : method_name,
66
+ line: node.location.start_line,
67
+ dynamic: dynamic,
68
+ absolute: absolute_constant?(node.receiver)
69
+ )
70
+ end
71
+
72
+ def absolute_constant?(node)
73
+ case node
74
+ when Prism::ConstantPathNode
75
+ current = node
76
+ current = current.parent while current.is_a?(Prism::ConstantPathNode)
77
+ current.nil?
78
+ when Prism::CallNode
79
+ absolute_constant?(node.receiver)
80
+ else
81
+ false
82
+ end
83
+ end
84
+
85
+ def receiver_label(receiver)
86
+ case receiver
87
+ when nil then nil
88
+ when Prism::SelfNode then "self"
89
+ when Prism::ConstantReadNode, Prism::InstanceVariableReadNode
90
+ receiver.name.to_s
91
+ when Prism::ConstantPathNode then constant_path_name(receiver)
92
+ when Prism::CallNode then call_chain_label(receiver)
93
+ else "[expr]"
94
+ end
95
+ end
96
+
97
+ def constant_path_name(node)
98
+ parts = []
99
+ current = node
100
+ while current.is_a?(Prism::ConstantPathNode)
101
+ parts.unshift(current.name.to_s)
102
+ current = current.parent
103
+ end
104
+ parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
105
+ parts.join("::")
106
+ end
107
+
108
+ def call_chain_label(node)
109
+ receiver_str = receiver_label(node.receiver)
110
+ method = node.name.to_s
111
+ receiver_str ? "#{receiver_str}.#{method}" : method
112
+ end
113
+
114
+ # Best-effort extraction of the target method name from send/public_send.
115
+ def dynamic_target(node)
116
+ first_arg = node.arguments&.arguments&.first
117
+ case first_arg
118
+ when Prism::SymbolNode then first_arg.value
119
+ when Prism::StringNode then first_arg.unescaped
120
+ else "[dynamic]"
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CallMap
4
+ # A node in the call tree. Holds the definition (if resolved), the original
5
+ # method call, and child nodes representing calls made from within this method.
6
+ class CallNode
7
+ # @param definition [Definition, nil] resolved definition, nil for unresolved leaves
8
+ # @param method_call [MethodCall, nil] the call site that led here (nil for the root)
9
+ # @param children [Array<CallNode>]
10
+ # @param circular [Boolean] true when this node revisits a definition already on the current path
11
+ def initialize(definition: nil, method_call: nil, children: [], circular: false)
12
+ @definition = definition
13
+ @method_call = method_call
14
+ @children = children
15
+ @circular = circular
16
+ end
17
+
18
+ attr_reader :definition, :method_call, :children
19
+
20
+ def resolved?
21
+ !definition.nil?
22
+ end
23
+
24
+ def circular?
25
+ @circular
26
+ end
27
+
28
+ # Human-readable label for this node. A callback-originated node keeps its
29
+ # callback label (e.g. "before_action set_order") even when resolved, so
30
+ # tree output can distinguish it from a plain call. An unresolved call
31
+ # that points outside the indexed app code is marked as a framework leaf.
32
+ def label
33
+ base = base_label
34
+ return "#{base} [circular]" if circular?
35
+ return "#{base} [framework]" if !resolved? && method_call&.framework_leaf?
36
+
37
+ base
38
+ end
39
+
40
+ private
41
+
42
+ def base_label
43
+ if definition && !method_call&.callback?
44
+ definition.qualified_name
45
+ elsif method_call
46
+ method_call.label
47
+ else
48
+ "[unknown]"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "method_call"
5
+
6
+ module CallMap
7
+ # Extracts before_action callbacks from a class body that apply to a given action.
8
+ #
9
+ # This is a Prism boundary class — Prism node types are referenced only here.
10
+ class CallbackExtractor < Prism::Visitor
11
+ # @param source [String] the file source
12
+ # @param action_name [String] the action method name
13
+ # @param owner [String] the class name to scope extraction to
14
+ # @return [Array<Hash>] callback events in declaration order:
15
+ # { type: :add, call: MethodCall } for before_action,
16
+ # { type: :skip, name: String } for skip_before_action.
17
+ # Order matters — a callback re-added after a skip runs again.
18
+ def self.extract(source, action_name, owner:)
19
+ root = Prism.parse(source).value
20
+ class_bodies = find_class_bodies(root, owner)
21
+ return [] if class_bodies.empty?
22
+
23
+ extractor = new(action_name)
24
+ class_bodies.each { |body| body.accept(extractor) }
25
+ extractor.events
26
+ end
27
+
28
+ # Collect ALL class bodies matching the owner (a class may be reopened
29
+ # multiple times in the same file), in source order.
30
+ def self.find_class_bodies(node, owner)
31
+ collect_class_nodes(node, owner, []).filter_map(&:body)
32
+ end
33
+
34
+ def self.collect_class_nodes(node, owner, namespace)
35
+ return collect_within_class_or_module(node, owner, namespace) if class_or_module?(node)
36
+
37
+ node.child_nodes.compact.flat_map { |child| collect_class_nodes(child, owner, namespace) }
38
+ end
39
+
40
+ def self.class_or_module?(node)
41
+ node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
42
+ end
43
+
44
+ def self.collect_within_class_or_module(node, owner, namespace)
45
+ qualified = build_qualified_name(node, namespace)
46
+ matches = node.is_a?(Prism::ClassNode) && qualified == owner ? [node] : []
47
+
48
+ body_children = (node.body&.child_nodes || []).compact
49
+ matches + body_children.flat_map { |child| collect_class_nodes(child, owner, qualified.split("::")) }
50
+ end
51
+
52
+ def self.build_qualified_name(node, namespace)
53
+ const = node.constant_path
54
+ return const_path_to_string(const) if namespace.empty? || absolute_constant?(const)
55
+
56
+ name = const_path_to_string(const)
57
+ return name if already_qualified?(name, namespace)
58
+
59
+ "#{namespace.join('::')}::#{name}"
60
+ end
61
+
62
+ def self.absolute_constant?(const)
63
+ return false unless const.is_a?(Prism::ConstantPathNode)
64
+
65
+ current = const
66
+ current = current.parent while current.is_a?(Prism::ConstantPathNode)
67
+ current.nil?
68
+ end
69
+
70
+ def self.already_qualified?(name, namespace)
71
+ prefix = namespace.join("::")
72
+ name == prefix || name.start_with?("#{prefix}::")
73
+ end
74
+
75
+ def self.const_path_to_string(const)
76
+ case const
77
+ when Prism::ConstantReadNode then const.name.to_s
78
+ when Prism::ConstantPathNode then full_constant_path(const)
79
+ else const.to_s
80
+ end
81
+ end
82
+
83
+ def self.full_constant_path(node)
84
+ parts = []
85
+ current = node
86
+ while current.is_a?(Prism::ConstantPathNode)
87
+ parts.unshift(current.name.to_s)
88
+ current = current.parent
89
+ end
90
+ parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
91
+ parts.join("::")
92
+ end
93
+
94
+ private_class_method :find_class_bodies, :collect_class_nodes, :class_or_module?,
95
+ :collect_within_class_or_module,
96
+ :build_qualified_name, :absolute_constant?, :already_qualified?,
97
+ :const_path_to_string, :full_constant_path
98
+
99
+ def initialize(action_name)
100
+ super()
101
+ @action_name = action_name
102
+ @events = []
103
+ end
104
+
105
+ attr_reader :events
106
+
107
+ def visit_call_node(node)
108
+ if node.name == :before_action && callback_applies?(node)
109
+ extract_callback_names(node).each do |name|
110
+ call = MethodCall.new(receiver: nil, method_name: name, line: node.location.start_line,
111
+ callback: "before_action")
112
+ @events << { type: :add, call: call }
113
+ end
114
+ elsif node.name == :skip_before_action && callback_applies?(node)
115
+ extract_callback_names(node).each { |name| @events << { type: :skip, name: name } }
116
+ end
117
+ super
118
+ end
119
+
120
+ # Callbacks belong to the class they are declared in — do not descend into
121
+ # nested classes/modules or method bodies within the target class body.
122
+ def visit_class_node(_node); end
123
+ def visit_module_node(_node); end
124
+ def visit_singleton_class_node(_node); end
125
+ def visit_def_node(_node); end
126
+
127
+ private
128
+
129
+ def extract_callback_names(node)
130
+ return [] unless node.arguments
131
+
132
+ node.arguments.arguments.filter_map do |arg|
133
+ arg.value if arg.is_a?(Prism::SymbolNode)
134
+ end
135
+ end
136
+
137
+ def callback_applies?(node)
138
+ filter = find_scope_filter(node)
139
+ return true unless filter
140
+
141
+ key, value = filter
142
+ key == "only" ? action_in_list?(value) : !action_in_list?(value)
143
+ end
144
+
145
+ def find_scope_filter(node)
146
+ # Options arrive as a KeywordHashNode (`only: :show`) or a HashNode
147
+ # when written with explicit braces (`{ only: :show }`).
148
+ options = node.arguments&.arguments&.find do |a|
149
+ a.is_a?(Prism::KeywordHashNode) || a.is_a?(Prism::HashNode)
150
+ end
151
+ return nil unless options
152
+
153
+ assoc = scope_assoc(options)
154
+ assoc && [assoc.key.value, assoc.value]
155
+ end
156
+
157
+ def scope_assoc(keyword_hash)
158
+ keyword_hash.elements.find do |el|
159
+ el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode) && %w[only except].include?(el.key.value)
160
+ end
161
+ end
162
+
163
+ def action_in_list?(value_node)
164
+ case value_node
165
+ when Prism::ArrayNode
166
+ value_node.elements.any? { |el| action_name_matches?(el) }
167
+ else
168
+ action_name_matches?(value_node)
169
+ end
170
+ end
171
+
172
+ def action_name_matches?(node)
173
+ case node
174
+ when Prism::SymbolNode then node.value == @action_name
175
+ when Prism::StringNode then node.unescaped == @action_name
176
+ else false
177
+ end
178
+ end
179
+ end
180
+ end