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

kettle-rb / tree_haver / 20630043612

01 Jan 2026 12:51AM UTC coverage: 83.266% (+4.4%) from 78.869%
20630043612

push

github

pboling
♻️ Refactor dependency_tags and backend handling

### Added

- Isolated backend RSpec tags for running tests without loading conflicting backends
  - `:ffi_backend_only` - runs FFI tests without triggering `mri_backend_available?` check
  - `:mri_backend_only` - runs MRI tests without triggering `ffi_available?` check
  - Uses `TreeHaver::Backends::BLOCKED_BY` to dynamically determine which availability checks to skip
  - Enables `rake ffi_specs` to run FFI tests before MRI is loaded
- `DependencyTags.ffi_backend_only_available?` - checks FFI availability without loading MRI
- `DependencyTags.mri_backend_only_available?` - checks MRI availability without checking FFI

### Changed

- Backend availability exclusions in `dependency_tags.rb` are now dynamic
  - Uses `TreeHaver::Backends::BLOCKED_BY` to skip availability checks for blocked backends
  - When running with `--tag ffi_backend_only`, MRI availability is not checked
  - Prevents MRI from being loaded before FFI tests can run
- Rakefile `ffi_specs` task now uses `:ffi_backend_only` tag
  - Ensures FFI tests run without loading MRI backend first

### Fixed

- Rakefile now uses correct RSpec tags for FFI isolation
  - The `ffi_specs` task uses `:ffi_backend_only` to prevent MRI from loading
  - The `remaining_specs` task excludes `:ffi_backend_only` tests
  - Tags in Rakefile align with canonical tags from `dependency_tags.rb`
- RSpec `dependency_tags.rb` now correctly detects `--tag` options during configuration
  - RSpec's `config.inclusion_filter.rules` is empty during configuration phase
  - Now parses `ARGV` directly to detect `--tag ffi_backend_only` and similar tags
  - Skips grammar availability checks (which load MRI) when running isolated backend tests
  - Skips full dependency summary in `before(:suite)` when backends are blocked
- `TreeHaver::Backends::FFI.reset!` now uses consistent pattern with other backends
  - Was using `@ffi_gem_available` with `defined?()` check, which returned t... (continued)

798 of 1122 branches covered (71.12%)

Branch coverage included in aggregate %.

66 of 92 new or added lines in 2 files covered. (71.74%)

31 existing lines in 4 files now uncovered.

2287 of 2583 relevant lines covered (88.54%)

29.01 hits per line

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

91.97
/lib/tree_haver/node.rb
1
# frozen_string_literal: true
2

3
module TreeHaver
3✔
4
  # Unified Node wrapper providing a consistent API across all backends
5
  #
6
  # This class wraps backend-specific node objects (TreeSitter::Node, TreeStump::Node, etc.)
7
  # and provides a unified interface so code works identically regardless of which backend
8
  # is being used.
9
  #
10
  # The wrapper automatically maps backend differences:
11
  # - TreeStump uses `node.kind` → mapped to `node.type`
12
  # - TreeStump uses `node.is_named?` → mapped to `node.named?`
13
  # - All backends return consistent Point objects from position methods
14
  #
15
  # @example Basic node traversal
16
  #   tree = parser.parse(source)
17
  #   root = tree.root_node
18
  #
19
  #   puts root.type        # => "document"
20
  #   puts root.start_byte  # => 0
21
  #   puts root.text        # => full source text
22
  #
23
  #   root.children.each do |child|
24
  #     puts "#{child.type} at line #{child.start_point.row + 1}"
25
  #   end
26
  #
27
  # @example Position information
28
  #   node = tree.root_node.children.first
29
  #
30
  #   # Point objects work as both objects and hashes
31
  #   point = node.start_point
32
  #   point.row              # => 0 (method access)
33
  #   point[:row]            # => 0 (hash access)
34
  #   point.column           # => 0
35
  #
