ast-merge 3.0.0 → 4.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +95 -1
- data/README.md +258 -186
- data/exe/ast-merge-recipe +20 -0
- data/lib/ast/merge/conflict_resolver_base.rb +47 -1
- data/lib/ast/merge/diff_mapper_base.rb +245 -0
- data/lib/ast/merge/emitter_base.rb +123 -0
- data/lib/ast/merge/freeze_node_base.rb +9 -0
- data/lib/ast/merge/navigable/injection_point.rb +132 -0
- data/lib/ast/merge/navigable/injection_point_finder.rb +98 -0
- data/lib/ast/merge/navigable/statement.rb +380 -0
- data/lib/ast/merge/navigable.rb +20 -0
- data/lib/ast/merge/node_typing.rb +21 -0
- data/lib/ast/merge/partial_template_merger_base.rb +4 -2
- data/lib/ast/merge/recipe/preset.rb +18 -0
- data/lib/ast/merge/recipe/runner.rb +8 -1
- data/lib/ast/merge/version.rb +1 -1
- data/lib/ast/merge.rb +3 -3
- data.tar.gz.sig +0 -0
- metadata +34 -9
- metadata.gz.sig +0 -0
- data/lib/ast/merge/navigable_statement.rb +0 -625
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Navigable
|
|
6
|
+
# Finds injection points in a document based on matching rules.
|
|
7
|
+
#
|
|
8
|
+
# This is language-agnostic - the matching rules work on the unified
|
|
9
|
+
# Statement interface regardless of the underlying parser.
|
|
10
|
+
#
|
|
11
|
+
# @example Find where to inject constants in a Ruby class
|
|
12
|
+
# finder = InjectionPointFinder.new(statements)
|
|
13
|
+
# point = finder.find(
|
|
14
|
+
# type: :class,
|
|
15
|
+
# text: /class Choo/,
|
|
16
|
+
# position: :first_child
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example Find and replace a constant definition
|
|
20
|
+
# point = finder.find(
|
|
21
|
+
# type: :constant_assignment,
|
|
22
|
+
# text: /DAR\s*=/,
|
|
23
|
+
# position: :replace
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
class InjectionPointFinder
|
|
27
|
+
# @return [Array<Statement>] The statement list to search
|
|
28
|
+
attr_reader :statements
|
|
29
|
+
|
|
30
|
+
def initialize(statements)
|
|
31
|
+
@statements = statements
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Find an injection point based on matching criteria.
|
|
35
|
+
#
|
|
36
|
+
# @param type [Symbol, String, nil] Node type to match
|
|
37
|
+
# @param text [String, Regexp, nil] Text pattern to match
|
|
38
|
+
# @param position [Symbol] Where to inject (:before, :after, :first_child, :last_child, :replace)
|
|
39
|
+
# @param boundary_type [Symbol, String, nil] Node type for replacement boundary
|
|
40
|
+
# @param boundary_text [String, Regexp, nil] Text pattern for replacement boundary
|
|
41
|
+
# @param boundary_matcher [Proc, nil] Custom matcher for boundary (receives Statement, returns boolean)
|
|
42
|
+
# @param boundary_same_or_shallower [Boolean] If true, boundary is next node at same or shallower tree depth
|
|
43
|
+
# @yield [Statement] Optional custom matcher
|
|
44
|
+
# @return [InjectionPoint, nil] Injection point if anchor found
|
|
45
|
+
def find(type: nil, text: nil, position:, boundary_type: nil, boundary_text: nil, boundary_matcher: nil, boundary_same_or_shallower: false, &block)
|
|
46
|
+
anchor = Statement.find_first(statements, type: type, text: text, &block)
|
|
47
|
+
return unless anchor
|
|
48
|
+
|
|
49
|
+
boundary = nil
|
|
50
|
+
if position == :replace && (boundary_type || boundary_text || boundary_matcher || boundary_same_or_shallower)
|
|
51
|
+
# Find boundary starting after anchor
|
|
52
|
+
remaining = statements[(anchor.index + 1)..]
|
|
53
|
+
|
|
54
|
+
if boundary_same_or_shallower
|
|
55
|
+
# Find next node at same or shallower tree depth
|
|
56
|
+
# This is language-agnostic: ends section at next sibling or ancestor's sibling
|
|
57
|
+
anchor_depth = anchor.tree_depth
|
|
58
|
+
boundary = remaining.find do |stmt|
|
|
59
|
+
# Must match type if specified
|
|
60
|
+
next false if boundary_type && stmt.type.to_s != boundary_type.to_s
|
|
61
|
+
next false if boundary_text && !stmt.text_matches?(boundary_text)
|
|
62
|
+
# Check tree depth
|
|
63
|
+
stmt.same_or_shallower_than?(anchor_depth)
|
|
64
|
+
end
|
|
65
|
+
elsif boundary_matcher
|
|
66
|
+
# Use custom matcher
|
|
67
|
+
boundary = remaining.find { |stmt| boundary_matcher.call(stmt) }
|
|
68
|
+
else
|
|
69
|
+
boundary = Statement.find_first(
|
|
70
|
+
remaining,
|
|
71
|
+
type: boundary_type,
|
|
72
|
+
text: boundary_text,
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
InjectionPoint.new(
|
|
78
|
+
anchor: anchor,
|
|
79
|
+
position: position,
|
|
80
|
+
boundary: boundary,
|
|
81
|
+
match: {type: type, text: text},
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Find all injection points matching criteria.
|
|
86
|
+
#
|
|
87
|
+
# @param (see #find)
|
|
88
|
+
# @return [Array<InjectionPoint>] All matching injection points
|
|
89
|
+
def find_all(type: nil, text: nil, position:, &block)
|
|
90
|
+
anchors = Statement.find_matching(statements, type: type, text: text, &block)
|
|
91
|
+
anchors.map do |anchor|
|
|
92
|
+
InjectionPoint.new(anchor: anchor, position: position)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Navigable
|
|
6
|
+
# Wraps any node (parser-backed or synthetic) with uniform navigation.
|
|
7
|
+
#
|
|
8
|
+
# Provides two levels of navigation:
|
|
9
|
+
# 1. **Flat list navigation**: prev_statement, next_statement, index
|
|
10
|
+
# - Works for ALL nodes (synthetic and parser-backed)
|
|
11
|
+
# - Represents position in the flattened statement list
|
|
12
|
+
#
|
|
13
|
+
# 2. **Tree navigation**: tree_parent, tree_next, tree_previous, tree_children
|
|
14
|
+
# - Only available for parser-backed nodes
|
|
15
|
+
# - Delegates to inner_node's tree methods
|
|
16
|
+
#
|
|
17
|
+
# This allows code to work with the flat list for simple merging,
|
|
18
|
+
# while still accessing tree structure for section-aware operations.
|
|
19
|
+
#
|
|
20
|
+
# @example Basic usage
|
|
21
|
+
# statements = Statement.build_list(raw_statements)
|
|
22
|
+
# stmt = statements[0]
|
|
23
|
+
#
|
|
24
|
+
# # Flat navigation (always works)
|
|
25
|
+
# stmt.next # => next statement in flat list
|
|
26
|
+
# stmt.previous # => previous statement in flat list
|
|
27
|
+
# stmt.index # => position in array
|
|
28
|
+
#
|
|
29
|
+
# # Tree navigation (when available)
|
|
30
|
+
# stmt.tree_parent # => parent in original AST (or nil)
|
|
31
|
+
# stmt.tree_next # => next sibling in original AST (or nil)
|
|
32
|
+
# stmt.tree_children # => children in original AST (or [])
|
|
33
|
+
#
|
|
34
|
+
# @example Section grouping
|
|
35
|
+
# # Group statements into sections by heading level
|
|
36
|
+
# sections = Statement.group_by_heading(statements, level: 3)
|
|
37
|
+
# sections.each do |section|
|
|
38
|
+
# puts "Section: #{section.heading.text}"
|
|
39
|
+
# section.statements.each { |s| puts " - #{s.type}" }
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
class Statement
|
|
43
|
+
# @return [Object] The wrapped node (parser-backed or synthetic)
|
|
44
|
+
attr_reader :node
|
|
45
|
+
|
|
46
|
+
# @return [Integer] Index in the flattened statement list
|
|
47
|
+
attr_reader :index
|
|
48
|
+
|
|
49
|
+
# @return [Statement, nil] Previous statement in flat list
|
|
50
|
+
attr_accessor :prev_statement
|
|
51
|
+
|
|
52
|
+
# @return [Statement, nil] Next statement in flat list
|
|
53
|
+
attr_accessor :next_statement
|
|
54
|
+
|
|
55
|
+
# @return [Object, nil] Optional context/metadata for this statement
|
|
56
|
+
attr_accessor :context
|
|
57
|
+
|
|
58
|
+
# Initialize a Statement wrapper.
|
|
59
|
+
#
|
|
60
|
+
# @param node [Object] The node to wrap
|
|
61
|
+
# @param index [Integer] Position in the statement list
|
|
62
|
+
def initialize(node, index:)
|
|
63
|
+
@node = node
|
|
64
|
+
@index = index
|
|
65
|
+
@prev_statement = nil
|
|
66
|
+
@next_statement = nil
|
|
67
|
+
@context = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Build a linked list of Statements from raw statements.
|
|
72
|
+
#
|
|
73
|
+
# @param raw_statements [Array<Object>] Raw statement nodes
|
|
74
|
+
# @return [Array<Statement>] Linked statement list
|
|
75
|
+
def build_list(raw_statements)
|
|
76
|
+
statements = raw_statements.each_with_index.map do |node, i|
|
|
77
|
+
new(node, index: i)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Link siblings in flat list
|
|
81
|
+
statements.each_cons(2) do |prev_stmt, next_stmt|
|
|
82
|
+
prev_stmt.next_statement = next_stmt
|
|
83
|
+
next_stmt.prev_statement = prev_stmt
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
statements
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Find statements matching a query.
|
|
90
|
+
#
|
|
91
|
+
# @param statements [Array<Statement>] Statement list
|
|
92
|
+
# @param type [Symbol, String, nil] Node type to match (nil = any)
|
|
93
|
+
# @param text [String, Regexp, nil] Text pattern to match
|
|
94
|
+
# @yield [Statement] Optional block for custom matching
|
|
95
|
+
# @return [Array<Statement>] Matching statements
|
|
96
|
+
def find_matching(statements, type: nil, text: nil, &block)
|
|
97
|
+
# If no criteria specified, return empty array (nothing to match)
|
|
98
|
+
return [] if type.nil? && text.nil? && !block_given?
|
|
99
|
+
|
|
100
|
+
statements.select do |stmt|
|
|
101
|
+
matches = true
|
|
102
|
+
matches &&= stmt.type.to_s == type.to_s if type
|
|
103
|
+
matches &&= text.is_a?(Regexp) ? stmt.text.match?(text) : stmt.text.include?(text.to_s) if text
|
|
104
|
+
matches &&= yield(stmt) if block_given?
|
|
105
|
+
matches
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Find the first statement matching criteria.
|
|
110
|
+
#
|
|
111
|
+
# @param statements [Array<Statement>] Statement list
|
|
112
|
+
# @param type [Symbol, String, nil] Node type to match
|
|
113
|
+
# @param text [String, Regexp, nil] Text pattern to match
|
|
114
|
+
# @yield [Statement] Optional block for custom matching
|
|
115
|
+
# @return [Statement, nil] First matching statement
|
|
116
|
+
def find_first(statements, type: nil, text: nil, &block)
|
|
117
|
+
find_matching(statements, type: type, text: text, &block).first
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ============================================================
|
|
122
|
+
# Flat list navigation (always available)
|
|
123
|
+
# ============================================================
|
|
124
|
+
|
|
125
|
+
# @return [Statement, nil] Next statement in flat list
|
|
126
|
+
def next
|
|
127
|
+
next_statement
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Statement, nil] Previous statement in flat list
|
|
131
|
+
def previous
|
|
132
|
+
prev_statement
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Boolean] true if this is the first statement
|
|
136
|
+
def first?
|
|
137
|
+
prev_statement.nil?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] true if this is the last statement
|
|
141
|
+
def last?
|
|
142
|
+
next_statement.nil?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Iterate from this statement to the end (or until block returns false).
|
|
146
|
+
#
|
|
147
|
+
# @yield [Statement] Each statement
|
|
148
|
+
# @return [Enumerator, nil]
|
|
149
|
+
def each_following(&block)
|
|
150
|
+
return to_enum(:each_following) unless block_given?
|
|
151
|
+
|
|
152
|
+
current = self.next
|
|
153
|
+
while current
|
|
154
|
+
break unless yield(current)
|
|
155
|
+
current = current.next
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Collect statements until a condition is met.
|
|
160
|
+
#
|
|
161
|
+
# @yield [Statement] Each statement
|
|
162
|
+
# @return [Array<Statement>] Statements until condition
|
|
163
|
+
def take_until(&block)
|
|
164
|
+
result = []
|
|
165
|
+
each_following do |stmt|
|
|
166
|
+
break if yield(stmt)
|
|
167
|
+
result << stmt
|
|
168
|
+
true
|
|
169
|
+
end
|
|
170
|
+
result
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ============================================================
|
|
174
|
+
# Tree navigation (delegates to inner_node when available)
|
|
175
|
+
# ============================================================
|
|
176
|
+
|
|
177
|
+
# @return [Object, nil] Parent node in original AST
|
|
178
|
+
def tree_parent
|
|
179
|
+
inner = unwrapped_node
|
|
180
|
+
inner.parent if inner.respond_to?(:parent)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @return [Object, nil] Next sibling in original AST
|
|
184
|
+
def tree_next
|
|
185
|
+
inner = unwrapped_node
|
|
186
|
+
inner.next if inner.respond_to?(:next)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [Object, nil] Previous sibling in original AST
|
|
190
|
+
def tree_previous
|
|
191
|
+
inner = unwrapped_node
|
|
192
|
+
inner.previous if inner.respond_to?(:previous)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @return [Array<Object>] Children in original AST
|
|
196
|
+
def tree_children
|
|
197
|
+
inner = unwrapped_node
|
|
198
|
+
if inner.respond_to?(:each)
|
|
199
|
+
inner.to_a
|
|
200
|
+
elsif inner.respond_to?(:children)
|
|
201
|
+
inner.children
|
|
202
|
+
else
|
|
203
|
+
[]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @return [Object, nil] First child in original AST
|
|
208
|
+
def tree_first_child
|
|
209
|
+
inner = unwrapped_node
|
|
210
|
+
inner.first_child if inner.respond_to?(:first_child)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @return [Object, nil] Last child in original AST
|
|
214
|
+
def tree_last_child
|
|
215
|
+
inner = unwrapped_node
|
|
216
|
+
inner.last_child if inner.respond_to?(:last_child)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @return [Boolean] true if tree navigation is available
|
|
220
|
+
def has_tree_navigation?
|
|
221
|
+
inner = unwrapped_node
|
|
222
|
+
inner.respond_to?(:parent) || inner.respond_to?(:next)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @return [Boolean] true if this is a synthetic node (no tree navigation)
|
|
226
|
+
def synthetic?
|
|
227
|
+
!has_tree_navigation?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Calculate the tree depth (distance from root).
|
|
231
|
+
#
|
|
232
|
+
# @return [Integer] Depth in tree (0 = root level)
|
|
233
|
+
def tree_depth
|
|
234
|
+
depth = 0
|
|
235
|
+
current = tree_parent
|
|
236
|
+
while current
|
|
237
|
+
depth += 1
|
|
238
|
+
# Navigate up through parents
|
|
239
|
+
if current.respond_to?(:parent)
|
|
240
|
+
current = current.parent
|
|
241
|
+
else
|
|
242
|
+
break
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
depth
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Check if this node is at same or shallower depth than another.
|
|
249
|
+
# Useful for determining section boundaries.
|
|
250
|
+
#
|
|
251
|
+
# @param other [Statement, Integer] Other statement or depth value
|
|
252
|
+
# @return [Boolean] true if this node is at same or shallower depth
|
|
253
|
+
def same_or_shallower_than?(other)
|
|
254
|
+
other_depth = other.is_a?(Integer) ? other : other.tree_depth
|
|
255
|
+
tree_depth <= other_depth
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ============================================================
|
|
259
|
+
# Node delegation
|
|
260
|
+
# ============================================================
|
|
261
|
+
|
|
262
|
+
# @return [Symbol, String] Node type
|
|
263
|
+
def type
|
|
264
|
+
return node.type if node.respond_to?(:type)
|
|
265
|
+
|
|
266
|
+
# Fallback: derive type from class name (handle anonymous classes)
|
|
267
|
+
class_name = node.class.name
|
|
268
|
+
class_name ? class_name.split("::").last : "Anonymous"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# @return [Array, Object, nil] Node signature for matching
|
|
272
|
+
def signature
|
|
273
|
+
node.signature if node.respond_to?(:signature)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @return [String] Node text content
|
|
277
|
+
def text
|
|
278
|
+
# TreeHaver nodes (and any node conforming to the unified API) provide #text.
|
|
279
|
+
# No conditional fallbacks - nodes must conform to the API.
|
|
280
|
+
node.text.to_s
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @return [Hash, nil] Source position info
|
|
284
|
+
def source_position
|
|
285
|
+
node.source_position if node.respond_to?(:source_position)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# @return [Integer, nil] Start line number
|
|
289
|
+
def start_line
|
|
290
|
+
pos = source_position
|
|
291
|
+
pos[:start_line] if pos
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# @return [Integer, nil] End line number
|
|
295
|
+
def end_line
|
|
296
|
+
pos = source_position
|
|
297
|
+
pos[:end_line] if pos
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# ============================================================
|
|
301
|
+
# Node attribute helpers (language-agnostic)
|
|
302
|
+
# ============================================================
|
|
303
|
+
|
|
304
|
+
# Check if this node matches a type.
|
|
305
|
+
#
|
|
306
|
+
# @param expected_type [Symbol, String] Type to check
|
|
307
|
+
# @return [Boolean]
|
|
308
|
+
def type?(expected_type)
|
|
309
|
+
type.to_s == expected_type.to_s
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Check if this node's text matches a pattern.
|
|
313
|
+
#
|
|
314
|
+
# @param pattern [String, Regexp] Pattern to match
|
|
315
|
+
# @return [Boolean]
|
|
316
|
+
def text_matches?(pattern)
|
|
317
|
+
case pattern
|
|
318
|
+
when Regexp
|
|
319
|
+
text.match?(pattern)
|
|
320
|
+
else
|
|
321
|
+
text.include?(pattern.to_s)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Get an attribute from the underlying node.
|
|
326
|
+
#
|
|
327
|
+
# Tries multiple method names to support different parser APIs.
|
|
328
|
+
#
|
|
329
|
+
# @param name [Symbol, String] Attribute name
|
|
330
|
+
# @param aliases [Array<Symbol>] Alternative method names
|
|
331
|
+
# @return [Object, nil] Attribute value
|
|
332
|
+
def node_attribute(name, *aliases)
|
|
333
|
+
inner = unwrapped_node
|
|
334
|
+
[name, *aliases].each do |method_name|
|
|
335
|
+
return inner.send(method_name) if inner.respond_to?(method_name)
|
|
336
|
+
end
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ============================================================
|
|
341
|
+
# Utilities
|
|
342
|
+
# ============================================================
|
|
343
|
+
|
|
344
|
+
# Get the unwrapped inner node.
|
|
345
|
+
#
|
|
346
|
+
# @return [Object] The innermost node
|
|
347
|
+
def unwrapped_node
|
|
348
|
+
current = node
|
|
349
|
+
while current.respond_to?(:inner_node) && current.inner_node != current
|
|
350
|
+
current = current.inner_node
|
|
351
|
+
end
|
|
352
|
+
current
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# @return [String] Human-readable representation
|
|
356
|
+
def inspect
|
|
357
|
+
"#<Navigable::Statement[#{index}] type=#{type} tree=#{has_tree_navigation?}>"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# @return [String] String representation
|
|
361
|
+
def to_s
|
|
362
|
+
text.to_s.strip[0, 50]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Delegate unknown methods to the wrapped node.
|
|
366
|
+
def method_missing(method, *args, &block)
|
|
367
|
+
if node.respond_to?(method)
|
|
368
|
+
node.send(method, *args, &block)
|
|
369
|
+
else
|
|
370
|
+
super
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def respond_to_missing?(method, include_private = false)
|
|
375
|
+
node.respond_to?(method, include_private) || super
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Namespace for navigation-related classes.
|
|
6
|
+
#
|
|
7
|
+
# Provides unified navigation over AST nodes regardless of the underlying parser.
|
|
8
|
+
# Classes in this namespace work together to enable finding and manipulating
|
|
9
|
+
# positions in document structures.
|
|
10
|
+
#
|
|
11
|
+
# @see Navigable::Statement Wraps nodes with navigation capabilities
|
|
12
|
+
# @see Navigable::InjectionPoint Represents a location for content injection
|
|
13
|
+
# @see Navigable::InjectionPointFinder Finds injection points by matching rules
|
|
14
|
+
module Navigable
|
|
15
|
+
autoload :Statement, "ast/merge/navigable/statement"
|
|
16
|
+
autoload :InjectionPoint, "ast/merge/navigable/injection_point"
|
|
17
|
+
autoload :InjectionPointFinder, "ast/merge/navigable/injection_point_finder"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -13,6 +13,27 @@ module Ast
|
|
|
13
13
|
# The `merge_type` attribute can then be used by other merge tools like
|
|
14
14
|
# `signature_generator`, `match_refiner`, and per-node-type `preference` settings.
|
|
15
15
|
#
|
|
16
|
+
# ## Important: Two Uses of merge_type
|
|
17
|
+
#
|
|
18
|
+
# The `merge_type` method serves two complementary purposes in the codebase:
|
|
19
|
+
#
|
|
20
|
+
# ### 1. NodeTyping-specific (gated by typed_node?)
|
|
21
|
+
# Wrapped nodes (Wrapper/FrozenWrapper) with custom type tagging for:
|
|
22
|
+
# - Per-node-type preferences (e.g., `:lint_gem` → `:template`)
|
|
23
|
+
# - Match refinement based on custom categories
|
|
24
|
+
# - Only applies when `typed_node?` returns true
|
|
25
|
+
# - Accessed via `NodeTyping.merge_type_for(node)`
|
|
26
|
+
#
|
|
27
|
+
# ### 2. General node classification (any node)
|
|
28
|
+
# Any node can implement `merge_type` for category identification:
|
|
29
|
+
# - FreezeNodeBase has `merge_type` → `:freeze_block`
|
|
30
|
+
# - GapLineNode has `merge_type` → `:gap_line`
|
|
31
|
+
# - Used by systems like MarkdownStructure for structural spacing rules
|
|
32
|
+
# - These nodes are NOT "typed nodes" (typed_node? returns false)
|
|
33
|
+
#
|
|
34
|
+
# The key distinction: **typed_node? is the gate** for NodeTyping wrapper
|
|
35
|
+
# semantics. A node can have `merge_type` without being a NodeTyping wrapper.
|
|
36
|
+
#
|
|
16
37
|
# @example Basic node typing for different gem types
|
|
17
38
|
# node_typing = {
|
|
18
39
|
# CallNode: ->(node) {
|
|
@@ -129,15 +129,16 @@ module Ast
|
|
|
129
129
|
def merge
|
|
130
130
|
# Parse destination and find injection point
|
|
131
131
|
d_analysis = create_analysis(destination)
|
|
132
|
-
d_statements =
|
|
132
|
+
d_statements = Navigable::Statement.build_list(d_analysis.statements)
|
|
133
133
|
|
|
134
|
-
finder = InjectionPointFinder.new(d_statements)
|
|
134
|
+
finder = Navigable::InjectionPointFinder.new(d_statements)
|
|
135
135
|
injection_point = finder.find(
|
|
136
136
|
type: anchor[:type],
|
|
137
137
|
text: anchor[:text],
|
|
138
138
|
position: :replace,
|
|
139
139
|
boundary_type: boundary&.dig(:type),
|
|
140
140
|
boundary_text: boundary&.dig(:text),
|
|
141
|
+
boundary_same_or_shallower: boundary&.dig(:same_or_shallower) || false,
|
|
141
142
|
)
|
|
142
143
|
|
|
143
144
|
if injection_point.nil?
|
|
@@ -200,6 +201,7 @@ module Ast
|
|
|
200
201
|
result[:level] = matcher[:level] if matcher[:level]
|
|
201
202
|
result[:level_lte] = matcher[:level_lte] if matcher[:level_lte]
|
|
202
203
|
result[:level_gte] = matcher[:level_gte] if matcher[:level_gte]
|
|
204
|
+
result[:same_or_shallower] = matcher[:same_or_shallower] if matcher.key?(:same_or_shallower)
|
|
203
205
|
result.compact
|
|
204
206
|
end
|
|
205
207
|
|
|
@@ -135,6 +135,20 @@ module Ast
|
|
|
135
135
|
script_loader.load_callable(value)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# Get the normalize_whitespace setting.
|
|
139
|
+
#
|
|
140
|
+
# @return [Boolean] Whether to collapse excessive blank lines
|
|
141
|
+
def normalize_whitespace
|
|
142
|
+
merge_config[:normalize_whitespace] == true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Get the rehydrate_link_references setting.
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean] Whether to convert inline links to reference style
|
|
148
|
+
def rehydrate_link_references
|
|
149
|
+
merge_config[:rehydrate_link_references] == true
|
|
150
|
+
end
|
|
151
|
+
|
|
138
152
|
# Convert preset to a hash suitable for SmartMerger options.
|
|
139
153
|
#
|
|
140
154
|
# @return [Hash]
|
|
@@ -146,6 +160,8 @@ module Ast
|
|
|
146
160
|
node_typing: node_typing,
|
|
147
161
|
match_refiner: match_refiner,
|
|
148
162
|
freeze_token: freeze_token,
|
|
163
|
+
normalize_whitespace: normalize_whitespace,
|
|
164
|
+
rehydrate_link_references: rehydrate_link_references,
|
|
149
165
|
}.compact
|
|
150
166
|
end
|
|
151
167
|
|
|
@@ -168,6 +184,8 @@ module Ast
|
|
|
168
184
|
signature_generator: config["signature_generator"],
|
|
169
185
|
node_typing: config["node_typing"],
|
|
170
186
|
match_refiner: config["match_refiner"],
|
|
187
|
+
normalize_whitespace: config["normalize_whitespace"] == true,
|
|
188
|
+
rehydrate_link_references: config["rehydrate_link_references"] == true,
|
|
171
189
|
}
|
|
172
190
|
end
|
|
173
191
|
|
|
@@ -26,7 +26,7 @@ module Ast
|
|
|
26
26
|
#
|
|
27
27
|
class Runner
|
|
28
28
|
# Result of processing a single file
|
|
29
|
-
Result = Struct.new(:path, :relative_path, :status, :changed, :has_anchor, :message, :stats, :error, keyword_init: true)
|
|
29
|
+
Result = Struct.new(:path, :relative_path, :status, :changed, :has_anchor, :message, :stats, :problems, :error, keyword_init: true)
|
|
30
30
|
|
|
31
31
|
# @return [Config] The recipe being executed
|
|
32
32
|
attr_reader :recipe
|
|
@@ -157,6 +157,8 @@ module Ast
|
|
|
157
157
|
signature_generator: recipe.signature_generator,
|
|
158
158
|
node_typing: recipe.node_typing,
|
|
159
159
|
match_refiner: recipe.match_refiner,
|
|
160
|
+
normalize_whitespace: recipe.normalize_whitespace,
|
|
161
|
+
rehydrate_link_references: recipe.rehydrate_link_references,
|
|
160
162
|
)
|
|
161
163
|
|
|
162
164
|
result = merger.merge
|
|
@@ -202,6 +204,9 @@ module Ast
|
|
|
202
204
|
def create_result_from_merge(target_path, relative_path, _destination_content, merge_result)
|
|
203
205
|
changed = merge_result.changed
|
|
204
206
|
|
|
207
|
+
# Extract problems from stats if present (PartialTemplateMerger stores them there)
|
|
208
|
+
problems = merge_result.stats[:problems] if merge_result.stats.is_a?(Hash)
|
|
209
|
+
|
|
205
210
|
if changed
|
|
206
211
|
unless dry_run
|
|
207
212
|
File.write(target_path, merge_result.content)
|
|
@@ -215,6 +220,7 @@ module Ast
|
|
|
215
220
|
has_anchor: true,
|
|
216
221
|
message: dry_run ? "Would update" : "Updated",
|
|
217
222
|
stats: merge_result.stats,
|
|
223
|
+
problems: problems,
|
|
218
224
|
)
|
|
219
225
|
else
|
|
220
226
|
Result.new(
|
|
@@ -225,6 +231,7 @@ module Ast
|
|
|
225
231
|
has_anchor: true,
|
|
226
232
|
message: "No changes needed",
|
|
227
233
|
stats: merge_result.stats,
|
|
234
|
+
problems: problems,
|
|
228
235
|
)
|
|
229
236
|
end
|
|
230
237
|
end
|
data/lib/ast/merge/version.rb
CHANGED
data/lib/ast/merge.rb
CHANGED
|
@@ -142,16 +142,16 @@ module Ast
|
|
|
142
142
|
autoload :ConflictResolverBase, "ast/merge/conflict_resolver_base"
|
|
143
143
|
autoload :ContentMatchRefiner, "ast/merge/content_match_refiner"
|
|
144
144
|
autoload :DebugLogger, "ast/merge/debug_logger"
|
|
145
|
+
autoload :DiffMapperBase, "ast/merge/diff_mapper_base"
|
|
146
|
+
autoload :EmitterBase, "ast/merge/emitter_base"
|
|
145
147
|
autoload :FileAnalyzable, "ast/merge/file_analyzable"
|
|
146
148
|
autoload :Freezable, "ast/merge/freezable"
|
|
147
149
|
autoload :FreezeNodeBase, "ast/merge/freeze_node_base"
|
|
148
|
-
autoload :InjectionPoint, "ast/merge/navigable_statement"
|
|
149
|
-
autoload :InjectionPointFinder, "ast/merge/navigable_statement"
|
|
150
150
|
autoload :MatchRefinerBase, "ast/merge/match_refiner_base"
|
|
151
151
|
autoload :MatchScoreBase, "ast/merge/match_score_base"
|
|
152
152
|
autoload :MergeResultBase, "ast/merge/merge_result_base"
|
|
153
153
|
autoload :MergerConfig, "ast/merge/merger_config"
|
|
154
|
-
autoload :
|
|
154
|
+
autoload :Navigable, "ast/merge/navigable"
|
|
155
155
|
autoload :NodeTyping, "ast/merge/node_typing"
|
|
156
156
|
autoload :NodeWrapperBase, "ast/merge/node_wrapper_base"
|
|
157
157
|
autoload :PartialTemplateMergerBase, "ast/merge/partial_template_merger_base"
|
data.tar.gz.sig
CHANGED
|
Binary file
|