ast-merge 3.1.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.
@@ -1,625 +0,0 @@
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
- # If no criteria specified, return empty array (nothing to match)
97
- return [] if type.nil? && text.nil? && !block_given?
98
-
99
- statements.select do |stmt|
100
- matches = true
101
- matches &&= stmt.type.to_s == type.to_s if type
102
- matches &&= text.is_a?(Regexp) ? stmt.text.match?(text) : stmt.text.include?(text.to_s) if text
103
- matches &&= yield(stmt) if block_given?
104
- matches
105
- end
106
- end
107
-
108
- # Find the first statement matching criteria.
109
- #
110
- # @param statements [Array<NavigableStatement>] Statement list
111
- # @param type [Symbol, String, nil] Node type to match
112
- # @param text [String, Regexp, nil] Text pattern to match
113
- # @yield [NavigableStatement] Optional block for custom matching
114
- # @return [NavigableStatement, nil] First matching statement
115
- def find_first(statements, type: nil, text: nil, &block)
116
- find_matching(statements, type: type, text: text, &block).first
117
- end
118
- end
119
-
120
- # ============================================================
121
- # Flat list navigation (always available)
122
- # ============================================================
123
-
124
- # @return [NavigableStatement, nil] Next statement in flat list
125
- def next
126
- next_statement
127
- end
128
-
129
- # @return [NavigableStatement, nil] Previous statement in flat list
130
- def previous
131
- prev_statement
132
- end
133
-
134
- # @return [Boolean] true if this is the first statement
135
- def first?
136
- prev_statement.nil?
137
- end
138
-
139
- # @return [Boolean] true if this is the last statement
140
- def last?
141
- next_statement.nil?
142
- end
143
-
144
- # Iterate from this statement to the end (or until block returns false).
145
- #
146
- # @yield [NavigableStatement] Each statement
147
- # @return [Enumerator, nil]
148
- def each_following(&block)
149
- return to_enum(:each_following) unless block_given?
150
-
151
- current = self.next
152
- while current
153
- break unless yield(current)
154
- current = current.next
155
- end
156
- end
157
-
158
- # Collect statements until a condition is met.
159
- #
160
- # @yield [NavigableStatement] Each statement
161
- # @return [Array<NavigableStatement>] Statements until condition
162
- def take_until(&block)
163
- result = []
164
- each_following do |stmt|
165
- break if yield(stmt)
166
- result << stmt
167
- true
168
- end
169
- result
170
- end
171
-
172
- # ============================================================
173
- # Tree navigation (delegates to inner_node when available)
174
- # ============================================================
175
-
176
- # @return [Object, nil] Parent node in original AST
177
- def tree_parent
178
- inner = unwrapped_node
179
- inner.parent if inner.respond_to?(:parent)
180
- end
181
-
182
- # @return [Object, nil] Next sibling in original AST
183
- def tree_next
184
- inner = unwrapped_node
185
- inner.next if inner.respond_to?(:next)
186
- end
187
-
188
- # @return [Object, nil] Previous sibling in original AST
189
- def tree_previous
190
- inner = unwrapped_node
191
- inner.previous if inner.respond_to?(:previous)
192
- end
193
-
194
- # @return [Array<Object>] Children in original AST
195
- def tree_children
196
- inner = unwrapped_node
197
- if inner.respond_to?(:each)
198
- inner.to_a
199
- elsif inner.respond_to?(:children)
200
- inner.children
201
- else
202
- []
203
- end
204
- end
205
-
206
- # @return [Object, nil] First child in original AST
207
- def tree_first_child
208
- inner = unwrapped_node
209
- inner.first_child if inner.respond_to?(:first_child)
210
- end
211
-
212
- # @return [Object, nil] Last child in original AST
213
- def tree_last_child
214
- inner = unwrapped_node
215
- inner.last_child if inner.respond_to?(:last_child)
216
- end
217
-
218
- # @return [Boolean] true if tree navigation is available
219
- def has_tree_navigation?
220
- inner = unwrapped_node
221
- inner.respond_to?(:parent) || inner.respond_to?(:next)
222
- end
223
-
224
- # @return [Boolean] true if this is a synthetic node (no tree navigation)
225
- def synthetic?
226
- !has_tree_navigation?
227
- end
228
-
229
- # Calculate the tree depth (distance from root).
230
- #
231
- # @return [Integer] Depth in tree (0 = root level)
232
- def tree_depth
233
- depth = 0
234
- current = tree_parent
235
- while current
236
- depth += 1
237
- # Navigate up through parents
238
- if current.respond_to?(:parent)
239
- current = current.parent
240
- else
241
- break
242
- end
243
- end
244
- depth
245
- end
246
-
247
- # Check if this node is at same or shallower depth than another.
248
- # Useful for determining section boundaries.
249
- #
250
- # @param other [NavigableStatement, Integer] Other statement or depth value
251
- # @return [Boolean] true if this node is at same or shallower depth
252
- def same_or_shallower_than?(other)
253
- other_depth = other.is_a?(Integer) ? other : other.tree_depth
254
- tree_depth <= other_depth
255
- end
256
-
257
- # ============================================================
258
- # Node delegation
259
- # ============================================================
260
-
261
- # @return [Symbol, String] Node type
262
- def type
263
- node.respond_to?(:type) ? node.type : node.class.name.split("::").last
264
- end
265
-
266
- # @return [Array, Object, nil] Node signature for matching
267
- def signature
268
- node.signature if node.respond_to?(:signature)
269
- end
270
-
271
- # @return [String] Node text content
272
- def text
273
- # TreeHaver nodes (and any node conforming to the unified API) provide #text.
274
- # No conditional fallbacks - nodes must conform to the API.
275
- node.text.to_s
276
- end
277
-
278
- # @return [Hash, nil] Source position info
279
- def source_position
280
- node.source_position if node.respond_to?(:source_position)
281
- end
282
-
283
- # @return [Integer, nil] Start line number
284
- def start_line
285
- pos = source_position
286
- pos[:start_line] if pos
287
- end
288
-
289
- # @return [Integer, nil] End line number
290
- def end_line
291
- pos = source_position
292
- pos[:end_line] if pos
293
- end
294
-
295
- # ============================================================
296
- # Node attribute helpers (language-agnostic)
297
- # ============================================================
298
-
299
- # Check if this node matches a type.
300
- #
301
- # @param expected_type [Symbol, String] Type to check
302
- # @return [Boolean]
303
- def type?(expected_type)
304
- type.to_s == expected_type.to_s
305
- end
306
-
307
- # Check if this node's text matches a pattern.
308
- #
309
- # @param pattern [String, Regexp] Pattern to match
310
- # @return [Boolean]
311
- def text_matches?(pattern)
312
- case pattern
313
- when Regexp
314
- text.match?(pattern)
315
- else
316
- text.include?(pattern.to_s)
317
- end
318
- end
319
-
320
- # Get an attribute from the underlying node.
321
- #
322
- # Tries multiple method names to support different parser APIs.
323
- #
324
- # @param name [Symbol, String] Attribute name
325
- # @param aliases [Array<Symbol>] Alternative method names
326
- # @return [Object, nil] Attribute value
327
- def node_attribute(name, *aliases)
328
- inner = unwrapped_node
329
- [name, *aliases].each do |method_name|
330
- return inner.send(method_name) if inner.respond_to?(method_name)
331
- end
332
- nil
333
- end
334
-
335
- # ============================================================
336
- # Utilities
337
- # ============================================================
338
-
339
- # Get the unwrapped inner node.
340
- #
341
- # @return [Object] The innermost node
342
- def unwrapped_node
343
- current = node
344
- while current.respond_to?(:inner_node) && current.inner_node != current
345
- current = current.inner_node
346
- end
347
- current
348
- end
349
-
350
- # @return [String] Human-readable representation
351
- def inspect
352
- "#<NavigableStatement[#{index}] type=#{type} tree=#{has_tree_navigation?}>"
353
- end
354
-
355
- # @return [String] String representation
356
- def to_s
357
- text.to_s.strip[0, 50]
358
- end
359
-
360
- # Delegate unknown methods to the wrapped node.
361
- def method_missing(method, *args, &block)
362
- if node.respond_to?(method)
363
- node.send(method, *args, &block)
364
- else
365
- super
366
- end
367
- end
368
-
369
- def respond_to_missing?(method, include_private = false)
370
- node.respond_to?(method, include_private) || super
371
- end
372
- end
373
-
374
- # Represents a location in a document where content can be injected.
375
- #
376
- # InjectionPoint is language-agnostic - it works with any AST structure.
377
- # It defines WHERE to inject content and HOW (as child, sibling, or replacement).
378
- #
379
- # @example Inject as first child of a class
380
- # point = InjectionPoint.new(
381
- # anchor: class_node,
382
- # position: :first_child
383
- # )
384
- #
385
- # @example Inject after a specific method
386
- # point = InjectionPoint.new(
387
- # anchor: method_node,
388
- # position: :after
389
- # )
390
- #
391
- # @example Replace a range of nodes
392
- # point = InjectionPoint.new(
393
- # anchor: start_node,
394
- # position: :replace,
395
- # boundary: end_node
396
- # )
397
- #
398
- class InjectionPoint
399
- # Valid positions for injection
400
- POSITIONS = %i[
401
- before
402
- #
403
- Insert
404
- as
405
- previous
406
- sibling
407
- of
408
- anchor
409
- after
410
- #
411
- Insert
412
- as
413
- next
414
- sibling
415
- of
416
- anchor
417
- first_child
418
- #
419
- Insert
420
- as
421
- first
422
- child
423
- of
424
- anchor
425
- last_child
426
- #
427
- Insert
428
- as
429
- last
430
- child
431
- of
432
- anchor
433
- replace
434
- #
435
- Replace
436
- anchor
437
- (and
438
- optionally
439
- through
440
- boundary)
441
- ].freeze
442
-
443
- # @return [NavigableStatement] The anchor node for injection
444
- attr_reader :anchor
445
-
446
- # @return [Symbol] Position relative to anchor (:before, :after, :first_child, :last_child, :replace)
447
- attr_reader :position
448
-
449
- # @return [NavigableStatement, nil] End boundary for :replace position
450
- attr_reader :boundary
451
-
452
- # @return [Hash] Additional metadata about this injection point
453
- attr_reader :metadata
454
-
455
- # Initialize an InjectionPoint.
456
- #
457
- # @param anchor [NavigableStatement] The reference node
458
- # @param position [Symbol] Where to inject relative to anchor
459
- # @param boundary [NavigableStatement, nil] End boundary for replacements
460
- # @param metadata [Hash] Additional info (e.g., match details)
461
- def initialize(anchor:, position:, boundary: nil, **metadata)
462
- validate_position!(position)
463
- validate_boundary!(position, boundary)
464
-
465
- @anchor = anchor
466
- @position = position
467
- @boundary = boundary
468
- @metadata = metadata
469
- end
470
-
471
- # @return [Boolean] true if this is a replacement (not insertion)
472
- def replacement?
473
- position == :replace
474
- end
475
-
476
- # @return [Boolean] true if this injects as a child
477
- def child_injection?
478
- %i[first_child last_child].include?(position)
479
- end
480
-
481
- # @return [Boolean] true if this injects as a sibling
482
- def sibling_injection?
483
- %i[before after].include?(position)
484
- end
485
-
486
- # Get all statements that would be replaced.
487
- #
488
- # @return [Array<NavigableStatement>] Statements to replace (empty if not replacement)
489
- def replaced_statements
490
- return [] unless replacement?
491
- return [anchor] unless boundary
492
-
493
- result = [anchor]
494
- current = anchor.next
495
- while current && current != boundary
496
- result << current
497
- current = current.next
498
- end
499
- result << boundary if boundary
500
- result
501
- end
502
-
503
- # @return [Integer, nil] Start line of injection point
504
- def start_line
505
- anchor.start_line
506
- end
507
-
508
- # @return [Integer, nil] End line of injection point
509
- def end_line
510
- (boundary || anchor).end_line
511
- end
512
-
513
- # @return [String] Human-readable representation
514
- def inspect
515
- boundary_info = boundary ? " to #{boundary.index}" : ""
516
- "#<InjectionPoint position=#{position} anchor=#{anchor.index}#{boundary_info}>"
517
- end
518
-
519
- private
520
-
521
- def validate_position!(position)
522
- return if POSITIONS.include?(position)
523
-
524
- raise ArgumentError, "Invalid position: #{position}. Must be one of: #{POSITIONS.join(", ")}"
525
- end
526
-
527
- def validate_boundary!(position, boundary)
528
- return unless boundary && position != :replace
529
-
530
- raise ArgumentError, "boundary is only valid with position: :replace"
531
- end
532
- end
533
-
534
- # Finds injection points in a document based on matching rules.
535
- #
536
- # This is language-agnostic - the matching rules work on the unified
537
- # NavigableStatement interface regardless of the underlying parser.
538
- #
539
- # @example Find where to inject constants in a Ruby class
540
- # finder = InjectionPointFinder.new(statements)
541
- # point = finder.find(
542
- # type: :class,
543
- # text: /class Choo/,
544
- # position: :first_child
545
- # )
546
- #
547
- # @example Find and replace a constant definition
548
- # point = finder.find(
549
- # type: :constant_assignment,
550
- # text: /DAR\s*=/,
551
- # position: :replace
552
- # )
553
- #
554
- class InjectionPointFinder
555
- # @return [Array<NavigableStatement>] The statement list to search
556
- attr_reader :statements
557
-
558
- def initialize(statements)
559
- @statements = statements
560
- end
561
-
562
- # Find an injection point based on matching criteria.
563
- #
564
- # @param type [Symbol, String, nil] Node type to match
565
- # @param text [String, Regexp, nil] Text pattern to match
566
- # @param position [Symbol] Where to inject (:before, :after, :first_child, :last_child, :replace)
567
- # @param boundary_type [Symbol, String, nil] Node type for replacement boundary
568
- # @param boundary_text [String, Regexp, nil] Text pattern for replacement boundary
569
- # @param boundary_matcher [Proc, nil] Custom matcher for boundary (receives NavigableStatement, returns boolean)
570
- # @param boundary_same_or_shallower [Boolean] If true, boundary is next node at same or shallower tree depth
571
- # @yield [NavigableStatement] Optional custom matcher
572
- # @return [InjectionPoint, nil] Injection point if anchor found
573
- def find(type: nil, text: nil, position:, boundary_type: nil, boundary_text: nil, boundary_matcher: nil, boundary_same_or_shallower: false, &block)
574
- anchor = NavigableStatement.find_first(statements, type: type, text: text, &block)
575
- return unless anchor
576
-
577
- boundary = nil
578
- if position == :replace && (boundary_type || boundary_text || boundary_matcher || boundary_same_or_shallower)
579
- # Find boundary starting after anchor
580
- remaining = statements[(anchor.index + 1)..]
581
-
582
- if boundary_same_or_shallower
583
- # Find next node at same or shallower tree depth
584
- # This is language-agnostic: ends section at next sibling or ancestor's sibling
585
- anchor_depth = anchor.tree_depth
586
- boundary = remaining.find do |stmt|
587
- # Must match type if specified
588
- next false if boundary_type && stmt.type.to_s != boundary_type.to_s
589
- next false if boundary_text && !stmt.text_matches?(boundary_text)
590
- # Check tree depth
591
- stmt.same_or_shallower_than?(anchor_depth)
592
- end
593
- elsif boundary_matcher
594
- # Use custom matcher
595
- boundary = remaining.find { |stmt| boundary_matcher.call(stmt) }
596
- else
597
- boundary = NavigableStatement.find_first(
598
- remaining,
599
- type: boundary_type,
600
- text: boundary_text,
601
- )
602
- end
603
- end
604
-
605
- InjectionPoint.new(
606
- anchor: anchor,
607
- position: position,
608
- boundary: boundary,
609
- match: {type: type, text: text},
610
- )
611
- end
612
-
613
- # Find all injection points matching criteria.
614
- #
615
- # @param (see #find)
616
- # @return [Array<InjectionPoint>] All matching injection points
617
- def find_all(type: nil, text: nil, position:, &block)
618
- anchors = NavigableStatement.find_matching(statements, type: type, text: text, &block)
619
- anchors.map do |anchor|
620
- InjectionPoint.new(anchor: anchor, position: position)
621
- end
622
- end
623
- end
624
- end
625
- end