• 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.15
/lib/ast/merge/node_wrapper_base.rb
1
# frozen_string_literal: true
2

3
module Ast
1✔
4
  module Merge
1✔
5
    # Base class for format-specific node wrappers used in *-merge gems.
6
    #
7
    # This provides common functionality for wrapping TreeHaver nodes with:
8
    # - Source context (lines, source string)
9
    # - Line information (start_line, end_line)
10
    # - Comment associations (leading_comments, inline_comment)
11
    # - Content extraction (text, content)
12
    # - Signature generation (abstract)
13
    #
14
    # ## Relationship to NodeTyping::Wrapper
15
    #
16
    # This class is DIFFERENT from `Ast::Merge::NodeTyping::Wrapper`:
17
    #
18
    # - **NodeWrapperBase**: Provides format-specific functionality (line info,
19
    #   signatures, comments, type predicates). Used to wrap raw TreeHaver nodes
20
    #   with rich context needed for merging.
21
    #
22
    # - **NodeTyping::Wrapper**: Adds a custom `merge_type` attribute for merge
23
    #   classification. Used by SmartMergerBase to apply custom typing rules.
24
    #
25
    # A node CAN be wrapped by both:
26
    # ```
27
    # NodeTyping::Wrapper(Toml::Merge::NodeWrapper(tree_sitter_node))
28
    # ```
29
    #
30
    # The `NodeTyping.unwrap` method handles unwrapping `NodeTyping::Wrapper`,
31
    # while `NodeWrapperBase#node` provides access to the underlying TreeHaver node.
32
    #
33
    # ## Subclass Responsibilities
34
    #
35
    # Subclasses MUST implement:
36
    # - `#compute_signature(node)` - Generate a signature for node matching
37
    #
38
    # Subclasses SHOULD implement format-specific type predicates:
39
    # - TOML: `#table?`, `#pair?`, `#array_of_tables?`, etc.
40
    # - JSON: `#object?`, `#array?`, `#pair?`, etc.
41
    # - Bash: `#function_definition?`, `#variable_assignment?`, etc.
42
    #
43
    # @example Creating a format-specific wrapper
44
    #   class NodeWrapper < Ast::Merge::NodeWrapperBase
45
    #     def table?
46
    #       type == :table
47
    #     end
48
    #
49
    #     private
50
    #
51
    #     def compute_signature(node)
52
    #       case node.type.to_sym
53
    #       when :table
54
    #         [:table, table_name]
55
    #       else
56
    #         [node.type.to_sym]
57
    #       end
58
    #     end
59
    #   end
60
    #
61
    # @abstract Subclass and implement `#compute_signature`
62
    class NodeWrapperBase
1✔
63
      # @return [Object] The wrapped TreeHaver node
64
      attr_reader :node
1✔
65

66
      # @return [Array<String>] Source lines for content extraction
67
      attr_reader :lines
1✔
68

69
      # @return [String] The original source string
70
      attr_reader :source
1✔
71

72
      # @return [Array<Hash>] Leading comments associated with this node
73
      attr_reader :leading_comments
1✔
74

75
      # @return [Hash, nil] Inline/trailing comment on the same line
76
      attr_reader :inline_comment
1✔
77

78
      # @return [Integer, nil] Start line (1-based)
79
      attr_reader :start_line
1✔
80

81
      # @return [Integer, nil] End line (1-based)
82
      attr_reader :end_line
1✔
83

84
      # Initialize the node wrapper with source context.
85
      #
86
      # @param node [Object] TreeHaver node to wrap
87
      # @param lines [Array<String>] Source lines for content extraction
88
      # @param source [String, nil] Original source string for byte-based text extraction
89
      # @param leading_comments [Array<Hash>] Comments before this node
90
      # @param inline_comment [Hash, nil] Inline comment on the node's line
91
      # @param options [Hash] Additional options for subclasses (forward compatibility)
92
      def initialize(node, lines:, source: nil, leading_comments: [], inline_comment: nil, **options)
1✔
93
        @node = node
33✔
94
        @lines = lines
33✔
95
        @source = source || lines.join("\n")
33✔
96
        @leading_comments = leading_comments
33✔
97
        @inline_comment = inline_comment
33✔
98

99
        # Store additional options for subclasses to use
100
        process_additional_options(options)
33✔
101

102
        # Extract line information from the node (0-indexed to 1-indexed)
103
        extract_line_info(node)
33✔
104

105
        # Handle edge case where end_line might be before start_line
106
        @end_line = @start_line if @start_line && @end_line && @end_line < @start_line
33✔
107
      end
108

109
      # Process additional options. Override in subclasses to handle format-specific options.
110
      # @param options [Hash] Additional options
111
      def process_additional_options(options)
1✔
112
        # Default: no-op. Subclasses can override to process options like :backend, :document_root
113
      end
114

115
      # Generate a signature for this node for matching purposes.
116
      # Signatures are used to identify corresponding nodes between template and destination.
117
      #
118
      # @return [Array, nil] Signature array or nil if not signaturable
