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.
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