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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +198 -7
  4. data/README.md +208 -39
  5. data/exe/ast-merge-recipe +366 -0
  6. data/lib/ast/merge/conflict_resolver_base.rb +8 -1
  7. data/lib/ast/merge/content_match_refiner.rb +278 -0
  8. data/lib/ast/merge/debug_logger.rb +2 -1
  9. data/lib/ast/merge/detector/base.rb +193 -0
  10. data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
  11. data/lib/ast/merge/detector/mergeable.rb +369 -0
  12. data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
  13. data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
  14. data/lib/ast/merge/merge_result_base.rb +4 -1
  15. data/lib/ast/merge/navigable_statement.rb +630 -0
  16. data/lib/ast/merge/partial_template_merger.rb +432 -0
  17. data/lib/ast/merge/recipe/config.rb +198 -0
  18. data/lib/ast/merge/recipe/preset.rb +171 -0
  19. data/lib/ast/merge/recipe/runner.rb +254 -0
  20. data/lib/ast/merge/recipe/script_loader.rb +181 -0
  21. data/lib/ast/merge/recipe.rb +26 -0
  22. data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
  23. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
  24. data/lib/ast/merge/rspec.rb +33 -2
  25. data/lib/ast/merge/smart_merger_base.rb +86 -3
  26. data/lib/ast/merge/version.rb +1 -1
  27. data/lib/ast/merge.rb +10 -6
  28. data/sig/ast/merge.rbs +389 -2
  29. data.tar.gz.sig +0 -0
  30. metadata +60 -16
  31. metadata.gz.sig +0 -0
  32. data/lib/ast/merge/fenced_code_block_detector.rb +0 -313
  33. data/lib/ast/merge/region.rb +0 -124
  34. data/lib/ast/merge/region_detector_base.rb +0 -114
  35. data/lib/ast/merge/region_mergeable.rb +0 -364
  36. data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
  37. 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