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,399 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Base class for conflict resolvers across all *-merge gems.
|
|
6
|
+
#
|
|
7
|
+
# Provides common functionality for resolving conflicts between template
|
|
8
|
+
# and destination content during merge operations. Supports three resolution
|
|
9
|
+
# strategies that can be selected based on the needs of each file format:
|
|
10
|
+
#
|
|
11
|
+
# - `:node` - Per-node resolution (resolve individual node pairs)
|
|
12
|
+
# - `:batch` - Batch resolution (resolve entire file using signature maps)
|
|
13
|
+
# - `:boundary` - Boundary resolution (resolve sections/ranges of content)
|
|
14
|
+
#
|
|
15
|
+
# @example Node-based resolution (commonmarker-merge style)
|
|
16
|
+
# class ConflictResolver < Ast::Merge::ConflictResolverBase
|
|
17
|
+
# def initialize(preference:, template_analysis:, dest_analysis:)
|
|
18
|
+
# super(
|
|
19
|
+
# strategy: :node,
|
|
20
|
+
# preference: preference,
|
|
21
|
+
# template_analysis: template_analysis,
|
|
22
|
+
# dest_analysis: dest_analysis
|
|
23
|
+
# )
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # Called for each node pair
|
|
27
|
+
# def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
|
|
28
|
+
# # Return resolution hash
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example Batch resolution (psych-merge/json-merge style)
|
|
33
|
+
# class ConflictResolver < Ast::Merge::ConflictResolverBase
|
|
34
|
+
# def initialize(template_analysis, dest_analysis, preference: :destination)
|
|
35
|
+
# super(
|
|
36
|
+
# strategy: :batch,
|
|
37
|
+
# preference: preference,
|
|
38
|
+
# template_analysis: template_analysis,
|
|
39
|
+
# dest_analysis: dest_analysis
|
|
40
|
+
# )
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# # Called once for entire merge
|
|
44
|
+
# def resolve_batch(result)
|
|
45
|
+
# # Populate result with merged content
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# @example Boundary resolution (prism-merge style)
|
|
50
|
+
# class ConflictResolver < Ast::Merge::ConflictResolverBase
|
|
51
|
+
# def initialize(template_analysis, dest_analysis, preference: :destination)
|
|
52
|
+
# super(
|
|
53
|
+
# strategy: :boundary,
|
|
54
|
+
# preference: preference,
|
|
55
|
+
# template_analysis: template_analysis,
|
|
56
|
+
# dest_analysis: dest_analysis
|
|
57
|
+
# )
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# # Called for each boundary (section with differences)
|
|
61
|
+
# def resolve_boundary(boundary, result)
|
|
62
|
+
# # Process boundary and populate result
|
|
63
|
+
# end
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# @abstract Subclass and implement resolve_node_pair, resolve_batch, or resolve_boundary
|
|
67
|
+
class ConflictResolverBase
|
|
68
|
+
# Decision constants - shared across all conflict resolvers
|
|
69
|
+
|
|
70
|
+
# Use destination version (customization preserved)
|
|
71
|
+
DECISION_DESTINATION = :destination
|
|
72
|
+
|
|
73
|
+
# Use template version (update applied)
|
|
74
|
+
DECISION_TEMPLATE = :template
|
|
75
|
+
|
|
76
|
+
# Content was added from template (template-only)
|
|
77
|
+
DECISION_ADDED = :added
|
|
78
|
+
|
|
79
|
+
# Content preserved from frozen block
|
|
80
|
+
DECISION_FROZEN = :frozen
|
|
81
|
+
|
|
82
|
+
# Content was identical (no conflict)
|
|
83
|
+
DECISION_IDENTICAL = :identical
|
|
84
|
+
|
|
85
|
+
# Content was kept from destination (signature match, dest preferred)
|
|
86
|
+
DECISION_KEPT_DEST = :kept_destination
|
|
87
|
+
|
|
88
|
+
# Content was kept from template (signature match, template preferred)
|
|
89
|
+
DECISION_KEPT_TEMPLATE = :kept_template
|
|
90
|
+
|
|
91
|
+
# Content was appended from destination (dest-only)
|
|
92
|
+
DECISION_APPENDED = :appended
|
|
93
|
+
|
|
94
|
+
# Content preserved from freeze block marker
|
|
95
|
+
DECISION_FREEZE_BLOCK = :freeze_block
|
|
96
|
+
|
|
97
|
+
# Content requires recursive merge (container types)
|
|
98
|
+
DECISION_RECURSIVE = :recursive
|
|
99
|
+
|
|
100
|
+
# Content was replaced (signature match with different content)
|
|
101
|
+
DECISION_REPLACED = :replaced
|
|
102
|
+
|
|
103
|
+
# @return [Symbol] Resolution strategy (:node, :batch, or :boundary)
|
|
104
|
+
attr_reader :strategy
|
|
105
|
+
|
|
106
|
+
# @return [Symbol, Hash] Merge preference.
|
|
107
|
+
# As Symbol: :destination or :template (applies to all nodes)
|
|
108
|
+
# As Hash: Maps node types/merge_types to preferences
|
|
109
|
+
# @example { default: :destination, lint_gem: :template }
|
|
110
|
+
attr_reader :preference
|
|
111
|
+
|
|
112
|
+
# @return [Object] Template file analysis
|
|
113
|
+
attr_reader :template_analysis
|
|
114
|
+
|
|
115
|
+
# @return [Object] Destination file analysis
|
|
116
|
+
attr_reader :dest_analysis
|
|
117
|
+
|
|
118
|
+
# @return [Boolean] Whether to add template-only nodes (batch strategy)
|
|
119
|
+
attr_reader :add_template_only_nodes
|
|
120
|
+
|
|
121
|
+
# Initialize the conflict resolver
|
|
122
|
+
#
|
|
123
|
+
# @param strategy [Symbol] Resolution strategy (:node, :batch, or :boundary)
|
|
124
|
+
# @param preference [Symbol, Hash] Which version to prefer.
|
|
125
|
+
# As Symbol: :destination or :template (applies to all nodes)
|
|
126
|
+
# As Hash: Maps node types/merge_types to preferences
|
|
127
|
+
# - Use :default key for fallback preference
|
|
128
|
+
# @example { default: :destination, lint_gem: :template }
|
|
129
|
+
# @param template_analysis [Object] Analysis of the template file
|
|
130
|
+
# @param dest_analysis [Object] Analysis of the destination file
|
|
131
|
+
# @param add_template_only_nodes [Boolean] Whether to add nodes only in template (batch/boundary strategy)
|
|
132
|
+
def initialize(strategy:, preference:, template_analysis:, dest_analysis:, add_template_only_nodes: false)
|
|
133
|
+
unless %i[node batch boundary].include?(strategy)
|
|
134
|
+
raise ArgumentError, "Invalid strategy: #{strategy}. Must be :node, :batch, or :boundary"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
validate_preference!(preference)
|
|
138
|
+
|
|
139
|
+
@strategy = strategy
|
|
140
|
+
@preference = preference
|
|
141
|
+
@template_analysis = template_analysis
|
|
142
|
+
@dest_analysis = dest_analysis
|
|
143
|
+
@add_template_only_nodes = add_template_only_nodes
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Resolve conflicts using the configured strategy
|
|
147
|
+
#
|
|
148
|
+
# For :node strategy, this delegates to resolve_node_pair
|
|
149
|
+
# For :batch strategy, this delegates to resolve_batch
|
|
150
|
+
# For :boundary strategy, this delegates to resolve_boundary
|
|
151
|
+
#
|
|
152
|
+
# @param args [Array] Arguments passed to the strategy method
|
|
153
|
+
# @return [Object] Resolution result (format depends on strategy)
|
|
154
|
+
def resolve(*args, **kwargs)
|
|
155
|
+
case @strategy
|
|
156
|
+
when :node
|
|
157
|
+
resolve_node_pair(*args, **kwargs)
|
|
158
|
+
when :batch
|
|
159
|
+
resolve_batch(*args)
|
|
160
|
+
when :boundary
|
|
161
|
+
resolve_boundary(*args)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Check if a node is a freeze node using duck typing
|
|
166
|
+
#
|
|
167
|
+
# @param node [Object] Node to check
|
|
168
|
+
# @return [Boolean] True if node is a freeze node
|
|
169
|
+
def freeze_node?(node)
|
|
170
|
+
node.respond_to?(:freeze_node?) && node.freeze_node?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get the preference for a specific node.
|
|
174
|
+
#
|
|
175
|
+
# When preference is a Hash, looks up the preference for the node's
|
|
176
|
+
# merge_type (if wrapped with NodeTyping) or falls back to :default.
|
|
177
|
+
#
|
|
178
|
+
# @param node [Object, nil] The node to get preference for
|
|
179
|
+
# @return [Symbol] :destination or :template
|
|
180
|
+
#
|
|
181
|
+
# @example With Symbol preference
|
|
182
|
+
# preference_for_node(any_node) # => returns @preference
|
|
183
|
+
#
|
|
184
|
+
# @example With Hash preference and typed node
|
|
185
|
+
# # Given preference: { default: :destination, lint_gem: :template }
|
|
186
|
+
# preference_for_node(lint_gem_node) # => :template
|
|
187
|
+
# preference_for_node(other_node) # => :destination
|
|
188
|
+
def preference_for_node(node)
|
|
189
|
+
return default_preference unless @preference.is_a?(Hash)
|
|
190
|
+
return default_preference unless node
|
|
191
|
+
|
|
192
|
+
# Check if node has a merge_type (from NodeTyping)
|
|
193
|
+
merge_type = NodeTyping.merge_type_for(node)
|
|
194
|
+
return @preference.fetch(merge_type) { default_preference } if merge_type
|
|
195
|
+
|
|
196
|
+
# Fall back to default
|
|
197
|
+
default_preference
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Get the default preference (used as fallback).
|
|
201
|
+
#
|
|
202
|
+
# @return [Symbol] :destination or :template
|
|
203
|
+
def default_preference
|
|
204
|
+
if @preference.is_a?(Hash)
|
|
205
|
+
@preference.fetch(:default, :destination)
|
|
206
|
+
else
|
|
207
|
+
@preference
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Check if Hash-based per-type preferences are configured.
|
|
212
|
+
#
|
|
213
|
+
# @return [Boolean] true if preference is a Hash
|
|
214
|
+
def per_type_preference?
|
|
215
|
+
@preference.is_a?(Hash)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
protected
|
|
219
|
+
|
|
220
|
+
# Resolve a single node pair (for :node strategy)
|
|
221
|
+
# Override this method in subclasses using node strategy
|
|
222
|
+
#
|
|
223
|
+
# @param template_node [Object] Node from template
|
|
224
|
+
# @param dest_node [Object] Node from destination
|
|
225
|
+
# @param template_index [Integer] Index in template statements
|
|
226
|
+
# @param dest_index [Integer] Index in destination statements
|
|
227
|
+
# @return [Hash] Resolution with :source, :decision, and node references
|
|
228
|
+
def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
|
|
229
|
+
raise NotImplementedError, "Subclass must implement resolve_node_pair for :node strategy"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Resolve all conflicts in batch (for :batch strategy)
|
|
233
|
+
# Override this method in subclasses using batch strategy
|
|
234
|
+
#
|
|
235
|
+
# @param result [Object] Result object to populate
|
|
236
|
+
# @return [void]
|
|
237
|
+
def resolve_batch(result)
|
|
238
|
+
raise NotImplementedError, "Subclass must implement resolve_batch for :batch strategy"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Resolve a boundary/section (for :boundary strategy)
|
|
242
|
+
# Override this method in subclasses using boundary strategy
|
|
243
|
+
#
|
|
244
|
+
# Boundaries represent sections of content where template and destination
|
|
245
|
+
# differ. This strategy is useful for ASTs where content is processed
|
|
246
|
+
# in ranges/sections rather than individual nodes or all at once.
|
|
247
|
+
#
|
|
248
|
+
# @param boundary [Object] Boundary object with template_range and dest_range
|
|
249
|
+
# @param result [Object] Result object to populate
|
|
250
|
+
# @return [void]
|
|
251
|
+
def resolve_boundary(boundary, result)
|
|
252
|
+
raise NotImplementedError, "Subclass must implement resolve_boundary for :boundary strategy"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Build a signature map from nodes
|
|
256
|
+
# Useful for batch resolution strategy
|
|
257
|
+
#
|
|
258
|
+
# @param nodes [Array] Nodes to map
|
|
259
|
+
# @param analysis [Object] Analysis for signature generation
|
|
260
|
+
# @return [Hash] Map of signature => [{node:, index:}, ...]
|
|
261
|
+
def build_signature_map(nodes, analysis)
|
|
262
|
+
map = {}
|
|
263
|
+
nodes.each_with_index do |node, idx|
|
|
264
|
+
sig = analysis.generate_signature(node)
|
|
265
|
+
next unless sig
|
|
266
|
+
|
|
267
|
+
map[sig] ||= []
|
|
268
|
+
map[sig] << {node: node, index: idx}
|
|
269
|
+
end
|
|
270
|
+
map
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Build a signature map from node_info hashes
|
|
274
|
+
# Useful for boundary resolution strategy where nodes are wrapped in info hashes
|
|
275
|
+
#
|
|
276
|
+
# @param node_infos [Array<Hash>] Node info hashes with :signature and :index keys
|
|
277
|
+
# @return [Hash] Map of signature => [node_info, ...]
|
|
278
|
+
def build_signature_map_from_infos(node_infos)
|
|
279
|
+
map = Hash.new { |h, k| h[k] = [] }
|
|
280
|
+
node_infos.each do |node_info|
|
|
281
|
+
sig = node_info[:signature]
|
|
282
|
+
map[sig] << node_info if sig
|
|
283
|
+
end
|
|
284
|
+
map
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Check if two line ranges overlap
|
|
288
|
+
#
|
|
289
|
+
# @param range1 [Range] First range
|
|
290
|
+
# @param range2 [Range] Second range
|
|
291
|
+
# @return [Boolean] True if ranges overlap
|
|
292
|
+
def ranges_overlap?(range1, range2)
|
|
293
|
+
range1.begin <= range2.end && range2.begin <= range1.end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Create a resolution hash for frozen block
|
|
297
|
+
#
|
|
298
|
+
# @param source [Symbol] :template or :destination
|
|
299
|
+
# @param template_node [Object] Template node
|
|
300
|
+
# @param dest_node [Object] Destination node
|
|
301
|
+
# @param reason [String, nil] Freeze reason
|
|
302
|
+
# @return [Hash] Resolution hash
|
|
303
|
+
def frozen_resolution(source:, template_node:, dest_node:, reason: nil)
|
|
304
|
+
{
|
|
305
|
+
source: source,
|
|
306
|
+
decision: DECISION_FROZEN,
|
|
307
|
+
template_node: template_node,
|
|
308
|
+
dest_node: dest_node,
|
|
309
|
+
reason: reason,
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Create a resolution hash for identical content
|
|
314
|
+
#
|
|
315
|
+
# @param template_node [Object] Template node
|
|
316
|
+
# @param dest_node [Object] Destination node
|
|
317
|
+
# @return [Hash] Resolution hash
|
|
318
|
+
def identical_resolution(template_node:, dest_node:)
|
|
319
|
+
{
|
|
320
|
+
source: :destination,
|
|
321
|
+
decision: DECISION_IDENTICAL,
|
|
322
|
+
template_node: template_node,
|
|
323
|
+
dest_node: dest_node,
|
|
324
|
+
}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Create a resolution hash based on preference.
|
|
328
|
+
# Supports per-node-type preferences when a Hash is configured.
|
|
329
|
+
#
|
|
330
|
+
# When per-type preferences are configured, checks template_node for
|
|
331
|
+
# merge_type (from NodeTyping wrapping). If template_node has no merge_type,
|
|
332
|
+
# falls back to dest_node's merge_type, then to the default preference.
|
|
333
|
+
#
|
|
334
|
+
# @param template_node [Object] Template node (may be a Wrapper)
|
|
335
|
+
# @param dest_node [Object] Destination node (may be a Wrapper)
|
|
336
|
+
# @return [Hash] Resolution hash
|
|
337
|
+
def preference_resolution(template_node:, dest_node:)
|
|
338
|
+
# Get the appropriate preference for this node pair
|
|
339
|
+
# Template node's merge_type takes precedence, then dest_node's
|
|
340
|
+
node_preference = if NodeTyping.typed_node?(template_node)
|
|
341
|
+
preference_for_node(template_node)
|
|
342
|
+
elsif NodeTyping.typed_node?(dest_node)
|
|
343
|
+
preference_for_node(dest_node)
|
|
344
|
+
else
|
|
345
|
+
default_preference
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
if node_preference == :template
|
|
349
|
+
{
|
|
350
|
+
source: :template,
|
|
351
|
+
decision: DECISION_TEMPLATE,
|
|
352
|
+
template_node: template_node,
|
|
353
|
+
dest_node: dest_node,
|
|
354
|
+
}
|
|
355
|
+
else
|
|
356
|
+
{
|
|
357
|
+
source: :destination,
|
|
358
|
+
decision: DECISION_DESTINATION,
|
|
359
|
+
template_node: template_node,
|
|
360
|
+
dest_node: dest_node,
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
private
|
|
366
|
+
|
|
367
|
+
# Validate the preference parameter.
|
|
368
|
+
#
|
|
369
|
+
# @param preference [Symbol, Hash] The preference to validate
|
|
370
|
+
# @raise [ArgumentError] If preference is invalid
|
|
371
|
+
def validate_preference!(preference)
|
|
372
|
+
if preference.is_a?(Hash)
|
|
373
|
+
validate_hash_preference!(preference)
|
|
374
|
+
elsif !%i[destination template].include?(preference)
|
|
375
|
+
raise ArgumentError, "Invalid preference: #{preference}. Must be :destination, :template, or a Hash"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Validate a Hash preference configuration.
|
|
380
|
+
#
|
|
381
|
+
# @param preference [Hash] The preference hash to validate
|
|
382
|
+
# @raise [ArgumentError] If any key or value is invalid
|
|
383
|
+
def validate_hash_preference!(preference)
|
|
384
|
+
preference.each do |key, value|
|
|
385
|
+
unless key.is_a?(Symbol)
|
|
386
|
+
raise ArgumentError,
|
|
387
|
+
"preference Hash keys must be Symbols, got #{key.class} for #{key.inspect}"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
unless %i[destination template].include?(value)
|
|
391
|
+
raise ArgumentError,
|
|
392
|
+
"preference Hash values must be :destination or :template, " \
|
|
393
|
+
"got #{value.inspect} for key #{key.inspect}"
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Base debug logging utility for AST merge libraries.
|
|
6
|
+
# Provides conditional debug output based on environment configuration.
|
|
7
|
+
#
|
|
8
|
+
# This module is designed to be extended by file-type-specific merge libraries
|
|
9
|
+
# (e.g., Prism::Merge, Psych::Merge) which configure their own environment
|
|
10
|
+
# variable and log prefix.
|
|
11
|
+
#
|
|
12
|
+
# == Minimal Integration
|
|
13
|
+
#
|
|
14
|
+
# Simply extend this module and configure your environment variable and log prefix:
|
|
15
|
+
#
|
|
16
|
+
# @example Creating a custom debug logger (minimal integration)
|
|
17
|
+
# module MyMerge
|
|
18
|
+
# module DebugLogger
|
|
19
|
+
# extend Ast::Merge::DebugLogger
|
|
20
|
+
#
|
|
21
|
+
# self.env_var_name = "MY_MERGE_DEBUG"
|
|
22
|
+
# self.log_prefix = "[MyMerge]"
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# == Overriding Methods
|
|
27
|
+
#
|
|
28
|
+
# When you +extend+ a module, its instance methods become singleton methods on
|
|
29
|
+
# your module. To override inherited behavior, you must define *singleton methods*
|
|
30
|
+
# (+def self.method_name+), not instance methods (+def method_name+).
|
|
31
|
+
#
|
|
32
|
+
# @example Overriding a method (correct - singleton method)
|
|
33
|
+
# module MyMerge
|
|
34
|
+
# module DebugLogger
|
|
35
|
+
# extend Ast::Merge::DebugLogger
|
|
36
|
+
#
|
|
37
|
+
# self.env_var_name = "MY_MERGE_DEBUG"
|
|
38
|
+
# self.log_prefix = "[MyMerge]"
|
|
39
|
+
#
|
|
40
|
+
# # Override extract_node_info for custom node types
|
|
41
|
+
# def self.extract_node_info(node)
|
|
42
|
+
# case node
|
|
43
|
+
# when MyMerge::CustomNode
|
|
44
|
+
# {type: "CustomNode", lines: "#{node.start_line}..#{node.end_line}"}
|
|
45
|
+
# else
|
|
46
|
+
# # Delegate to base implementation
|
|
47
|
+
# Ast::Merge::DebugLogger.extract_node_info(node)
|
|
48
|
+
# end
|
|
49
|
+
# end
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# @example Enable debug logging
|
|
54
|
+
# ENV['AST_MERGE_DEBUG'] = '1'
|
|
55
|
+
# Ast::Merge::DebugLogger.debug("Processing node", {type: "mapping", line: 5})
|
|
56
|
+
#
|
|
57
|
+
# == Testing with Shared Examples
|
|
58
|
+
#
|
|
59
|
+
# Use the provided shared examples to validate your integration:
|
|
60
|
+
#
|
|
61
|
+
# require "ast/merge/rspec/shared_examples"
|
|
62
|
+
#
|
|
63
|
+
# RSpec.describe MyMerge::DebugLogger do
|
|
64
|
+
# it_behaves_like "Ast::Merge::DebugLogger" do
|
|
65
|
+
# let(:described_logger) { MyMerge::DebugLogger }
|
|
66
|
+
# let(:env_var_name) { "MY_MERGE_DEBUG" }
|
|
67
|
+
# let(:log_prefix) { "[MyMerge]" }
|
|
68
|
+
# end
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# @note Shared examples require +silent_stream+ and +rspec-stubbed_env+ gems.
|
|
72
|
+
module DebugLogger
|
|
73
|
+
# Benchmark is optional - gracefully degrade if not available
|
|
74
|
+
BENCHMARK_AVAILABLE = begin
|
|
75
|
+
require "benchmark"
|
|
76
|
+
true
|
|
77
|
+
rescue LoadError
|
|
78
|
+
# :nocov:
|
|
79
|
+
# Platform-specific: benchmark is part of Ruby stdlib, LoadError only on unusual Ruby builds
|
|
80
|
+
false
|
|
81
|
+
# :nocov:
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
# @return [String] Environment variable name to check for debug mode
|
|
86
|
+
attr_accessor :env_var_name
|
|
87
|
+
|
|
88
|
+
# @return [String] Prefix for log messages
|
|
89
|
+
attr_accessor :log_prefix
|
|
90
|
+
|
|
91
|
+
# Hook called when a module extends Ast::Merge::DebugLogger.
|
|
92
|
+
# Sets up attr_accessor for env_var_name and log_prefix on the extending module,
|
|
93
|
+
# and copies the BENCHMARK_AVAILABLE constant.
|
|
94
|
+
#
|
|
95
|
+
# @param base [Module] The module that is extending this module
|
|
96
|
+
def extended(base)
|
|
97
|
+
# Create a module with the accessors and prepend it to the singleton class.
|
|
98
|
+
# This avoids "method redefined" warnings when extending multiple times.
|
|
99
|
+
accessors_module = Module.new do
|
|
100
|
+
attr_accessor :env_var_name
|
|
101
|
+
attr_accessor :log_prefix
|
|
102
|
+
end
|
|
103
|
+
base.singleton_class.prepend(accessors_module)
|
|
104
|
+
|
|
105
|
+
# Set default values (inherit from Ast::Merge::DebugLogger)
|
|
106
|
+
base.env_var_name = env_var_name
|
|
107
|
+
base.log_prefix = log_prefix
|
|
108
|
+
|
|
109
|
+
# Copy the BENCHMARK_AVAILABLE constant
|
|
110
|
+
base.const_set(:BENCHMARK_AVAILABLE, BENCHMARK_AVAILABLE) unless base.const_defined?(:BENCHMARK_AVAILABLE)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Default configuration
|
|
115
|
+
self.env_var_name = "AST_MERGE_DEBUG"
|
|
116
|
+
self.log_prefix = "[Ast::Merge]"
|
|
117
|
+
|
|
118
|
+
# Check if debug mode is enabled
|
|
119
|
+
#
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def enabled?
|
|
122
|
+
val = ENV[env_var_name]
|
|
123
|
+
%w[1 true].include?(val)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get the environment variable name.
|
|
127
|
+
# When called as a module method (via extend self), returns own config.
|
|
128
|
+
# When called as instance method, checks class first, then falls back to base.
|
|
129
|
+
#
|
|
130
|
+
# @return [String]
|
|
131
|
+
def env_var_name
|
|
132
|
+
if is_a?(Module) && singleton_class.method_defined?(:env_var_name)
|
|
133
|
+
# Called as module method on a module that extended us
|
|
134
|
+
(self.class.superclass == Module) ? @env_var_name : self.class.env_var_name
|
|
135
|
+
elsif self.class.respond_to?(:env_var_name)
|
|
136
|
+
self.class.env_var_name
|
|
137
|
+
else
|
|
138
|
+
Ast::Merge::DebugLogger.env_var_name
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Get the log prefix.
|
|
143
|
+
# When called as a module method (via extend self), returns own config.
|
|
144
|
+
# When called as instance method, checks class first, then falls back to base.
|
|
145
|
+
#
|
|
146
|
+
# @return [String]
|
|
147
|
+
def log_prefix
|
|
148
|
+
if is_a?(Module) && singleton_class.method_defined?(:log_prefix)
|
|
149
|
+
# Called as module method on a module that extended us
|
|
150
|
+
(self.class.superclass == Module) ? @log_prefix : self.class.log_prefix
|
|
151
|
+
elsif self.class.respond_to?(:log_prefix)
|
|
152
|
+
self.class.log_prefix
|
|
153
|
+
else
|
|
154
|
+
Ast::Merge::DebugLogger.log_prefix
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Log a debug message with optional context
|
|
159
|
+
#
|
|
160
|
+
# @param message [String] The debug message
|
|
161
|
+
# @param context [Hash] Optional context to include
|
|
162
|
+
def debug(message, context = {})
|
|
163
|
+
return unless enabled?
|
|
164
|
+
|
|
165
|
+
output = "#{log_prefix} #{message}"
|
|
166
|
+
output += " #{context.inspect}" unless context.empty?
|
|
167
|
+
warn(output)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Log an info message (always shown when debug is enabled)
|
|
171
|
+
#
|
|
172
|
+
# @param message [String] The info message
|
|
173
|
+
def info(message)
|
|
174
|
+
return unless enabled?
|
|
175
|
+
|
|
176
|
+
warn("#{log_prefix} INFO] #{message}")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Log a warning message (always shown)
|
|
180
|
+
#
|
|
181
|
+
# @param message [String] The warning message
|
|
182
|
+
def warning(message)
|
|
183
|
+
warn("#{log_prefix} WARNING] #{message}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Time a block and log the duration
|
|
187
|
+
#
|
|
188
|
+
# @param operation [String] Name of the operation
|
|
189
|
+
# @yield The block to time
|
|
190
|
+
# @return [Object] The result of the block
|
|
191
|
+
def time(operation)
|
|
192
|
+
return yield unless enabled?
|
|
193
|
+
|
|
194
|
+
unless BENCHMARK_AVAILABLE
|
|
195
|
+
warning("Benchmark gem not available - timing disabled for: #{operation}")
|
|
196
|
+
return yield
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
debug("Starting: #{operation}")
|
|
200
|
+
result = nil
|
|
201
|
+
timing = Benchmark.measure { result = yield }
|
|
202
|
+
debug("Completed: #{operation}", {
|
|
203
|
+
real_ms: (timing.real * 1000).round(2),
|
|
204
|
+
user_ms: (timing.utime * 1000).round(2),
|
|
205
|
+
system_ms: (timing.stime * 1000).round(2),
|
|
206
|
+
})
|
|
207
|
+
result
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Log node information - override in submodules for file-type-specific logging
|
|
211
|
+
#
|
|
212
|
+
# @param node [Object] Node to log information about
|
|
213
|
+
# @param label [String] Label for the node
|
|
214
|
+
def log_node(node, label: "Node")
|
|
215
|
+
return unless enabled?
|
|
216
|
+
|
|
217
|
+
info = extract_node_info(node)
|
|
218
|
+
debug(label, info)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Extract information from a node for logging.
|
|
222
|
+
# Override in submodules for file-type-specific node types.
|
|
223
|
+
#
|
|
224
|
+
# @param node [Object] Node to extract info from
|
|
225
|
+
# @return [Hash] Node information
|
|
226
|
+
def extract_node_info(node)
|
|
227
|
+
type_name = safe_type_name(node)
|
|
228
|
+
lines = extract_lines(node)
|
|
229
|
+
|
|
230
|
+
info = {type: type_name}
|
|
231
|
+
info[:lines] = lines if lines
|
|
232
|
+
info
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Safely extract the type name from a node
|
|
236
|
+
#
|
|
237
|
+
# @param node [Object] Node to get type from
|
|
238
|
+
# @return [String] Type name
|
|
239
|
+
def safe_type_name(node)
|
|
240
|
+
klass = node.class
|
|
241
|
+
if klass.respond_to?(:name) && klass.name
|
|
242
|
+
klass.name.split("::").last
|
|
243
|
+
else
|
|
244
|
+
klass.to_s
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError
|
|
247
|
+
node.class.to_s
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Extract line information from a node if available
|
|
251
|
+
#
|
|
252
|
+
# @param node [Object] Node to extract lines from
|
|
253
|
+
# @return [String, nil] Line range string or nil
|
|
254
|
+
def extract_lines(node)
|
|
255
|
+
if node.respond_to?(:location)
|
|
256
|
+
loc = node.location
|
|
257
|
+
if loc.respond_to?(:start_line) && loc.respond_to?(:end_line)
|
|
258
|
+
"#{loc.start_line}..#{loc.end_line}"
|
|
259
|
+
elsif loc.respond_to?(:start_line)
|
|
260
|
+
loc.start_line.to_s
|
|
261
|
+
end
|
|
262
|
+
elsif node.respond_to?(:start_line) && node.respond_to?(:end_line)
|
|
263
|
+
"#{node.start_line}..#{node.end_line}"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Make all methods available as both instance and module methods
|
|
268
|
+
extend self
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|