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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +852 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/ast/merge/ast_node.rb +87 -0
  14. data/lib/ast/merge/comment/block.rb +195 -0
  15. data/lib/ast/merge/comment/empty.rb +78 -0
  16. data/lib/ast/merge/comment/line.rb +138 -0
  17. data/lib/ast/merge/comment/parser.rb +278 -0
  18. data/lib/ast/merge/comment/style.rb +282 -0
  19. data/lib/ast/merge/comment.rb +36 -0
  20. data/lib/ast/merge/conflict_resolver_base.rb +399 -0
  21. data/lib/ast/merge/debug_logger.rb +271 -0
  22. data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
  23. data/lib/ast/merge/file_analyzable.rb +307 -0
  24. data/lib/ast/merge/freezable.rb +82 -0
  25. data/lib/ast/merge/freeze_node_base.rb +434 -0
  26. data/lib/ast/merge/match_refiner_base.rb +312 -0
  27. data/lib/ast/merge/match_score_base.rb +135 -0
  28. data/lib/ast/merge/merge_result_base.rb +169 -0
  29. data/lib/ast/merge/merger_config.rb +258 -0
  30. data/lib/ast/merge/node_typing.rb +373 -0
  31. data/lib/ast/merge/region.rb +124 -0
  32. data/lib/ast/merge/region_detector_base.rb +114 -0
  33. data/lib/ast/merge/region_mergeable.rb +364 -0
  34. data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
  35. data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
  36. data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
  37. data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
  38. data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
  39. data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
  40. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
  41. data/lib/ast/merge/rspec/shared_examples.rb +26 -0
  42. data/lib/ast/merge/rspec.rb +4 -0
  43. data/lib/ast/merge/section_typing.rb +303 -0
  44. data/lib/ast/merge/smart_merger_base.rb +417 -0
  45. data/lib/ast/merge/text/conflict_resolver.rb +161 -0
  46. data/lib/ast/merge/text/file_analysis.rb +168 -0
  47. data/lib/ast/merge/text/line_node.rb +142 -0
  48. data/lib/ast/merge/text/merge_result.rb +42 -0
  49. data/lib/ast/merge/text/section.rb +93 -0
  50. data/lib/ast/merge/text/section_splitter.rb +397 -0
  51. data/lib/ast/merge/text/smart_merger.rb +141 -0
  52. data/lib/ast/merge/text/word_node.rb +86 -0
  53. data/lib/ast/merge/text.rb +35 -0
  54. data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
  55. data/lib/ast/merge/version.rb +12 -0
  56. data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
  57. data/lib/ast/merge.rb +165 -0
  58. data/lib/ast-merge.rb +4 -0
  59. data/sig/ast/merge.rbs +195 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +326 -0
  62. metadata.gz.sig +0 -0
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Represents a detected region within a document.
6
+ #
7
+ # Regions are portions of a document that can be handled by a specialized
8
+ # merger. For example, YAML frontmatter in a Markdown file, or a Ruby code
9
+ # block that should be merged using a Ruby-aware merger.
10
+ #
11
+ # @example Creating a region for YAML frontmatter
12
+ # Region.new(
13
+ # type: :yaml_frontmatter,
14
+ # content: "title: My Doc\nversion: 1.0\n",
15
+ # start_line: 1,
16
+ # end_line: 4,
17
+ # delimiters: ["---", "---"],
18
+ # metadata: { format: :yaml }
19
+ # )
20
+ #
21
+ # @example Creating a region for a Ruby code block
22
+ # Region.new(
23
+ # type: :ruby_code_block,
24
+ # content: "def hello\n puts 'world'\nend\n",
25
+ # start_line: 5,
26
+ # end_line: 9,
27
+ # delimiters: ["```ruby", "```"],
28
+ # metadata: { language: "ruby" }
29
+ # )
30
+ #
31
+ # @api public
32
+ Region = Struct.new(
33
+ # @return [Symbol] The type of region (e.g., :yaml_frontmatter, :ruby_code_block)
34
+ :type,
35
+
36
+ # @return [String] The raw string content of this region (inner content, without delimiters)
37
+ :content,
38
+
39
+ # @return [Integer] 1-indexed start line in the original document
40
+ :start_line,
41
+
42
+ # @return [Integer] 1-indexed end line in the original document
43
+ :end_line,
44
+
45
+ # @return [Array<String>, nil] Delimiter strings to reconstruct the region
46
+ # ["```ruby", "```"] - [opening_delimiter, closing_delimiter]
47
+ :delimiters,
48
+
49
+ # @return [Hash, nil] Optional metadata for detector-specific information
50
+ # (e.g., { language: "ruby" }, { format: :yaml })
51
+ :metadata,
52
+ keyword_init: true,
53
+ ) do
54
+ # Returns the line range covered by this region.
55
+ #
56
+ # @return [Range] The range from start_line to end_line (inclusive)
57
+ # @example
58
+ # region.line_range # => 1..4
59
+ def line_range
60
+ start_line..end_line
61
+ end
62
+
63
+ # Returns the number of lines this region spans.
64
+ #
65
+ # @return [Integer] The number of lines
66
+ # @example
67
+ # region.line_count # => 4
68
+ def line_count
69
+ end_line - start_line + 1
70
+ end
71
+
72
+ # Reconstructs the full region text including delimiters.
73
+ #
74
+ # @return [String] The complete region with start and end delimiters
75
+ # @example
76
+ # region.full_text
77
+ # # => "```ruby\ndef hello\n puts 'world'\nend\n```"
78
+ def full_text
79
+ return content if delimiters.nil? || delimiters.empty?
80
+
81
+ opening = delimiters[0] || ""
82
+ closing = delimiters[1] || ""
83
+ "#{opening}\n#{content}#{closing}"
84
+ end
85
+
86
+ # Checks if this region overlaps with the given line number.
87
+ #
88
+ # @param line [Integer] The line number to check (1-indexed)
89
+ # @return [Boolean] true if the line is within this region
90
+ def contains_line?(line)
91
+ line_range.cover?(line)
92
+ end
93
+
94
+ # Checks if this region overlaps with another region.
95
+ #
96
+ # @param other [Region] Another region to check for overlap
97
+ # @return [Boolean] true if the regions overlap
98
+ def overlaps?(other)
99
+ line_range.cover?(other.start_line) ||
100
+ line_range.cover?(other.end_line) ||
101
+ other.line_range.cover?(start_line)
102
+ end
103
+
104
+ # Returns a short string representation of the region.
105
+ #
106
+ # @return [String] A concise string describing the region
107
+ def to_s
108
+ "Region<#{type}:#{start_line}-#{end_line}>"
109
+ end
110
+
111
+ # Returns a detailed human-readable representation of the region.
112
+ #
113
+ # @return [String] A string describing the region with truncated content
114
+ def inspect
115
+ truncated = if content && content.length > 30
116
+ "#{content[0, 30]}..."
117
+ else
118
+ content.inspect
119
+ end
120
+ "#{self} #{truncated}"
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for region detection.
6
+ #
7
+ # Region detectors identify portions of a document that should be handled
8
+ # by a specialized merger. For example, detecting YAML frontmatter in a
9
+ # Markdown file, or Ruby code blocks that should be merged with Prism.
10
+ #
11
+ # Subclasses must implement:
12
+ # - {#region_type} - Returns the type symbol for detected regions
13
+ # - {#detect_all} - Finds all regions of this type in a document
14
+ #
15
+ # @example Implementing a custom detector
16
+ # class MyBlockDetector < Ast::Merge::RegionDetectorBase
17
+ # def region_type
18
+ # :my_block
19
+ # end
20
+ #
21
+ # def detect_all(source)
22
+ # # Return array of Region structs
23
+ # []
24
+ # end
25
+ # end
26
+ #
27
+ # @abstract Subclass and implement {#region_type} and {#detect_all}
28
+ # @api public
29
+ class RegionDetectorBase
30
+ # Returns the type symbol for regions detected by this detector.
31
+ #
32
+ # This symbol is used to identify the region type in the Region struct
33
+ # and for matching regions between template and destination documents.
34
+ #
35
+ # @return [Symbol] The region type (e.g., :yaml_frontmatter, :ruby_code_block)
36
+ # @abstract Subclasses must implement this method
37
+ def region_type
38
+ raise NotImplementedError, "#{self.class}#region_type must be implemented"
39
+ end
40
+
41
+ # Detects all regions of this type in the given source.
42
+ #
43
+ # @param source [String] The full document content to scan
44
+ # @return [Array<Region>] All detected regions, sorted by start_line
45
+ # @abstract Subclasses must implement this method
46
+ #
47
+ # @example Return value structure
48
+ # [
49
+ # Region.new(
50
+ # type: :yaml_frontmatter,
51
+ # content: "title: My Doc\n",
52
+ # start_line: 1,
53
+ # end_line: 3,
54
+ # delimiters: { start: "---\n", end: "---\n" },
55
+ # metadata: { format: :yaml }
56
+ # )
57
+ # ]
58
+ def detect_all(source)
59
+ raise NotImplementedError, "#{self.class}#detect_all must be implemented"
60
+ end
61
+
62
+ # Whether to strip delimiters from content before passing to merger.
63
+ #
64
+ # When true (default), only the inner content is passed to the region's
65
+ # merger. The delimiters are stored separately and reattached after merging.
66
+ #
67
+ # When false, the full content including delimiters is passed to the merger,
68
+ # which must then handle the delimiters itself.
69
+ #
70
+ # @return [Boolean] true if delimiters should be stripped (default: true)
71
+ def strip_delimiters?
72
+ true
73
+ end
74
+
75
+ # A human-readable name for this detector.
76
+ #
77
+ # Used in error messages and debugging output.
78
+ #
79
+ # @return [String] The detector name
80
+ def name
81
+ self.class.name || "AnonymousDetector"
82
+ end
83
+
84
+ # Returns a string representation of this detector.
85
+ #
86
+ # @return [String] A description of the detector
87
+ def inspect
88
+ "#<#{name} region_type=#{region_type}>"
89
+ end
90
+
91
+ protected
92
+
93
+ # Helper to build a Region struct with common defaults.
94
+ #
95
+ # @param type [Symbol] The region type
96
+ # @param content [String] The inner content (without delimiters)
97
+ # @param start_line [Integer] 1-indexed start line
98
+ # @param end_line [Integer] 1-indexed end line
99
+ # @param delimiters [Hash, nil] { start: String, end: String }
100
+ # @param metadata [Hash, nil] Additional metadata
101
+ # @return [Region] A new Region struct
102
+ def build_region(type:, content:, start_line:, end_line:, delimiters: nil, metadata: nil)
103
+ Region.new(
104
+ type: type,
105
+ content: content,
106
+ start_line: start_line,
107
+ end_line: end_line,
108
+ delimiters: delimiters,
109
+ metadata: metadata || {},
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ ##
6
+ # Mixin for adding region support to SmartMerger classes.
7
+ #
8
+ # This module provides functionality for detecting and handling regions
9
+ # within documents that should be merged with different strategies.
10
+ # Regions are portions of a document (like YAML frontmatter or fenced
11
+ # code blocks) that may require specialized merging.
12
+ #
13
+ # @example Basic region configuration
14
+ # class SmartMerger
15
+ # include RegionMergeable
16
+ #
17
+ # def initialize(template, dest, regions: [], region_placeholder: nil)
18
+ # @template_content = template
19
+ # @dest_content = dest
20
+ # setup_regions(regions: regions, region_placeholder: region_placeholder)
21
+ # end
22
+ # end
23
+ #
24
+ # @example With YAML frontmatter regions
25
+ # merger = SmartMerger.new(
26
+ # template,
27
+ # dest,
28
+ # regions: [
29
+ # {
30
+ # detector: YamlFrontmatterDetector.new,
31
+ # merger_class: SomeYamlMerger,
32
+ # merger_options: { preserve_order: true }
33
+ # }
34
+ # ]
35
+ # )
36
+ #
37
+ # @example With nested regions (code blocks in markdown)
38
+ # merger = SmartMerger.new(
39
+ # template,
40
+ # dest,
41
+ # regions: [
42
+ # {
43
+ # detector: FencedCodeBlockDetector.ruby,
44
+ # merger_class: Prism::Merge::SmartMerger,
45
+ # regions: [...] # Nested regions!
46
+ # }
47
+ # ]
48
+ # )
49
+ #
50
+ module RegionMergeable
51
+ # Default placeholder prefix for extracted regions
52
+ DEFAULT_PLACEHOLDER_PREFIX = "<<<AST_MERGE_REGION_"
53
+ DEFAULT_PLACEHOLDER_SUFFIX = ">>>"
54
+
55
+ ##
56
+ # Configuration for a single region type.
57
+ #
58
+ # @attr detector [RegionDetectorBase] Detector instance for finding regions
59
+ # @attr merger_class [Class, nil] Merger class for merging region content (nil to skip merging)
60
+ # @attr merger_options [Hash] Options to pass to the region merger
61
+ # @attr regions [Array<Hash>] Nested region configurations (recursive)
62
+ #
63
+ RegionConfig = Struct.new(:detector, :merger_class, :merger_options, :regions, keyword_init: true) do
64
+ def initialize(detector:, merger_class: nil, merger_options: {}, regions: [])
65
+ super(
66
+ detector: detector,
67
+ merger_class: merger_class,
68
+ merger_options: merger_options || {},
69
+ regions: regions || [],
70
+ )
71
+ end
72
+ end
73
+
74
+ ##
75
+ # Extracted region with its content and placeholder.
76
+ #
77
+ # @attr region [Region] The detected region
78
+ # @attr config [RegionConfig] The configuration that matched this region
79
+ # @attr placeholder [String] The placeholder used in the document
80
+ # @attr merged_content [String, nil] The merged content (set after merging)
81
+ #
82
+ ExtractedRegion = Struct.new(:region, :config, :placeholder, :merged_content, keyword_init: true)
83
+
84
+ ##
85
+ # Set up region handling for this merger instance.
86
+ #
87
+ # @param regions [Array<Hash>] Array of region configurations
88
+ # @param region_placeholder [String, nil] Custom placeholder prefix (optional)
89
+ # @raise [ArgumentError] if regions configuration is invalid
90
+ #
91
+ def setup_regions(regions:, region_placeholder: nil)
92
+ @region_configs = build_region_configs(regions)
93
+ @region_placeholder_prefix = region_placeholder || DEFAULT_PLACEHOLDER_PREFIX
94
+ @extracted_template_regions = []
95
+ @extracted_dest_regions = []
96
+ end
97
+
98
+ ##
99
+ # Check if this merger has region configurations.
100
+ #
101
+ # @return [Boolean] true if regions are configured
102
+ #
103
+ def regions_configured?
104
+ @region_configs && !@region_configs.empty?
105
+ end
106
+
107
+ ##
108
+ # Extract regions from the template content, replacing with placeholders.
109
+ #
110
+ # @param content [String] Template content
111
+ # @return [String] Content with regions replaced by placeholders
112
+ # @raise [PlaceholderCollisionError] if content contains placeholder text
113
+ #
114
+ def extract_template_regions(content)
115
+ return content unless regions_configured?
116
+
117
+ extract_regions(content, @extracted_template_regions)
118
+ end
119
+
120
+ ##
121
+ # Extract regions from the destination content, replacing with placeholders.
122
+ #
123
+ # @param content [String] Destination content
124
+ # @return [String] Content with regions replaced by placeholders
125
+ # @raise [PlaceholderCollisionError] if content contains placeholder text
126
+ #
127
+ def extract_dest_regions(content)
128
+ return content unless regions_configured?
129
+
130
+ extract_regions(content, @extracted_dest_regions)
131
+ end
132
+
133
+ ##
134
+ # Merge extracted regions and substitute them back into the merged content.
135
+ #
136
+ # @param merged_content [String] The merged content with placeholders
137
+ # @return [String] Content with placeholders replaced by merged regions
138
+ #
139
+ def substitute_merged_regions(merged_content)
140
+ return merged_content unless regions_configured?
141
+
142
+ result = merged_content
143
+
144
+ # Process regions in reverse order of extraction to handle nested placeholders
145
+ # We need to merge template and dest regions by their placeholder index
146
+ merge_and_substitute_regions(result)
147
+ end
148
+
149
+ private
150
+
151
+ ##
152
+ # Build RegionConfig objects from configuration hashes.
153
+ #
154
+ # @param configs [Array<Hash>] Array of configuration hashes
155
+ # @return [Array<RegionConfig>] Array of RegionConfig objects
156
+ #
157
+ def build_region_configs(configs)
158
+ return [] if configs.nil? || configs.empty?
159
+
160
+ configs.map do |config|
161
+ case config
162
+ when RegionConfig
163
+ config
164
+ when Hash
165
+ RegionConfig.new(
166
+ detector: config[:detector],
167
+ merger_class: config[:merger_class],
168
+ merger_options: config[:merger_options] || {},
169
+ regions: config[:regions] || [],
170
+ )
171
+ else
172
+ raise ArgumentError, "Invalid region config: #{config.inspect}"
173
+ end
174
+ end
175
+ end
176
+
177
+ ##
178
+ # Extract regions from content, replacing with placeholders.
179
+ #
180
+ # @param content [String] Content to process
181
+ # @param storage [Array<ExtractedRegion>] Array to store extracted regions
182
+ # @return [String] Content with placeholders
183
+ #
184
+ def extract_regions(content, storage)
185
+ validate_no_placeholder_collision!(content)
186
+
187
+ result = content
188
+ region_index = storage.size
189
+
190
+ @region_configs.each do |config|
191
+ regions = config.detector.detect_all(result)
192
+
193
+ # Process regions in reverse order to maintain correct positions
194
+ regions.sort_by { |r| -r.start_line }.each do |region|
195
+ placeholder = build_placeholder(region_index)
196
+ region_index += 1
197
+
198
+ extracted = ExtractedRegion.new(
199
+ region: region,
200
+ config: config,
201
+ placeholder: placeholder,
202
+ merged_content: nil,
203
+ )
204
+ storage.unshift(extracted) # Add to front since we process in reverse
205
+
206
+ # Replace the region with the placeholder
207
+ result = replace_region_with_placeholder(result, region, placeholder)
208
+ end
209
+ end
210
+
211
+ storage.sort_by! { |e| placeholder_index(e.placeholder) }
212
+ result
213
+ end
214
+
215
+ ##
216
+ # Validate that the content doesn't contain placeholder text.
217
+ #
218
+ # @param content [String] Content to validate
219
+ # @raise [PlaceholderCollisionError] if placeholder is found
220
+ #
221
+ def validate_no_placeholder_collision!(content)
222
+ return if content.nil? || content.empty?
223
+
224
+ if content.include?(@region_placeholder_prefix)
225
+ raise PlaceholderCollisionError, @region_placeholder_prefix
226
+ end
227
+ end
228
+
229
+ ##
230
+ # Build a placeholder string for a given index.
231
+ #
232
+ # @param index [Integer] The region index
233
+ # @return [String] The placeholder string
234
+ #
235
+ def build_placeholder(index)
236
+ "#{@region_placeholder_prefix}#{index}#{DEFAULT_PLACEHOLDER_SUFFIX}"
237
+ end
238
+
239
+ ##
240
+ # Extract the index from a placeholder string.
241
+ #
242
+ # @param placeholder [String] The placeholder string
243
+ # @return [Integer] The extracted index
244
+ #
245
+ def placeholder_index(placeholder)
246
+ placeholder.match(/#{Regexp.escape(@region_placeholder_prefix)}(\d+)/)[1].to_i
247
+ end
248
+
249
+ ##
250
+ # Replace a region in content with a placeholder.
251
+ #
252
+ # @param content [String] The content
253
+ # @param region [Region] The region to replace
254
+ # @param placeholder [String] The placeholder to insert
255
+ # @return [String] Content with region replaced
256
+ #
257
+ def replace_region_with_placeholder(content, region, placeholder)
258
+ lines = content.lines
259
+ # Region line numbers are 1-indexed
260
+ start_idx = region.start_line - 1
261
+ end_idx = region.end_line - 1
262
+
263
+ # Replace the region lines with the placeholder
264
+ before = lines[0...start_idx]
265
+ after = lines[(end_idx + 1)..]
266
+
267
+ # Preserve the newline style
268
+ newline = content.include?("\r\n") ? "\r\n" : "\n"
269
+ placeholder_line = "#{placeholder}#{newline}"
270
+
271
+ (before + [placeholder_line] + (after || [])).join
272
+ end
273
+
274
+ ##
275
+ # Merge and substitute regions back into the merged content.
276
+ #
277
+ # @param content [String] Merged content with placeholders
278
+ # @return [String] Content with merged regions substituted
279
+ #
280
+ def merge_and_substitute_regions(content)
281
+ result = content
282
+
283
+ # Build a mapping of placeholder index to extracted regions from both sources
284
+ template_by_idx = @extracted_template_regions.each_with_object({}) do |e, h|
285
+ h[placeholder_index(e.placeholder)] = e
286
+ end
287
+ dest_by_idx = @extracted_dest_regions.each_with_object({}) do |e, h|
288
+ h[placeholder_index(e.placeholder)] = e
289
+ end
290
+
291
+ # Find all placeholder indices in the merged content
292
+ all_indices = (template_by_idx.keys + dest_by_idx.keys).uniq.sort
293
+
294
+ all_indices.each do |idx|
295
+ template_extracted = template_by_idx[idx]
296
+ dest_extracted = dest_by_idx[idx]
297
+ placeholder = build_placeholder(idx)
298
+
299
+ merged_region_content = merge_region(template_extracted, dest_extracted)
300
+ result = result.gsub(placeholder, merged_region_content) if merged_region_content
301
+ end
302
+
303
+ result
304
+ end
305
+
306
+ ##
307
+ # Merge a region from template and destination.
308
+ #
309
+ # @param template_extracted [ExtractedRegion, nil] Template region
310
+ # @param dest_extracted [ExtractedRegion, nil] Destination region
311
+ # @return [String, nil] Merged region content, or nil if no content
312
+ #
313
+ def merge_region(template_extracted, dest_extracted)
314
+ config = template_extracted&.config || dest_extracted&.config
315
+ return unless config
316
+
317
+ template_region = template_extracted&.region
318
+ dest_region = dest_extracted&.region
319
+
320
+ # Get the full text (including delimiters) for each region
321
+ template_text = template_region&.full_text || ""
322
+ dest_text = dest_region&.full_text || ""
323
+
324
+ # If no merger class, prefer destination content (preserve customizations)
325
+ unless config.merger_class
326
+ return dest_text.empty? ? template_text : dest_text
327
+ end
328
+
329
+ # Extract just the content (without delimiters) for merging
330
+ template_content = template_region&.content || ""
331
+ dest_content = dest_region&.content || ""
332
+
333
+ # Build merger options, including nested regions if configured
334
+ merger_options = config.merger_options.dup
335
+ merger_options[:regions] = config.regions unless config.regions.empty?
336
+
337
+ # Create the merger and merge the region content
338
+ merger = config.merger_class.new(template_content, dest_content, **merger_options)
339
+ merged_content = merger.merge
340
+
341
+ # Reconstruct with delimiters
342
+ reconstruct_region_with_delimiters(template_region || dest_region, merged_content)
343
+ end
344
+
345
+ ##
346
+ # Reconstruct a region with its delimiters around the merged content.
347
+ #
348
+ # @param region [Region] The original region (for delimiter info)
349
+ # @param content [String] The merged content
350
+ # @return [String] Full region text with delimiters
351
+ #
352
+ def reconstruct_region_with_delimiters(region, content)
353
+ return content unless region&.delimiters
354
+
355
+ opening, closing = region.delimiters
356
+
357
+ # Ensure content ends with newline if it doesn't
358
+ normalized_content = content.end_with?("\n") ? content : "#{content}\n"
359
+
360
+ "#{opening}\n#{normalized_content}#{closing}\n"
361
+ end
362
+ end
363
+ end
364
+ end