36
  #   # Byte offsets
37
  #   node.start_byte        # => 0
38
  #   node.end_byte          # => 23
39
  #
40
  # @example Error detection
41
  #   if node.has_error?
42
  #     puts "Parse error in subtree"
43
  #   end
44
  #
45
  #   if node.missing?
46
  #     puts "This node was inserted by error recovery"
47
  #   end
48
  #
49
  # @example Accessing backend-specific features
50
  #   # Via passthrough (method_missing delegates to inner_node)
51
  #   node.grammar_name  # TreeStump-specific, automatically delegated
52
  #
53
  #   # Or explicitly via inner_node
54
  #   node.inner_node.grammar_name  # Same result
55
  #
56
  #   # Check if backend supports a feature
57
  #   if node.inner_node.respond_to?(:some_feature)
58
  #     node.some_feature
59
  #   end
60
  #
61
  # @note This is the key to tree_haver's "write once, run anywhere" promise
62
  class Node
3✔
63
    include Comparable
3✔
64
    include Enumerable
3✔
65

66
    # The wrapped backend-specific node object
67
    #
68
    # This provides direct access to the underlying backend node for advanced usage
69
    # when you need backend-specific features not exposed by the unified API.
70
    #
71
    # @return [Object] The underlying node (TreeSitter::Node, TreeStump::Node, etc.)
72
    # @example Accessing backend-specific methods
73
    #   # TreeStump-specific: grammar information
74
    #   if node.inner_node.respond_to?(:grammar_name)
75
    #     puts node.inner_node.grammar_name  # => "toml"
76
    #     puts node.inner_node.grammar_id    # => Integer
77
    #   end
78
    #
79
    #   # Check backend type
80
    #   case node.inner_node.class.name
81
    #   when /TreeStump/
82
    #     # TreeStump-specific code
83
    #   when /TreeSitter/
84
    #     # ruby_tree_sitter-specific code
85
    #   end
86
    attr_reader :inner_node
3✔
87

88
    # The source text for text extraction
89
    # @return [String]
90
    attr_reader :source
3✔
91

92
    # @param node [Object] Backend-specific node object
93
    # @param source [String] Source text for text extraction
94
    def initialize(node, source: nil)
3✔
95
      @inner_node = node
262✔
96
      @source = source
262✔
97
    end
98

99
    # Get the node's type/kind as a string
100
    #
101
    # Maps backend-specific methods to a unified API:
102
    # - ruby_tree_sitter: node.type
103
    # - tree_stump: node.kind
104
    # - FFI: node.type
105
    #
106
    # @return [String] The node type
107
    def type
3✔
108
      if @inner_node.respond_to?(:type)
50✔
109
        @inner_node.type.to_s
37✔
110
      elsif @inner_node.respond_to?(:kind)
13✔
111
        @inner_node.kind.to_s
10✔
112
      else
3✔
113
        raise TreeHaver::Error, "Backend node does not support type/kind"
3✔
114
      end
115
    end
116

117
    # Get the node's start byte offset
118
    # @return [Integer]
119
    def start_byte
3✔
120
      @inner_node.start_byte
28✔
121
    end
122

123
    # Get the node's end byte offset
124
    # @return [Integer]
125
    def end_byte
3✔
126
      @inner_node.end_byte
19✔
127
    end
128

129
    # Get the node's start position (row, column)
130
    #
131
    # @return [Point] with row and column accessors (also works as Hash)
132
    def start_point
3✔
133
      if @inner_node.respond_to?(:start_point)
75✔
134
        point = @inner_node.start_point
71✔
135
        # Handle both Point objects and hashes
136
        if point.is_a?(Hash)
71✔
137
          Point.new(point[:row], point[:column])
71✔
138
        else
×
UNCOV
139
          Point.new(point.row, point.column)
×
140
        end
4✔
141
      elsif @inner_node.respond_to?(:start_position)
