ast-merge 1.0.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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +852 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/ast/merge/ast_node.rb +87 -0
  14. data/lib/ast/merge/comment/block.rb +195 -0
  15. data/lib/ast/merge/comment/empty.rb +78 -0
  16. data/lib/ast/merge/comment/line.rb +138 -0
  17. data/lib/ast/merge/comment/parser.rb +278 -0
  18. data/lib/ast/merge/comment/style.rb +282 -0
  19. data/lib/ast/merge/comment.rb +36 -0
  20. data/lib/ast/merge/conflict_resolver_base.rb +399 -0
  21. data/lib/ast/merge/debug_logger.rb +271 -0
  22. data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
  23. data/lib/ast/merge/file_analyzable.rb +307 -0
  24. data/lib/ast/merge/freezable.rb +82 -0
  25. data/lib/ast/merge/freeze_node_base.rb +434 -0
  26. data/lib/ast/merge/match_refiner_base.rb +312 -0
  27. data/lib/ast/merge/match_score_base.rb +135 -0
  28. data/lib/ast/merge/merge_result_base.rb +169 -0
  29. data/lib/ast/merge/merger_config.rb +258 -0
  30. data/lib/ast/merge/node_typing.rb +373 -0
  31. data/lib/ast/merge/region.rb +124 -0
  32. data/lib/ast/merge/region_detector_base.rb +114 -0
  33. data/lib/ast/merge/region_mergeable.rb +364 -0
  34. data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
  35. data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
  36. data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
  37. data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
  38. data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
  39. data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
  40. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
  41. data/lib/ast/merge/rspec/shared_examples.rb +26 -0
  42. data/lib/ast/merge/rspec.rb +4 -0
  43. data/lib/ast/merge/section_typing.rb +303 -0
  44. data/lib/ast/merge/smart_merger_base.rb +417 -0
  45. data/lib/ast/merge/text/conflict_resolver.rb +161 -0
  46. data/lib/ast/merge/text/file_analysis.rb +168 -0
  47. data/lib/ast/merge/text/line_node.rb +142 -0
  48. data/lib/ast/merge/text/merge_result.rb +42 -0
  49. data/lib/ast/merge/text/section.rb +93 -0
  50. data/lib/ast/merge/text/section_splitter.rb +397 -0
  51. data/lib/ast/merge/text/smart_merger.rb +141 -0
  52. data/lib/ast/merge/text/word_node.rb +86 -0
  53. data/lib/ast/merge/text.rb +35 -0
  54. data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
  55. data/lib/ast/merge/version.rb +12 -0
  56. data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
  57. data/lib/ast/merge.rb +165 -0
  58. data/lib/ast-merge.rb +4 -0
  59. data/sig/ast/merge.rbs +195 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +326 -0
  62. metadata.gz.sig +0 -0
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Configuration object for SmartMerger options.
6
+ #
7
+ # This class encapsulates common configuration options used across all
8
+ # *-merge gem SmartMerger implementations. It provides a standardized
9
+ # interface for merge configuration and validates option values.
10
+ #
11
+ # @example Creating a config with defaults
12
+ # config = MergerConfig.new
13
+ # config.preference # => :destination
14
+ # config.add_template_only_nodes # => false
15
+ #
16
+ # @example Creating a config for template-wins merge
17
+ # config = MergerConfig.new(
18
+ # preference: :template,
19
+ # add_template_only_nodes: true
20
+ # )
21
+ #
22
+ # @example Using with SmartMerger
23
+ # config = MergerConfig.new(preference: :template)
24
+ # merger = SmartMerger.new(template, dest, **config.to_h)
25
+ #
26
+ # @example Per-node-type preferences with node_typing
27
+ # node_typing = {
28
+ # CallNode: ->(node) {
29
+ # return node unless node.name == :gem
30
+ # gem_name = node.arguments&.arguments&.first&.unescaped
31
+ # if gem_name&.start_with?("rubocop")
32
+ # Ast::Merge::NodeTyping.with_merge_type(node, :lint_gem)
33
+ # else
34
+ # node
35
+ # end
36
+ # }
37
+ # }
38
+ #
39
+ # config = MergerConfig.new(
40
+ # node_typing: node_typing,
41
+ # preference: {
42
+ # default: :destination,
43
+ # lint_gem: :template # Use template versions for lint gems
44
+ # }
45
+ # )
46
+ class MergerConfig
47
+ # Valid values for preference (when using Symbol)
48
+ VALID_PREFERENCES = %i[destination template].freeze
49
+
50
+ # @return [Symbol, Hash] Which version to prefer when nodes have matching signatures.
51
+ # As Symbol:
52
+ # - :destination (default) - Keep destination version (preserves customizations)
53
+ # - :template - Use template version (applies updates)
54
+ # As Hash:
55
+ # - Keys are node types (Symbol) or merge_types from node_typing
56
+ # - Values are :destination or :template
57
+ # - Use :default key for fallback preference
58
+ # @example { default: :destination, lint_gem: :template, config_call: :template }
59
+ attr_reader :preference
60
+
61
+ # @return [Boolean] Whether to add nodes that only exist in template
62
+ # - false (default) - Skip template-only nodes
63
+ # - true - Add template-only nodes to result
64
+ attr_reader :add_template_only_nodes
65
+
66
+ # @return [String] Token used for freeze block markers
67
+ attr_reader :freeze_token
68
+
69
+ # @return [Proc, nil] Custom signature generator proc
70
+ attr_reader :signature_generator
71
+
72
+ # @return [Hash{Symbol,String => #call}, nil] Node typing configuration.
73
+ # Maps node type names to callable objects that can transform nodes
74
+ # and optionally add merge_type attributes for per-node-type preferences.
75
+ attr_reader :node_typing
76
+
77
+ # Initialize a new MergerConfig.
78
+ #
79
+ # @param preference [Symbol, Hash] Which version to prefer on match.
80
+ # As Symbol: :destination or :template
81
+ # As Hash: Maps node types/merge_types to preferences
82
+ # @example { default: :destination, lint_gem: :template }
83
+ # @param add_template_only_nodes [Boolean] Whether to add template-only nodes
84
+ # @param freeze_token [String, nil] Token for freeze block markers (nil uses gem default)
85
+ # @param signature_generator [Proc, nil] Custom signature generator
86
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
87
+ #
88
+ # @raise [ArgumentError] If preference is invalid
89
+ # @raise [ArgumentError] If node_typing is invalid
90
+ def initialize(
91
+ preference: :destination,
92
+ add_template_only_nodes: false,
93
+ freeze_token: nil,
94
+ signature_generator: nil,
95
+ node_typing: nil
96
+ )
97
+ validate_preference!(preference)
98
+ NodeTyping.validate!(node_typing) if node_typing
99
+
100
+ @preference = preference
101
+ @add_template_only_nodes = add_template_only_nodes
102
+ @freeze_token = freeze_token
103
+ @signature_generator = signature_generator
104
+ @node_typing = node_typing
105
+ end
106
+
107
+ # Check if destination version should be preferred on signature match.
108
+ # For Hash preferences, checks the :default key.
109
+ #
110
+ # @return [Boolean] true if destination preference
111
+ def prefer_destination?
112
+ if @preference.is_a?(Hash)
113
+ @preference.fetch(:default, :destination) == :destination
114
+ else
115
+ @preference == :destination
116
+ end
117
+ end
118
+
119
+ # Check if template version should be preferred on signature match.
120
+ # For Hash preferences, checks the :default key.
121
+ #
122
+ # @return [Boolean] true if template preference
123
+ def prefer_template?
124
+ if @preference.is_a?(Hash)
125
+ @preference.fetch(:default, :destination) == :template
126
+ else
127
+ @preference == :template
128
+ end
129
+ end
130
+
131
+ # Get the preference for a specific node type or merge_type.
132
+ #
133
+ # When preference is a Hash, looks up the preference
134
+ # for the given type, falling back to :default, then to :destination.
135
+ #
136
+ # @param type [Symbol, nil] The node type or merge_type to look up
137
+ # @return [Symbol] :destination or :template
138
+ #
139
+ # @example With Symbol preference
140
+ # config = MergerConfig.new(preference: :template)
141
+ # config.preference_for(:any_type) # => :template
142
+ #
143
+ # @example With Hash preference
144
+ # config = MergerConfig.new(
145
+ # preference: { default: :destination, lint_gem: :template }
146
+ # )
147
+ # config.preference_for(:lint_gem) # => :template
148
+ # config.preference_for(:other_type) # => :destination
149
+ def preference_for(type)
150
+ if @preference.is_a?(Hash)
151
+ @preference.fetch(type) do
152
+ @preference.fetch(:default, :destination)
153
+ end
154
+ else
155
+ @preference
156
+ end
157
+ end
158
+
159
+ # Check if Hash-based per-type preferences are configured.
160
+ #
161
+ # @return [Boolean] true if preference is a Hash
162
+ def per_type_preference?
163
+ @preference.is_a?(Hash)
164
+ end
165
+
166
+ # Convert config to a hash suitable for passing to SmartMerger.
167
+ #
168
+ # @param default_freeze_token [String, nil] Default freeze token to use if none specified
169
+ # @return [Hash] Configuration as keyword arguments hash
170
+ # @note Uses :preference key to match SmartMerger's API (not :preference)
171
+ def to_h(default_freeze_token: nil)
172
+ result = {
173
+ preference: @preference,
174
+ add_template_only_nodes: @add_template_only_nodes,
175
+ }
176
+ result[:freeze_token] = @freeze_token || default_freeze_token if @freeze_token || default_freeze_token
177
+ result[:signature_generator] = @signature_generator if @signature_generator
178
+ result[:node_typing] = @node_typing if @node_typing
179
+ result
180
+ end
181
+
182
+ # Create a new config with updated values.
183
+ #
184
+ # @param options [Hash] Options to override
185
+ # @return [MergerConfig] New config with updated values
186
+ def with(**options)
187
+ self.class.new(
188
+ preference: options.fetch(:preference, @preference),
189
+ add_template_only_nodes: options.fetch(:add_template_only_nodes, @add_template_only_nodes),
190
+ freeze_token: options.fetch(:freeze_token, @freeze_token),
191
+ signature_generator: options.fetch(:signature_generator, @signature_generator),
192
+ node_typing: options.fetch(:node_typing, @node_typing),
193
+ )
194
+ end
195
+
196
+ # Create a config preset for "destination wins" merging.
197
+ # Destination customizations are preserved, template-only content is skipped.
198
+ #
199
+ # @param freeze_token [String, nil] Optional freeze token
200
+ # @param signature_generator [Proc, nil] Optional signature generator
201
+ # @param node_typing [Hash, nil] Optional node typing configuration
202
+ # @return [MergerConfig] Config preset
203
+ def self.destination_wins(freeze_token: nil, signature_generator: nil, node_typing: nil)
204
+ new(
205
+ preference: :destination,
206
+ add_template_only_nodes: false,
207
+ freeze_token: freeze_token,
208
+ signature_generator: signature_generator,
209
+ node_typing: node_typing,
210
+ )
211
+ end
212
+
213
+ # Create a config preset for "template wins" merging.
214
+ # Template updates are applied, template-only content is added.
215
+ #
216
+ # @param freeze_token [String, nil] Optional freeze token
217
+ # @param signature_generator [Proc, nil] Optional signature generator
218
+ # @param node_typing [Hash, nil] Optional node typing configuration
219
+ # @return [MergerConfig] Config preset
220
+ def self.template_wins(freeze_token: nil, signature_generator: nil, node_typing: nil)
221
+ new(
222
+ preference: :template,
223
+ add_template_only_nodes: true,
224
+ freeze_token: freeze_token,
225
+ signature_generator: signature_generator,
226
+ node_typing: node_typing,
227
+ )
228
+ end
229
+
230
+ private
231
+
232
+ def validate_preference!(preference)
233
+ if preference.is_a?(Hash)
234
+ validate_hash_preference!(preference)
235
+ elsif !VALID_PREFERENCES.include?(preference)
236
+ raise ArgumentError,
237
+ "Invalid preference: #{preference.inspect}. " \
238
+ "Must be one of: #{VALID_PREFERENCES.map(&:inspect).join(", ")} or a Hash"
239
+ end
240
+ end
241
+
242
+ def validate_hash_preference!(preference)
243
+ preference.each do |key, value|
244
+ unless key.is_a?(Symbol)
245
+ raise ArgumentError,
246
+ "preference Hash keys must be Symbols, got #{key.class} for #{key.inspect}"
247
+ end
248
+
249
+ unless VALID_PREFERENCES.include?(value)
250
+ raise ArgumentError,
251
+ "preference Hash values must be :destination or :template, " \
252
+ "got #{value.inspect} for key #{key.inspect}"
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "freezable"
4
+
5
+ module Ast
6
+ module Merge
7
+ # Provides node type wrapping support for SmartMerger implementations.
8
+ #
9
+ # NodeTyping allows custom callable objects to be associated with specific
10
+ # node types. When a node is processed, the corresponding callable can:
11
+ # - Return the node unchanged (passthrough)
12
+ # - Return a modified node with a custom `merge_type` attribute
13
+ # - Return nil to indicate the node should be skipped
14
+ #
15
+ # The `merge_type` attribute can then be used by other merge tools like
16
+ # `signature_generator`, `match_refiner`, and per-node-type `preference` settings.
17
+ #
18
+ # @example Basic node typing for different gem types
19
+ # node_typing = {
20
+ # CallNode: ->(node) {
21
+ # return node unless node.name == :gem
22
+ # first_arg = node.arguments&.arguments&.first
23
+ # return node unless first_arg.is_a?(StringNode)
24
+ #
25
+ # gem_name = first_arg.unescaped
26
+ # if gem_name.start_with?("rubocop")
27
+ # NodeTyping.with_merge_type(node, :lint_gem)
28
+ # elsif gem_name.start_with?("rspec")
29
+ # NodeTyping.with_merge_type(node, :test_gem)
30
+ # else
31
+ # node
32
+ # end
33
+ # }
34
+ # }
35
+ #
36
+ # @example Using with per-node-type preference
37
+ # merger = SmartMerger.new(
38
+ # template,
39
+ # destination,
40
+ # node_typing: node_typing,
41
+ # preference: {
42
+ # default: :destination,
43
+ # lint_gem: :template, # Use template versions for lint gems
44
+ # test_gem: :destination # Keep destination versions for test gems
45
+ # }
46
+ # )
47
+ #
48
+ # @see MergerConfig
49
+ # @see ConflictResolverBase
50
+ module NodeTyping
51
+ # Node wrapper that adds a merge_type attribute to an existing node.
52
+ # This uses a simple delegation pattern to preserve all original node
53
+ # behavior while adding the merge_type.
54
+ class Wrapper
55
+ # @return [Object] The original node being wrapped
56
+ attr_reader :node
57
+
58
+ # @return [Symbol] The custom merge type for this node
59
+ attr_reader :merge_type
60
+
61
+ # Create a new node type wrapper.
62
+ #
63
+ # @param node [Object] The original node to wrap
64
+ # @param merge_type [Symbol] The custom merge type
65
+ def initialize(node, merge_type)
66
+ @node = node
67
+ @merge_type = merge_type
68
+ end
69
+
70
+ # Delegate all unknown methods to the wrapped node.
71
+ # This allows the wrapper to be used transparently in place of the node.
72
+ def method_missing(method, *args, &block)
73
+ if @node.respond_to?(method)
74
+ @node.send(method, *args, &block)
75
+ else
76
+ super
77
+ end
78
+ end
79
+
80
+ # Check if the wrapped node responds to a method.
81
+ def respond_to_missing?(method, include_private = false)
82
+ @node.respond_to?(method, include_private) || super
83
+ end
84
+
85
+ # Returns true to indicate this is a node type wrapper.
86
+ def typed_node?
87
+ true
88
+ end
89
+
90
+ # Unwrap to get the original node.
91
+ # @return [Object] The original unwrapped node
92
+ def unwrap
93
+ @node
94
+ end
95
+
96
+ # Forward equality check to the wrapped node.
97
+ def ==(other)
98
+ if other.is_a?(Wrapper)
99
+ @node == other.node && @merge_type == other.merge_type
100
+ else
101
+ @node == other
102
+ end
103
+ end
104
+
105
+ # Forward hash to the wrapped node.
106
+ def hash
107
+ [@node, @merge_type].hash
108
+ end
109
+
110
+ # Forward eql? to the wrapped node.
111
+ def eql?(other)
112
+ self == other
113
+ end
114
+
115
+ # Forward inspect to show both the type and node.
116
+ def inspect
117
+ "#<NodeTyping::Wrapper merge_type=#{@merge_type.inspect} node=#{@node.inspect}>"
118
+ end
119
+ end
120
+
121
+ # Wrapper for frozen AST nodes that includes Freezable behavior.
122
+ #
123
+ # FrozenWrapper extends Wrapper to add freeze node semantics, making the
124
+ # wrapped node satisfy both the NodeTyping API and the Freezable API.
125
+ # This enables composition where frozen nodes are:
126
+ # - Wrapped AST nodes (can unwrap to get original)
127
+ # - Typed nodes (have merge_type)
128
+ # - Freeze nodes (satisfy is_a?(Freezable) and freeze_node?)
129
+ #
130
+ # ## Key Distinction from FreezeNodeBase
131
+ #
132
+ # FrozenWrapper and FreezeNodeBase both include Freezable, but they represent
133
+ # fundamentally different concepts:
134
+ #
135
+ # ### FrozenWrapper (this class)
136
+ # - Wraps an AST node that has a freeze marker in its leading comments
137
+ # - The node is still a structural AST node (e.g., a `gem` call in a gemspec)
138
+ # - During matching, we want to match by the underlying node's IDENTITY
139
+ # (e.g., the gem name), NOT by the full content
140
+ # - Signature generation should unwrap and use the underlying node's structure
141
+ # - Example: `# token:freeze\ngem "example_gem", "~> 1.0"` wraps a CallNode
142
+ #
143
+ # ### FreezeNodeBase
144
+ # - Represents an explicit freeze block with `# token:freeze ... # token:unfreeze`
145
+ # - The entire block is opaque content that should be preserved verbatim
146
+ # - During matching, we match by the full CONTENT of the block
147
+ # - Signature generation uses freeze_signature (content-based)
148
+ # - Example: A multi-line comment block with custom formatting
149
+ #
150
+ # ## Signature Generation Behavior
151
+ #
152
+ # When FileAnalyzable#generate_signature encounters a FrozenWrapper:
153
+ # 1. It unwraps to get the underlying AST node
154
+ # 2. Passes the unwrapped node to the signature_generator
155
+ # 3. This allows the signature generator to recognize the node type
156
+ # (e.g., Prism::CallNode) and generate appropriate signatures
157
+ #
158
+ # This is critical because signature generators check for specific AST types.
159
+ # If we passed the wrapper, the generator wouldn't recognize it as a CallNode
160
+ # and would fall back to a generic signature, breaking matching.
161
+ #
162
+ # @example Creating a frozen wrapper
163
+ # frozen = NodeTyping::FrozenWrapper.new(prism_node, :frozen)
164
+ # frozen.freeze_node? # => true
165
+ # frozen.is_a?(Ast::Merge::Freezable) # => true
166
+ # frozen.unwrap # => prism_node
167
+ #
168
+ # @see Wrapper
169
+ # @see Ast::Merge::Freezable
170
+ # @see FreezeNodeBase
171
+ # @see FileAnalyzable#generate_signature
172
+ class FrozenWrapper < Wrapper
173
+ include Ast::Merge::Freezable
174
+
175
+ # Create a frozen wrapper for an AST node.
176
+ #
177
+ # @param node [Object] The AST node to wrap
178
+ # @param merge_type [Symbol] The merge type (defaults to :frozen)
179
+ def initialize(node, merge_type = :frozen)
180
+ super(node, merge_type)
181
+ end
182
+
183
+ # Returns true to indicate this is a frozen node.
184
+ # Overrides both Wrapper#typed_node? context and provides freeze_node? from Freezable.
185
+ #
186
+ # @return [Boolean] true
187
+ def frozen_node?
188
+ true
189
+ end
190
+
191
+ # Returns the content of this frozen node.
192
+ # Delegates to the wrapped node's slice method.
193
+ #
194
+ # @return [String] The node content
195
+ def slice
196
+ @node.slice
197
+ end
198
+
199
+ # Returns the signature for this frozen node.
200
+ # Uses the freeze_signature from Freezable module.
201
+ #
202
+ # @return [Array] Signature in the form [:FreezeNode, content]
203
+ def signature
204
+ freeze_signature
205
+ end
206
+
207
+ # Forward inspect to show frozen status.
208
+ def inspect
209
+ "#<NodeTyping::FrozenWrapper merge_type=#{@merge_type.inspect} node=#{@node.inspect}>"
210
+ end
211
+ end
212
+
213
+ class << self
214
+ # Wrap a node with a custom merge_type.
215
+ #
216
+ # @param node [Object] The node to wrap
217
+ # @param merge_type [Symbol] The merge type to assign
218
+ # @return [Wrapper] The wrapped node
219
+ #
220
+ # @example
221
+ # typed_node = NodeTyping.with_merge_type(call_node, :config_call)
222
+ # typed_node.merge_type # => :config_call
223
+ # typed_node.name # => delegates to call_node.name
224
+ def with_merge_type(node, merge_type)
225
+ Wrapper.new(node, merge_type)
226
+ end
227
+
228
+ # Wrap a node as frozen with the Freezable behavior.
229
+ #
230
+ # @param node [Object] The node to wrap as frozen
231
+ # @param merge_type [Symbol] The merge type (defaults to :frozen)
232
+ # @return [FrozenWrapper] The frozen wrapped node
233
+ #
234
+ # @example
235
+ # frozen_node = NodeTyping.frozen(call_node)
236
+ # frozen_node.freeze_node? # => true
237
+ # frozen_node.is_a?(Ast::Merge::Freezable) # => true
238
+ def frozen(node, merge_type = :frozen)
239
+ FrozenWrapper.new(node, merge_type)
240
+ end
241
+
242
+ # Check if a node is a frozen wrapper.
243
+ #
244
+ # @param node [Object] The node to check
245
+ # @return [Boolean] true if the node is a FrozenWrapper or includes Freezable
246
+ def frozen_node?(node)
247
+ node.is_a?(Freezable)
248
+ end
249
+
250
+ # Check if a node is a node type wrapper.
251
+ #
252
+ # @param node [Object] The node to check
253
+ # @return [Boolean] true if the node is a Wrapper
254
+ def typed_node?(node)
255
+ node.respond_to?(:typed_node?) && node.typed_node?
256
+ end
257
+
258
+ # Get the merge_type from a node, returning nil if it's not a typed node.
259
+ #
260
+ # @param node [Object] The node to get merge_type from
261
+ # @return [Symbol, nil] The merge_type or nil
262
+ def merge_type_for(node)
263
+ typed_node?(node) ? node.merge_type : nil
264
+ end
265
+
266
+ # Unwrap a typed node to get the original node.
267
+ # Returns the node unchanged if it's not wrapped.
268
+ #
269
+ # @param node [Object] The node to unwrap
270
+ # @return [Object] The unwrapped node
271
+ def unwrap(node)
272
+ typed_node?(node) ? node.unwrap : node
273
+ end
274
+
275
+ # Process a node through a typing configuration.
276
+ #
277
+ # @param node [Object] The node to process
278
+ # @param typing_config [Hash{Symbol,String => #call}, nil] Hash mapping node type names
279
+ # to callables. Keys can be symbols or strings representing node class names
280
+ # (e.g., :CallNode, "DefNode", :Prism_CallNode for fully qualified names)
281
+ # @return [Object, nil] The processed node (possibly wrapped with merge_type),
282
+ # or nil if the node should be skipped
283
+ #
284
+ # @example
285
+ # config = {
286
+ # CallNode: ->(node) {
287
+ # NodeTyping.with_merge_type(node, :special_call)
288
+ # }
289
+ # }
290
+ # result = NodeTyping.process(call_node, config)
291
+ def process(node, typing_config)
292
+ return node unless typing_config
293
+ return node if typing_config.empty?
294
+
295
+ # Get the node type name for lookup
296
+ type_key = node_type_key(node)
297
+
298
+ # Try to find a matching typing callable
299
+ callable = find_typing_callable(typing_config, type_key, node)
300
+ return node unless callable
301
+
302
+ # Call the typing callable with the node
303
+ callable.call(node)
304
+ end
305
+
306
+ # Validate a typing configuration hash.
307
+ #
308
+ # @param typing_config [Hash, nil] The configuration to validate
309
+ # @raise [ArgumentError] If the configuration is invalid
310
+ # @return [void]
311
+ def validate!(typing_config)
312
+ return if typing_config.nil?
313
+
314
+ unless typing_config.is_a?(Hash)
315
+ raise ArgumentError, "node_typing must be a Hash, got #{typing_config.class}"
316
+ end
317
+
318
+ typing_config.each do |key, value|
319
+ unless key.is_a?(Symbol) || key.is_a?(String)
320
+ raise ArgumentError,
321
+ "node_typing keys must be Symbol or String, got #{key.class} for #{key.inspect}"
322
+ end
323
+
324
+ unless value.respond_to?(:call)
325
+ raise ArgumentError,
326
+ "node_typing values must be callable (respond to #call), " \
327
+ "got #{value.class} for key #{key.inspect}"
328
+ end
329
+ end
330
+ end
331
+
332
+ private
333
+
334
+ # Get the type key for looking up a typing callable.
335
+ # Handles both simple class names and fully-qualified names.
336
+ #
337
+ # @param node [Object] The node to get the type key for
338
+ # @return [String] The type key
339
+ def node_type_key(node)
340
+ # Handle Wrapper - use the wrapped node's class
341
+ actual_node = typed_node?(node) ? node.unwrap : node
342
+ actual_node.class.name&.split("::")&.last || actual_node.class.to_s
343
+ end
344
+
345
+ # Find a typing callable for the given type key.
346
+ #
347
+ # @param config [Hash] The typing configuration
348
+ # @param type_key [String] The type key to look up
349
+ # @param node [Object] The original node (for fully-qualified lookup)
350
+ # @return [#call, nil] The typing callable or nil
351
+ def find_typing_callable(config, type_key, node)
352
+ # Try exact match with symbol key
353
+ return config[type_key.to_sym] if config.key?(type_key.to_sym)
354
+
355
+ # Try exact match with string key
356
+ return config[type_key] if config.key?(type_key)
357
+
358
+ # Try fully-qualified class name (e.g., "Prism::CallNode")
359
+ actual_node = typed_node?(node) ? node.unwrap : node
360
+ full_name = actual_node.class.name
361
+ return config[full_name.to_sym] if full_name && config.key?(full_name.to_sym)
362
+ return config[full_name] if full_name && config.key?(full_name)
363
+
364
+ # Try with underscored naming (e.g., :prism_call_node)
365
+ underscored = full_name&.gsub("::", "_")&.gsub(/([a-z])([A-Z])/, '\1_\2')&.downcase
366
+ return config[underscored&.to_sym] if underscored && config.key?(underscored.to_sym)
367
+
368
+ nil
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end