ast-merge 1.0.0 → 2.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 (51) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +194 -1
  4. data/README.md +235 -53
  5. data/exe/ast-merge-recipe +366 -0
  6. data/lib/ast/merge/ast_node.rb +224 -24
  7. data/lib/ast/merge/comment/block.rb +6 -0
  8. data/lib/ast/merge/comment/empty.rb +6 -0
  9. data/lib/ast/merge/comment/line.rb +6 -0
  10. data/lib/ast/merge/comment/parser.rb +9 -7
  11. data/lib/ast/merge/conflict_resolver_base.rb +8 -1
  12. data/lib/ast/merge/content_match_refiner.rb +278 -0
  13. data/lib/ast/merge/debug_logger.rb +6 -1
  14. data/lib/ast/merge/detector/base.rb +193 -0
  15. data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
  16. data/lib/ast/merge/detector/mergeable.rb +369 -0
  17. data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
  18. data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
  19. data/lib/ast/merge/file_analyzable.rb +5 -3
  20. data/lib/ast/merge/freeze_node_base.rb +1 -1
  21. data/lib/ast/merge/match_refiner_base.rb +1 -1
  22. data/lib/ast/merge/match_score_base.rb +1 -1
  23. data/lib/ast/merge/merge_result_base.rb +4 -1
  24. data/lib/ast/merge/merger_config.rb +33 -31
  25. data/lib/ast/merge/navigable_statement.rb +630 -0
  26. data/lib/ast/merge/partial_template_merger.rb +432 -0
  27. data/lib/ast/merge/recipe/config.rb +198 -0
  28. data/lib/ast/merge/recipe/preset.rb +171 -0
  29. data/lib/ast/merge/recipe/runner.rb +254 -0
  30. data/lib/ast/merge/recipe/script_loader.rb +181 -0
  31. data/lib/ast/merge/recipe.rb +26 -0
  32. data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
  33. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
  34. data/lib/ast/merge/rspec.rb +33 -2
  35. data/lib/ast/merge/section_typing.rb +52 -50
  36. data/lib/ast/merge/smart_merger_base.rb +86 -3
  37. data/lib/ast/merge/text/line_node.rb +42 -9
  38. data/lib/ast/merge/text/section_splitter.rb +12 -10
  39. data/lib/ast/merge/text/word_node.rb +47 -14
  40. data/lib/ast/merge/version.rb +1 -1
  41. data/lib/ast/merge.rb +10 -6
  42. data/sig/ast/merge.rbs +389 -2
  43. data.tar.gz.sig +0 -0
  44. metadata +76 -12
  45. metadata.gz.sig +0 -0
  46. data/lib/ast/merge/fenced_code_block_detector.rb +0 -211
  47. data/lib/ast/merge/region.rb +0 -124
  48. data/lib/ast/merge/region_detector_base.rb +0 -114
  49. data/lib/ast/merge/region_mergeable.rb +0 -364
  50. data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
  51. data/lib/ast/merge/yaml_frontmatter_detector.rb +0 -108
@@ -1,364 +0,0 @@
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
@@ -1,88 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ast
4
- module Merge
5
- ##
6
- # Detects TOML frontmatter at the beginning of a document.
7
- #
8
- # TOML frontmatter is delimited by `+++` at the start and end,
9
- # and must begin on the first line of the document (optionally
10
- # preceded by a UTF-8 BOM). This format is commonly used by
11
- # Hugo and other static site generators.
12
- #
13
- # @example TOML frontmatter
14
- # +++
15
- # title = "My Document"
16
- # author = "Jane Doe"
17
- # +++
18
- #
19
- # @example Usage
20
- # detector = TomlFrontmatterDetector.new
21
- # regions = detector.detect_all(markdown_source)
22
- # # => [#<Region type=:toml_frontmatter content="title = \"My Document\"\n...">]
23
- #
24
- class TomlFrontmatterDetector < RegionDetectorBase
25
- ##
26
- # Pattern for detecting TOML frontmatter.
27
- # - Must start at beginning of document (or after BOM)
28
- # - Opening delimiter is `+++` followed by optional whitespace and newline
29
- # - Content is captured (non-greedy)
30
- # - Closing delimiter is `+++` at start of line, followed by optional whitespace and newline/EOF
31
- #
32
- FRONTMATTER_PATTERN = /\A(?:\xEF\xBB\xBF)?(\+\+\+[ \t]*\r?\n)(.*?)(^\+\+\+[ \t]*(?:\r?\n|\z))/m
33
-
34
- ##
35
- # @return [Symbol] the type identifier for TOML frontmatter regions
36
- #
37
- def region_type
38
- :toml_frontmatter
39
- end
40
-
41
- ##
42
- # Detects TOML frontmatter at the beginning of the document.
43
- #
44
- # @param source [String] the source document to scan
45
- # @return [Array<Region>] array containing at most one Region for frontmatter
46
- #
47
- def detect_all(source)
48
- return [] if source.nil? || source.empty?
49
-
50
- match = source.match(FRONTMATTER_PATTERN)
51
- return [] unless match
52
-
53
- opening_delimiter = match[1]
54
- content = match[2]
55
- closing_delimiter = match[3]
56
-
57
- # Calculate line numbers
58
- start_line = 1
59
-
60
- # Count total newlines in the full match to determine end line
61
- full_match = match[0]
62
- total_newlines = full_match.count("\n")
63
- end_line = total_newlines + (full_match.end_with?("\n") ? 0 : 1)
64
-
65
- [
66
- Region.new(
67
- type: region_type,
68
- content: content,
69
- start_line: start_line,
70
- end_line: end_line,
71
- delimiters: [opening_delimiter.strip, closing_delimiter.strip],
72
- metadata: {format: :toml},
73
- ),
74
- ]
75
- end
76
-
77
- private
78
-
79
- ##
80
- # @return [Array<Region>] array containing at most one Region
81
- #
82
- def build_regions(source, matches)
83
- # Not used - detect_all is overridden directly
84
- raise NotImplementedError, "TomlFrontmatterDetector overrides detect_all directly"
85
- end
86
- end
87
- end
88
- end
@@ -1,108 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ast
4
- module Merge
5
- ##
6
- # Detects YAML frontmatter at the beginning of a document.
7
- #
8
- # YAML frontmatter is delimited by `---` at the start and end,
9
- # and must begin on the first line of the document (optionally
10
- # preceded by a UTF-8 BOM).
11
- #
12
- # @example YAML frontmatter
13
- # ---
14
- # title: My Document
15
- # author: Jane Doe
16
- # ---
17
- #
18
- # @example Usage
19
- # detector = YamlFrontmatterDetector.new
20
- # regions = detector.detect_all(markdown_source)
21
- # # => [#<Region type=:yaml_frontmatter content="title: My Document\n...">]
22
- #
23
- class YamlFrontmatterDetector < RegionDetectorBase
24
- ##
25
- # Pattern for detecting YAML frontmatter.
26
- # - Must start at beginning of document (or after BOM)
27
- # - Opening delimiter is `---` followed by optional whitespace and newline
28
- # - Content is captured (non-greedy)
29
- # - Closing delimiter is `---` at start of line, followed by optional whitespace and newline/EOF
30
- #
31
- FRONTMATTER_PATTERN = /\A(?:\xEF\xBB\xBF)?(---[ \t]*\r?\n)(.*?)(^---[ \t]*(?:\r?\n|\z))/m
32
-
33
- ##
34
- # @return [Symbol] the type identifier for YAML frontmatter regions
35
- #
36
- def region_type
37
- :yaml_frontmatter
38
- end
39
-
40
- ##
41
- # Detects YAML frontmatter at the beginning of the document.
42
- #
43
- # @param source [String] the source document to scan
44
- # @return [Array<Region>] array containing at most one Region for frontmatter
45
- #
46
- def detect_all(source)
47
- return [] if source.nil? || source.empty?
48
-
49
- match = source.match(FRONTMATTER_PATTERN)
50
- return [] unless match
51
-
52
- opening_delimiter = match[1]
53
- content = match[2]
54
- closing_delimiter = match[3]
55
-
56
- # Calculate line numbers
57
- # Frontmatter starts at line 1 (or after BOM)
58
- start_line = 1
59
- # Count newlines in content to determine end line
60
- # Opening delimiter ends at line 1
61
- # Content spans from line 2 to line 2 + content_lines - 1
62
- # Closing delimiter is on the next line
63
- content_newlines = content.count("\n")
64
- # end_line is the line with the closing ---
65
- end_line = start_line + 1 + content_newlines
66
-
67
- # Adjust if content ends without newline
68
- end_line - 1 if content.end_with?("\n") && content_newlines > 0
69
-
70
- # Actually, let's calculate more carefully
71
- # Line 1: ---
72
- # Line 2 to N: content
73
- # Line N+1: ---
74
- if content.empty?
75
- 0
76
- else
77
- content.count("\n") + (content.end_with?("\n") ? 0 : 1)
78
- end
79
-
80
- # Simplify: count total newlines in the full match to determine end line
81
- full_match = match[0]
82
- total_newlines = full_match.count("\n")
83
- end_line = total_newlines + (full_match.end_with?("\n") ? 0 : 1)
84
-
85
- [
86
- Region.new(
87
- type: region_type,
88
- content: content,
89
- start_line: start_line,
90
- end_line: end_line,
91
- delimiters: [opening_delimiter.strip, closing_delimiter.strip],
92
- metadata: {format: :yaml},
93
- ),
94
- ]
95
- end
96
-
97
- private
98
-
99
- ##
100
- # @return [Array<Region>] array containing at most one Region
101
- #
102
- def build_regions(source, matches)
103
- # Not used - detect_all is overridden directly
104
- raise NotImplementedError, "YamlFrontmatterDetector overrides detect_all directly"
105
- end
106
- end
107
- end
108
- end