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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Detects fenced code blocks with a specific language identifier.
6
+ #
7
+ # This detector finds Markdown-style fenced code blocks (using ``` or ~~~)
8
+ # that have a specific language identifier. It can be configured for any
9
+ # language: ruby, json, yaml, mermaid, etc.
10
+ #
11
+ # @example Detecting Ruby code blocks
12
+ # detector = FencedCodeBlockDetector.new("ruby", aliases: ["rb"])
13
+ # regions = detector.detect_all(markdown_source)
14
+ #
15
+ # @example Using factory methods
16
+ # detector = FencedCodeBlockDetector.ruby
17
+ # detector = FencedCodeBlockDetector.yaml
18
+ # detector = FencedCodeBlockDetector.json
19
+ #
20
+ # @api public
21
+ class FencedCodeBlockDetector < RegionDetectorBase
22
+ # @return [String] The primary language identifier
23
+ attr_reader :language
24
+
25
+ # @return [Array<String>] Alternative language identifiers
26
+ attr_reader :aliases
27
+
28
+ # Creates a new detector for the specified language.
29
+ #
30
+ # @param language [String, Symbol] The language identifier (e.g., "ruby", "json")
31
+ # @param aliases [Array<String, Symbol>] Alternative identifiers (e.g., ["rb"] for ruby)
32
+ def initialize(language, aliases: [])
33
+ super()
34
+ @language = language.to_s.downcase
35
+ @aliases = aliases.map { |a| a.to_s.downcase }
36
+ @all_identifiers = [@language] + @aliases
37
+ end
38
+
39
+ # @return [Symbol] The region type (e.g., :ruby_code_block)
40
+ def region_type
41
+ :"#{@language}_code_block"
42
+ end
43
+
44
+ # Check if a language identifier matches this detector.
45
+ #
46
+ # @param lang [String] The language identifier to check
47
+ # @return [Boolean] true if the language matches
48
+ def matches_language?(lang)
49
+ @all_identifiers.include?(lang.to_s.downcase)
50
+ end
51
+
52
+ # Detects all fenced code blocks with the configured language.
53
+ #
54
+ # @param source [String] The full document content
55
+ # @return [Array<Region>] All detected code blocks, sorted by start_line
56
+ def detect_all(source)
57
+ return [] if source.nil? || source.empty?
58
+
59
+ regions = []
60
+ lines = source.lines
61
+ in_block = false
62
+ start_line = nil
63
+ content_lines = []
64
+ current_language = nil
65
+ fence_char = nil
66
+ fence_length = nil
67
+ indent = ""
68
+
69
+ lines.each_with_index do |line, idx|
70
+ line_num = idx + 1
71
+
72
+ if !in_block
73
+ # Match opening fence: ```lang or ~~~lang (optionally indented)
74
+ match = line.match(/^(\s*)(`{3,}|~{3,})(\w*)\s*$/)
75
+ if match
76
+ indent = match[1] || ""
77
+ fence = match[2]
78
+ lang = match[3].downcase
79
+
80
+ if @all_identifiers.include?(lang)
81
+ in_block = true
82
+ start_line = line_num
83
+ content_lines = []
84
+ current_language = lang
85
+ fence_char = fence[0]
86
+ fence_length = fence.length
87
+ end
88
+ end
89
+ elsif line.match?(/^#{Regexp.escape(indent)}#{Regexp.escape(fence_char)}{#{fence_length},}\s*$/)
90
+ # Match closing fence (must use same char, same indent, and at least same length)
91
+ opening_fence = "#{fence_char * fence_length}#{current_language}"
92
+ closing_fence = fence_char * fence_length
93
+
94
+ regions << build_region(
95
+ type: region_type,
96
+ content: content_lines.join,
97
+ start_line: start_line,
98
+ end_line: line_num,
99
+ delimiters: [opening_fence, closing_fence],
100
+ metadata: {language: current_language, indent: indent.empty? ? nil : indent},
101
+ )
102
+ in_block = false
103
+ start_line = nil
104
+ content_lines = []
105
+ current_language = nil
106
+ fence_char = nil
107
+ fence_length = nil
108
+ indent = ""
109
+ else
110
+ # Accumulate content lines (strip the indent if present)
111
+ content_lines << if indent.empty?
112
+ line
113
+ else
114
+ # Strip the common indent from content lines
115
+ line.sub(/^#{Regexp.escape(indent)}/, "")
116
+ end
117
+ end
118
+ end
119
+
120
+ # Note: Unclosed blocks are ignored (no region created)
121
+ regions
122
+ end
123
+
124
+ # @return [String] A description of this detector
125
+ def inspect
126
+ aliases_str = @aliases.empty? ? "" : " aliases=#{@aliases.inspect}"
127
+ "#<#{self.class.name} language=#{@language}#{aliases_str}>"
128
+ end
129
+
130
+ class << self
131
+ # Creates a detector for Ruby code blocks.
132
+ # @return [FencedCodeBlockDetector]
133
+ def ruby
134
+ new("ruby", aliases: ["rb"])
135
+ end
136
+
137
+ # Creates a detector for JSON code blocks.
138
+ # @return [FencedCodeBlockDetector]
139
+ def json
140
+ new("json")
141
+ end
142
+
143
+ # Creates a detector for YAML code blocks.
144
+ # @return [FencedCodeBlockDetector]
145
+ def yaml
146
+ new("yaml", aliases: ["yml"])
147
+ end
148
+
149
+ # Creates a detector for TOML code blocks.
150
+ # @return [FencedCodeBlockDetector]
151
+ def toml
152
+ new("toml")
153
+ end
154
+
155
+ # Creates a detector for Mermaid diagram blocks.
156
+ # @return [FencedCodeBlockDetector]
157
+ def mermaid
158
+ new("mermaid")
159
+ end
160
+
161
+ # Creates a detector for JavaScript code blocks.
162
+ # @return [FencedCodeBlockDetector]
163
+ def javascript
164
+ new("javascript", aliases: ["js"])
165
+ end
166
+
167
+ # Creates a detector for TypeScript code blocks.
168
+ # @return [FencedCodeBlockDetector]
169
+ def typescript
170
+ new("typescript", aliases: ["ts"])
171
+ end
172
+
173
+ # Creates a detector for Python code blocks.
174
+ # @return [FencedCodeBlockDetector]
175
+ def python
176
+ new("python", aliases: ["py"])
177
+ end
178
+
179
+ # Creates a detector for Bash/Shell code blocks.
180
+ # @return [FencedCodeBlockDetector]
181
+ def bash
182
+ new("bash", aliases: ["sh", "shell", "zsh"])
183
+ end
184
+
185
+ # Creates a detector for SQL code blocks.
186
+ # @return [FencedCodeBlockDetector]
187
+ def sql
188
+ new("sql")
189
+ end
190
+
191
+ # Creates a detector for HTML code blocks.
192
+ # @return [FencedCodeBlockDetector]
193
+ def html
194
+ new("html")
195
+ end
196
+
197
+ # Creates a detector for CSS code blocks.
198
+ # @return [FencedCodeBlockDetector]
199
+ def css
200
+ new("css")
201
+ end
202
+
203
+ # Creates a detector for Markdown code blocks (nested markdown).
204
+ # @return [FencedCodeBlockDetector]
205
+ def markdown
206
+ new("markdown", aliases: ["md"])
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "freezable"
4
+
5
+ module Ast
6
+ module Merge
7
+ # Mixin module for file analysis classes across all *-merge gems.
8
+ #
9
+ # This module provides common functionality for analyzing source files,
10
+ # including freeze block detection, line access, and signature generation.
11
+ # Include this module in your FileAnalysis class and implement the required
12
+ # abstract methods.
13
+ #
14
+ # @example Including in a FileAnalysis class
15
+ # class FileAnalysis
16
+ # include Ast::Merge::FileAnalyzable
17
+ #
18
+ # def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil)
19
+ # @source = source
20
+ # @lines = source.split("\n", -1)
21
+ # @freeze_token = freeze_token
22
+ # @signature_generator = signature_generator
23
+ # @statements = parse_and_extract_statements
24
+ # end
25
+ #
26
+ # # Required: implement this method for parser-specific signature logic
27
+ # def compute_node_signature(node)
28
+ # # Return signature array or nil
29
+ # end
30
+ #
31
+ # # Required: implement if using generate_signature with custom node type detection
32
+ # def fallthrough_node?(node)
33
+ # node.is_a?(MyParser::Node) || node.is_a?(FreezeNodeBase)
34
+ # end
35
+ # end
36
+ #
37
+ # @abstract Include this module and implement {#compute_node_signature} and optionally {#fallthrough_node?}
38
+ module FileAnalyzable
39
+ # Common attributes shared by all FileAnalysis classes.
40
+ # These attr_reader declarations provide consistent interface across all merge gems.
41
+ # Including classes should set these instance variables in their initialize method.
42
+ #
43
+ # @!attribute [r] source
44
+ # @return [String] Original source content
45
+ # @!attribute [r] lines
46
+ # @return [Array<String>] Lines of source code (may be specialized in subclasses)
47
+ # @!attribute [r] freeze_token
48
+ # @return [String] Token used to mark freeze blocks (e.g., "prism-merge", "psych-merge")
49
+ # @!attribute [r] signature_generator
50
+ # @return [Proc, nil] Custom signature generator, or nil to use default
51
+ def self.included(base)
52
+ base.class_eval do
53
+ attr_reader(:source, :lines, :freeze_token, :signature_generator)
54
+ end
55
+ end
56
+
57
+ # Get all top-level statements (nodes and freeze blocks).
58
+ # Override this method in including classes to return the appropriate collection.
59
+ # The default implementation returns @statements if set, otherwise an empty array.
60
+ #
61
+ # @return [Array] All top-level statements
62
+ def statements
63
+ @statements ||= []
64
+ end
65
+
66
+ # Get all freeze blocks/nodes from statements.
67
+ # Includes both traditional FreezeNodeBase blocks and Freezable-wrapped nodes.
68
+ #
69
+ # @return [Array<Freezable>] All freeze nodes
70
+ def freeze_blocks
71
+ statements.select { |node| node.is_a?(Freezable) }
72
+ end
73
+
74
+ # Check if a line is within a freeze block.
75
+ #
76
+ # @param line_num [Integer] 1-based line number
77
+ # @return [Boolean] true if line is inside a freeze block
78
+ def in_freeze_block?(line_num)
79
+ freeze_blocks.any? { |fb| fb.location.cover?(line_num) }
80
+ end
81
+
82
+ # Get the freeze block containing the given line, if any.
83
+ #
84
+ # @param line_num [Integer] 1-based line number
85
+ # @return [FreezeNodeBase, nil] Freeze block node or nil
86
+ def freeze_block_at(line_num)
87
+ freeze_blocks.find { |fb| fb.location.cover?(line_num) }
88
+ end
89
+
90
+ # Get structural signature for a statement at given index.
91
+ #
92
+ # @param index [Integer] Statement index (0-based)
93
+ # @return [Array, nil] Signature array or nil if index out of bounds
94
+ def signature_at(index)
95
+ return if index < 0 || index >= statements.length
96
+
97
+ generate_signature(statements[index])
98
+ end
99
+
100
+ # Get a specific line (1-indexed).
101
+ #
102
+ # @param line_num [Integer] Line number (1-indexed)
103
+ # @return [String, nil] The line content or nil if out of bounds
104
+ def line_at(line_num)
105
+ return if line_num < 1
106
+
107
+ lines[line_num - 1]
108
+ end
109
+
110
+ # Get a normalized line (whitespace-trimmed, for comparison).
111
+ #
112
+ # @param line_num [Integer] Line number (1-indexed)
113
+ # @return [String, nil] Normalized line content or nil if out of bounds
114
+ def normalized_line(line_num)
115
+ line = line_at(line_num)
116
+ line&.strip
117
+ end
118
+
119
+ # Generate signature for a node.
120
+ #
121
+ # Signatures are used to match nodes between template and destination files.
122
+ # Two nodes with the same signature are considered "the same" for merge purposes,
123
+ # allowing the merger to decide which version to keep based on preference settings.
124
+ #
125
+ # ## Signature Generation Flow
126
+ #
127
+ # 1. **FreezeNodeBase** (explicit freeze blocks like `# token:freeze ... # token:unfreeze`):
128
+ # Uses content-based signature via `freeze_signature`. This ensures explicit freeze
129
+ # blocks match between files based on their actual content.
130
+ #
131
+ # 2. **FrozenWrapper** (AST nodes with freeze markers in leading comments):
132
+ # The wrapper is **unwrapped first** to get the underlying AST node. The signature
133
+ # is then generated from the underlying node, NOT the wrapper. This is critical
134
+ # because the freeze marker only affects merge *preference* (destination wins),
135
+ # not *matching*. Two nodes should match by their structural identity even if
136
+ # their content differs slightly.
137
+ #
138
+ # 3. **Custom signature_generator**: If provided, receives the unwrapped node and can:
139
+ # - Return an Array signature (e.g., `[:gem, "foo"]`) - used directly
140
+ # - Return `nil` - node gets no signature, won't be matched
141
+ # - Return the node (fallthrough) - default signature computation is used
142
+ #
143
+ # 4. **Default computation**: Falls through to `compute_node_signature` for
144
+ # parser-specific default signature generation.
145
+ #
146
+ # ## Why FrozenWrapper Must Be Unwrapped
147
+ #
148
+ # Consider a gemspec with a frozen `gem_version` variable:
149
+ #
150
+ # Template: Destination:
151
+ # # kettle-dev:freeze # kettle-dev:freeze
152
+ # # Comment # Comment
153
+ # # kettle-dev:unfreeze # More comments
154
+ # gem_version = "1.0" # kettle-dev:unfreeze
155
+ # gem_version = "1.0"
156
+ #
157
+ # Both have a `gem_version` assignment with a freeze marker in leading comments.
158
+ # The assignments are wrapped in FrozenWrapper, but their CONTENT differs
159
+ # (template has fewer comments in the freeze block).
160
+ #
161
+ # If we generated signatures from the wrapper (which delegates `slice` to the
162
+ # full node content), they would NOT match and both would be output - duplicating
163
+ # the freeze block!
164
+ #
165
+ # By unwrapping first, we generate signatures from the underlying
166
+ # `LocalVariableWriteNode`, which matches by variable name (`gem_version`),
167
+ # ensuring only ONE version is output (the destination version, since it's frozen).
168
+ #
169
+ # @param node [Object] Node to generate signature for (may be wrapped)
170
+ # @return [Array, nil] Signature array or nil
171
+ #
172
+ # @example Custom generator with fallthrough
173
+ # signature_generator = ->(node) {
174
+ # case node
175
+ # when MyParser::SpecialNode
176
+ # [:special, node.name]
177
+ # else
178
+ # node # Return original node for default signature computation
179
+ # end
180
+ # }
181
+ #
182
+ # @see FreezeNodeBase#freeze_signature
183
+ # @see NodeTyping::FrozenWrapper
184
+ # @see Freezable
185
+ def generate_signature(node)
186
+ # ==========================================================================
187
+ # CASE 1: FreezeNodeBase (explicit freeze blocks)
188
+ # ==========================================================================
189
+ # FreezeNodeBase represents an explicit freeze block delimited by markers:
190
+ # # token:freeze
191
+ # ... content ...
192
+ # # token:unfreeze
193
+ #
194
+ # These are standalone structural elements (not attached to AST nodes).
195
+ # They use content-based signatures so identical freeze blocks match.
196
+ # This is different from FrozenWrapper which wraps AST nodes.
197
+ if node.is_a?(FreezeNodeBase)
198
+ return node.freeze_signature
199
+ end
200
+
201
+ # ==========================================================================
202
+ # CASE 2: Unwrap FrozenWrapper (and other wrappers)
203
+ # ==========================================================================
204
+ # FrozenWrapper wraps AST nodes that have freeze markers in their leading
205
+ # comments. The wrapper marks the node as "frozen" (prefer destination),
206
+ # but for MATCHING purposes, we need the underlying node's identity.
207
+ #
208
+ # Example: A `gem_version = ...` assignment wrapped in FrozenWrapper should
209
+ # match another `gem_version = ...` assignment by variable name, not by
210
+ # the full content of the assignment (which may differ).
211
+ #
212
+ # CRITICAL: We must unwrap BEFORE calling the signature_generator so it
213
+ # receives the actual AST node type (e.g., Prism::LocalVariableWriteNode)
214
+ # rather than the wrapper (FrozenWrapper). Otherwise, type-based signature
215
+ # generators (like kettle-jem's gemspec generator) won't recognize the node
216
+ # and will fall through to default handling incorrectly.
217
+ actual_node = node.respond_to?(:unwrap) ? node.unwrap : node
218
+
219
+ result = if signature_generator
220
+ # ==========================================================================
221
+ # CASE 3: Custom signature generator
222
+ # ==========================================================================
223
+ # Pass the UNWRAPPED node to the custom generator. This ensures:
224
+ # - Type checks work (e.g., `node.is_a?(Prism::CallNode)`)
225
+ # - The generator sees the real AST structure
226
+ # - Frozen nodes match by their underlying identity
227
+ custom_result = signature_generator.call(actual_node)
228
+ case custom_result
229
+ when Array, nil
230
+ # Generator returned a final signature or nil - use as-is
231
+ custom_result
232
+ else
233
+ # Generator returned a node (fallthrough) - compute default signature
234
+ if fallthrough_node?(custom_result)
235
+ # Special case: if fallthrough result is Freezable, use freeze_signature
236
+ # This handles cases where the generator wraps a node in Freezable
237
+ if custom_result.is_a?(Freezable)
238
+ custom_result.freeze_signature
239
+ else
240
+ # Unwrap any wrapper and compute default signature
241
+ unwrapped = custom_result.respond_to?(:unwrap) ? custom_result.unwrap : custom_result
242
+ compute_node_signature(unwrapped)
243
+ end
244
+ else
245
+ # Non-node return value - pass through (allows arbitrary signature types)
246
+ custom_result
247
+ end
248
+ end
249
+ else
250
+ # ==========================================================================
251
+ # CASE 4: No custom generator - use default computation
252
+ # ==========================================================================
253
+ # Pass the UNWRAPPED node to compute_node_signature. This is critical
254
+ # because compute_node_signature uses type checking (e.g., case statements
255
+ # matching Prism::DefNode, Prism::CallNode, etc.). If we pass a
256
+ # FrozenWrapper, it won't match any of those types and will fall through
257
+ # to a generic handler, producing incorrect signatures.
258
+ #
259
+ # For FrozenWrapper nodes, the underlying AST node determines the signature
260
+ # (e.g., method name for DefNode, gem name for CallNode). The wrapper only
261
+ # affects merge preference (destination wins), not matching.
262
+ compute_node_signature(actual_node)
263
+ end
264
+
265
+ DebugLogger.debug("Generated signature", {
266
+ node_type: node.class.name.split("::").last,
267
+ signature: result,
268
+ generator: signature_generator ? "custom" : "default",
269
+ }) if result
270
+
271
+ result
272
+ end
273
+
274
+ # Check if a value represents a fallthrough node that should be used for
275
+ # default signature computation.
276
+ #
277
+ # When a signature_generator returns a non-Array/nil value, we check if it's
278
+ # a "fallthrough" node that should be passed to compute_node_signature.
279
+ # This includes:
280
+ # - AstNode instances (custom AST nodes like Comment::Line)
281
+ # - Freezable nodes (frozen wrappers)
282
+ # - FreezeNodeBase instances
283
+ # - NodeTyping::Wrapper instances (unwrapped to get the underlying node)
284
+ #
285
+ # Override this method to add custom node type detection for your parser.
286
+ #
287
+ # @param value [Object] The value to check
288
+ # @return [Boolean] true if this is a fallthrough node
289
+ def fallthrough_node?(value)
290
+ value.is_a?(AstNode) ||
291
+ value.is_a?(Freezable) ||
292
+ value.is_a?(FreezeNodeBase) ||
293
+ value.is_a?(NodeTyping::Wrapper)
294
+ end
295
+
296
+ # Compute default signature for a node.
297
+ # This method must be implemented by including classes.
298
+ #
299
+ # @param node [Object] The node to compute signature for
300
+ # @return [Array, nil] Signature array or nil
301
+ # @abstract
302
+ def compute_node_signature(node)
303
+ raise NotImplementedError, "#{self.class} must implement #compute_node_signature"
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Mixin module that provides freeze node behavior.
6
+ #
7
+ # This module can be included in any class to make it behave as a frozen node
8
+ # for merge operations. It provides the core API that identifies a node as frozen
9
+ # and allows it to participate in freeze-aware merging.
10
+ #
11
+ # The primary use cases are:
12
+ # 1. Included by FreezeNodeBase for traditional freeze block nodes
13
+ # 2. Included by NodeTyping::FrozenWrapper for wrapping AST nodes with freeze markers
14
+ #
15
+ # ## Critical: Understanding freeze_signature vs structural matching
16
+ #
17
+ # The `freeze_signature` method returns a content-based signature that includes
18
+ # the full text of the frozen content. This is appropriate for:
19
+ # - **FreezeNodeBase**: Explicit freeze blocks where the entire content is opaque
20
+ # and should be matched by content identity
21
+ #
22
+ # However, for **FrozenWrapper**, using freeze_signature would cause matching
23
+ # problems because the wrapper contains an AST node (like a `gem` call) where
24
+ # we want to match by the node's structural identity, not its full content.
25
+ #
26
+ # For example, in a gemspec:
27
+ # - Template: `# token:freeze\ngem "example", "~> 1.0"`
28
+ # - Destination: `# token:freeze\ngem "example", "~> 2.0"` (different version)
29
+ #
30
+ # If we used freeze_signature for both, they would NOT match (different content),
31
+ # causing duplication. Instead, FileAnalyzable#generate_signature unwraps
32
+ # FrozenWrapper nodes and uses the underlying node's signature (gem name),
33
+ # so they DO match and merge correctly.
34
+ #
35
+ # @example Checking if something is freezable
36
+ # if node.is_a?(Ast::Merge::Freezable)
37
+ # # Node will be preserved during merge
38
+ # end
39
+ #
40
+ # @example Including in a custom class
41
+ # class MyFrozenNode
42
+ # include Ast::Merge::Freezable
43
+ #
44
+ # def slice
45
+ # @content
46
+ # end
47
+ # end
48
+ #
49
+ # @see FreezeNodeBase - Uses freeze_signature for content-based matching
50
+ # @see NodeTyping::FrozenWrapper - Unwrapped for structural matching
51
+ # @see FileAnalyzable#generate_signature - Implements the matching logic
52
+ module Freezable
53
+ # Check if this is a freeze node.
54
+ # Always returns true for classes that include this module.
55
+ #
56
+ # @return [Boolean] true
57
+ def freeze_node?
58
+ true
59
+ end
60
+
61
+ # Returns a stable signature for this freeze node.
62
+ # The signature uses the content to allow matching freeze blocks
63
+ # between template and destination.
64
+ #
65
+ # Subclasses can override this for custom signature behavior.
66
+ #
67
+ # @return [Array] Signature array in the form [:FreezeNode, content]
68
+ def freeze_signature
69
+ [:FreezeNode, slice&.strip]
70
+ end
71
+
72
+ # Returns the content of this freeze node.
73
+ # Must be implemented by including classes.
74
+ #
75
+ # @return [String] The frozen content
76
+ # @abstract
77
+ def slice
78
+ raise NotImplementedError, "#{self.class} must implement #slice"
79
+ end
80
+ end
81
+ end
82
+ end