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,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
|