119
      def signature
1✔
120
        compute_signature(@node)
2✔
121
      end
122

123
      # Get the node type as a symbol.
124
      # @return [Symbol]
125
      def type
1✔
126
        @node.type.to_sym
2✔
127
      end
128

129
      # Check if this node has a specific type.
130
      # @param type_name [Symbol, String] Type to check
131
      # @return [Boolean]
132
      def type?(type_name)
1✔
133
        @node.type.to_s == type_name.to_s
3✔
134
      end
135

136
      # Check if this is a freeze node.
137
      # Override in subclasses if freeze node detection differs.
138
      # @return [Boolean]
139
      def freeze_node?
1✔
140
        false
1✔
141
      end
142

143
      # Get the text content for this node by extracting from source using byte positions.
144
      # @return [String]
145
      def text
1✔
146
        node_text(@node)
2✔
147
      end
148

149
      # Extract text from a node using byte positions.
150
      # @param ts_node [Object] The TreeHaver node
151
      # @return [String]
152
      def node_text(ts_node)
1✔
153
        return "" unless ts_node.respond_to?(:start_byte) && ts_node.respond_to?(:end_byte)
2✔
154

155
        @source[ts_node.start_byte...ts_node.end_byte] || ""
1✔
156
      end
157

158
      # Get the content for this node from source lines.
159
      # @return [String]
160
      def content
1✔
161
        return "" unless @start_line && @end_line
2✔
162

163
        (@start_line..@end_line).map { |ln| @lines[ln - 1] }.compact.join("\n")
4✔
164
      end
165

166
      # Check if this node is a container (has children for merging).
167
      # Override in subclasses to define container types.
168
      # @return [Boolean]
169
      def container?
1✔
170
        false
2✔
171
      end
172

173
      # Check if this node is a leaf (no mergeable children).
174
      # @return [Boolean]
175
      def leaf?
1✔
176
        !container?
1✔
177
      end
178

179
      # Get children wrapped as NodeWrappers.
180
      # Override in subclasses to return wrapped children.
181
      # @return [Array<NodeWrapperBase>]
182
      def children
1✔
183
        return [] unless @node.respond_to?(:each)
×
184

185
        result = []
×
186
        @node.each do |child|
×
187
          result << wrap_child(child)
×
188
        end
189
        result
×
190
      end
191

192
      # Get mergeable children - the semantically meaningful children for tree merging.
193
      # Override in subclasses to return format-specific mergeable children.
194
      # @return [Array<NodeWrapperBase>]
195
      def mergeable_children
1✔
196
        children
×
197
      end
198

199
      # String representation for debugging.
200
      # @return [String]
201
      def inspect
1✔
202
        "#<#{self.class.name} type=#{@node.type} lines=#{@start_line}..#{@end_line}>"
1✔
203
      end
204

205
      # Returns true to indicate this is a node wrapper.
206
      # Used to distinguish from NodeTyping::Wrapper.
207
      # @return [Boolean]
208
      def node_wrapper?
1✔
209
        true
4✔
210
      end
211

212
      # Get the underlying TreeHaver node.
213
      # Note: This is NOT the same as NodeTyping::Wrapper#unwrap which removes
214
      # the typing wrapper. This method provides access to the raw parser node.
215
      # @return [Object] The underlying TreeHaver node
216
      def underlying_node
1✔
217
        @node
2✔
218
      end
219

220
      protected
1✔
221

222
      # Wrap a child node. Override in subclasses to use the specific wrapper class.
223
      # @param child [Object] Child node to wrap
224
      # @return [NodeWrapperBase]
225
      def wrap_child(child)
1✔
226
        self.class.new(child, lines: @lines, source: @source)
×
227
      end
228

229
      # Compute signature for a node. Subclasses MUST implement this.
230
      # @param node [Object] The node to compute signature for
231
      # @return [Array, nil] Signature array
232
      # @abstract
233
      def compute_signature(node)
1✔
234
        raise NotImplementedError, "#{self.class} must implement #compute_signature"
1✔
235
      end
236

237
      private
1✔
238

239
      # Extract line information from the node.
240
      # @param node [Object] The node to extract line info from
241
      def extract_line_info(node)
1✔
242
        if node.respond_to?(:start_point)
33✔
243
          point = node.start_point
32✔
244
          @start_line = extract_row(point) + 1
32✔
245
        end
246

247
        if node.respond_to?(:end_point)
33✔
248
          point = node.end_point
32✔
249
          @end_line = extract_row(point) + 1
32✔
250
        end
251
      end
252

253
      # Extract row from a point, handling different point implementations.
254
      # @param point [Object] The point object
255
      # @return [Integer]
256
      def extract_row(point)
1✔
257
        if point.respond_to?(:row)
64✔
258
          point.row
64!
UNCOV
259
        elsif point.is_a?(Hash)
×
UNCOV
260
          point[:row]
×
261
        else
×
262
          0
×
263
        end
264
      end
265
    end
266
  end
267
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