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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +67 -1
- data/README.md +228 -179
- 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/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/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 +2 -3
- data.tar.gz.sig +0 -0
- metadata +33 -9
- metadata.gz.sig +0 -0
- data/lib/ast/merge/navigable_statement.rb +0 -625
data/exe/ast-merge-recipe
CHANGED
|
@@ -185,6 +185,10 @@ class AstMergeRecipeCLI
|
|
|
185
185
|
@options[:verbose] = true
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
+
opts.on("--show-problems", "Show document problems found during merge") do
|
|
189
|
+
@options[:show_problems] = true
|
|
190
|
+
end
|
|
191
|
+
|
|
188
192
|
opts.on(
|
|
189
193
|
"-p",
|
|
190
194
|
"--parser=PARSER",
|
|
@@ -318,6 +322,22 @@ class AstMergeRecipeCLI
|
|
|
318
322
|
puts Colors.dim(" Stats: #{result.stats.inspect}")
|
|
319
323
|
end
|
|
320
324
|
|
|
325
|
+
# Show problems if --show-problems or --verbose
|
|
326
|
+
if (@options[:show_problems] || @options[:verbose]) && result.problems&.any?
|
|
327
|
+
puts Colors.yellow(" Problems:")
|
|
328
|
+
result.problems.each do |problem|
|
|
329
|
+
severity_color = case problem[:severity]
|
|
330
|
+
when :error then :red
|
|
331
|
+
when :warning then :yellow
|
|
332
|
+
else :dim
|
|
333
|
+
end
|
|
334
|
+
msg = " [#{problem[:severity]}] #{problem[:category]}"
|
|
335
|
+
details = problem.reject { |k, _| [:category, :severity].include?(k) }
|
|
336
|
+
msg += ": #{details.inspect}" unless details.empty?
|
|
337
|
+
puts Colors.send(severity_color, msg)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
321
341
|
if result.error && @options[:verbose]
|
|
322
342
|
puts Colors.red(" #{result.error.class}: #{result.error.message}")
|
|
323
343
|
puts Colors.dim(" #{result.error.backtrace&.first(3)&.join("\n ")}")
|
|
@@ -118,6 +118,15 @@ module Ast
|
|
|
118
118
|
# @return [Boolean] Whether to add template-only nodes (batch strategy)
|
|
119
119
|
attr_reader :add_template_only_nodes
|
|
120
120
|
|
|
121
|
+
# @return [Boolean] Whether to remove destination nodes not in template (batch strategy)
|
|
122
|
+
attr_reader :remove_template_missing_nodes
|
|
123
|
+
|
|
124
|
+
# @return [Boolean, Integer] Whether to merge nested structures recursively
|
|
125
|
+
# - true: unlimited depth (default)
|
|
126
|
+
# - false: disabled
|
|
127
|
+
# - Integer > 0: max depth
|
|
128
|
+
attr_reader :recursive
|
|
129
|
+
|
|
121
130
|
# @return [Object, nil] Match refiner for fuzzy matching
|
|
122
131
|
attr_reader :match_refiner
|
|
123
132
|
|
|
@@ -132,20 +141,29 @@ module Ast
|
|
|
132
141
|
# @param template_analysis [Object] Analysis of the template file
|
|
133
142
|
# @param dest_analysis [Object] Analysis of the destination file
|
|
134
143
|
# @param add_template_only_nodes [Boolean] Whether to add nodes only in template (batch/boundary strategy)
|
|
144
|
+
# @param remove_template_missing_nodes [Boolean] Whether to remove destination nodes not in template
|
|
145
|
+
# @param recursive [Boolean, Integer] Whether to merge nested structures recursively
|
|
146
|
+
# - true: unlimited depth (default)
|
|
147
|
+
# - false: disabled
|
|
148
|
+
# - Integer > 0: max depth
|
|
149
|
+
# - 0: invalid, raises ArgumentError
|
|
135
150
|
# @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
|
|
136
151
|
# @param options [Hash] Additional options for forward compatibility
|
|
137
|
-
def initialize(strategy:, preference:, template_analysis:, dest_analysis:, add_template_only_nodes: false, match_refiner: nil, **options)
|
|
152
|
+
def initialize(strategy:, preference:, template_analysis:, dest_analysis:, add_template_only_nodes: false, remove_template_missing_nodes: false, recursive: true, match_refiner: nil, **options)
|
|
138
153
|
unless %i[node batch boundary].include?(strategy)
|
|
139
154
|
raise ArgumentError, "Invalid strategy: #{strategy}. Must be :node, :batch, or :boundary"
|
|
140
155
|
end
|
|
141
156
|
|
|
142
157
|
validate_preference!(preference)
|
|
158
|
+
validate_recursive!(recursive)
|
|
143
159
|
|
|
144
160
|
@strategy = strategy
|
|
145
161
|
@preference = preference
|
|
146
162
|
@template_analysis = template_analysis
|
|
147
163
|
@dest_analysis = dest_analysis
|
|
148
164
|
@add_template_only_nodes = add_template_only_nodes
|
|
165
|
+
@remove_template_missing_nodes = remove_template_missing_nodes
|
|
166
|
+
@recursive = recursive
|
|
149
167
|
@match_refiner = match_refiner
|
|
150
168
|
# **options captured for forward compatibility - subclasses may use additional options
|
|
151
169
|
end
|
|
@@ -401,6 +419,34 @@ module Ast
|
|
|
401
419
|
end
|
|
402
420
|
end
|
|
403
421
|
end
|
|
422
|
+
|
|
423
|
+
# Validate the recursive parameter.
|
|
424
|
+
#
|
|
425
|
+
# @param recursive [Boolean, Integer] The recursive value to validate
|
|
426
|
+
# @raise [ArgumentError] If recursive is invalid
|
|
427
|
+
def validate_recursive!(recursive)
|
|
428
|
+
return if recursive == true || recursive == false
|
|
429
|
+
return if recursive.is_a?(Integer) && recursive > 0
|
|
430
|
+
|
|
431
|
+
if recursive == 0
|
|
432
|
+
raise ArgumentError, "recursive: 0 is invalid, use false to disable recursive merging"
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
raise ArgumentError,
|
|
436
|
+
"Invalid recursive: #{recursive.inspect}. Must be true, false, or a positive Integer"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Check if recursive merging should be applied at a given depth.
|
|
440
|
+
#
|
|
441
|
+
# @param current_depth [Integer] Current recursion depth (0 = root level)
|
|
442
|
+
# @return [Boolean] Whether to continue recursive merging
|
|
443
|
+
def should_recurse?(current_depth)
|
|
444
|
+
return false if @recursive == false
|
|
445
|
+
return true if @recursive == true
|
|
446
|
+
|
|
447
|
+
# @recursive is a positive Integer representing max depth
|
|
448
|
+
current_depth < @recursive
|
|
449
|
+
end
|
|
404
450
|
end
|
|
405
451
|
end
|
|
406
452
|
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Base class for mapping unified git diffs to AST node paths.
|
|
6
|
+
#
|
|
7
|
+
# DiffMapperBase provides a format-agnostic foundation for parsing unified
|
|
8
|
+
# git diffs and mapping changed lines to AST node paths. Subclasses implement
|
|
9
|
+
# format-specific logic to determine which AST nodes are affected by each change.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage with a subclass
|
|
12
|
+
# class Psych::Merge::DiffMapper < Ast::Merge::DiffMapperBase
|
|
13
|
+
# def map_hunk_to_paths(hunk, original_analysis)
|
|
14
|
+
# # YAML-specific implementation
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# mapper = Psych::Merge::DiffMapper.new
|
|
19
|
+
# mappings = mapper.map(diff_text, original_content)
|
|
20
|
+
#
|
|
21
|
+
# @abstract Subclass and implement {#map_hunk_to_paths}
|
|
22
|
+
class DiffMapperBase
|
|
23
|
+
# Represents a single hunk from a unified diff
|
|
24
|
+
DiffHunk = Struct.new(
|
|
25
|
+
:old_start, # Starting line in original file (1-based)
|
|
26
|
+
:old_count, # Number of lines in original
|
|
27
|
+
:new_start, # Starting line in new file (1-based)
|
|
28
|
+
:new_count, # Number of lines in new file
|
|
29
|
+
:lines, # Array of DiffLine objects
|
|
30
|
+
:header, # The @@ header line
|
|
31
|
+
keyword_init: true,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Represents a single line in a diff hunk
|
|
35
|
+
DiffLine = Struct.new(
|
|
36
|
+
:type, # :context, :addition, :removal
|
|
37
|
+
:content, # Line content (without +/- prefix)
|
|
38
|
+
:old_line_num, # Line number in original file (nil for additions)
|
|
39
|
+
:new_line_num, # Line number in new file (nil for removals)
|
|
40
|
+
keyword_init: true,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Represents a mapping from diff changes to AST paths
|
|
44
|
+
DiffMapping = Struct.new(
|
|
45
|
+
:path, # Array of keys/indices representing AST path (e.g., ["AllCops", "Exclude"])
|
|
46
|
+
:operation, # :add, :remove, or :modify
|
|
47
|
+
:lines, # Array of DiffLine objects for this path
|
|
48
|
+
:hunk, # The source DiffHunk
|
|
49
|
+
keyword_init: true,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Result of parsing a diff file
|
|
53
|
+
DiffParseResult = Struct.new(
|
|
54
|
+
:old_file, # Original file path from --- line
|
|
55
|
+
:new_file, # New file path from +++ line
|
|
56
|
+
:hunks, # Array of DiffHunk objects
|
|
57
|
+
keyword_init: true,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Parse a unified diff and map changes to AST paths.
|
|
61
|
+
#
|
|
62
|
+
# @param diff_text [String] The unified diff content
|
|
63
|
+
# @param original_content [String] The original file content (for AST path mapping)
|
|
64
|
+
# @return [Array<DiffMapping>] Mappings from changes to AST paths
|
|
65
|
+
def map(diff_text, original_content)
|
|
66
|
+
parse_result = parse_diff(diff_text)
|
|
67
|
+
return [] if parse_result.hunks.empty?
|
|
68
|
+
|
|
69
|
+
original_analysis = create_analysis(original_content)
|
|
70
|
+
|
|
71
|
+
parse_result.hunks.flat_map do |hunk|
|
|
72
|
+
map_hunk_to_paths(hunk, original_analysis)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parse a unified diff into structured hunks.
|
|
77
|
+
#
|
|
78
|
+
# @param diff_text [String] The unified diff content
|
|
79
|
+
# @return [DiffParseResult] Parsed diff with file paths and hunks
|
|
80
|
+
def parse_diff(diff_text)
|
|
81
|
+
lines = diff_text.lines.map(&:chomp)
|
|
82
|
+
|
|
83
|
+
old_file = nil
|
|
84
|
+
new_file = nil
|
|
85
|
+
hunks = []
|
|
86
|
+
current_hunk = nil
|
|
87
|
+
old_line_num = nil
|
|
88
|
+
new_line_num = nil
|
|
89
|
+
|
|
90
|
+
lines.each do |line|
|
|
91
|
+
case line
|
|
92
|
+
when /^---\s+(.+)$/
|
|
93
|
+
# Original file path
|
|
94
|
+
old_file = extract_file_path($1)
|
|
95
|
+
when /^\+\+\+\s+(.+)$/
|
|
96
|
+
# New file path
|
|
97
|
+
new_file = extract_file_path($1)
|
|
98
|
+
when /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/
|
|
99
|
+
# Hunk header
|
|
100
|
+
# Finalize previous hunk
|
|
101
|
+
hunks << current_hunk if current_hunk
|
|
102
|
+
|
|
103
|
+
old_start = $1.to_i
|
|
104
|
+
old_count = ($2 || "1").to_i
|
|
105
|
+
new_start = $3.to_i
|
|
106
|
+
new_count = ($4 || "1").to_i
|
|
107
|
+
|
|
108
|
+
current_hunk = DiffHunk.new(
|
|
109
|
+
old_start: old_start,
|
|
110
|
+
old_count: old_count,
|
|
111
|
+
new_start: new_start,
|
|
112
|
+
new_count: new_count,
|
|
113
|
+
lines: [],
|
|
114
|
+
header: line,
|
|
115
|
+
)
|
|
116
|
+
old_line_num = old_start
|
|
117
|
+
new_line_num = new_start
|
|
118
|
+
when /^\+(.*)$/
|
|
119
|
+
# Addition (not +++ header line, already handled)
|
|
120
|
+
next unless current_hunk
|
|
121
|
+
|
|
122
|
+
current_hunk.lines << DiffLine.new(
|
|
123
|
+
type: :addition,
|
|
124
|
+
content: $1,
|
|
125
|
+
old_line_num: nil,
|
|
126
|
+
new_line_num: new_line_num,
|
|
127
|
+
)
|
|
128
|
+
new_line_num += 1
|
|
129
|
+
when /^-(.*)$/
|
|
130
|
+
# Removal (not --- header line, already handled)
|
|
131
|
+
next unless current_hunk
|
|
132
|
+
|
|
133
|
+
current_hunk.lines << DiffLine.new(
|
|
134
|
+
type: :removal,
|
|
135
|
+
content: $1,
|
|
136
|
+
old_line_num: old_line_num,
|
|
137
|
+
new_line_num: nil,
|
|
138
|
+
)
|
|
139
|
+
old_line_num += 1
|
|
140
|
+
when /^ (.*)$/
|
|
141
|
+
# Context line
|
|
142
|
+
next unless current_hunk
|
|
143
|
+
|
|
144
|
+
current_hunk.lines << DiffLine.new(
|
|
145
|
+
type: :context,
|
|
146
|
+
content: $1,
|
|
147
|
+
old_line_num: old_line_num,
|
|
148
|
+
new_line_num: new_line_num,
|
|
149
|
+
)
|
|
150
|
+
old_line_num += 1
|
|
151
|
+
new_line_num += 1
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Finalize last hunk
|
|
156
|
+
hunks << current_hunk if current_hunk
|
|
157
|
+
|
|
158
|
+
DiffParseResult.new(
|
|
159
|
+
old_file: old_file,
|
|
160
|
+
new_file: new_file,
|
|
161
|
+
hunks: hunks,
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Determine the operation type for a hunk.
|
|
166
|
+
#
|
|
167
|
+
# @param hunk [DiffHunk] The hunk to analyze
|
|
168
|
+
# @return [Symbol] :add, :remove, or :modify
|
|
169
|
+
def determine_operation(hunk)
|
|
170
|
+
has_additions = hunk.lines.any? { |l| l.type == :addition }
|
|
171
|
+
has_removals = hunk.lines.any? { |l| l.type == :removal }
|
|
172
|
+
|
|
173
|
+
if has_additions && has_removals
|
|
174
|
+
:modify
|
|
175
|
+
elsif has_additions
|
|
176
|
+
:add
|
|
177
|
+
elsif has_removals
|
|
178
|
+
:remove
|
|
179
|
+
else
|
|
180
|
+
:modify # Context-only hunk (unusual)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Create a file analysis for the original content.
|
|
185
|
+
# Subclasses must implement this to return their format-specific analysis.
|
|
186
|
+
#
|
|
187
|
+
# @param content [String] The original file content
|
|
188
|
+
# @return [Object] A FileAnalysis object for the format
|
|
189
|
+
# @abstract
|
|
190
|
+
def create_analysis(content)
|
|
191
|
+
raise NotImplementedError, "Subclasses must implement #create_analysis"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Map a single hunk to AST paths.
|
|
195
|
+
# Subclasses must implement this with format-specific logic.
|
|
196
|
+
#
|
|
197
|
+
# @param hunk [DiffHunk] The hunk to map
|
|
198
|
+
# @param original_analysis [Object] FileAnalysis of the original content
|
|
199
|
+
# @return [Array<DiffMapping>] Mappings for this hunk
|
|
200
|
+
# @abstract
|
|
201
|
+
def map_hunk_to_paths(hunk, original_analysis)
|
|
202
|
+
raise NotImplementedError, "Subclasses must implement #map_hunk_to_paths"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
protected
|
|
206
|
+
|
|
207
|
+
# Extract file path from diff header, handling common prefixes.
|
|
208
|
+
#
|
|
209
|
+
# @param path_string [String] Path from --- or +++ line
|
|
210
|
+
# @return [String] Cleaned file path
|
|
211
|
+
def extract_file_path(path_string)
|
|
212
|
+
# Remove common prefixes: a/, b/, or timestamp suffixes
|
|
213
|
+
path_string
|
|
214
|
+
.sub(%r{^[ab]/}, "")
|
|
215
|
+
.sub(/\t.*$/, "") # Remove timestamp suffix
|
|
216
|
+
.strip
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Find the AST node that contains a given line number.
|
|
220
|
+
# Helper method for subclasses.
|
|
221
|
+
#
|
|
222
|
+
# @param line_num [Integer] 1-based line number
|
|
223
|
+
# @param statements [Array] Array of statement nodes
|
|
224
|
+
# @return [Object, nil] The containing node or nil
|
|
225
|
+
def find_node_at_line(line_num, statements)
|
|
226
|
+
statements.find do |node|
|
|
227
|
+
next unless node.respond_to?(:start_line) && node.respond_to?(:end_line)
|
|
228
|
+
next unless node.start_line && node.end_line
|
|
229
|
+
|
|
230
|
+
line_num >= node.start_line && line_num <= node.end_line
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Build a path array from a node's ancestry.
|
|
235
|
+
# Helper method for subclasses to override with format-specific logic.
|
|
236
|
+
#
|
|
237
|
+
# @param node [Object] The AST node
|
|
238
|
+
# @param analysis [Object] The file analysis
|
|
239
|
+
# @return [Array<String, Integer>] Path components
|
|
240
|
+
def build_path_for_node(node, analysis)
|
|
241
|
+
raise NotImplementedError, "Subclasses must implement #build_path_for_node"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Navigable
|
|
6
|
+
# Represents a location in a document where content can be injected.
|
|
7
|
+
#
|
|
8
|
+
# InjectionPoint is language-agnostic - it works with any AST structure.
|
|
9
|
+
# It defines WHERE to inject content and HOW (as child, sibling, or replacement).
|
|
10
|
+
#
|
|
11
|
+
# @example Inject as first child of a class
|
|
12
|
+
# point = InjectionPoint.new(
|
|
13
|
+
# anchor: class_node,
|
|
14
|
+
# position: :first_child
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example Inject after a specific method
|
|
18
|
+
# point = InjectionPoint.new(
|
|
19
|
+
# anchor: method_node,
|
|
20
|
+
# position: :after
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# @example Replace a range of nodes
|
|
24
|
+
# point = InjectionPoint.new(
|
|
25
|
+
# anchor: start_node,
|
|
26
|
+
# position: :replace,
|
|
27
|
+
# boundary: end_node
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
class InjectionPoint
|
|
31
|
+
# Valid positions for injection
|
|
32
|
+
POSITIONS = %i[
|
|
33
|
+
before
|
|
34
|
+
after
|
|
35
|
+
first_child
|
|
36
|
+
last_child
|
|
37
|
+
replace
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# @return [Statement] The anchor node for injection
|
|
41
|
+
attr_reader :anchor
|
|
42
|
+
|
|
43
|
+
# @return [Symbol] Position relative to anchor (:before, :after, :first_child, :last_child, :replace)
|
|
44
|
+
attr_reader :position
|
|
45
|
+
|
|
46
|
+
# @return [Statement, nil] End boundary for :replace position
|
|
47
|
+
attr_reader :boundary
|
|
48
|
+
|
|
49
|
+
# @return [Hash] Additional metadata about this injection point
|
|
50
|
+
attr_reader :metadata
|
|
51
|
+
|
|
52
|
+
# Initialize an InjectionPoint.
|
|
53
|
+
#
|
|
54
|
+
# @param anchor [Statement] The reference node
|
|
55
|
+
# @param position [Symbol] Where to inject relative to anchor
|
|
56
|
+
# @param boundary [Statement, nil] End boundary for replacements
|
|
57
|
+
# @param metadata [Hash] Additional info (e.g., match details)
|
|
58
|
+
def initialize(anchor:, position:, boundary: nil, **metadata)
|
|
59
|
+
validate_position!(position)
|
|
60
|
+
validate_boundary!(position, boundary)
|
|
61
|
+
|
|
62
|
+
@anchor = anchor
|
|
63
|
+
@position = position
|
|
64
|
+
@boundary = boundary
|
|
65
|
+
@metadata = metadata
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] true if this is a replacement (not insertion)
|
|
69
|
+
def replacement?
|
|
70
|
+
position == :replace
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] true if this injects as a child
|
|
74
|
+
def child_injection?
|
|
75
|
+
%i[first_child last_child].include?(position)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] true if this injects as a sibling
|
|
79
|
+
def sibling_injection?
|
|
80
|
+
%i[before after].include?(position)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get all statements that would be replaced.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Statement>] Statements to replace (empty if not replacement)
|
|
86
|
+
def replaced_statements
|
|
87
|
+
return [] unless replacement?
|
|
88
|
+
return [anchor] unless boundary
|
|
89
|
+
|
|
90
|
+
result = [anchor]
|
|
91
|
+
current = anchor.next
|
|
92
|
+
while current && current != boundary
|
|
93
|
+
result << current
|
|
94
|
+
current = current.next
|
|
95
|
+
end
|
|
96
|
+
result << boundary if boundary
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Integer, nil] Start line of injection point
|
|
101
|
+
def start_line
|
|
102
|
+
anchor.start_line
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Integer, nil] End line of injection point
|
|
106
|
+
def end_line
|
|
107
|
+
(boundary || anchor).end_line
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [String] Human-readable representation
|
|
111
|
+
def inspect
|
|
112
|
+
boundary_info = boundary ? " to #{boundary.index}" : ""
|
|
113
|
+
"#<Navigable::InjectionPoint position=#{position} anchor=#{anchor.index}#{boundary_info}>"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def validate_position!(position)
|
|
119
|
+
return if POSITIONS.include?(position)
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "Invalid position: #{position}. Must be one of: #{POSITIONS.join(", ")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_boundary!(position, boundary)
|
|
125
|
+
return unless boundary && position != :replace
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, "boundary is only valid with position: :replace"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -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
|