ast-merge 1.1.0 → 2.0.1
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 +198 -7
- data/README.md +208 -39
- data/exe/ast-merge-recipe +366 -0
- data/lib/ast/merge/conflict_resolver_base.rb +8 -1
- data/lib/ast/merge/content_match_refiner.rb +278 -0
- data/lib/ast/merge/debug_logger.rb +2 -1
- data/lib/ast/merge/detector/base.rb +193 -0
- data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
- data/lib/ast/merge/detector/mergeable.rb +369 -0
- data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
- data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
- data/lib/ast/merge/merge_result_base.rb +4 -1
- data/lib/ast/merge/navigable_statement.rb +630 -0
- data/lib/ast/merge/partial_template_merger.rb +432 -0
- data/lib/ast/merge/recipe/config.rb +198 -0
- data/lib/ast/merge/recipe/preset.rb +171 -0
- data/lib/ast/merge/recipe/runner.rb +254 -0
- data/lib/ast/merge/recipe/script_loader.rb +181 -0
- data/lib/ast/merge/recipe.rb +26 -0
- data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
- data/lib/ast/merge/rspec.rb +33 -2
- data/lib/ast/merge/smart_merger_base.rb +86 -3
- data/lib/ast/merge/version.rb +1 -1
- data/lib/ast/merge.rb +10 -6
- data/sig/ast/merge.rbs +389 -2
- data.tar.gz.sig +0 -0
- metadata +60 -16
- metadata.gz.sig +0 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +0 -313
- data/lib/ast/merge/region.rb +0 -124
- data/lib/ast/merge/region_detector_base.rb +0 -114
- data/lib/ast/merge/region_mergeable.rb +0 -364
- data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
- data/lib/ast/merge/yaml_frontmatter_detector.rb +0 -88
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Wraps any node (parser-backed or synthetic) with uniform navigation.
|
|
6
|
+
#
|
|
7
|
+
# Provides two levels of navigation:
|
|
8
|
+
# 1. **Flat list navigation**: prev_statement, next_statement, index
|
|
9
|
+
# - Works for ALL nodes (synthetic and parser-backed)
|
|
10
|
+
# - Represents position in the flattened statement list
|
|
11
|
+
#
|
|
12
|
+
# 2. **Tree navigation**: tree_parent, tree_next, tree_previous, tree_children
|
|
13
|
+
# - Only available for parser-backed nodes
|
|
14
|
+
# - Delegates to inner_node's tree methods
|
|
15
|
+
#
|
|
16
|
+
# This allows code to work with the flat list for simple merging,
|
|
17
|
+
# while still accessing tree structure for section-aware operations.
|
|
18
|
+
#
|
|
19
|
+
# @example Basic usage
|
|
20
|
+
# statements = NavigableStatement.build_list(raw_statements)
|
|
21
|
+
# stmt = statements[0]
|
|
22
|
+
#
|
|
23
|
+
# # Flat navigation (always works)
|
|
24
|
+
# stmt.next # => next statement in flat list
|
|
25
|
+
# stmt.previous # => previous statement in flat list
|
|
26
|
+
# stmt.index # => position in array
|
|
27
|
+
#
|
|
28
|
+
# # Tree navigation (when available)
|
|
29
|
+
# stmt.tree_parent # => parent in original AST (or nil)
|
|
30
|
+
# stmt.tree_next # => next sibling in original AST (or nil)
|
|
31
|
+
# stmt.tree_children # => children in original AST (or [])
|
|
32
|
+
#
|
|
33
|
+
# @example Section grouping
|
|
34
|
+
# # Group statements into sections by heading level
|
|
35
|
+
# sections = NavigableStatement.group_by_heading(statements, level: 3)
|
|
36
|
+
# sections.each do |section|
|
|
37
|
+
# puts "Section: #{section.heading.text}"
|
|
38
|
+
# section.statements.each { |s| puts " - #{s.type}" }
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
class NavigableStatement
|
|
42
|
+
# @return [Object] The wrapped node (parser-backed or synthetic)
|
|
43
|
+
attr_reader :node
|
|
44
|
+
|
|
45
|
+
# @return [Integer] Index in the flattened statement list
|
|
46
|
+
attr_reader :index
|
|
47
|
+
|
|
48
|
+
# @return [NavigableStatement, nil] Previous statement in flat list
|
|
49
|
+
attr_accessor :prev_statement
|
|
50
|
+
|
|
51
|
+
# @return [NavigableStatement, nil] Next statement in flat list
|
|
52
|
+
attr_accessor :next_statement
|
|
53
|
+
|
|
54
|
+
# @return [Object, nil] Optional context/metadata for this statement
|
|
55
|
+
attr_accessor :context
|
|
56
|
+
|
|
57
|
+
# Initialize a NavigableStatement wrapper.
|
|
58
|
+
#
|
|
59
|
+
# @param node [Object] The node to wrap
|
|
60
|
+
# @param index [Integer] Position in the statement list
|
|
61
|
+
def initialize(node, index:)
|
|
62
|
+
@node = node
|
|
63
|
+
@index = index
|
|
64
|
+
@prev_statement = nil
|
|
65
|
+
@next_statement = nil
|
|
66
|
+
@context = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Build a linked list of NavigableStatements from raw statements.
|
|
71
|
+
#
|
|
72
|
+
# @param raw_statements [Array<Object>] Raw statement nodes
|
|
73
|
+
# @return [Array<NavigableStatement>] Linked statement list
|
|
74
|
+
def build_list(raw_statements)
|
|
75
|
+
statements = raw_statements.each_with_index.map do |node, i|
|
|
76
|
+
new(node, index: i)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Link siblings in flat list
|
|
80
|
+
statements.each_cons(2) do |prev_stmt, next_stmt|
|
|
81
|
+
prev_stmt.next_statement = next_stmt
|
|
82
|
+
next_stmt.prev_statement = prev_stmt
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
statements
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Find statements matching a query.
|
|
89
|
+
#
|
|
90
|
+
# @param statements [Array<NavigableStatement>] Statement list
|
|
91
|
+
# @param type [Symbol, String, nil] Node type to match (nil = any)
|
|
92
|
+
# @param text [String, Regexp, nil] Text pattern to match
|
|
93
|
+
# @yield [NavigableStatement] Optional block for custom matching
|
|
94
|
+
# @return [Array<NavigableStatement>] Matching statements
|
|
95
|
+
def find_matching(statements, type: nil, text: nil, &block)
|
|
96
|
+
statements.select do |stmt|
|
|
97
|
+
matches = true
|
|
98
|
+
matches &&= stmt.type.to_s == type.to_s if type
|
|
99
|
+
matches &&= text.is_a?(Regexp) ? stmt.text.match?(text) : stmt.text.include?(text.to_s) if text
|
|
100
|
+
matches &&= yield(stmt) if block_given?
|
|
101
|
+
matches
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Find the first statement matching criteria.
|
|
106
|
+
#
|
|
107
|
+
# @param statements [Array<NavigableStatement>] Statement list
|
|
108
|
+
# @param type [Symbol, String, nil] Node type to match
|
|
109
|
+
# @param text [String, Regexp, nil] Text pattern to match
|
|
110
|
+
# @yield [NavigableStatement] Optional block for custom matching
|
|
111
|
+
# @return [NavigableStatement, nil] First matching statement
|
|
112
|
+
def find_first(statements, type: nil, text: nil, &block)
|
|
113
|
+
find_matching(statements, type: type, text: text, &block).first
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ============================================================
|
|
118
|
+
# Flat list navigation (always available)
|
|
119
|
+
# ============================================================
|
|
120
|
+
|
|
121
|
+
# @return [NavigableStatement, nil] Next statement in flat list
|
|
122
|
+
def next
|
|
123
|
+
next_statement
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [NavigableStatement, nil] Previous statement in flat list
|
|
127
|
+
def previous
|
|
128
|
+
prev_statement
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Boolean] true if this is the first statement
|
|
132
|
+
def first?
|
|
133
|
+
prev_statement.nil?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Boolean] true if this is the last statement
|
|
137
|
+
def last?
|
|
138
|
+
next_statement.nil?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Iterate from this statement to the end (or until block returns false).
|
|
142
|
+
#
|
|
143
|
+
# @yield [NavigableStatement] Each statement
|
|
144
|
+
# @return [Enumerator, nil]
|
|
145
|
+
def each_following(&block)
|
|
146
|
+
return to_enum(:each_following) unless block_given?
|
|
147
|
+
|
|
148
|
+
current = self.next
|
|
149
|
+
while current
|
|
150
|
+
break unless yield(current)
|
|
151
|
+
current = current.next
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Collect statements until a condition is met.
|
|
156
|
+
#
|
|
157
|
+
# @yield [NavigableStatement] Each statement
|
|
158
|
+
# @return [Array<NavigableStatement>] Statements until condition
|
|
159
|
+
def take_until(&block)
|
|
160
|
+
result = []
|
|
161
|
+
each_following do |stmt|
|
|
162
|
+
break if yield(stmt)
|
|
163
|
+
result << stmt
|
|
164
|
+
true
|
|
165
|
+
end
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# ============================================================
|
|
170
|
+
# Tree navigation (delegates to inner_node when available)
|
|
171
|
+
# ============================================================
|
|
172
|
+
|
|
173
|
+
# @return [Object, nil] Parent node in original AST
|
|
174
|
+
def tree_parent
|
|
175
|
+
inner = unwrapped_node
|
|
176
|
+
inner.parent if inner.respond_to?(:parent)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @return [Object, nil] Next sibling in original AST
|
|
180
|
+
def tree_next
|
|
181
|
+
inner = unwrapped_node
|
|
182
|
+
inner.next if inner.respond_to?(:next)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [Object, nil] Previous sibling in original AST
|
|
186
|
+
def tree_previous
|
|
187
|
+
inner = unwrapped_node
|
|
188
|
+
inner.previous if inner.respond_to?(:previous)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @return [Array<Object>] Children in original AST
|
|
192
|
+
def tree_children
|
|
193
|
+
inner = unwrapped_node
|
|
194
|
+
if inner.respond_to?(:each)
|
|
195
|
+
inner.to_a
|
|
196
|
+
elsif inner.respond_to?(:children)
|
|
197
|
+
inner.children
|
|
198
|
+
else
|
|
199
|
+
[]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# @return [Object, nil] First child in original AST
|
|
204
|
+
def tree_first_child
|
|
205
|
+
inner = unwrapped_node
|
|
206
|
+
inner.first_child if inner.respond_to?(:first_child)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @return [Object, nil] Last child in original AST
|
|
210
|
+
def tree_last_child
|
|
211
|
+
inner = unwrapped_node
|
|
212
|
+
inner.last_child if inner.respond_to?(:last_child)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [Boolean] true if tree navigation is available
|
|
216
|
+
def has_tree_navigation?
|
|
217
|
+
inner = unwrapped_node
|
|
218
|
+
inner.respond_to?(:parent) || inner.respond_to?(:next)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @return [Boolean] true if this is a synthetic node (no tree navigation)
|
|
222
|
+
def synthetic?
|
|
223
|
+
!has_tree_navigation?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Calculate the tree depth (distance from root).
|
|
227
|
+
#
|
|
228
|
+
# @return [Integer] Depth in tree (0 = root level)
|
|
229
|
+
def tree_depth
|
|
230
|
+
depth = 0
|
|
231
|
+
current = tree_parent
|
|
232
|
+
while current
|
|
233
|
+
depth += 1
|
|
234
|
+
# Navigate up through parents
|
|
235
|
+
if current.respond_to?(:parent)
|
|
236
|
+
current = current.parent
|
|
237
|
+
else
|
|
238
|
+
break
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
depth
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Check if this node is at same or shallower depth than another.
|
|
245
|
+
# Useful for determining section boundaries.
|
|
246
|
+
#
|
|
247
|
+
# @param other [NavigableStatement, Integer] Other statement or depth value
|
|
248
|
+
# @return [Boolean] true if this node is at same or shallower depth
|
|
249
|
+
def same_or_shallower_than?(other)
|
|
250
|
+
other_depth = other.is_a?(Integer) ? other : other.tree_depth
|
|
251
|
+
tree_depth <= other_depth
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ============================================================
|
|
255
|
+
# Node delegation
|
|
256
|
+
# ============================================================
|
|
257
|
+
|
|
258
|
+
# @return [Symbol, String] Node type
|
|
259
|
+
def type
|
|
260
|
+
node.respond_to?(:type) ? node.type : node.class.name.split("::").last
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @return [Array, Object, nil] Node signature for matching
|
|
264
|
+
def signature
|
|
265
|
+
node.signature if node.respond_to?(:signature)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @return [String] Node text content
|
|
269
|
+
def text
|
|
270
|
+
if node.respond_to?(:to_plaintext)
|
|
271
|
+
node.to_plaintext.to_s
|
|
272
|
+
elsif node.respond_to?(:to_commonmark)
|
|
273
|
+
node.to_commonmark.to_s
|
|
274
|
+
elsif node.respond_to?(:slice)
|
|
275
|
+
node.slice.to_s
|
|
276
|
+
elsif node.respond_to?(:text)
|
|
277
|
+
node.text.to_s
|
|
278
|
+
else
|
|
279
|
+
node.to_s
|
|
280
|
+
end
|
|
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
|
+
"#<NavigableStatement[#{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
|
+
|
|
379
|
+
# Represents a location in a document where content can be injected.
|
|
380
|
+
#
|
|
381
|
+
# InjectionPoint is language-agnostic - it works with any AST structure.
|
|
382
|
+
# It defines WHERE to inject content and HOW (as child, sibling, or replacement).
|
|
383
|
+
#
|
|
384
|
+
# @example Inject as first child of a class
|
|
385
|
+
# point = InjectionPoint.new(
|
|
386
|
+
# anchor: class_node,
|
|
387
|
+
# position: :first_child
|
|
388
|
+
# )
|
|
389
|
+
#
|
|
390
|
+
# @example Inject after a specific method
|
|
391
|
+
# point = InjectionPoint.new(
|
|
392
|
+
# anchor: method_node,
|
|
393
|
+
# position: :after
|
|
394
|
+
# )
|
|
395
|
+
#
|
|
396
|
+
# @example Replace a range of nodes
|
|
397
|
+
# point = InjectionPoint.new(
|
|
398
|
+
# anchor: start_node,
|
|
399
|
+
# position: :replace,
|
|
400
|
+
# boundary: end_node
|
|
401
|
+
# )
|
|
402
|
+
#
|
|
403
|
+
class InjectionPoint
|
|
404
|
+
# Valid positions for injection
|
|
405
|
+
POSITIONS = %i[
|
|
406
|
+
before
|
|
407
|
+
#
|
|
408
|
+
Insert
|
|
409
|
+
as
|
|
410
|
+
previous
|
|
411
|
+
sibling
|
|
412
|
+
of
|
|
413
|
+
anchor
|
|
414
|
+
after
|
|
415
|
+
#
|
|
416
|
+
Insert
|
|
417
|
+
as
|
|
418
|
+
next
|
|
419
|
+
sibling
|
|
420
|
+
of
|
|
421
|
+
anchor
|
|
422
|
+
first_child
|
|
423
|
+
#
|
|
424
|
+
Insert
|
|
425
|
+
as
|
|
426
|
+
first
|
|
427
|
+
child
|
|
428
|
+
of
|
|
429
|
+
anchor
|
|
430
|
+
last_child
|
|
431
|
+
#
|
|
432
|
+
Insert
|
|
433
|
+
as
|
|
434
|
+
last
|
|
435
|
+
child
|
|
436
|
+
of
|
|
437
|
+
anchor
|
|
438
|
+
replace
|
|
439
|
+
#
|
|
440
|
+
Replace
|
|
441
|
+
anchor
|
|
442
|
+
(and
|
|
443
|
+
optionally
|
|
444
|
+
through
|
|
445
|
+
boundary)
|
|
446
|
+
].freeze
|
|
447
|
+
|
|
448
|
+
# @return [NavigableStatement] The anchor node for injection
|
|
449
|
+
attr_reader :anchor
|
|
450
|
+
|
|
451
|
+
# @return [Symbol] Position relative to anchor (:before, :after, :first_child, :last_child, :replace)
|
|
452
|
+
attr_reader :position
|
|
453
|
+
|
|
454
|
+
# @return [NavigableStatement, nil] End boundary for :replace position
|
|
455
|
+
attr_reader :boundary
|
|
456
|
+
|
|
457
|
+
# @return [Hash] Additional metadata about this injection point
|
|
458
|
+
attr_reader :metadata
|
|
459
|
+
|
|
460
|
+
# Initialize an InjectionPoint.
|
|
461
|
+
#
|
|
462
|
+
# @param anchor [NavigableStatement] The reference node
|
|
463
|
+
# @param position [Symbol] Where to inject relative to anchor
|
|
464
|
+
# @param boundary [NavigableStatement, nil] End boundary for replacements
|
|
465
|
+
# @param metadata [Hash] Additional info (e.g., match details)
|
|
466
|
+
def initialize(anchor:, position:, boundary: nil, **metadata)
|
|
467
|
+
validate_position!(position)
|
|
468
|
+
validate_boundary!(position, boundary)
|
|
469
|
+
|
|
470
|
+
@anchor = anchor
|
|
471
|
+
@position = position
|
|
472
|
+
@boundary = boundary
|
|
473
|
+
@metadata = metadata
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# @return [Boolean] true if this is a replacement (not insertion)
|
|
477
|
+
def replacement?
|
|
478
|
+
position == :replace
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# @return [Boolean] true if this injects as a child
|
|
482
|
+
def child_injection?
|
|
483
|
+
%i[first_child last_child].include?(position)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# @return [Boolean] true if this injects as a sibling
|
|
487
|
+
def sibling_injection?
|
|
488
|
+
%i[before after].include?(position)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Get all statements that would be replaced.
|
|
492
|
+
#
|
|
493
|
+
# @return [Array<NavigableStatement>] Statements to replace (empty if not replacement)
|
|
494
|
+
def replaced_statements
|
|
495
|
+
return [] unless replacement?
|
|
496
|
+
return [anchor] unless boundary
|
|
497
|
+
|
|
498
|
+
result = [anchor]
|
|
499
|
+
current = anchor.next
|
|
500
|
+
while current && current != boundary
|
|
501
|
+
result << current
|
|
502
|
+
current = current.next
|
|
503
|
+
end
|
|
504
|
+
result << boundary if boundary
|
|
505
|
+
result
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# @return [Integer, nil] Start line of injection point
|
|
509
|
+
def start_line
|
|
510
|
+
anchor.start_line
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# @return [Integer, nil] End line of injection point
|
|
514
|
+
def end_line
|
|
515
|
+
(boundary || anchor).end_line
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# @return [String] Human-readable representation
|
|
519
|
+
def inspect
|
|
520
|
+
boundary_info = boundary ? " to #{boundary.index}" : ""
|
|
521
|
+
"#<InjectionPoint position=#{position} anchor=#{anchor.index}#{boundary_info}>"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
private
|
|
525
|
+
|
|
526
|
+
def validate_position!(position)
|
|
527
|
+
return if POSITIONS.include?(position)
|
|
528
|
+
|
|
529
|
+
raise ArgumentError, "Invalid position: #{position}. Must be one of: #{POSITIONS.join(", ")}"
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def validate_boundary!(position, boundary)
|
|
533
|
+
return unless boundary && position != :replace
|
|
534
|
+
|
|
535
|
+
raise ArgumentError, "boundary is only valid with position: :replace"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Finds injection points in a document based on matching rules.
|
|
540
|
+
#
|
|
541
|
+
# This is language-agnostic - the matching rules work on the unified
|
|
542
|
+
# NavigableStatement interface regardless of the underlying parser.
|
|
543
|
+
#
|
|
544
|
+
# @example Find where to inject constants in a Ruby class
|
|
545
|
+
# finder = InjectionPointFinder.new(statements)
|
|
546
|
+
# point = finder.find(
|
|
547
|
+
# type: :class,
|
|
548
|
+
# text: /class Choo/,
|
|
549
|
+
# position: :first_child
|
|
550
|
+
# )
|
|
551
|
+
#
|
|
552
|
+
# @example Find and replace a constant definition
|
|
553
|
+
# point = finder.find(
|
|
554
|
+
# type: :constant_assignment,
|
|
555
|
+
# text: /DAR\s*=/,
|
|
556
|
+
# position: :replace
|
|
557
|
+
# )
|
|
558
|
+
#
|
|
559
|
+
class InjectionPointFinder
|
|
560
|
+
# @return [Array<NavigableStatement>] The statement list to search
|
|
561
|
+
attr_reader :statements
|
|
562
|
+
|
|
563
|
+
def initialize(statements)
|
|
564
|
+
@statements = statements
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Find an injection point based on matching criteria.
|
|
568
|
+
#
|
|
569
|
+
# @param type [Symbol, String, nil] Node type to match
|
|
570
|
+
# @param text [String, Regexp, nil] Text pattern to match
|
|
571
|
+
# @param position [Symbol] Where to inject (:before, :after, :first_child, :last_child, :replace)
|
|
572
|
+
# @param boundary_type [Symbol, String, nil] Node type for replacement boundary
|
|
573
|
+
# @param boundary_text [String, Regexp, nil] Text pattern for replacement boundary
|
|
574
|
+
# @param boundary_matcher [Proc, nil] Custom matcher for boundary (receives NavigableStatement, returns boolean)
|
|
575
|
+
# @param boundary_same_or_shallower [Boolean] If true, boundary is next node at same or shallower tree depth
|
|
576
|
+
# @yield [NavigableStatement] Optional custom matcher
|
|
577
|
+
# @return [InjectionPoint, nil] Injection point if anchor found
|
|
578
|
+
def find(type: nil, text: nil, position:, boundary_type: nil, boundary_text: nil, boundary_matcher: nil, boundary_same_or_shallower: false, &block)
|
|
579
|
+
anchor = NavigableStatement.find_first(statements, type: type, text: text, &block)
|
|
580
|
+
return unless anchor
|
|
581
|
+
|
|
582
|
+
boundary = nil
|
|
583
|
+
if position == :replace && (boundary_type || boundary_text || boundary_matcher || boundary_same_or_shallower)
|
|
584
|
+
# Find boundary starting after anchor
|
|
585
|
+
remaining = statements[(anchor.index + 1)..]
|
|
586
|
+
|
|
587
|
+
if boundary_same_or_shallower
|
|
588
|
+
# Find next node at same or shallower tree depth
|
|
589
|
+
# This is language-agnostic: ends section at next sibling or ancestor's sibling
|
|
590
|
+
anchor_depth = anchor.tree_depth
|
|
591
|
+
boundary = remaining.find do |stmt|
|
|
592
|
+
# Must match type if specified
|
|
593
|
+
next false if boundary_type && stmt.type.to_s != boundary_type.to_s
|
|
594
|
+
next false if boundary_text && !stmt.text_matches?(boundary_text)
|
|
595
|
+
# Check tree depth
|
|
596
|
+
stmt.same_or_shallower_than?(anchor_depth)
|
|
597
|
+
end
|
|
598
|
+
elsif boundary_matcher
|
|
599
|
+
# Use custom matcher
|
|
600
|
+
boundary = remaining.find { |stmt| boundary_matcher.call(stmt) }
|
|
601
|
+
else
|
|
602
|
+
boundary = NavigableStatement.find_first(
|
|
603
|
+
remaining,
|
|
604
|
+
type: boundary_type,
|
|
605
|
+
text: boundary_text,
|
|
606
|
+
)
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
InjectionPoint.new(
|
|
611
|
+
anchor: anchor,
|
|
612
|
+
position: position,
|
|
613
|
+
boundary: boundary,
|
|
614
|
+
match: {type: type, text: text},
|
|
615
|
+
)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Find all injection points matching criteria.
|
|
619
|
+
#
|
|
620
|
+
# @param (see #find)
|
|
621
|
+
# @return [Array<InjectionPoint>] All matching injection points
|
|
622
|
+
def find_all(type: nil, text: nil, position:, &block)
|
|
623
|
+
anchors = NavigableStatement.find_matching(statements, type: type, text: text, &block)
|
|
624
|
+
anchors.map do |anchor|
|
|
625
|
+
InjectionPoint.new(anchor: anchor, position: position)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
end
|