prism-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.
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prism
4
+ module Merge
5
+ # Represents the merged output with bidirectional links to source lines.
6
+ # Tracks merge decisions and provenance for validation and debugging.
7
+ class MergeResult
8
+ # Line was kept from template (no conflict or template preferred).
9
+ # Used when template content is included without modification.
10
+ DECISION_KEPT_TEMPLATE = :kept_template
11
+
12
+ # Line was kept from destination (no conflict or destination preferred).
13
+ # Used when destination content is included without modification.
14
+ DECISION_KEPT_DEST = :kept_destination
15
+
16
+ # Line was appended from destination (destination-only content).
17
+ # Used for content that exists only in destination and is added to result.
18
+ # Common for destination-specific customizations like extra methods or constants.
19
+ DECISION_APPENDED = :appended
20
+
21
+ # Line replaced matching content (signature match with preference applied).
22
+ # Used when template and destination have nodes with same signature but
23
+ # different content, and one version replaced the other based on preference.
24
+ DECISION_REPLACED = :replaced
25
+
26
+ # Line from destination freeze block (always preserved).
27
+ # Used for content within kettle-dev:freeze markers that must be kept
28
+ # from destination regardless of template content.
29
+ DECISION_FREEZE_BLOCK = :freeze_block
30
+
31
+ attr_reader :lines, :line_metadata
32
+
33
+ def initialize
34
+ @lines = []
35
+ @line_metadata = []
36
+ end
37
+
38
+ # Add a line to the result
39
+ # @param content [String] Line content (without newline)
40
+ # @param decision [Symbol] How this line was decided
41
+ # @param template_line [Integer, nil] 1-based line number from template
42
+ # @param dest_line [Integer, nil] 1-based line number from destination
43
+ # @param comment [String, nil] Optional note about this decision
44
+ def add_line(content, decision:, template_line: nil, dest_line: nil, comment: nil)
45
+ @lines << content
46
+ @line_metadata << {
47
+ decision: decision,
48
+ template_line: template_line,
49
+ dest_line: dest_line,
50
+ comment: comment,
51
+ result_line: @lines.length,
52
+ }
53
+ end
54
+
55
+ # Add multiple lines from a source with same decision
56
+ # @param source_lines [Array<String>] Lines to add
57
+ # @param decision [Symbol] Merge decision
58
+ # @param source [Symbol] :template or :destination
59
+ # @param start_line [Integer] Starting line number in source
60
+ # @param comment [String, nil] Optional note
61
+ def add_lines_from(source_lines, decision:, source:, start_line:, comment: nil)
62
+ source_lines.each_with_index do |line, idx|
63
+ line_num = start_line + idx
64
+ if source == :template
65
+ add_line(line, decision: decision, template_line: line_num, comment: comment)
66
+ else
67
+ add_line(line, decision: decision, dest_line: line_num, comment: comment)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Add a node's content with its comments
73
+ # @param node_info [Hash] Node information from FileAnalysis
74
+ # @param decision [Symbol] Merge decision
75
+ # @param source [Symbol] :template or :destination
76
+ # @param source_analysis [FileAnalysis] Source file analysis (unused but kept for compatibility)
77
+ def add_node(node_info, decision:, source:, source_analysis: nil)
78
+ node = node_info[:node]
79
+ start_line = node.location.start_line
80
+
81
+ # Add leading comments
82
+ node_info[:leading_comments].each do |comment|
83
+ line = comment.slice.rstrip
84
+ comment_line = comment.location.start_line
85
+ if source == :template
86
+ add_line(line, decision: decision, template_line: comment_line)
87
+ else
88
+ add_line(line, decision: decision, dest_line: comment_line)
89
+ end
90
+ end
91
+
92
+ # Add node source
93
+ node_source = node.slice
94
+ node_lines = node_source.lines(chomp: true)
95
+
96
+ # Handle inline comments
97
+ inline_comments = node_info[:inline_comments]
98
+ if inline_comments.any?
99
+ # Inline comments are on the last line
100
+ last_idx = node_lines.length - 1
101
+ if last_idx >= 0
102
+ inline_text = inline_comments.map { |c| c.slice.strip }.join(" ")
103
+ node_lines[last_idx] = node_lines[last_idx].rstrip + " " + inline_text
104
+ end
105
+ end
106
+
107
+ node_lines.each_with_index do |line, idx|
108
+ line_num = start_line + idx
109
+ if source == :template
110
+ add_line(line, decision: decision, template_line: line_num)
111
+ else
112
+ add_line(line, decision: decision, dest_line: line_num)
113
+ end
114
+ end
115
+ end
116
+
117
+ # Convert to final merged content string
118
+ # @return [String]
119
+ def to_s
120
+ @lines.join("\n") + "\n"
121
+ end
122
+
123
+ # Get statistics about merge decisions
124
+ # @return [Hash<Symbol, Integer>]
125
+ def statistics
126
+ stats = Hash.new(0)
127
+ @line_metadata.each do |meta|
128
+ stats[meta[:decision]] += 1
129
+ end
130
+ stats
131
+ end
132
+
133
+ # Get lines by decision type
134
+ # @param decision [Symbol] Decision type to filter by
135
+ # @return [Array<Hash>] Metadata for matching lines
136
+ def lines_by_decision(decision)
137
+ @line_metadata.select { |meta| meta[:decision] == decision }
138
+ end
139
+
140
+ # Debug output showing merge provenance
141
+ # @return [String]
142
+ def debug_output
143
+ output = ["=== Merge Result Debug ==="]
144
+ output << "Total lines: #{@lines.length}"
145
+ output << "Statistics: #{statistics.inspect}"
146
+ output << ""
147
+ output << "Line-by-line provenance:"
148
+
149
+ @lines.each_with_index do |line, idx|
150
+ meta = @line_metadata[idx]
151
+ parts = [
152
+ "#{idx + 1}:".rjust(4),
153
+ meta[:decision].to_s.ljust(20),
154
+ ]
155
+
156
+ parts << if meta[:template_line]
157
+ "T:#{meta[:template_line]}".ljust(8)
158
+ else
159
+ " " * 8
160
+ end
161
+
162
+ parts << if meta[:dest_line]
163
+ "D:#{meta[:dest_line]}".ljust(8)
164
+ else
165
+ " " * 8
166
+ end
167
+
168
+ parts << "| #{line[0..60]}"
169
+ output << parts.join(" ")
170
+ end
171
+
172
+ output.join("\n")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prism
4
+ module Merge
5
+ # Orchestrates the smart merge process using FileAnalysis, FileAligner,
6
+ # ConflictResolver, and MergeResult to merge two Ruby files intelligently.
7
+ #
8
+ # SmartMerger provides flexible configuration for different merge scenarios:
9
+ #
10
+ # @example Basic merge (destination customizations preserved)
11
+ # merger = SmartMerger.new(template_content, dest_content)
12
+ # result = merger.merge
13
+ #
14
+ # @example Version file merge (template updates win)
15
+ # merger = SmartMerger.new(
16
+ # template_content,
17
+ # dest_content,
18
+ # signature_match_preference: :template,
19
+ # add_template_only_nodes: true
20
+ # )
21
+ # result = merger.merge
22
+ # # Result: VERSION = "2.0.0" (from template), new constants added
23
+ #
24
+ # @example Appraisals merge (destination customizations preserved)
25
+ # merger = SmartMerger.new(
26
+ # template_content,
27
+ # dest_content,
28
+ # signature_match_preference: :destination, # default
29
+ # add_template_only_nodes: false # default
30
+ # )
31
+ # result = merger.merge
32
+ # # Result: Custom gem versions preserved, template-only blocks skipped
33
+ #
34
+ # @example Custom signature matching
35
+ # sig_gen = ->(node) { [node.class.name, node.name] }
36
+ # merger = SmartMerger.new(
37
+ # template_content,
38
+ # dest_content,
39
+ # signature_generator: sig_gen
40
+ # )
41
+ #
42
+ # @see FileAnalysis
43
+ # @see FileAligner
44
+ # @see ConflictResolver
45
+ # @see MergeResult
46
+ class SmartMerger
47
+ # @return [FileAnalysis] Analysis of the template file
48
+ attr_reader :template_analysis
49
+
50
+ # @return [FileAnalysis] Analysis of the destination file
51
+ attr_reader :dest_analysis
52
+
53
+ # @return [FileAligner] Aligner for finding matches and differences
54
+ attr_reader :aligner
55
+
56
+ # @return [ConflictResolver] Resolver for handling conflicting content
57
+ attr_reader :resolver
58
+
59
+ # @return [MergeResult] Result object tracking merged content
60
+ attr_reader :result
61
+
62
+ # Creates a new SmartMerger for intelligent Ruby file merging.
63
+ #
64
+ # @param template_content [String] Template Ruby source code
65
+ # @param dest_content [String] Destination Ruby source code
66
+ #
67
+ # @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
68
+ # The proc receives a Prism node and should return an array representing its signature.
69
+ # Nodes with identical signatures are considered matches during merge.
70
+ # Default: Uses {FileAnalysis#default_signature} which matches:
71
+ # - Conditionals by condition only (not body)
72
+ # - Assignments by name only (not value)
73
+ # - Method calls by name and args (not block)
74
+ #
75
+ # @param signature_match_preference [Symbol] Controls which version to use when nodes
76
+ # have matching signatures but different content:
77
+ # - `:destination` (default) - Use destination version (preserves customizations).
78
+ # Use for Appraisals files, configs with project-specific values.
79
+ # - `:template` - Use template version (applies updates).
80
+ # Use for version files, canonical configs, conditional implementations.
81
+ #
82
+ # @param add_template_only_nodes [Boolean] Controls whether to add nodes that only
83
+ # exist in template:
84
+ # - `false` (default) - Skip template-only nodes.
85
+ # Use for templates with placeholder/example content.
86
+ # - `true` - Add template-only nodes to result.
87
+ # Use when template has new required constants/methods to add.
88
+ #
89
+ # @raise [TemplateParseError] If template has syntax errors
90
+ # @raise [DestinationParseError] If destination has syntax errors
91
+ #
92
+ # @example Basic usage
93
+ # merger = SmartMerger.new(template, destination)
94
+ # result = merger.merge
95
+ #
96
+ # @example Template updates win (version files)
97
+ # merger = SmartMerger.new(
98
+ # template,
99
+ # destination,
100
+ # signature_match_preference: :template,
101
+ # add_template_only_nodes: true
102
+ # )
103
+ #
104
+ # @example Destination customizations win (Appraisals)
105
+ # merger = SmartMerger.new(
106
+ # template,
107
+ # destination,
108
+ # signature_match_preference: :destination,
109
+ # add_template_only_nodes: false
110
+ # )
111
+ #
112
+ # @example Custom signature matching
113
+ # sig_gen = lambda do |node|
114
+ # case node
115
+ # when Prism::DefNode
116
+ # [:method, node.name]
117
+ # else
118
+ # [node.class.name, node.slice]
119
+ # end
120
+ # end
121
+ #
122
+ # merger = SmartMerger.new(
123
+ # template,
124
+ # destination,
125
+ # signature_generator: sig_gen
126
+ # )
127
+ def initialize(template_content, dest_content, signature_generator: nil, signature_match_preference: :destination, add_template_only_nodes: false)
128
+ @template_content = template_content
129
+ @dest_content = dest_content
130
+ @signature_match_preference = signature_match_preference
131
+ @add_template_only_nodes = add_template_only_nodes
132
+ @template_analysis = FileAnalysis.new(template_content, signature_generator: signature_generator)
133
+ @dest_analysis = FileAnalysis.new(dest_content, signature_generator: signature_generator)
134
+ @aligner = FileAligner.new(@template_analysis, @dest_analysis)
135
+ @resolver = ConflictResolver.new(
136
+ @template_analysis,
137
+ @dest_analysis,
138
+ signature_match_preference: signature_match_preference,
139
+ add_template_only_nodes: add_template_only_nodes,
140
+ )
141
+ @result = MergeResult.new
142
+ end
143
+
144
+ # Performs the intelligent merge of template and destination files.
145
+ #
146
+ # The merge process:
147
+ # 1. Validates both files for syntax errors
148
+ # 2. Finds anchors (matching sections) and boundaries (differences)
149
+ # 3. Processes anchors and boundaries in order
150
+ # 4. Returns merged content as a string
151
+ #
152
+ # Merge behavior is controlled by constructor parameters:
153
+ # - `signature_match_preference`: Which version wins for matching nodes
154
+ # - `add_template_only_nodes`: Whether to add template-only content
155
+ #
156
+ # @return [String] The merged Ruby source code
157
+ #
158
+ # @raise [TemplateParseError] If template has syntax errors
159
+ # @raise [DestinationParseError] If destination has syntax errors
160
+ #
161
+ # @example Basic merge
162
+ # merger = SmartMerger.new(template, destination)
163
+ # result = merger.merge
164
+ # File.write("output.rb", result)
165
+ #
166
+ # @example With error handling
167
+ # begin
168
+ # result = merger.merge
169
+ # rescue Prism::Merge::TemplateParseError => e
170
+ # puts "Template error: #{e.message}"
171
+ # puts "Parse errors: #{e.parse_result.errors}"
172
+ # end
173
+ #
174
+ # @see #merge_with_debug for detailed merge information
175
+ def merge
176
+ # Handle invalid files
177
+ unless @template_analysis.valid?
178
+ raise Prism::Merge::TemplateParseError.new(
179
+ "Template file has parsing errors",
180
+ content: @template_content,
181
+ parse_result: @template_analysis.parse_result,
182
+ )
183
+ end
184
+
185
+ unless @dest_analysis.valid?
186
+ raise Prism::Merge::DestinationParseError.new(
187
+ "Destination file has parsing errors",
188
+ content: @dest_content,
189
+ parse_result: @dest_analysis.parse_result,
190
+ )
191
+ end
192
+
193
+ # Find anchors and boundaries
194
+ boundaries = @aligner.align
195
+
196
+ # Process the merge by walking through anchors and boundaries in order
197
+ process_merge(boundaries)
198
+
199
+ # Return final content
200
+ @result.to_s
201
+ end
202
+
203
+ # Performs merge and returns detailed debug information.
204
+ #
205
+ # This method provides comprehensive information about merge decisions,
206
+ # useful for debugging, testing, and understanding merge behavior.
207
+ #
208
+ # @return [Hash] Hash containing:
209
+ # - `:content` [String] - Final merged content
210
+ # - `:debug` [String] - Line-by-line provenance information
211
+ # - `:statistics` [Hash] - Counts of merge decisions:
212
+ # - `:kept_template` - Lines from template (no conflict)
213
+ # - `:kept_destination` - Lines from destination (no conflict)
214
+ # - `:replaced` - Template replaced matching destination
215
+ # - `:appended` - Destination-only content added
216
+ # - `:freeze_block` - Lines from freeze blocks
217
+ #
218
+ # @example Get merge statistics
219
+ # result = merger.merge_with_debug
220
+ # puts "Template lines: #{result[:statistics][:kept_template]}"
221
+ # puts "Replaced lines: #{result[:statistics][:replaced]}"
222
+ #
223
+ # @example Debug line provenance
224
+ # result = merger.merge_with_debug
225
+ # puts result[:debug]
226
+ # # Output shows source file and decision for each line:
227
+ # # Line 1: [KEPT_TEMPLATE] # frozen_string_literal: true
228
+ # # Line 2: [KEPT_TEMPLATE]
229
+ # # Line 3: [REPLACED] VERSION = "2.0.0"
230
+ #
231
+ # @see #merge for basic merge without debug info
232
+ def merge_with_debug
233
+ content = merge
234
+ {
235
+ content: content,
236
+ debug: @result.debug_output,
237
+ statistics: @result.statistics,
238
+ }
239
+ end
240
+
241
+ private
242
+
243
+ def process_merge(boundaries)
244
+ # Build complete timeline of anchors and boundaries
245
+ timeline = build_timeline(boundaries)
246
+
247
+ timeline.each do |item|
248
+ if item[:type] == :anchor
249
+ process_anchor(item[:anchor])
250
+ else
251
+ process_boundary(item[:boundary])
252
+ end
253
+ end
254
+ end
255
+
256
+ def build_timeline(boundaries)
257
+ timeline = []
258
+
259
+ # Add all anchors and boundaries sorted by position
260
+ @aligner.anchors.each do |anchor|
261
+ timeline << {type: :anchor, anchor: anchor, sort_key: [anchor.template_start, 0]}
262
+ end
263
+
264
+ boundaries.each do |boundary|
265
+ # Sort boundaries by their starting position
266
+ t_start = boundary.template_range&.begin || 0
267
+ d_start = boundary.dest_range&.begin || 0
268
+ sort_key = [t_start, d_start, 1] # 1 ensures boundaries come after anchors at same position
269
+
270
+ timeline << {type: :boundary, boundary: boundary, sort_key: sort_key}
271
+ end
272
+
273
+ timeline.sort_by! { |item| item[:sort_key] }
274
+ timeline
275
+ end
276
+
277
+ def process_anchor(anchor)
278
+ # Anchors represent identical or equivalent sections - just copy them
279
+ case anchor.match_type
280
+ when :freeze_block
281
+ # Freeze blocks from destination take precedence
282
+ add_freeze_block_from_dest(anchor)
283
+ when :signature_match
284
+ # For signature matches (same structure, different content), prefer destination
285
+ add_signature_match_from_dest(anchor)
286
+ when :exact_match
287
+ # For exact matches, prefer template (it's the source of truth)
288
+ add_exact_match_from_template(anchor)
289
+ else
290
+ # Unknown match type - default to template
291
+ add_exact_match_from_template(anchor)
292
+ end
293
+ end
294
+
295
+ def add_freeze_block_from_dest(anchor)
296
+ anchor.dest_range.each do |line_num|
297
+ line = @dest_analysis.line_at(line_num)
298
+ @result.add_line(
299
+ line.chomp,
300
+ decision: MergeResult::DECISION_FREEZE_BLOCK,
301
+ dest_line: line_num,
302
+ )
303
+ end
304
+ end
305
+
306
+ def add_signature_match_from_dest(anchor)
307
+ # For signature matches, use the configured preference
308
+ if @signature_match_preference == :template
309
+ # Use template version (for updates/canonical values)
310
+ anchor.template_range.each do |line_num|
311
+ line = @template_analysis.line_at(line_num)
312
+ @result.add_line(
313
+ line.chomp,
314
+ decision: MergeResult::DECISION_REPLACED,
315
+ template_line: line_num,
316
+ )
317
+ end
318
+ else
319
+ # Use destination version (for customizations)
320
+ anchor.dest_range.each do |line_num|
321
+ line = @dest_analysis.line_at(line_num)
322
+ @result.add_line(
323
+ line.chomp,
324
+ decision: MergeResult::DECISION_REPLACED,
325
+ dest_line: line_num,
326
+ )
327
+ end
328
+ end
329
+ end
330
+
331
+ def add_exact_match_from_template(anchor)
332
+ anchor.template_range.each do |line_num|
333
+ line = @template_analysis.line_at(line_num)
334
+ @result.add_line(
335
+ line.chomp,
336
+ decision: MergeResult::DECISION_KEPT_TEMPLATE,
337
+ template_line: line_num,
338
+ )
339
+ end
340
+ end
341
+
342
+ def process_boundary(boundary)
343
+ @resolver.resolve(boundary, @result)
344
+ end
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prism
4
+ module Merge
5
+ # Version information for Prism::Merge
6
+ module Version
7
+ # Current version of the prism-merge gem
8
+ VERSION = "1.0.0"
9
+ end
10
+ VERSION = Version::VERSION # traditional location
11
+ end
12
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External gems
4
+ require "prism"
5
+ require "version_gem"
6
+
7
+ # This gem
8
+ require_relative "merge/version"
9
+
10
+ # Prism::Merge provides a generic Ruby file smart merge system using Prism AST analysis.
11
+ # It intelligently merges template and destination Ruby files by identifying matching
12
+ # sections (anchors) and resolving differences (boundaries) using structural signatures.
13
+ #
14
+ # @example Basic usage
15
+ # template = File.read("template.rb")
16
+ # destination = File.read("destination.rb")
17
+ # merger = Prism::Merge::SmartMerger.new(template, destination)
18
+ # result = merger.merge
19
+ #
20
+ # @example With debug information
21
+ # merger = Prism::Merge::SmartMerger.new(template, destination)
22
+ # debug_result = merger.merge_with_debug
23
+ # puts debug_result[:debug]
24
+ # puts debug_result[:statistics]
25
+ module Prism
26
+ # Smart merge system for Ruby files using Prism AST analysis.
27
+ # Provides intelligent merging by understanding Ruby code structure
28
+ # rather than treating files as plain text.
29
+ #
30
+ # @see SmartMerger Main entry point for merge operations
31
+ # @see FileAligner Identifies matching sections and boundaries
32
+ # @see ConflictResolver Resolves content within boundaries
33
+ module Merge
34
+ # Base error class for Prism::Merge
35
+ class Error < StandardError; end
36
+
37
+ # Raised when the template/destination file has parsing errors
38
+ class ParseError < Error
39
+ # @return [String] The content that failed to parse
40
+ attr_reader :content
41
+
42
+ # @return [Prism::ParseResult] The Prism parse result containing error details
43
+ attr_reader :parse_result
44
+
45
+ # @param message [String] Error message
46
+ # @param content [String] The Ruby source that failed to parse
47
+ # @param parse_result [Prism::ParseResult] Parse result with error information
48
+ def initialize(message, content:, parse_result:)
49
+ super(message)
50
+ @content = content
51
+ @parse_result = parse_result
52
+ end
53
+ end
54
+
55
+ # Raised when the template file has syntax errors.
56
+ #
57
+ # @example Handling template parse errors
58
+ # begin
59
+ # merger = SmartMerger.new(template, destination)
60
+ # result = merger.merge
61
+ # rescue TemplateParseError => e
62
+ # puts "Template syntax error: #{e.message}"
63
+ # e.parse_result.errors.each do |error|
64
+ # puts " #{error.message}"
65
+ # end
66
+ # end
67
+ class TemplateParseError < ParseError; end
68
+
69
+ # Raised when the destination file has syntax errors.
70
+ #
71
+ # @example Handling destination parse errors
72
+ # begin
73
+ # merger = SmartMerger.new(template, destination)
74
+ # result = merger.merge
75
+ # rescue DestinationParseError => e
76
+ # puts "Destination syntax error: #{e.message}"
77
+ # e.parse_result.errors.each do |error|
78
+ # puts " #{error.message}"
79
+ # end
80
+ # end
81
+ class DestinationParseError < ParseError; end
82
+
83
+ autoload :FileAnalysis, "prism/merge/file_analysis"
84
+ autoload :MergeResult, "prism/merge/merge_result"
85
+ autoload :FileAligner, "prism/merge/file_aligner"
86
+ autoload :ConflictResolver, "prism/merge/conflict_resolver"
87
+ autoload :SmartMerger, "prism/merge/smart_merger"
88
+ end
89
+ end
90
+
91
+ Prism::Merge::Version.class_eval do
92
+ extend VersionGem::Basic
93
+ end
@@ -0,0 +1,4 @@
1
+ # For technical reasons, if we move to Zeitwerk, this cannot be require_relative.
2
+ # See: https://github.com/fxn/zeitwerk#for_gem_extension
3
+ # Hook for other libraries to load this library (e.g. via bundler)
4
+ require "prism/merge"