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.
- 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 +987 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/prism/merge/conflict_resolver.rb +463 -0
- data/lib/prism/merge/file_aligner.rb +381 -0
- data/lib/prism/merge/file_analysis.rb +298 -0
- data/lib/prism/merge/merge_result.rb +176 -0
- data/lib/prism/merge/smart_merger.rb +347 -0
- data/lib/prism/merge/version.rb +12 -0
- data/lib/prism/merge.rb +93 -0
- data/lib/prism-merge.rb +4 -0
- data/sig/prism/merge.rbs +265 -0
- data.tar.gz.sig +6 -0
- metadata +303 -0
- metadata.gz.sig +0 -0
|
@@ -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
|
data/lib/prism/merge.rb
ADDED
|
@@ -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
|
data/lib/prism-merge.rb
ADDED