4✔
142
        point = @inner_node.start_position
2✔
143
        # Handle both Point objects and hashes
144
        if point.is_a?(Hash)
2✔
145
          Point.new(point[:row], point[:column])
1✔
146
        else
1✔
147
          Point.new(point.row, point.column)
1✔
148
        end
149
      else
2✔
150
        raise TreeHaver::Error, "Backend node does not support start_point/start_position"
2✔
151
      end
152
    end
153

154
    # Get the node's end position (row, column)
155
    #
156
    # @return [Point] with row and column accessors (also works as Hash)
157
    def end_point
3✔
158
      if @inner_node.respond_to?(:end_point)
64✔
159
        point = @inner_node.end_point
60✔
160
        # Handle both Point objects and hashes
161
        if point.is_a?(Hash)
60✔
162
          Point.new(point[:row], point[:column])
60✔
163
        else
×
UNCOV
164
          Point.new(point.row, point.column)
×
165
        end
4✔
166
      elsif @inner_node.respond_to?(:end_position)
4✔
167
        point = @inner_node.end_position
2✔
168
        # Handle both Point objects and hashes
169
        if point.is_a?(Hash)
2✔
170
          Point.new(point[:row], point[:column])
1✔
171
        else
1✔
172
          Point.new(point.row, point.column)
1✔
173
        end
174
      else
2✔
175
        raise TreeHaver::Error, "Backend node does not support end_point/end_position"
2✔
176
      end
177
    end
178

179
    # Get the 1-based line number where this node starts
180
    #
181
    # Convenience method that converts 0-based row to 1-based line number.
182
    # This is useful for error messages and matching with editor line numbers.
183
    #
184
    # @return [Integer] 1-based line number
185
    def start_line
3✔
186
      start_point.row + 1
39✔
187
    end
188

189
    # Get the 1-based line number where this node ends
190
    #
191
    # Convenience method that converts 0-based row to 1-based line number.
192
    #
193
    # @return [Integer] 1-based line number
194
    def end_line
3✔
195
      end_point.row + 1
29✔
196
    end
197

198
    # Get position information as a hash
199
    #
200
    # Returns a hash with 1-based line numbers and 0-based columns.
201
    # This format is compatible with *-merge gems' FileAnalysisBase.
202
    #
203
    # @return [Hash{Symbol => Integer}] Position hash
204
    # @example
205
    #   node.source_position
206
    #   # => { start_line: 1, end_line: 3, start_column: 0, end_column: 10 }
207
    def source_position
3✔
208
      {
209
        start_line: start_line,
23✔
210
        end_line: end_line,
211
        start_column: start_point.column,
212
        end_column: end_point.column,
213
      }
214
    end
215

216
    # Get the first child node
217
    #
218
    # Convenience method for iteration patterns that expect first_child.
219
    #
220
    # @return [Node, nil] First child node or nil if no children
221
    def first_child
3✔
222
      child(0)
4✔
223
    end
224

225
    # Get the node's text content
226
    #
227
    # @return [String]
228
    def text
3✔
229
      if @inner_node.respond_to?(:text)
11✔
230
        @inner_node.text
7✔
231
      elsif @source
4✔
232
        # Fallback: extract from source using byte positions
2✔
233
        @source[start_byte...end_byte] || ""
2✔
234
      else
2✔
235
        raise TreeHaver::Error, "Cannot extract text: node has no text method and no source provided"
2✔
236
      end
237
    end
238

239
    # Check if the node has an error
240
    # @return [Boolean]
241
    def has_error?
3✔
242
      @inner_node.has_error?
4✔
243
    end
244

245
    # Check if the node is missing
246
    # @return [Boolean]
247
    def missing?
3✔
248
      return false unless @inner_node.respond_to?(:missing?)
4✔
249
      @inner_node.missing?
2✔
250
    end
251

252
    # Check if the node is named
253
    # @return [Boolean]
254
    def named?
