• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kettle-rb / ast-merge / 20908698901

12 Jan 2026 05:07AM UTC coverage: 94.1% (-0.1%) from 94.211%
20908698901

push

github

pboling
✨ More RSpec support

### Added

- **`Ast::Merge::RSpec::MergeGemRegistry`** - Fully dynamic merge gem registration for RSpec dependency tags
  - `register(tag_name, require_path:, merger_class:, test_source:, category:)` - Register a merge gem
  - `available?(tag_name)` - Check if a merge gem is available and functional
  - `registered_gems` - Get all registered gem tag names
  - `gems_by_category(category)` - Filter gems by category (:markdown, :data, :code, :config, :other)
  - `summary` - Get availability status of all registered gems
  - Automatically defines `*_available?` methods on `DependencyTags` at registration time
  - External merge gems can now get full RSpec tag support without modifying ast-merge

### Changed

- **`Ast::Merge::AstNode` now inherits from `TreeHaver::Base::Node`**
  - Ensures synthetic nodes stay in sync with the canonical Node API
  - Inherits `Comparable`, `Enumerable` from base class
  - Retains all existing methods and behavior (Point, Location, signature, etc.)
  - Constructor calls `super(self, source: source)` to properly initialize base class
- **RSpec Dependency Tags refactored to use MergeGemRegistry**
  - Removed hardcoded merge gem availability checks
  - Removed `MERGE_GEM_TEST_SOURCES` constant
  - `*_available?` methods are now defined dynamically when gems register
  - `any_markdown_merge_available?` now queries registry by category
  - RSpec exclusion filters are configured dynamically from registry
- `Ast::Merge::Testing::TestableNode` now delegates to `TreeHaver::RSpec::TestableNode`
  - The TestableNode implementation has been moved to tree_haver for sharing across all merge gems
  - `spec/support/testable_node.rb` now requires and re-exports the tree_haver version
  - Backward compatible: existing tests continue to work unchanged
- `spec/ast/merge/node_wrapper_base_spec.rb` refactored to use `TestableNode` instead of mocks
  - Real TreeHaver::Node behavior for most tests
  - Mocks only retained for e... (continued)

812 of 929 branches covered (87.41%)

Branch coverage included in aggregate %.

8 of 9 new or added lines in 3 files covered. (88.89%)

2 existing lines in 1 file now uncovered.

2553 of 2647 relevant lines covered (96.45%)

30.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.67
/lib/ast/merge/diff_mapper_base.rb
1
# frozen_string_literal: true
2

3
module Ast
1✔
4
  module Merge
1✔
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
1✔
23
      # Represents a single hunk from a unified diff
24
      DiffHunk = Struct.new(
1✔
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(
1✔
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(
1✔
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(
1✔
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)
1✔
66
        parse_result = parse_diff(diff_text)
2✔
67
        return [] if parse_result.hunks.empty?
2!
68

69
        original_analysis = create_analysis(original_content)
2✔
70

71
        parse_result.hunks.flat_map do |hunk|
2✔
72
          map_hunk_to_paths(hunk, original_analysis)
2✔
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)
1✔
81
        lines = diff_text.lines.map(&:chomp)
9✔
82

83
        old_file = nil
9✔
84
        new_file = nil
9✔
85
        hunks = []
9✔
86
        current_hunk = nil
9✔
87
        old_line_num = nil
9✔
88
        new_line_num = nil
9✔
89

90
        lines.each do |line|
9✔
91
          case line
68!
92
          when /^---\s+(.+)$/
93
            # Original file path
9✔
94
            old_file = extract_file_path($1)
9✔
95
          when /^\+\+\+\s+(.+)$/
96
            # New file path
9✔
97
            new_file = extract_file_path($1)
9✔
98
          when /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/
99
            # Hunk header
100
            # Finalize previous hunk
10✔
101
            hunks << current_hunk if current_hunk
10✔
102

103
            old_start = $1.to_i
10✔
104
            old_count = ($2 || "1").to_i
10✔
105
            new_start = $3.to_i
10✔
106
            new_count = ($4 || "1").to_i
10✔
107

108
            current_hunk = DiffHunk.new(
10✔
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
10✔
117
            new_line_num = new_start
10✔
118
          when /^\+(.*)$/
119
            # Addition (not +++ header line, already handled)
15✔
120
            next unless current_hunk
15!
121

122
            current_hunk.lines << DiffLine.new(
15✔
123
              type: :addition,
124
              content: $1,
125
              old_line_num: nil,
126
              new_line_num: new_line_num,
127
            )
128
            new_line_num += 1
15✔
129
          when /^-(.*)$/
130
            # Removal (not --- header line, already handled)
8✔
131
            next unless current_hunk
8!
132

133
            current_hunk.lines << DiffLine.new(
8✔
134
              type: :removal,
135
              content: $1,
136
              old_line_num: old_line_num,
137
              new_line_num: nil,
138
            )
139
            old_line_num += 1
8✔
140
          when /^ (.*)$/
141
            # Context line
17✔
142
            next unless current_hunk
17!
143

144
            current_hunk.lines << DiffLine.new(
17✔
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
17✔
151
            new_line_num += 1
17✔
152
          end
153
        end
154

155
        # Finalize last hunk
156
        hunks << current_hunk if current_hunk
9!
157

158
        DiffParseResult.new(
9✔
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)
1✔
170
        has_additions = hunk.lines.any? { |l| l.type == :addition }
8✔
171
        has_removals = hunk.lines.any? { |l| l.type == :removal }
7✔
172

173
        if has_additions && has_removals
3✔
174
          :modify
1✔
175
        elsif has_additions
2✔
176
          :add
1✔
177
        elsif has_removals
1✔
178
          :remove
1✔
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)
1✔
191
        raise NotImplementedError, "Subclasses must implement #create_analysis"
1✔
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)
1✔
202
        raise NotImplementedError, "Subclasses must implement #map_hunk_to_paths"
1✔
203
      end
204

205
      protected
1✔
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)
1✔
212
        # Remove common prefixes: a/, b/, or timestamp suffixes
213
        path_string
22✔
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)
1✔
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

NEW
230
          line_num.between?(node.start_line, 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)
1✔
241
        raise NotImplementedError, "Subclasses must implement #build_path_for_node"
1✔
242
      end
243
    end
244
  end
245
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc