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,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "freezable"
4
+
5
+ module Ast
6
+ module Merge
7
+ # Base class for freeze block nodes in AST merge libraries.
8
+ #
9
+ # A freeze block is a section marked with freeze/unfreeze comment markers that
10
+ # should be preserved from the destination during merges. The entire content
11
+ # between the markers is treated as opaque and matched by content identity.
12
+ #
13
+ # ## Key Distinction from FrozenWrapper
14
+ #
15
+ # FreezeNodeBase represents **explicit freeze blocks** with clear boundaries:
16
+ # - Starts with `# token:freeze` (or equivalent in other comment styles)
17
+ # - Ends with `# token:unfreeze`
18
+ # - The content between markers is opaque and preserved verbatim
19
+ # - Matched by CONTENT identity via `freeze_signature`
20
+ #
21
+ # In contrast, NodeTyping::FrozenWrapper represents **AST nodes with freeze markers
22
+ # in their leading comments**:
23
+ # - The marker appears in the node's leading comments, not as a block boundary
24
+ # - The node is still a structural AST element (e.g., a `gem` call)
25
+ # - Matched by the underlying node's STRUCTURAL identity
26
+ #
27
+ # ## Signature Generation Behavior
28
+ #
29
+ # When FileAnalyzable#generate_signature encounters a FreezeNodeBase, it uses
30
+ # the `freeze_signature` method directly, which returns `[:FreezeNode, content]`.
31
+ # This ensures that explicit freeze blocks are matched by their exact content.
32
+ #
33
+ # This class provides shared functionality for file-type-specific implementations
34
+ # (e.g., Prism::Merge::FreezeNode, Psych::Merge::FreezeNode).
35
+ #
36
+ # Supports multiple comment syntax styles via configurable marker patterns:
37
+ # - `:hash_comment` - Ruby/Python/YAML style (`# freeze-begin` / `# freeze-end`)
38
+ # - `:html_comment` - HTML/Markdown style (`<!-- freeze-begin -->` / `<!-- freeze-end -->`)
39
+ # - `:c_style_line` - C/JavaScript line comments (`// freeze-begin` / `// freeze-end`)
40
+ # - `:c_style_block` - C/JavaScript block comments (`/* freeze-begin */` / `/* freeze-end */`)
41
+ #
42
+ # @example Freeze block with hash comments (Ruby/YAML)
43
+ # # <token>:freeze
44
+ # content to preserve...
45
+ # # <token>:unfreeze
46
+ #
47
+ # @example Freeze block with HTML comments (Markdown)
48
+ # <!-- <token>:freeze -->
49
+ # content to preserve...
50
+ # <!-- <token>:unfreeze -->
51
+ #
52
+ # @example Creating a custom pattern
53
+ # FreezeNodeBase.register_pattern(:custom,
54
+ # start: /^--\s*freeze-begin/i,
55
+ # end_pattern: /^--\s*freeze-end/i
56
+ # )
57
+ #
58
+ # @see Freezable#freeze_signature - Content-based signature for matching
59
+ # @see NodeTyping::FrozenWrapper - Structural matching alternative
60
+ # @see FileAnalyzable#generate_signature - Routing logic for signature generation
61
+ class FreezeNodeBase
62
+ include Freezable
63
+
64
+ # Error raised when a freeze block has invalid structure
65
+ class InvalidStructureError < StandardError
66
+ # @return [Integer, nil] Starting line of the freeze block
67
+ attr_reader :start_line
68
+
69
+ # @return [Integer, nil] Ending line of the freeze block
70
+ attr_reader :end_line
71
+
72
+ # @return [Array] Nodes that caused the structure error (optional)
73
+ attr_reader :unclosed_nodes
74
+
75
+ # @param message [String] Error message
76
+ # @param start_line [Integer, nil] Start line number
77
+ # @param end_line [Integer, nil] End line number
78
+ # @param unclosed_nodes [Array] Nodes causing the error
79
+ def initialize(message, start_line: nil, end_line: nil, unclosed_nodes: [])
80
+ super(message)
81
+ @start_line = start_line
82
+ @end_line = end_line
83
+ @unclosed_nodes = unclosed_nodes
84
+ end
85
+ end
86
+
87
+ # Simple location struct for compatibility with AST nodes
88
+ Location = Struct.new(:start_line, :end_line) do
89
+ # Check if a line number is within this location
90
+ # @param line [Integer] Line number to check
91
+ # @return [Boolean]
92
+ def cover?(line)
93
+ (start_line..end_line).cover?(line)
94
+ end
95
+ end
96
+
97
+ # Pattern configuration for freeze block markers.
98
+ # Mutable to allow runtime registration of custom patterns.
99
+ # @return [Hash{Symbol => Hash{Symbol => Regexp}}] Registered marker patterns
100
+ MARKER_PATTERNS = {
101
+ hash_comment: {
102
+ start: /^\s*#\s*[\w-]+:freeze\b/i,
103
+ end: /^\s*#\s*[\w-]+:unfreeze\b/i,
104
+ },
105
+ html_comment: {
106
+ start: /^\s*<!--\s*[\w-]+:freeze\b.*-->/i,
107
+ end: /^\s*<!--\s*[\w-]+:unfreeze\b.*-->/i,
108
+ },
109
+ c_style_line: {
110
+ start: %r{^\s*//\s*[\w-]+:freeze\b}i,
111
+ end: %r{^\s*//\s*[\w-]+:unfreeze\b}i,
112
+ },
113
+ c_style_block: {
114
+ start: %r{^\s*/\*\s*[\w-]+:freeze\b.*\*/}i,
115
+ end: %r{^\s*/\*\s*[\w-]+:unfreeze\b.*\*/}i,
116
+ },
117
+ }
118
+
119
+ # Default pattern when none specified
120
+ # @return [Symbol]
121
+ DEFAULT_PATTERN = :hash_comment
122
+
123
+ class << self
124
+ # Register a custom marker pattern
125
+ # @param name [Symbol] Pattern name
126
+ # @param start [Regexp] Regex to match freeze start marker
127
+ # @param end_pattern [Regexp] Regex to match freeze end marker
128
+ # @return [Hash{Symbol => Regexp}] The registered pattern
129
+ # @raise [ArgumentError] if name already exists or patterns invalid
130
+ def register_pattern(name, start:, end_pattern:)
131
+ raise ArgumentError, "Pattern :#{name} already registered" if MARKER_PATTERNS.key?(name)
132
+ raise ArgumentError, "Start pattern must be a Regexp" unless start.is_a?(Regexp)
133
+ raise ArgumentError, "End pattern must be a Regexp" unless end_pattern.is_a?(Regexp)
134
+
135
+ MARKER_PATTERNS[name] = {start: start, end: end_pattern}
136
+ end
137
+
138
+ # Get start marker pattern for a given pattern type
139
+ # @param pattern_type [Symbol] Pattern type name (defaults to DEFAULT_PATTERN)
140
+ # @return [Regexp] Start marker regex
141
+ # @raise [ArgumentError] if pattern type not found
142
+ def start_pattern(pattern_type = DEFAULT_PATTERN)
143
+ patterns = MARKER_PATTERNS[pattern_type]
144
+ raise ArgumentError, "Unknown pattern type: #{pattern_type}" unless patterns
145
+
146
+ patterns[:start]
147
+ end
148
+
149
+ # Get end marker pattern for a given pattern type
150
+ # @param pattern_type [Symbol] Pattern type name (defaults to DEFAULT_PATTERN)
151
+ # @return [Regexp] End marker regex
152
+ # @raise [ArgumentError] if pattern type not found
153
+ def end_pattern(pattern_type = DEFAULT_PATTERN)
154
+ patterns = MARKER_PATTERNS[pattern_type]
155
+ raise ArgumentError, "Unknown pattern type: #{pattern_type}" unless patterns
156
+
157
+ patterns[:end]
158
+ end
159
+
160
+ # Get both start and end patterns for a given pattern type
161
+ # When token is provided, returns a combined pattern with capture groups
162
+ # for marker type (freeze/unfreeze) and optional reason.
163
+ #
164
+ # @param pattern_type [Symbol] Pattern type name (defaults to DEFAULT_PATTERN)
165
+ # @param token [String, nil] Optional freeze token to build specific pattern
166
+ # @return [Hash{Symbol => Regexp}, Regexp] Hash with :start/:end keys, or combined Regexp if token provided
167
+ # @raise [ArgumentError] if pattern type not found
168
+ #
169
+ # @example Without token (returns hash of patterns)
170
+ # FreezeNode.pattern_for(:hash_comment)
171
+ # # => { start: /.../, end: /.../ }
172
+ #
173
+ # @example With token (returns combined pattern with capture groups)
174
+ # FreezeNode.pattern_for(:hash_comment, "my-merge")
175
+ # # => /^\s*#\s*my-merge:(freeze|unfreeze)\b\s*(.*)?$/i
176
+ # # Capture group 1: "freeze" or "unfreeze"
177
+ # # Capture group 2: optional reason text
178
+ def pattern_for(pattern_type = DEFAULT_PATTERN, token = nil)
179
+ raise ArgumentError, "Unknown pattern type: #{pattern_type}" unless MARKER_PATTERNS.key?(pattern_type)
180
+
181
+ # If no token provided, return the static patterns hash
182
+ return MARKER_PATTERNS[pattern_type] unless token
183
+
184
+ # Build a combined pattern with capture groups for the specific token
185
+ escaped_token = Regexp.escape(token)
186
+
187
+ case pattern_type
188
+ when :hash_comment
189
+ /^\s*#\s*#{escaped_token}:(freeze|unfreeze)\b\s*(.*)?$/i
190
+ when :html_comment
191
+ /^\s*<!--\s*#{escaped_token}:(freeze|unfreeze)(?:\s+(.+?))?\s*-->/i
192
+ when :c_style_line
193
+ %r{^\s*//\s*#{escaped_token}:(freeze|unfreeze)\b\s*(.*)?$}i
194
+ when :c_style_block
195
+ %r{^\s*/\*\s*#{escaped_token}:(freeze|unfreeze)\b\s*(.*)? *\*/}i
196
+ else
197
+ # Fallback for custom registered patterns - can't build token-specific
198
+ raise ArgumentError, "Cannot build token-specific pattern for custom type: #{pattern_type}"
199
+ end
200
+ end
201
+
202
+ # Check if a line matches a freeze start marker
203
+ # @param line [String] Line content to check
204
+ # @param pattern_type [Symbol] Pattern type to use (defaults to DEFAULT_PATTERN)
205
+ # @return [Boolean]
206
+ def freeze_start?(line, pattern_type = DEFAULT_PATTERN)
207
+ return false if line.nil?
208
+
209
+ start_pattern(pattern_type).match?(line)
210
+ end
211
+
212
+ # Check if a line matches a freeze end marker
213
+ # @param line [String] Line content to check
214
+ # @param pattern_type [Symbol] Pattern type to use (defaults to DEFAULT_PATTERN)
215
+ # @return [Boolean]
216
+ def freeze_end?(line, pattern_type = DEFAULT_PATTERN)
217
+ return false if line.nil?
218
+
219
+ end_pattern(pattern_type).match?(line)
220
+ end
221
+
222
+ # Available pattern types
223
+ # @return [Array<Symbol>]
224
+ def pattern_types
225
+ MARKER_PATTERNS.keys
226
+ end
227
+ end
228
+
229
+ # @return [Integer] Line number of freeze marker (1-based)
230
+ attr_reader :start_line
231
+
232
+ # @return [Integer] Line number of unfreeze marker (1-based)
233
+ attr_reader :end_line
234
+
235
+ # @return [String] Content of the freeze block
236
+ attr_reader :content
237
+
238
+ # @return [String, nil] The freeze start marker text
239
+ attr_reader :start_marker
240
+
241
+ # @return [String, nil] The freeze end marker text
242
+ attr_reader :end_marker
243
+
244
+ # @return [Symbol] The pattern type used for this freeze node
245
+ attr_reader :pattern_type
246
+
247
+ # @return [Array<String>, nil] Lines within the freeze block
248
+ attr_reader :lines
249
+
250
+ # @return [Object, nil] Reference to FileAnalysis (for subclasses that need it)
251
+ attr_reader :analysis
252
+
253
+ # @return [Array] AST nodes contained within the freeze block
254
+ attr_reader :nodes
255
+
256
+ # @return [Array, nil] Nodes that overlap with the freeze block boundaries
257
+ attr_reader :overlapping_nodes
258
+
259
+ # Initialize a freeze node.
260
+ #
261
+ # This unified constructor accepts all parameters that any *-merge gem might need.
262
+ # Subclasses should call super with the parameters they use.
263
+ #
264
+ # Content can be provided via:
265
+ # - `lines:` - Direct array of line strings
266
+ # - `analysis:` - FileAnalysis reference (lines extracted via analysis.lines)
267
+ # - `content:` - Direct content string (will be split into lines)
268
+ #
269
+ # @param start_line [Integer] Line number of freeze marker (1-based)
270
+ # @param end_line [Integer] Line number of unfreeze marker (1-based)
271
+ # @param lines [Array<String>, nil] Direct array of source lines
272
+ # @param analysis [Object, nil] FileAnalysis reference for content access
273
+ # @param content [String, nil] Direct content string
274
+ # @param nodes [Array] AST nodes contained within the freeze block
275
+ # @param overlapping_nodes [Array, nil] Nodes that overlap block boundaries
276
+ # @param start_marker [String, nil] The freeze start marker text
277
+ # @param end_marker [String, nil] The freeze end marker text
278
+ # @param pattern_type [Symbol] Pattern type for marker matching
279
+ # @param reason [String, nil] Optional reason extracted from freeze marker
280
+ def initialize(
281
+ start_line:,
282
+ end_line:,
283
+ lines: nil,
284
+ analysis: nil,
285
+ content: nil,
286
+ nodes: [],
287
+ overlapping_nodes: nil,
288
+ start_marker: nil,
289
+ end_marker: nil,
290
+ pattern_type: DEFAULT_PATTERN,
291
+ reason: nil
292
+ )
293
+ @start_line = start_line
294
+ @end_line = end_line
295
+ @start_marker = start_marker
296
+ @end_marker = end_marker
297
+ @pattern_type = pattern_type
298
+ @explicit_reason = reason
299
+ @nodes = nodes
300
+ @overlapping_nodes = overlapping_nodes
301
+ @analysis = analysis
302
+
303
+ # Handle content from various sources
304
+ @lines = resolve_lines(lines, analysis, content)
305
+ @content = resolve_content(@lines, content)
306
+ end
307
+
308
+ # Returns a location-like object for compatibility with AST nodes
309
+ # @return [Location]
310
+ def location
311
+ @location ||= Location.new(@start_line, @end_line)
312
+ end
313
+
314
+ # Extract the reason/comment from the freeze start marker.
315
+ # The reason is any text after the freeze directive.
316
+ # If an explicit reason was provided at initialization, that takes precedence.
317
+ #
318
+ # @example With reason
319
+ # # rbs-merge:freeze Custom reason here
320
+ # => "Custom reason here"
321
+ #
322
+ # @example Without reason
323
+ # # rbs-merge:freeze
324
+ # => nil
325
+ #
326
+ # @return [String, nil] The reason text, or nil if not present
327
+ def reason
328
+ # Return explicit reason if provided at initialization
329
+ return @explicit_reason if @explicit_reason
330
+
331
+ return unless @start_marker
332
+
333
+ # Use the canonical pattern which has capture group 2 for reason
334
+ # We need to extract the token from the marker first
335
+ token = extract_token_from_marker
336
+ return unless token
337
+
338
+ pattern = self.class.pattern_for(@pattern_type, token)
339
+ match = @start_marker.match(pattern)
340
+ return unless match
341
+
342
+ # Capture group 2 is the reason text
343
+ reason_text = match[2]&.strip
344
+ reason_text&.empty? ? nil : reason_text
345
+ end
346
+
347
+ # Returns the freeze block content
348
+ # @return [String]
349
+ def slice
350
+ @content
351
+ end
352
+
353
+ # Check if this is a freeze node (always true for FreezeNode)
354
+ # @return [Boolean]
355
+ def freeze_node?
356
+ true
357
+ end
358
+
359
+ # Returns a stable signature for this freeze block.
360
+ # Override in subclasses for file-type-specific normalization.
361
+ # @return [Array] Signature array
362
+ def signature
363
+ [:FreezeNode, @content&.strip]
364
+ end
365
+
366
+ # String representation for debugging
367
+ # @return [String]
368
+ def inspect
369
+ "#<#{self.class.name} lines=#{start_line}..#{end_line} pattern=#{pattern_type}>"
370
+ end
371
+
372
+ # @return [String]
373
+ def to_s
374
+ inspect
375
+ end
376
+
377
+ protected
378
+
379
+ # Validate that end_line is not before start_line
380
+ # @raise [InvalidStructureError] if structure is invalid
381
+ def validate_line_order!
382
+ return unless @end_line < @start_line
383
+
384
+ raise InvalidStructureError.new(
385
+ "Freeze block end line (#{@end_line}) is before start line (#{@start_line})",
386
+ start_line: @start_line,
387
+ end_line: @end_line,
388
+ )
389
+ end
390
+
391
+ private
392
+
393
+ # Resolve lines from various sources
394
+ # @param lines [Array<String>, nil] Direct lines array
395
+ # @param analysis [Object, nil] FileAnalysis with lines method
396
+ # @param content [String, nil] Direct content string
397
+ # @return [Array<String>, nil] Resolved lines
398
+ def resolve_lines(lines, analysis, content)
399
+ return lines if lines
400
+
401
+ if analysis&.respond_to?(:lines)
402
+ # Extract lines from analysis using line numbers (1-based to 0-based)
403
+ all_lines = analysis.lines
404
+ return all_lines[(@start_line - 1)..(@end_line - 1)] if all_lines
405
+ end
406
+
407
+ content&.split("\n", -1)
408
+ end
409
+
410
+ # Resolve content from various sources
411
+ # @param lines [Array<String>, nil] Resolved lines
412
+ # @param content [String, nil] Direct content string
413
+ # @return [String, nil] Resolved content
414
+ def resolve_content(lines, content)
415
+ return content if content
416
+
417
+ lines&.join("\n")
418
+ end
419
+
420
+ # Extract the token from the start marker
421
+ # @return [String, nil] The token (e.g., "rbs-merge" from "# rbs-merge:freeze")
422
+ def extract_token_from_marker
423
+ # :nocov:
424
+ # Defensive: @start_marker is always set in normal usage, nil check is for safety
425
+ return unless @start_marker
426
+ # :nocov:
427
+
428
+ # Match the token before :freeze
429
+ match = @start_marker.match(/([\w-]+):freeze/i)
430
+ match&.[](1)
431
+ end
432
+ end
433
+ end
434
+ end