3✔
255
      if @inner_node.respond_to?(:named?)
50✔
256
        @inner_node.named?
46✔
257
      elsif @inner_node.respond_to?(:is_named?)
4✔
258
        @inner_node.is_named?
2✔
259
      else
2✔
260
        true # Default to true if not supported
2✔
261
      end
262
    end
263

264
    # Check if the node is structural (non-terminal)
265
    #
266
    # In tree-sitter, this is equivalent to being a "named" node.
267
    # Named nodes represent actual syntactic constructs (e.g., table, keyvalue, string)
268
    # while anonymous nodes are syntax/punctuation (e.g., [, =, whitespace).
269
    #
270
    # For Citrus backends, this checks if the node is a non-terminal rule.
271
    #
272
    # @return [Boolean] true if this is a structural (non-terminal) node
273
    def structural?
3✔
274
      # Delegate to inner_node if it has its own structural? method (e.g., Citrus)
275
      if @inner_node.respond_to?(:structural?)
3✔
276
        @inner_node.structural?
2✔
277
      else
278
        # For tree-sitter backends, named? is equivalent to structural?
279
        # Named nodes are syntactic constructs; anonymous nodes are punctuation
1✔
280
        named?
1✔
281
      end
282
    end
283

284
    # Get the number of children
285
    # @return [Integer]
286
    def child_count
3✔
287
      @inner_node.child_count
47✔
288
    end
289

290
    # Get a child by index
291
    #
292
    # @param index [Integer] Child index
293
    # @return [Node, nil] Wrapped child node, or nil if index out of bounds
294
    def child(index)
3✔
295
      child_node = @inner_node.child(index)
79✔
296
      return if child_node.nil?
79✔
297
      Node.new(child_node, source: @source)
75✔
298
    rescue IndexError
299
      # Some backends (e.g., MRI w/ ruby_tree_sitter) raise IndexError for out of bounds
UNCOV
300
      nil
×
301
    end
302

303
    # Get a named child by index
304
    #
305
    # Returns the nth named child (skipping unnamed children).
306
    # Uses backend's native named_child if available, otherwise provides fallback.
307
    #
308
    # @param index [Integer] Named child index (0-based)
309
    # @return [Node, nil] Wrapped named child node, or nil if index out of bounds
310
    def named_child(index)
3✔
311
      # Try native implementation first
312
      if @inner_node.respond_to?(:named_child)
10✔
313
        child_node = @inner_node.named_child(index)
2✔
314
        return if child_node.nil?
2✔
315
        return Node.new(child_node, source: @source)
1✔
316
      end
317

318
      # Fallback: manually iterate through children and count named ones
319
      named_count = 0
8✔
320
      (0...child_count).each do |i|
8✔
321
        child_node = @inner_node.child(i)
17✔
322
        next if child_node.nil?
17!
323

324
        # Check if this child is named
325
        is_named = if child_node.respond_to?(:named?)
17✔
326
          child_node.named?
13✔
327
        elsif child_node.respond_to?(:is_named?)
4✔
328
          child_node.is_named?
1✔
329
        else
3✔
330
          true  # Assume named if we can't determine
3✔
331
        end
332

333
        if is_named
17✔
334
          return Node.new(child_node, source: @source) if named_count == index
11✔
335
          named_count += 1
5✔
336
        end
337
      end
338

339
      nil  # Index out of bounds
2✔
340
    end
341

342
    # Get the count of named children
343
    #
344
    # Uses backend's native named_child_count if available, otherwise provides fallback.
345
    #
346
    # @return [Integer] Number of named children
347
    def named_child_count
3✔
348
      # Try native implementation first
349
      if @inner_node.respond_to?(:named_child_count)
6✔
350
        return @inner_node.named_child_count
1✔
351
      end
352

353
      # Fallback: count named children manually
354
      count = 0
5✔
355
      (0...child_count).each do |i|
5✔
356
        child_node = @inner_node.child(i)
