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,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Abstract base class for SmartMerger implementations across all *-merge gems.
|
|
6
|
+
#
|
|
7
|
+
# SmartMergerBase provides the standard interface and common functionality
|
|
8
|
+
# for intelligent file merging. Subclasses implement format-specific parsing,
|
|
9
|
+
# analysis, and merge logic while inheriting the common API.
|
|
10
|
+
#
|
|
11
|
+
# ## Standard Options
|
|
12
|
+
#
|
|
13
|
+
# All SmartMerger implementations support these common options:
|
|
14
|
+
#
|
|
15
|
+
# - `preference` - `:destination` (default) or `:template`
|
|
16
|
+
# - `add_template_only_nodes` - `false` (default) or `true`
|
|
17
|
+
# - `signature_generator` - Custom signature proc or `nil`
|
|
18
|
+
# - `freeze_token` - Token for freeze block markers
|
|
19
|
+
# - `match_refiner` - Fuzzy match refiner or `nil`
|
|
20
|
+
# - `regions` - Region configurations for nested merging
|
|
21
|
+
# - `region_placeholder` - Custom placeholder for regions
|
|
22
|
+
#
|
|
23
|
+
# ## Implementing a SmartMerger
|
|
24
|
+
#
|
|
25
|
+
# Subclasses must implement:
|
|
26
|
+
# - `analysis_class` - Returns the FileAnalysis class for this format
|
|
27
|
+
# - `perform_merge` - Performs the format-specific merge logic
|
|
28
|
+
#
|
|
29
|
+
# Subclasses may override:
|
|
30
|
+
# - `default_freeze_token` - Format-specific default freeze token
|
|
31
|
+
# - `resolver_class` - Returns the ConflictResolver class (if different)
|
|
32
|
+
# - `result_class` - Returns the MergeResult class (if different)
|
|
33
|
+
# - `aligner_class` - Returns the FileAligner class (if used)
|
|
34
|
+
# - `parse_content` - Custom parsing logic
|
|
35
|
+
# - `build_analysis_options` - Additional analysis options
|
|
36
|
+
# - `build_resolver_options` - Additional resolver options
|
|
37
|
+
#
|
|
38
|
+
# @example Implementing a custom SmartMerger
|
|
39
|
+
# class MyFormat::SmartMerger < Ast::Merge::SmartMergerBase
|
|
40
|
+
# def analysis_class
|
|
41
|
+
# MyFormat::FileAnalysis
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# def default_freeze_token
|
|
45
|
+
# "myformat-merge"
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# private
|
|
49
|
+
#
|
|
50
|
+
# def perform_merge
|
|
51
|
+
# alignment = @aligner.align
|
|
52
|
+
# process_alignment(alignment)
|
|
53
|
+
# @result
|
|
54
|
+
# end
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# @abstract Subclass and implement {#analysis_class} and {#perform_merge}
|
|
58
|
+
# @api public
|
|
59
|
+
class SmartMergerBase
|
|
60
|
+
include RegionMergeable
|
|
61
|
+
|
|
62
|
+
# @return [String] Template source content
|
|
63
|
+
attr_reader :template_content
|
|
64
|
+
|
|
65
|
+
# @return [String] Destination source content
|
|
66
|
+
attr_reader :dest_content
|
|
67
|
+
|
|
68
|
+
# @return [Object] Analysis of the template file
|
|
69
|
+
attr_reader :template_analysis
|
|
70
|
+
|
|
71
|
+
# @return [Object] Analysis of the destination file
|
|
72
|
+
attr_reader :dest_analysis
|
|
73
|
+
|
|
74
|
+
# @return [Object, nil] Aligner for finding matches (if applicable)
|
|
75
|
+
attr_reader :aligner
|
|
76
|
+
|
|
77
|
+
# @return [Object] Resolver for handling conflicts
|
|
78
|
+
attr_reader :resolver
|
|
79
|
+
|
|
80
|
+
# @return [Object] Result object tracking merged content
|
|
81
|
+
attr_reader :result
|
|
82
|
+
|
|
83
|
+
# @return [Symbol, Hash] Preference for signature matches
|
|
84
|
+
attr_reader :preference
|
|
85
|
+
|
|
86
|
+
# @return [Boolean] Whether to add template-only nodes
|
|
87
|
+
attr_reader :add_template_only_nodes
|
|
88
|
+
|
|
89
|
+
# @return [String] Token for freeze block markers
|
|
90
|
+
attr_reader :freeze_token
|
|
91
|
+
|
|
92
|
+
# @return [Proc, nil] Custom signature generator
|
|
93
|
+
attr_reader :signature_generator
|
|
94
|
+
|
|
95
|
+
# @return [Object, nil] Match refiner for fuzzy matching
|
|
96
|
+
attr_reader :match_refiner
|
|
97
|
+
|
|
98
|
+
# Creates a new SmartMerger for intelligent file merging.
|
|
99
|
+
#
|
|
100
|
+
# @param template_content [String] Template source content
|
|
101
|
+
# @param dest_content [String] Destination source content
|
|
102
|
+
#
|
|
103
|
+
# @param signature_generator [Proc, nil] Optional proc to generate custom signatures.
|
|
104
|
+
# The proc receives a node and should return one of:
|
|
105
|
+
# - An array representing the node's signature
|
|
106
|
+
# - `nil` to indicate the node should have no signature
|
|
107
|
+
# - The original node to fall through to default signature computation
|
|
108
|
+
#
|
|
109
|
+
# @param preference [Symbol, Hash] Controls which version to use
|
|
110
|
+
# when nodes have matching signatures but different content:
|
|
111
|
+
# - `:destination` (default) - Use destination version (preserves customizations)
|
|
112
|
+
# - `:template` - Use template version (applies updates)
|
|
113
|
+
# - Hash for per-type preferences: `{ default: :destination, special: :template }`
|
|
114
|
+
#
|
|
115
|
+
# @param add_template_only_nodes [Boolean] Controls whether to add nodes that only
|
|
116
|
+
# exist in template:
|
|
117
|
+
# - `false` (default) - Skip template-only nodes
|
|
118
|
+
# - `true` - Add template-only nodes to result
|
|
119
|
+
#
|
|
120
|
+
# @param freeze_token [String, nil] Token to use for freeze block markers.
|
|
121
|
+
# Default varies by format (e.g., "prism-merge", "markly-merge")
|
|
122
|
+
#
|
|
123
|
+
# @param match_refiner [#call, nil] Optional match refiner for fuzzy matching.
|
|
124
|
+
# Default: nil (fuzzy matching disabled)
|
|
125
|
+
#
|
|
126
|
+
# @param regions [Array<Hash>, nil] Region configurations for nested merging.
|
|
127
|
+
# Each hash should contain:
|
|
128
|
+
# - `:detector` - RegionDetectorBase instance
|
|
129
|
+
# - `:merger_class` - SmartMerger class for the region (optional)
|
|
130
|
+
# - `:merger_options` - Options for the region merger (optional)
|
|
131
|
+
# - `:regions` - Nested region configs (optional, for recursive regions)
|
|
132
|
+
#
|
|
133
|
+
# @param region_placeholder [String, nil] Custom placeholder prefix for regions.
|
|
134
|
+
# Default: "<<<AST_MERGE_REGION_"
|
|
135
|
+
#
|
|
136
|
+
# @param format_options [Hash] Format-specific parser options passed to FileAnalysis.
|
|
137
|
+
# These are merged with freeze_token and signature_generator in build_full_analysis_options.
|
|
138
|
+
# Examples:
|
|
139
|
+
# - Markly: `flags: Markly::FOOTNOTES, extensions: [:table, :strikethrough]`
|
|
140
|
+
# - Commonmarker: `options: { parse: { smart: true } }`
|
|
141
|
+
# - Prism: (no additional parser options needed)
|
|
142
|
+
#
|
|
143
|
+
# @raise [Ast::Merge::TemplateParseError] If template has syntax errors
|
|
144
|
+
# @raise [Ast::Merge::DestinationParseError] If destination has syntax errors
|
|
145
|
+
def initialize(
|
|
146
|
+
template_content,
|
|
147
|
+
dest_content,
|
|
148
|
+
signature_generator: nil,
|
|
149
|
+
preference: :destination,
|
|
150
|
+
add_template_only_nodes: false,
|
|
151
|
+
freeze_token: nil,
|
|
152
|
+
match_refiner: nil,
|
|
153
|
+
regions: nil,
|
|
154
|
+
region_placeholder: nil,
|
|
155
|
+
**format_options
|
|
156
|
+
)
|
|
157
|
+
@template_content = template_content
|
|
158
|
+
@dest_content = dest_content
|
|
159
|
+
@signature_generator = signature_generator
|
|
160
|
+
@preference = preference
|
|
161
|
+
@add_template_only_nodes = add_template_only_nodes
|
|
162
|
+
@freeze_token = freeze_token || default_freeze_token
|
|
163
|
+
@match_refiner = match_refiner
|
|
164
|
+
@format_options = format_options
|
|
165
|
+
|
|
166
|
+
# Set up region support
|
|
167
|
+
setup_regions(regions: regions || [], region_placeholder: region_placeholder)
|
|
168
|
+
|
|
169
|
+
# Extract regions before parsing (if configured)
|
|
170
|
+
template_for_parsing = extract_template_regions(@template_content)
|
|
171
|
+
dest_for_parsing = extract_dest_regions(@dest_content)
|
|
172
|
+
|
|
173
|
+
# Parse and analyze both files
|
|
174
|
+
@template_analysis = parse_and_analyze(template_for_parsing, :template)
|
|
175
|
+
@dest_analysis = parse_and_analyze(dest_for_parsing, :destination)
|
|
176
|
+
|
|
177
|
+
# Set up aligner (if applicable)
|
|
178
|
+
@aligner = build_aligner if respond_to?(:aligner_class, true) && aligner_class
|
|
179
|
+
|
|
180
|
+
# Set up resolver
|
|
181
|
+
@resolver = build_resolver
|
|
182
|
+
|
|
183
|
+
# Set up result
|
|
184
|
+
@result = build_result
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Perform the merge operation and return the merged content as a string.
|
|
188
|
+
#
|
|
189
|
+
# @return [String] The merged content
|
|
190
|
+
def merge
|
|
191
|
+
merge_result.to_s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Perform the merge operation and return the full result object.
|
|
195
|
+
#
|
|
196
|
+
# This method is memoized - subsequent calls return the cached result.
|
|
197
|
+
#
|
|
198
|
+
# @return [Object] The merge result (format-specific MergeResult subclass)
|
|
199
|
+
def merge_result
|
|
200
|
+
return @merge_result if @merge_result
|
|
201
|
+
|
|
202
|
+
@merge_result = DebugLogger.time("#{self.class.name}#merge") do
|
|
203
|
+
result = perform_merge
|
|
204
|
+
|
|
205
|
+
# Substitute merged regions back into the result if configured
|
|
206
|
+
if regions_configured? && (merged_content = result.to_s)
|
|
207
|
+
final_content = substitute_merged_regions(merged_content)
|
|
208
|
+
update_result_content(result, final_content)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
result
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Perform the merge and return detailed debug information.
|
|
216
|
+
#
|
|
217
|
+
# @return [Hash] Hash containing:
|
|
218
|
+
# - `:content` [String] - Final merged content
|
|
219
|
+
# - `:statistics` [Hash] - Merge decision counts
|
|
220
|
+
def merge_with_debug
|
|
221
|
+
content = merge
|
|
222
|
+
|
|
223
|
+
{
|
|
224
|
+
content: content,
|
|
225
|
+
statistics: @result.decision_summary,
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Get merge statistics.
|
|
230
|
+
#
|
|
231
|
+
# @return [Hash] Statistics about the merge
|
|
232
|
+
def stats
|
|
233
|
+
merge_result # Ensure merge has run
|
|
234
|
+
@result.decision_summary
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
protected
|
|
238
|
+
|
|
239
|
+
# Returns the FileAnalysis class for this format.
|
|
240
|
+
#
|
|
241
|
+
# @return [Class] The analysis class
|
|
242
|
+
# @abstract Subclasses must implement this method
|
|
243
|
+
def analysis_class
|
|
244
|
+
raise NotImplementedError, "#{self.class}#analysis_class must be implemented"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Returns the default freeze token for this format.
|
|
248
|
+
#
|
|
249
|
+
# @return [String] The default freeze token (e.g., "prism-merge")
|
|
250
|
+
def default_freeze_token
|
|
251
|
+
"ast-merge"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Returns the ConflictResolver class for this format.
|
|
255
|
+
#
|
|
256
|
+
# Override if your format uses a custom resolver.
|
|
257
|
+
#
|
|
258
|
+
# @return [Class, nil] The resolver class, or nil to skip resolver creation
|
|
259
|
+
def resolver_class
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Returns the MergeResult class for this format.
|
|
264
|
+
#
|
|
265
|
+
# Override if your format uses a custom result class.
|
|
266
|
+
#
|
|
267
|
+
# @return [Class, nil] The result class, or nil to skip result creation
|
|
268
|
+
def result_class
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Returns the FileAligner class for this format.
|
|
273
|
+
#
|
|
274
|
+
# Override if your format uses an aligner.
|
|
275
|
+
#
|
|
276
|
+
# @return [Class, nil] The aligner class, or nil if not used
|
|
277
|
+
def aligner_class
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Performs the format-specific merge logic.
|
|
282
|
+
#
|
|
283
|
+
# This method should use @template_analysis, @dest_analysis, @resolver, etc.
|
|
284
|
+
# to perform the merge and populate @result.
|
|
285
|
+
#
|
|
286
|
+
# @return [Object] The merge result (typically @result)
|
|
287
|
+
# @abstract Subclasses must implement this method
|
|
288
|
+
def perform_merge
|
|
289
|
+
raise NotImplementedError, "#{self.class}#perform_merge must be implemented"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Build additional options for FileAnalysis.
|
|
293
|
+
#
|
|
294
|
+
# Override to add format-specific options.
|
|
295
|
+
#
|
|
296
|
+
# @return [Hash] Additional options for the analysis class
|
|
297
|
+
def build_analysis_options
|
|
298
|
+
{}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Build additional options for ConflictResolver.
|
|
302
|
+
#
|
|
303
|
+
# Override to add format-specific options.
|
|
304
|
+
#
|
|
305
|
+
# @return [Hash] Additional options for the resolver class
|
|
306
|
+
def build_resolver_options
|
|
307
|
+
{}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Update the result content after region substitution.
|
|
311
|
+
#
|
|
312
|
+
# Override if your result class needs special handling.
|
|
313
|
+
#
|
|
314
|
+
# @param result [Object] The merge result
|
|
315
|
+
# @param content [String] The final content with regions substituted
|
|
316
|
+
def update_result_content(result, content)
|
|
317
|
+
result.content = content
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
# Parse and analyze content, raising appropriate errors.
|
|
323
|
+
#
|
|
324
|
+
# @param content [String] Content to parse
|
|
325
|
+
# @param source [Symbol] :template or :destination
|
|
326
|
+
# @return [Object] The analysis result
|
|
327
|
+
def parse_and_analyze(content, source)
|
|
328
|
+
options = build_full_analysis_options
|
|
329
|
+
|
|
330
|
+
DebugLogger.time("#{self.class.name}#analyze_#{source}") do
|
|
331
|
+
analysis_class.new(content, **options)
|
|
332
|
+
end
|
|
333
|
+
rescue StandardError => e
|
|
334
|
+
# Don't re-wrap our own parse errors
|
|
335
|
+
raise if e.is_a?(template_parse_error_class) || e.is_a?(destination_parse_error_class)
|
|
336
|
+
|
|
337
|
+
error_class = (source == :template) ? template_parse_error_class : destination_parse_error_class
|
|
338
|
+
raise error_class.new(errors: [e], content: content)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Returns the TemplateParseError class for this merger.
|
|
342
|
+
# Override in subclasses to use format-specific error classes.
|
|
343
|
+
#
|
|
344
|
+
# @return [Class] The template parse error class
|
|
345
|
+
def template_parse_error_class
|
|
346
|
+
TemplateParseError
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Returns the DestinationParseError class for this merger.
|
|
350
|
+
# Override in subclasses to use format-specific error classes.
|
|
351
|
+
#
|
|
352
|
+
# @return [Class] The destination parse error class
|
|
353
|
+
def destination_parse_error_class
|
|
354
|
+
DestinationParseError
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Build the complete options hash for FileAnalysis.
|
|
358
|
+
#
|
|
359
|
+
# Override this method to completely control what options are passed.
|
|
360
|
+
# By default, includes freeze_token, signature_generator, and format_options.
|
|
361
|
+
#
|
|
362
|
+
# @return [Hash] Options for the analysis class
|
|
363
|
+
def build_full_analysis_options
|
|
364
|
+
{
|
|
365
|
+
freeze_token: @freeze_token,
|
|
366
|
+
signature_generator: @signature_generator,
|
|
367
|
+
}.merge(build_analysis_options).merge(@format_options)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Build the aligner instance.
|
|
371
|
+
#
|
|
372
|
+
# Override if your aligner has a different constructor signature.
|
|
373
|
+
#
|
|
374
|
+
# @return [Object] The aligner instance
|
|
375
|
+
def build_aligner
|
|
376
|
+
aligner_class.new(@template_analysis, @dest_analysis, match_refiner: @match_refiner)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Build the resolver instance.
|
|
380
|
+
#
|
|
381
|
+
# Override if your resolver has a different constructor signature.
|
|
382
|
+
#
|
|
383
|
+
# @return [Object, nil] The resolver instance
|
|
384
|
+
def build_resolver
|
|
385
|
+
return unless resolver_class
|
|
386
|
+
|
|
387
|
+
options = {
|
|
388
|
+
preference: @preference,
|
|
389
|
+
template_analysis: @template_analysis,
|
|
390
|
+
dest_analysis: @dest_analysis,
|
|
391
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
392
|
+
match_refiner: @match_refiner,
|
|
393
|
+
}.merge(build_resolver_options)
|
|
394
|
+
|
|
395
|
+
resolver_class.new(**options)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Build the result instance.
|
|
399
|
+
#
|
|
400
|
+
# Override if your result class has a different constructor signature.
|
|
401
|
+
#
|
|
402
|
+
# @return [Object, nil] The result instance
|
|
403
|
+
def build_result
|
|
404
|
+
return unless result_class
|
|
405
|
+
|
|
406
|
+
if result_class.instance_method(:initialize).arity == 0
|
|
407
|
+
result_class.new
|
|
408
|
+
else
|
|
409
|
+
result_class.new(
|
|
410
|
+
template_analysis: @template_analysis,
|
|
411
|
+
dest_analysis: @dest_analysis,
|
|
412
|
+
)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Text
|
|
6
|
+
# Conflict resolver for text-based AST merging.
|
|
7
|
+
#
|
|
8
|
+
# Uses content-based matching with destination-order preservation:
|
|
9
|
+
# 1. Lines are matched by normalized content (whitespace-trimmed)
|
|
10
|
+
# 2. Destination order is preserved (destination is source of truth for structure)
|
|
11
|
+
# 3. Template-only lines are optionally added at the end
|
|
12
|
+
# 4. Freeze blocks are always preserved from destination
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# resolver = ConflictResolver.new(template_analysis, dest_analysis)
|
|
16
|
+
# result = MergeResult.new
|
|
17
|
+
# resolver.resolve(result)
|
|
18
|
+
class ConflictResolver < ConflictResolverBase
|
|
19
|
+
# Initialize the conflict resolver
|
|
20
|
+
#
|
|
21
|
+
# @param template_analysis [FileAnalysis] Analysis of template
|
|
22
|
+
# @param dest_analysis [FileAnalysis] Analysis of destination
|
|
23
|
+
# @param preference [Symbol] :destination or :template
|
|
24
|
+
# @param add_template_only_nodes [Boolean] Whether to add template-only lines
|
|
25
|
+
def initialize(
|
|
26
|
+
template_analysis,
|
|
27
|
+
dest_analysis,
|
|
28
|
+
preference: :destination,
|
|
29
|
+
add_template_only_nodes: false
|
|
30
|
+
)
|
|
31
|
+
super(
|
|
32
|
+
strategy: :batch,
|
|
33
|
+
preference: preference,
|
|
34
|
+
template_analysis: template_analysis,
|
|
35
|
+
dest_analysis: dest_analysis,
|
|
36
|
+
add_template_only_nodes: add_template_only_nodes
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
protected
|
|
41
|
+
|
|
42
|
+
# Resolve using content-based matching with destination order preservation
|
|
43
|
+
#
|
|
44
|
+
# @param result [MergeResult] Result object to populate
|
|
45
|
+
# @return [void]
|
|
46
|
+
def resolve_batch(result)
|
|
47
|
+
template_statements = @template_analysis.statements
|
|
48
|
+
dest_statements = @dest_analysis.statements
|
|
49
|
+
|
|
50
|
+
# Build content index for matching
|
|
51
|
+
template_by_content = build_content_index(template_statements)
|
|
52
|
+
|
|
53
|
+
# Track matched template indices
|
|
54
|
+
matched_template_indices = Set.new
|
|
55
|
+
|
|
56
|
+
# Process destination in order - destination structure is preserved
|
|
57
|
+
dest_statements.each do |dest_node|
|
|
58
|
+
if freeze_node?(dest_node)
|
|
59
|
+
# Freeze blocks are always preserved from destination
|
|
60
|
+
add_freeze_block(result, dest_node)
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Find matching template line by normalized content
|
|
65
|
+
normalized = dest_node.normalized_content
|
|
66
|
+
template_match = find_unmatched(template_by_content[normalized], matched_template_indices)
|
|
67
|
+
|
|
68
|
+
if template_match
|
|
69
|
+
matched_template_indices << template_match[:index]
|
|
70
|
+
resolve_matched_pair(result, template_match[:node], dest_node)
|
|
71
|
+
else
|
|
72
|
+
# Destination-only content - always preserve
|
|
73
|
+
result.add_line(dest_node.content)
|
|
74
|
+
result.record_decision(DECISION_APPENDED, nil, dest_node)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Add template-only lines if configured
|
|
79
|
+
if @add_template_only_nodes
|
|
80
|
+
add_unmatched_template_lines(result, template_statements, matched_template_indices)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Build an index of statements by normalized content
|
|
87
|
+
#
|
|
88
|
+
# @param statements [Array] Statements to index
|
|
89
|
+
# @return [Hash] Map of normalized content => [{node:, index:}, ...]
|
|
90
|
+
def build_content_index(statements)
|
|
91
|
+
index = Hash.new { |h, k| h[k] = [] }
|
|
92
|
+
statements.each_with_index do |node, idx|
|
|
93
|
+
next if freeze_node?(node)
|
|
94
|
+
|
|
95
|
+
normalized = node.normalized_content
|
|
96
|
+
index[normalized] << {node: node, index: idx}
|
|
97
|
+
end
|
|
98
|
+
index
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Find first unmatched entry from a list
|
|
102
|
+
#
|
|
103
|
+
# @param entries [Array, nil] List of {node:, index:} hashes
|
|
104
|
+
# @param matched_indices [Set] Already matched indices
|
|
105
|
+
# @return [Hash, nil] First unmatched entry or nil
|
|
106
|
+
def find_unmatched(entries, matched_indices)
|
|
107
|
+
return unless entries
|
|
108
|
+
|
|
109
|
+
entries.find { |e| !matched_indices.include?(e[:index]) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Add a freeze block to the result
|
|
113
|
+
#
|
|
114
|
+
# @param result [MergeResult] Result to populate
|
|
115
|
+
# @param freeze_node [FreezeNodeBase] Freeze block node
|
|
116
|
+
def add_freeze_block(result, freeze_node)
|
|
117
|
+
freeze_node.content.split("\n").each do |line|
|
|
118
|
+
result.add_line(line)
|
|
119
|
+
end
|
|
120
|
+
result.record_decision(DECISION_FROZEN, nil, freeze_node)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Add unmatched template lines in their original order
|
|
124
|
+
#
|
|
125
|
+
# @param result [MergeResult] Result to populate
|
|
126
|
+
# @param template_statements [Array] All template statements
|
|
127
|
+
# @param matched_indices [Set] Indices of matched template nodes
|
|
128
|
+
def add_unmatched_template_lines(result, template_statements, matched_indices)
|
|
129
|
+
template_statements.each_with_index do |template_node, idx|
|
|
130
|
+
next if matched_indices.include?(idx)
|
|
131
|
+
next if freeze_node?(template_node)
|
|
132
|
+
|
|
133
|
+
result.add_line(template_node.content)
|
|
134
|
+
result.record_decision(DECISION_ADDED, template_node, nil)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Resolve a matched pair of nodes
|
|
139
|
+
#
|
|
140
|
+
# @param result [MergeResult] Result to populate
|
|
141
|
+
# @param template_node [LineNode] Template node
|
|
142
|
+
# @param dest_node [LineNode] Destination node
|
|
143
|
+
def resolve_matched_pair(result, template_node, dest_node)
|
|
144
|
+
if template_node.content == dest_node.content
|
|
145
|
+
# Identical content
|
|
146
|
+
result.add_line(dest_node.content)
|
|
147
|
+
result.record_decision(DECISION_IDENTICAL, template_node, dest_node)
|
|
148
|
+
elsif @preference == :template
|
|
149
|
+
# Template wins - use template content
|
|
150
|
+
result.add_line(template_node.content)
|
|
151
|
+
result.record_decision(DECISION_KEPT_TEMPLATE, template_node, dest_node)
|
|
152
|
+
else
|
|
153
|
+
# Destination wins (default) - use destination content
|
|
154
|
+
result.add_line(dest_node.content)
|
|
155
|
+
result.record_decision(DECISION_KEPT_DEST, template_node, dest_node)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|