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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/ast/merge/ast_node.rb +87 -0
- data/lib/ast/merge/comment/block.rb +195 -0
- data/lib/ast/merge/comment/empty.rb +78 -0
- data/lib/ast/merge/comment/line.rb +138 -0
- data/lib/ast/merge/comment/parser.rb +278 -0
- data/lib/ast/merge/comment/style.rb +282 -0
- data/lib/ast/merge/comment.rb +36 -0
- data/lib/ast/merge/conflict_resolver_base.rb +399 -0
- data/lib/ast/merge/debug_logger.rb +271 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
- data/lib/ast/merge/file_analyzable.rb +307 -0
- data/lib/ast/merge/freezable.rb +82 -0
- data/lib/ast/merge/freeze_node_base.rb +434 -0
- data/lib/ast/merge/match_refiner_base.rb +312 -0
- data/lib/ast/merge/match_score_base.rb +135 -0
- data/lib/ast/merge/merge_result_base.rb +169 -0
- data/lib/ast/merge/merger_config.rb +258 -0
- data/lib/ast/merge/node_typing.rb +373 -0
- data/lib/ast/merge/region.rb +124 -0
- data/lib/ast/merge/region_detector_base.rb +114 -0
- data/lib/ast/merge/region_mergeable.rb +364 -0
- data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
- data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
- data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
- data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
- data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
- data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
- data/lib/ast/merge/rspec/shared_examples.rb +26 -0
- data/lib/ast/merge/rspec.rb +4 -0
- data/lib/ast/merge/section_typing.rb +303 -0
- data/lib/ast/merge/smart_merger_base.rb +417 -0
- data/lib/ast/merge/text/conflict_resolver.rb +161 -0
- data/lib/ast/merge/text/file_analysis.rb +168 -0
- data/lib/ast/merge/text/line_node.rb +142 -0
- data/lib/ast/merge/text/merge_result.rb +42 -0
- data/lib/ast/merge/text/section.rb +93 -0
- data/lib/ast/merge/text/section_splitter.rb +397 -0
- data/lib/ast/merge/text/smart_merger.rb +141 -0
- data/lib/ast/merge/text/word_node.rb +86 -0
- data/lib/ast/merge/text.rb +35 -0
- data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
- data/lib/ast/merge/version.rb +12 -0
- data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
- data/lib/ast/merge.rb +165 -0
- data/lib/ast-merge.rb +4 -0
- data/sig/ast/merge.rbs +195 -0
- data.tar.gz.sig +0 -0
- metadata +326 -0
- 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
|