11✔
357
        next if child_node.nil?
11✔
358

359
        # Check if this child is named
360
        is_named = if child_node.respond_to?(:named?)
10✔
361
          child_node.named?
6✔
362
        elsif child_node.respond_to?(:is_named?)
4✔
363
          child_node.is_named?
2✔
364
        else
2✔
365
          true  # Assume named if we can't determine
2✔
366
        end
367

368
        count += 1 if is_named
10✔
369
      end
370

371
      count
5✔
372
    end
373

374
    # Get all children as wrapped nodes
375
    #
376
    # @return [Array<Node>] Array of wrapped child nodes
377
    def children
3✔
378
      (0...child_count).map { |i| child(i) }.compact
77✔
379
    end
380

381
    # Get named children only
382
    #
383
    # @return [Array<Node>] Array of named child nodes
384
    def named_children
3✔
385
      children.select(&:named?)
5✔
386
    end
387

388
    # Iterate over children
389
    #
390
    # @yield [Node] Each child node
391
    # @return [Enumerator, nil]
392
    def each(&block)
3✔
393
      return to_enum(__method__) unless block_given?
4✔
394
      children.each(&block)
2✔
395
    end
396

397
    # Get a child by field name
398
    #
399
    # @param name [String, Symbol] Field name
400
    # @return [Node, nil] The child node for that field
401
    def child_by_field_name(name)
3✔
402
      if @inner_node.respond_to?(:child_by_field_name)
3✔
403
        child_node = @inner_node.child_by_field_name(name.to_s)
2✔
404
        return if child_node.nil?
2✔
405
        Node.new(child_node, source: @source)
1✔
406
      else
407
        # Not all backends support field names
1✔
408
        nil
1✔
409
      end
410
    end
411

412
    # Alias for child_by_field_name
413
    alias_method :field, :child_by_field_name
3✔
414

415
    # Get the parent node
416
    #
417
    # @return [Node, nil] The parent node
418
    def parent
3✔
419
      return unless @inner_node.respond_to?(:parent)
2!
UNCOV
420
      parent_node = @inner_node.parent
×
UNCOV
421
      return if parent_node.nil?
×
UNCOV
422
      Node.new(parent_node, source: @source)
×
423
    end
424

425
    # Get next sibling
426
    #
427
    # @return [Node, nil]
428
    def next_sibling
3✔
429
      return unless @inner_node.respond_to?(:next_sibling)
2!
UNCOV
430
      sibling = @inner_node.next_sibling
×
UNCOV
431
      return if sibling.nil?
×
UNCOV
432
      Node.new(sibling, source: @source)
×
433
    end
434

435
    # Get previous sibling
436
    #
437
    # @return [Node, nil]
438
    def prev_sibling
3✔
439
      return unless @inner_node.respond_to?(:prev_sibling)
2✔
440
      sibling = @inner_node.prev_sibling
1✔
441
      return if sibling.nil?
1!
442
      Node.new(sibling, source: @source)
×
443
    end
444

445
    # String representation for debugging
446
    # @return [String]
447
    def inspect
3✔
448
      "#<#{self.class} type=#{type} bytes=#{start_byte}..#{end_byte}>"
1✔
449
    end
450

451
    # String representation
452
    # @return [String]
453
    def to_s
3✔
454
      text
1✔
455
    end
456

457
    # Compare nodes for ordering (used by Comparable module)
458
    #
459
    # Nodes are ordered by their position in the source:
460
    # 1. First by start_byte (earlier nodes come first)
461
    # 2. Then by end_byte for tie-breaking (shorter spans come first)
462
    # 3. Then by type for deterministic ordering
463
    #
464
    # This allows nodes to be sorted by position and used in sorted collections.
465
    # The Comparable module provides <, <=, ==, >=, >, and between? based on this.
466
    #
467
    # @param other [Node] node to compare with
