ast-merge 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +95 -1
- data/README.md +258 -186
- data/exe/ast-merge-recipe +20 -0
- data/lib/ast/merge/conflict_resolver_base.rb +47 -1
- data/lib/ast/merge/diff_mapper_base.rb +245 -0
- data/lib/ast/merge/emitter_base.rb +123 -0
- data/lib/ast/merge/freeze_node_base.rb +9 -0
- data/lib/ast/merge/navigable/injection_point.rb +132 -0
- data/lib/ast/merge/navigable/injection_point_finder.rb +98 -0
- data/lib/ast/merge/navigable/statement.rb +380 -0
- data/lib/ast/merge/navigable.rb +20 -0
- data/lib/ast/merge/node_typing.rb +21 -0
- data/lib/ast/merge/partial_template_merger_base.rb +4 -2
- data/lib/ast/merge/recipe/preset.rb +18 -0
- data/lib/ast/merge/recipe/runner.rb +8 -1
- data/lib/ast/merge/version.rb +1 -1
- data/lib/ast/merge.rb +3 -3
- data.tar.gz.sig +0 -0
- metadata +34 -9
- metadata.gz.sig +0 -0
- data/lib/ast/merge/navigable_statement.rb +0 -625
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,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Base class for emitters that convert AST structures back to text.
|
|
6
|
+
# Provides common functionality for tracking indentation, managing output lines,
|
|
7
|
+
# and handling comments.
|
|
8
|
+
#
|
|
9
|
+
# Subclasses implement format-specific emission methods (e.g., emit_pair for JSON,
|
|
10
|
+
# emit_variable_assignment for Bash, etc.)
|
|
11
|
+
#
|
|
12
|
+
# @example Implementing a custom emitter
|
|
13
|
+
# class MyEmitter < Ast::Merge::EmitterBase
|
|
14
|
+
# def emit_my_construct(data)
|
|
15
|
+
# add_comma_if_needed if @needs_separator
|
|
16
|
+
# @lines << "#{current_indent}my_syntax: #{data}"
|
|
17
|
+
# @needs_separator = true
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
class EmitterBase
|
|
21
|
+
# @return [Array<String>] Output lines
|
|
22
|
+
attr_reader :lines
|
|
23
|
+
|
|
24
|
+
# @return [Integer] Current indentation level
|
|
25
|
+
attr_reader :indent_level
|
|
26
|
+
|
|
27
|
+
# @return [Integer] Spaces per indent level
|
|
28
|
+
attr_reader :indent_size
|
|
29
|
+
|
|
30
|
+
# Initialize a new emitter
|
|
31
|
+
#
|
|
32
|
+
# @param indent_size [Integer] Number of spaces per indent level
|
|
33
|
+
# @param options [Hash] Additional options for subclasses
|
|
34
|
+
def initialize(indent_size: 2, **options)
|
|
35
|
+
@lines = []
|
|
36
|
+
@indent_level = 0
|
|
37
|
+
@indent_size = indent_size
|
|
38
|
+
initialize_subclass_state(**options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Hook for subclasses to initialize their own state
|
|
42
|
+
# @param options [Hash] Additional options
|
|
43
|
+
def initialize_subclass_state(**options)
|
|
44
|
+
# Override in subclasses if needed
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Emit a blank line
|
|
48
|
+
def emit_blank_line
|
|
49
|
+
@lines << ""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Emit leading comments from CommentTracker
|
|
53
|
+
#
|
|
54
|
+
# @param comments [Array<Hash>] Comment hashes with :text, :indent, etc.
|
|
55
|
+
def emit_leading_comments(comments)
|
|
56
|
+
comments.each do |comment|
|
|
57
|
+
emit_tracked_comment(comment)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Emit a comment from CommentTracker hash
|
|
62
|
+
# Subclasses should override this to handle format-specific comment syntax
|
|
63
|
+
#
|
|
64
|
+
# @param comment [Hash] Comment hash with :text, :indent, :block, etc.
|
|
65
|
+
def emit_tracked_comment(comment)
|
|
66
|
+
raise NotImplementedError, "Subclasses must implement emit_tracked_comment"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Emit raw lines as-is (for preserving exact formatting)
|
|
70
|
+
#
|
|
71
|
+
# @param raw_lines [Array<String>] Lines to emit without modification
|
|
72
|
+
def emit_raw_lines(raw_lines)
|
|
73
|
+
raw_lines.each { |line| @lines << line.chomp }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get the output as a single string
|
|
77
|
+
# Subclasses may override to customize output format (e.g., to_json, to_yaml)
|
|
78
|
+
#
|
|
79
|
+
# @return [String]
|
|
80
|
+
def to_s
|
|
81
|
+
content = @lines.join("\n")
|
|
82
|
+
content += "\n" unless content.empty? || content.end_with?("\n")
|
|
83
|
+
content
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Clear the emitter state
|
|
87
|
+
def clear
|
|
88
|
+
@lines = []
|
|
89
|
+
@indent_level = 0
|
|
90
|
+
clear_subclass_state
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Hook for subclasses to clear their own state
|
|
94
|
+
def clear_subclass_state
|
|
95
|
+
# Override in subclasses if needed
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Increase indentation level
|
|
99
|
+
def indent
|
|
100
|
+
@indent_level += 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Decrease indentation level
|
|
104
|
+
def dedent
|
|
105
|
+
@indent_level -= 1 if @indent_level > 0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
protected
|
|
109
|
+
|
|
110
|
+
# Get the current indentation string
|
|
111
|
+
# @return [String]
|
|
112
|
+
def current_indent
|
|
113
|
+
" " * (@indent_level * @indent_size)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Add a line with current indentation
|
|
117
|
+
# @param content [String] Line content
|
|
118
|
+
def add_indented_line(content)
|
|
119
|
+
@lines << "#{current_indent}#{content}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -354,6 +354,15 @@ module Ast
|
|
|
354
354
|
true
|
|
355
355
|
end
|
|
356
356
|
|
|
357
|
+
# Node type for merge classification
|
|
358
|
+
# @return [Symbol] :freeze_block
|
|
359
|
+
def merge_type
|
|
360
|
+
:freeze_block
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Alias for compatibility
|
|
364
|
+
alias_method :type, :merge_type
|
|
365
|
+
|
|
357
366
|
# Returns a stable signature for this freeze block.
|
|
358
367
|
# Override in subclasses for file-type-specific normalization.
|
|
359
368
|
# @return [Array] Signature array
|
|
@@ -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
|