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