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,397 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Text
|
|
6
|
+
# Abstract base class for text-based section splitters.
|
|
7
|
+
#
|
|
8
|
+
# A SectionSplitter takes text content (typically from a leaf node in an AST)
|
|
9
|
+
# and divides it into logical sections that can be matched, compared, and
|
|
10
|
+
# merged independently. This is useful for:
|
|
11
|
+
#
|
|
12
|
+
# - Markdown documents split by headings
|
|
13
|
+
# - Plain text files with comment-delimited sections
|
|
14
|
+
# - Configuration files with section markers
|
|
15
|
+
# - Any text where structure is defined by patterns, not AST
|
|
16
|
+
#
|
|
17
|
+
# **Important**: This is for TEXT-BASED splitting of content that doesn't
|
|
18
|
+
# have a structured AST. For AST-level node classification (like identifying
|
|
19
|
+
# `appraise` blocks in Ruby), use `Ast::Merge::SectionTyping` instead.
|
|
20
|
+
#
|
|
21
|
+
# ## How Section Splitting Works
|
|
22
|
+
#
|
|
23
|
+
# 1. **Split**: Parse text content into sections with unique names
|
|
24
|
+
# 2. **Match**: Compare sections between template and destination by name
|
|
25
|
+
# 3. **Merge**: Apply merge rules per-section (template wins, dest wins, merge)
|
|
26
|
+
# 4. **Join**: Reconstruct the text from merged sections
|
|
27
|
+
#
|
|
28
|
+
# ## Implementing a SectionSplitter
|
|
29
|
+
#
|
|
30
|
+
# Subclasses must implement:
|
|
31
|
+
# - `split(content)` - Parse content into an array of Section objects
|
|
32
|
+
# - `join(sections)` - Reconstruct content from sections
|
|
33
|
+
#
|
|
34
|
+
# Subclasses may override:
|
|
35
|
+
# - `section_signature(section)` - Custom matching logic beyond name
|
|
36
|
+
# - `merge_sections(template_section, dest_section)` - Custom section merge
|
|
37
|
+
# - `normalize_name(name)` - Custom name normalization for matching
|
|
38
|
+
#
|
|
39
|
+
# @example Implementing a Markdown heading splitter
|
|
40
|
+
# class HeadingSplitter < SectionSplitter
|
|
41
|
+
# def initialize(split_level: 2)
|
|
42
|
+
# @split_level = split_level
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# def split(content)
|
|
46
|
+
# # Parse and split on headings at @split_level
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# def join(sections)
|
|
50
|
+
# sections.map(&:full_text).join
|
|
51
|
+
# end
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# @example Using a splitter for section-based merging
|
|
55
|
+
# splitter = HeadingSplitter.new(split_level: 2)
|
|
56
|
+
# template_sections = splitter.split(template_content)
|
|
57
|
+
# dest_sections = splitter.split(dest_content)
|
|
58
|
+
#
|
|
59
|
+
# merged = splitter.merge_documents(
|
|
60
|
+
# template_sections,
|
|
61
|
+
# dest_sections,
|
|
62
|
+
# preference: {
|
|
63
|
+
# default: :destination,
|
|
64
|
+
# "Installation" => :template
|
|
65
|
+
# }
|
|
66
|
+
# )
|
|
67
|
+
#
|
|
68
|
+
# result = splitter.join(merged)
|
|
69
|
+
#
|
|
70
|
+
# @abstract Subclass and implement {#split} and {#join}
|
|
71
|
+
# @api public
|
|
72
|
+
class SectionSplitter
|
|
73
|
+
# Default preference when none specified
|
|
74
|
+
DEFAULT_PREFERENCE = :destination
|
|
75
|
+
|
|
76
|
+
# @return [Hash] Options passed to the splitter
|
|
77
|
+
attr_reader :options
|
|
78
|
+
|
|
79
|
+
# Initialize the splitter with options.
|
|
80
|
+
#
|
|
81
|
+
# @param options [Hash] Splitter-specific options
|
|
82
|
+
def initialize(**options)
|
|
83
|
+
@options = options
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Split text content into sections.
|
|
87
|
+
#
|
|
88
|
+
# @param content [String] The text content to split
|
|
89
|
+
# @return [Array<Section>] Array of sections in document order
|
|
90
|
+
# @abstract Subclasses must implement this method
|
|
91
|
+
def split(content)
|
|
92
|
+
raise NotImplementedError, "#{self.class}#split must be implemented"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Reconstruct text content from sections.
|
|
96
|
+
#
|
|
97
|
+
# @param sections [Array<Section>] Sections to join
|
|
98
|
+
# @return [String] Reconstructed text content
|
|
99
|
+
# @abstract Subclasses must implement this method
|
|
100
|
+
def join(sections)
|
|
101
|
+
raise NotImplementedError, "#{self.class}#join must be implemented"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Merge two text documents using section-based semantics.
|
|
105
|
+
#
|
|
106
|
+
# This is the main entry point for section-based merging. It:
|
|
107
|
+
# 1. Splits both documents into sections
|
|
108
|
+
# 2. Matches sections by name
|
|
109
|
+
# 3. Merges each section according to preferences
|
|
110
|
+
# 4. Joins the result back into text
|
|
111
|
+
#
|
|
112
|
+
# @param template_content [String] Template text content
|
|
113
|
+
# @param dest_content [String] Destination text content
|
|
114
|
+
# @param preference [Symbol, Hash] Merge preference
|
|
115
|
+
# - `:template` - Template wins for all sections
|
|
116
|
+
# - `:destination` - Destination wins for all sections
|
|
117
|
+
# - Hash - Per-section preferences: `{ default: :dest, "Section Name" => :template }`
|
|
118
|
+
# @param add_template_only [Boolean] Whether to add sections only in template
|
|
119
|
+
# @return [String] Merged text content
|
|
120
|
+
def merge(template_content, dest_content, preference: DEFAULT_PREFERENCE, add_template_only: false)
|
|
121
|
+
template_sections = split(template_content)
|
|
122
|
+
dest_sections = split(dest_content)
|
|
123
|
+
|
|
124
|
+
merged_sections = merge_section_lists(
|
|
125
|
+
template_sections,
|
|
126
|
+
dest_sections,
|
|
127
|
+
preference: preference,
|
|
128
|
+
add_template_only: add_template_only,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
join(merged_sections)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Merge two lists of sections.
|
|
135
|
+
#
|
|
136
|
+
# @param template_sections [Array<Section>] Sections from template
|
|
137
|
+
# @param dest_sections [Array<Section>] Sections from destination
|
|
138
|
+
# @param preference [Symbol, Hash] Merge preference
|
|
139
|
+
# @param add_template_only [Boolean] Whether to add template-only sections
|
|
140
|
+
# @return [Array<Section>] Merged sections
|
|
141
|
+
def merge_section_lists(template_sections, dest_sections, preference: DEFAULT_PREFERENCE, add_template_only: false)
|
|
142
|
+
# Build lookup by normalized name
|
|
143
|
+
dest_by_name = dest_sections.each_with_object({}) do |section, hash|
|
|
144
|
+
key = normalize_name(section.name)
|
|
145
|
+
hash[key] = section
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
merged = []
|
|
149
|
+
seen_names = Set.new
|
|
150
|
+
|
|
151
|
+
# Process template sections in order
|
|
152
|
+
template_sections.each do |template_section|
|
|
153
|
+
key = normalize_name(template_section.name)
|
|
154
|
+
seen_names << key
|
|
155
|
+
|
|
156
|
+
dest_section = dest_by_name[key]
|
|
157
|
+
|
|
158
|
+
if dest_section
|
|
159
|
+
# Section exists in both - merge according to preference
|
|
160
|
+
section_pref = preference_for_section(template_section.name, preference)
|
|
161
|
+
merged << merge_sections(template_section, dest_section, section_pref)
|
|
162
|
+
elsif add_template_only
|
|
163
|
+
# Template-only section - add if configured
|
|
164
|
+
merged << template_section
|
|
165
|
+
end
|
|
166
|
+
# Otherwise skip template-only sections
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Append destination-only sections (preserve destination content)
|
|
170
|
+
dest_sections.each do |dest_section|
|
|
171
|
+
key = normalize_name(dest_section.name)
|
|
172
|
+
next if seen_names.include?(key)
|
|
173
|
+
merged << dest_section
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
merged
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Merge a single pair of matching sections.
|
|
180
|
+
#
|
|
181
|
+
# The default implementation simply chooses one section based on preference.
|
|
182
|
+
# Subclasses can override for more sophisticated merging (e.g., line-level
|
|
183
|
+
# merging within sections).
|
|
184
|
+
#
|
|
185
|
+
# @param template_section [Section] Section from template
|
|
186
|
+
# @param dest_section [Section] Section from destination
|
|
187
|
+
# @param preference [Symbol] :template or :destination
|
|
188
|
+
# @return [Section] Merged section
|
|
189
|
+
def merge_sections(template_section, dest_section, preference)
|
|
190
|
+
case preference
|
|
191
|
+
when :template
|
|
192
|
+
template_section
|
|
193
|
+
when :destination
|
|
194
|
+
dest_section
|
|
195
|
+
when :merge
|
|
196
|
+
# Subclasses can implement actual content merging
|
|
197
|
+
merge_section_content(template_section, dest_section)
|
|
198
|
+
else
|
|
199
|
+
dest_section
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Merge content within a section (for :merge preference).
|
|
204
|
+
#
|
|
205
|
+
# Default implementation prefers destination. Subclasses should override
|
|
206
|
+
# for format-specific content merging.
|
|
207
|
+
#
|
|
208
|
+
# @param template_section [Section] Section from template
|
|
209
|
+
# @param dest_section [Section] Section from destination
|
|
210
|
+
# @return [Section] Section with merged content
|
|
211
|
+
def merge_section_content(template_section, dest_section)
|
|
212
|
+
# Default: use template header, dest body
|
|
213
|
+
Section.new(
|
|
214
|
+
name: dest_section.name,
|
|
215
|
+
header: template_section.header || dest_section.header,
|
|
216
|
+
body: dest_section.body,
|
|
217
|
+
start_line: dest_section.start_line,
|
|
218
|
+
end_line: dest_section.end_line,
|
|
219
|
+
metadata: dest_section.metadata&.merge(template_section.metadata || {}),
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Get the preference for a specific section.
|
|
224
|
+
#
|
|
225
|
+
# @param section_name [String, Symbol] The section name
|
|
226
|
+
# @param preference [Symbol, Hash] Overall preference configuration
|
|
227
|
+
# @return [Symbol] :template or :destination
|
|
228
|
+
def preference_for_section(section_name, preference)
|
|
229
|
+
return preference unless preference.is_a?(Hash)
|
|
230
|
+
|
|
231
|
+
# Try exact match first
|
|
232
|
+
return preference[section_name] if preference.key?(section_name)
|
|
233
|
+
|
|
234
|
+
# Try normalized name
|
|
235
|
+
normalized = normalize_name(section_name)
|
|
236
|
+
preference.each do |key, value|
|
|
237
|
+
return value if normalize_name(key) == normalized
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Fall back to default
|
|
241
|
+
preference.fetch(:default, DEFAULT_PREFERENCE)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Normalize a section name for matching.
|
|
245
|
+
#
|
|
246
|
+
# Default implementation strips whitespace, downcases, normalizes spaces.
|
|
247
|
+
# Subclasses can override for format-specific normalization.
|
|
248
|
+
#
|
|
249
|
+
# @param name [String, Symbol, nil] The section name
|
|
250
|
+
# @return [String] Normalized name
|
|
251
|
+
def normalize_name(name)
|
|
252
|
+
return "" if name.nil?
|
|
253
|
+
return name.to_s if name.is_a?(Symbol)
|
|
254
|
+
name.to_s.strip.downcase.gsub(/\s+/, " ")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Generate a signature for section matching.
|
|
258
|
+
#
|
|
259
|
+
# Default uses normalized name. Subclasses can override for more
|
|
260
|
+
# sophisticated matching (e.g., including metadata).
|
|
261
|
+
#
|
|
262
|
+
# @param section [Section] The section
|
|
263
|
+
# @return [Array, String] Signature for matching
|
|
264
|
+
def section_signature(section)
|
|
265
|
+
normalize_name(section.name)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Validate splitter configuration.
|
|
269
|
+
#
|
|
270
|
+
# @param config [Hash, nil] Configuration to validate
|
|
271
|
+
# @raise [ArgumentError] If configuration is invalid
|
|
272
|
+
# @return [void]
|
|
273
|
+
def self.validate!(config)
|
|
274
|
+
return if config.nil?
|
|
275
|
+
|
|
276
|
+
unless config.is_a?(Hash)
|
|
277
|
+
raise ArgumentError, "splitter config must be a Hash, got #{config.class}"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Line-pattern section splitter for text content.
|
|
283
|
+
#
|
|
284
|
+
# Splits text content into sections based on a line pattern (regex).
|
|
285
|
+
# Useful for documents with consistent structural markers like headings.
|
|
286
|
+
#
|
|
287
|
+
# @example Split Markdown on level-2 headings
|
|
288
|
+
# splitter = LineSectionSplitter.new(pattern: /^## (.+)$/)
|
|
289
|
+
# sections = splitter.split(markdown_content)
|
|
290
|
+
#
|
|
291
|
+
# @example Split on comment markers
|
|
292
|
+
# splitter = LineSectionSplitter.new(pattern: /^# === (.+) ===\s*$/)
|
|
293
|
+
# sections = splitter.split(config_file)
|
|
294
|
+
#
|
|
295
|
+
class LineSectionSplitter < SectionSplitter
|
|
296
|
+
# @return [Regexp] Pattern to match section headers
|
|
297
|
+
attr_reader :pattern
|
|
298
|
+
|
|
299
|
+
# @return [Integer] Capture group index for section name (1-based)
|
|
300
|
+
attr_reader :name_capture
|
|
301
|
+
|
|
302
|
+
# Initialize a line-based splitter.
|
|
303
|
+
#
|
|
304
|
+
# @param pattern [Regexp] Pattern to match section header lines
|
|
305
|
+
# @param name_capture [Integer] Capture group for section name (default: 1)
|
|
306
|
+
# @param options [Hash] Additional options
|
|
307
|
+
def initialize(pattern:, name_capture: 1, **options)
|
|
308
|
+
super(**options)
|
|
309
|
+
@pattern = pattern
|
|
310
|
+
@name_capture = name_capture
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Split content on lines matching the pattern.
|
|
314
|
+
#
|
|
315
|
+
# @param content [String] Text content
|
|
316
|
+
# @return [Array<Section>] Sections
|
|
317
|
+
def split(content)
|
|
318
|
+
lines = content.lines
|
|
319
|
+
sections = []
|
|
320
|
+
current_section = nil
|
|
321
|
+
preamble_lines = []
|
|
322
|
+
|
|
323
|
+
lines.each_with_index do |line, index|
|
|
324
|
+
line_num = index + 1
|
|
325
|
+
|
|
326
|
+
if (match = line.match(pattern))
|
|
327
|
+
# Start new section
|
|
328
|
+
if current_section
|
|
329
|
+
sections << finalize_section(current_section)
|
|
330
|
+
elsif preamble_lines.any?
|
|
331
|
+
sections << Section.new(
|
|
332
|
+
name: :preamble,
|
|
333
|
+
header: nil,
|
|
334
|
+
body: preamble_lines.join,
|
|
335
|
+
start_line: 1,
|
|
336
|
+
end_line: line_num - 1,
|
|
337
|
+
metadata: {type: :preamble},
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
section_name = match[name_capture] || match[0]
|
|
342
|
+
current_section = {
|
|
343
|
+
name: section_name.strip,
|
|
344
|
+
header: line,
|
|
345
|
+
body_lines: [],
|
|
346
|
+
start_line: line_num,
|
|
347
|
+
}
|
|
348
|
+
elsif current_section
|
|
349
|
+
current_section[:body_lines] << line
|
|
350
|
+
else
|
|
351
|
+
preamble_lines << line
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Finalize last section
|
|
356
|
+
if current_section
|
|
357
|
+
current_section[:end_line] = lines.length
|
|
358
|
+
sections << finalize_section(current_section)
|
|
359
|
+
elsif preamble_lines.any? && sections.empty?
|
|
360
|
+
# Entire document is preamble (no sections found)
|
|
361
|
+
sections << Section.new(
|
|
362
|
+
name: :preamble,
|
|
363
|
+
header: nil,
|
|
364
|
+
body: preamble_lines.join,
|
|
365
|
+
start_line: 1,
|
|
366
|
+
end_line: lines.length,
|
|
367
|
+
metadata: {type: :preamble},
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
sections
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Join sections back into text content.
|
|
375
|
+
#
|
|
376
|
+
# @param sections [Array<Section>] Sections to join
|
|
377
|
+
# @return [String] Reconstructed content
|
|
378
|
+
def join(sections)
|
|
379
|
+
sections.map(&:full_text).join
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
private
|
|
383
|
+
|
|
384
|
+
def finalize_section(section_data)
|
|
385
|
+
Section.new(
|
|
386
|
+
name: section_data[:name],
|
|
387
|
+
header: section_data[:header],
|
|
388
|
+
body: section_data[:body_lines].join,
|
|
389
|
+
start_line: section_data[:start_line],
|
|
390
|
+
end_line: section_data[:end_line] || section_data[:start_line] + section_data[:body_lines].length,
|
|
391
|
+
metadata: nil,
|
|
392
|
+
)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Text
|
|
6
|
+
# Smart merger for text-based files.
|
|
7
|
+
#
|
|
8
|
+
# Provides intelligent merging of two text files using a simple line-based AST
|
|
9
|
+
# where lines are top-level nodes and words are nested nodes.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic merge (destination customizations preserved)
|
|
12
|
+
# merger = SmartMerger.new(template_content, dest_content)
|
|
13
|
+
# result = merger.merge
|
|
14
|
+
# puts result # Merged content
|
|
15
|
+
#
|
|
16
|
+
# @example Template wins merge
|
|
17
|
+
# merger = SmartMerger.new(
|
|
18
|
+
# template_content,
|
|
19
|
+
# dest_content,
|
|
20
|
+
# preference: :template,
|
|
21
|
+
# add_template_only_nodes: true
|
|
22
|
+
# )
|
|
23
|
+
# result = merger.merge
|
|
24
|
+
#
|
|
25
|
+
# @example With freeze blocks
|
|
26
|
+
# template = <<~TEXT
|
|
27
|
+
# Line one
|
|
28
|
+
# Line two
|
|
29
|
+
# TEXT
|
|
30
|
+
#
|
|
31
|
+
# dest = <<~TEXT
|
|
32
|
+
# Line one modified
|
|
33
|
+
# # text-merge:freeze
|
|
34
|
+
# Custom content
|
|
35
|
+
# # text-merge:unfreeze
|
|
36
|
+
# TEXT
|
|
37
|
+
#
|
|
38
|
+
# merger = SmartMerger.new(template, dest)
|
|
39
|
+
# result = merger.merge
|
|
40
|
+
# # => "Line one modified\n# text-merge:freeze\nCustom content\n# text-merge:unfreeze"
|
|
41
|
+
#
|
|
42
|
+
# @example With regions (embedded code blocks)
|
|
43
|
+
# merger = SmartMerger.new(
|
|
44
|
+
# template_content,
|
|
45
|
+
# dest_content,
|
|
46
|
+
# regions: [
|
|
47
|
+
# { detector: FencedCodeBlockDetector.ruby, merger_class: SomeRubyMerger }
|
|
48
|
+
# ]
|
|
49
|
+
# )
|
|
50
|
+
class SmartMerger < SmartMergerBase
|
|
51
|
+
# Default freeze token for text merging
|
|
52
|
+
DEFAULT_FREEZE_TOKEN = "text-merge"
|
|
53
|
+
|
|
54
|
+
# Initialize a new SmartMerger
|
|
55
|
+
#
|
|
56
|
+
# @param template_content [String] Template text content
|
|
57
|
+
# @param dest_content [String] Destination text content
|
|
58
|
+
# @param preference [Symbol] :destination or :template
|
|
59
|
+
# @param add_template_only_nodes [Boolean] Whether to add template-only lines
|
|
60
|
+
# @param freeze_token [String] Token for freeze block markers
|
|
61
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
62
|
+
# @param regions [Array<Hash>, nil] Region configurations for nested merging
|
|
63
|
+
# @param region_placeholder [String, nil] Custom placeholder for regions
|
|
64
|
+
def initialize(
|
|
65
|
+
template_content,
|
|
66
|
+
dest_content,
|
|
67
|
+
preference: :destination,
|
|
68
|
+
add_template_only_nodes: false,
|
|
69
|
+
freeze_token: DEFAULT_FREEZE_TOKEN,
|
|
70
|
+
signature_generator: nil,
|
|
71
|
+
regions: nil,
|
|
72
|
+
region_placeholder: nil
|
|
73
|
+
)
|
|
74
|
+
super(
|
|
75
|
+
template_content,
|
|
76
|
+
dest_content,
|
|
77
|
+
signature_generator: signature_generator,
|
|
78
|
+
preference: preference,
|
|
79
|
+
add_template_only_nodes: add_template_only_nodes,
|
|
80
|
+
freeze_token: freeze_token,
|
|
81
|
+
regions: regions,
|
|
82
|
+
region_placeholder: region_placeholder,
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get merge statistics
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash] Statistics about the merge
|
|
89
|
+
def stats
|
|
90
|
+
merge_result # Ensure merge has run
|
|
91
|
+
{
|
|
92
|
+
template_lines: @template_analysis.statements.count { |s| s.is_a?(LineNode) },
|
|
93
|
+
dest_lines: @dest_analysis.statements.count { |s| s.is_a?(LineNode) },
|
|
94
|
+
result_lines: @result.lines.size,
|
|
95
|
+
decisions: @result.decision_summary,
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
protected
|
|
100
|
+
|
|
101
|
+
# @return [Class] The analysis class for text files
|
|
102
|
+
def analysis_class
|
|
103
|
+
FileAnalysis
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [String] The default freeze token
|
|
107
|
+
def default_freeze_token
|
|
108
|
+
DEFAULT_FREEZE_TOKEN
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Class] The resolver class for text files
|
|
112
|
+
def resolver_class
|
|
113
|
+
ConflictResolver
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Class] The result class for text files
|
|
117
|
+
def result_class
|
|
118
|
+
MergeResult
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Perform the text-specific merge
|
|
122
|
+
#
|
|
123
|
+
# @return [MergeResult] The merge result
|
|
124
|
+
def perform_merge
|
|
125
|
+
@resolver.resolve(@result)
|
|
126
|
+
@result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build the resolver with positional arguments (Text::ConflictResolver signature)
|
|
130
|
+
def build_resolver
|
|
131
|
+
ConflictResolver.new(
|
|
132
|
+
@template_analysis,
|
|
133
|
+
@dest_analysis,
|
|
134
|
+
preference: @preference,
|
|
135
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Text
|
|
6
|
+
# Represents a word within a line of text.
|
|
7
|
+
# Words are the nested level of the text-based AST.
|
|
8
|
+
# They are identified by word boundaries (regex \b).
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# word = WordNode.new("hello", line_number: 1, word_index: 0, start_col: 0, end_col: 5)
|
|
12
|
+
# word.content # => "hello"
|
|
13
|
+
# word.signature # => [:word, "hello"]
|
|
14
|
+
class WordNode
|
|
15
|
+
# @return [String] The word content
|
|
16
|
+
attr_reader :content
|
|
17
|
+
|
|
18
|
+
# @return [Integer] 1-based line number containing this word
|
|
19
|
+
attr_reader :line_number
|
|
20
|
+
|
|
21
|
+
# @return [Integer] 0-based index of this word within the line
|
|
22
|
+
attr_reader :word_index
|
|
23
|
+
|
|
24
|
+
# @return [Integer] 0-based starting column position
|
|
25
|
+
attr_reader :start_col
|
|
26
|
+
|
|
27
|
+
# @return [Integer] 0-based ending column position (exclusive)
|
|
28
|
+
attr_reader :end_col
|
|
29
|
+
|
|
30
|
+
# Initialize a new WordNode
|
|
31
|
+
#
|
|
32
|
+
# @param content [String] The word content
|
|
33
|
+
# @param line_number [Integer] 1-based line number
|
|
34
|
+
# @param word_index [Integer] 0-based word index within line
|
|
35
|
+
# @param start_col [Integer] 0-based start column
|
|
36
|
+
# @param end_col [Integer] 0-based end column (exclusive)
|
|
37
|
+
def initialize(content, line_number:, word_index:, start_col:, end_col:)
|
|
38
|
+
@content = content
|
|
39
|
+
@line_number = line_number
|
|
40
|
+
@word_index = word_index
|
|
41
|
+
@start_col = start_col
|
|
42
|
+
@end_col = end_col
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Generate a signature for this word node.
|
|
46
|
+
# The signature is used for matching words across template/destination.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array] Signature array [:word, content]
|
|
49
|
+
def signature
|
|
50
|
+
[:word, @content]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check equality with another WordNode
|
|
54
|
+
#
|
|
55
|
+
# @param other [WordNode] Other node to compare
|
|
56
|
+
# @return [Boolean] True if content matches
|
|
57
|
+
def ==(other)
|
|
58
|
+
other.is_a?(WordNode) && @content == other.content
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
alias_method :eql?, :==
|
|
62
|
+
|
|
63
|
+
# Hash code for use in Hash keys
|
|
64
|
+
#
|
|
65
|
+
# @return [Integer] Hash code
|
|
66
|
+
def hash
|
|
67
|
+
@content.hash
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# String representation for debugging
|
|
71
|
+
#
|
|
72
|
+
# @return [String] Debug representation
|
|
73
|
+
def inspect
|
|
74
|
+
"#<WordNode #{@content.inspect} line=#{@line_number} col=#{@start_col}..#{@end_col}>"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Convert to string (returns content)
|
|
78
|
+
#
|
|
79
|
+
# @return [String] Word content
|
|
80
|
+
def to_s
|
|
81
|
+
@content
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Text-based AST module for ast-merge.
|
|
6
|
+
#
|
|
7
|
+
# Provides a simple line/word based AST that can be used with any text file.
|
|
8
|
+
# This serves as both:
|
|
9
|
+
# 1. A reference implementation for *-merge gems
|
|
10
|
+
# 2. A testing tool for validating merge behavior
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# require "ast/merge/text"
|
|
14
|
+
#
|
|
15
|
+
# template = "Line one\nLine two\nLine three"
|
|
16
|
+
# dest = "Line one modified\nLine two\nCustom line"
|
|
17
|
+
#
|
|
18
|
+
# merger = Ast::Merge::Text::SmartMerger.new(template, dest)
|
|
19
|
+
# result = merger.merge
|
|
20
|
+
module Text
|
|
21
|
+
# Default freeze token for text files
|
|
22
|
+
DEFAULT_FREEZE_TOKEN = "text-merge"
|
|
23
|
+
|
|
24
|
+
autoload :WordNode, "ast/merge/text/word_node"
|
|
25
|
+
autoload :LineNode, "ast/merge/text/line_node"
|
|
26
|
+
autoload :FileAnalysis, "ast/merge/text/file_analysis"
|
|
27
|
+
autoload :MergeResult, "ast/merge/text/merge_result"
|
|
28
|
+
autoload :ConflictResolver, "ast/merge/text/conflict_resolver"
|
|
29
|
+
autoload :SmartMerger, "ast/merge/text/smart_merger"
|
|
30
|
+
autoload :Section, "ast/merge/text/section"
|
|
31
|
+
autoload :SectionSplitter, "ast/merge/text/section_splitter"
|
|
32
|
+
autoload :LineSectionSplitter, "ast/merge/text/section_splitter"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|