468
    # @return [Integer, nil] -1, 0, 1, or nil if not comparable
469
    def <=>(other)
3✔
470
      return unless other.is_a?(Node)
10✔
471

472
      # Compare by position first (start_byte, then end_byte)
473
      cmp = start_byte <=> other.start_byte
7✔
474
      return cmp if cmp.nonzero?
7✔
475

476
      cmp = end_byte <=> other.end_byte
5✔
477
      return cmp if cmp.nonzero?
5✔
478

479
      # For nodes at the same position with same span, compare by type
480
      type <=> other.type
3✔
481
    end
482

483
    # Check equality based on inner_node identity
484
    #
485
    # Two nodes are equal if they wrap the same backend node object.
486
    # This is separate from the <=> comparison which orders by position.
487
    # Nodes at the same position but wrapping different backend nodes are
488
    # equal according to <=> (positional equality) but not equal according to == (identity equality).
489
    #
490
    # Note: We override Comparable's default == behavior to check inner_node identity
491
    # rather than just relying on <=> returning 0, because we want identity-based
492
    # equality for testing and collection membership, not position-based equality.
493
    #
494
    # @param other [Object] object to compare with
495
    # @return [Boolean] true if both nodes wrap the same inner_node
496
    def ==(other)
3✔
497
      return false unless other.is_a?(Node)
7✔
498
      @inner_node == other.inner_node
4✔
499
    end
500

501
    # Alias for == to support both styles
502
    alias_method :eql?, :==
3✔
503

504
    # Generate hash value for this node
505
    #
506
    # Uses the hash of the inner_node to ensure nodes wrapping the same
507
    # backend node have the same hash value.
508
    #
509
    # @return [Integer] hash value
510
    def hash
3✔
511
      @inner_node.hash
3✔
512
    end
513

514
    # Check if node responds to a method (includes delegation to inner_node)
515
    #
516
    # @param method_name [Symbol] method to check
517
    # @param include_private [Boolean] include private methods
518
    # @return [Boolean]
519
    def respond_to_missing?(method_name, include_private = false)
3✔
520
      @inner_node.respond_to?(method_name, include_private) || super
1✔
521
    end
522

523
    # Delegate unknown methods to the underlying backend-specific node
524
    #
525
    # This provides passthrough access for advanced usage when you need
526
    # backend-specific features not exposed by TreeHaver's unified API.
527
    #
528
    # The delegation is automatic and transparent - you can call backend-specific
529
    # methods directly on the TreeHaver::Node and they'll be forwarded to the
530
    # underlying node implementation.
531
    #
532
    # @param method_name [Symbol] method to call
533
    # @param args [Array] arguments to pass
534
    # @param block [Proc] block to pass
535
    # @return [Object] result from the underlying node
536
    #
537
    # @example Using TreeStump-specific methods
538
    #   # These methods don't exist in the unified API but are in TreeStump
539
    #   node.grammar_name      # => "toml" (delegated to inner_node)
540
    #   node.grammar_id        # => Integer (delegated to inner_node)
541
    #   node.kind_id           # => Integer (delegated to inner_node)
542
    #
543
    # @example Safe usage with respond_to? check
544
    #   if node.respond_to?(:grammar_name)
545
    #     puts "Using #{node.grammar_name} grammar"
546
    #   end
547
    #
548
    # @example Equivalent explicit access
549
    #   node.grammar_name              # Via passthrough (method_missing)
550
    #   node.inner_node.grammar_name   # Explicit access (same result)
551
    #
552
    # @note This maintains backward compatibility with code written for
553
    #   specific backends while providing the benefits of the unified API
554
    def method_missing(method_name, *args, **kwargs, &block)
3✔
555
      if @inner_node.respond_to?(method_name)
2✔
556
        @inner_node.public_send(method_name, *args, **kwargs, &block)
1✔
557
      else
1✔
558
        super
1✔
559
      end
560
    end
561
  end
562
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