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

kettle-rb / tree_haver / 20693260440

04 Jan 2026 12:58PM UTC coverage: 86.866%. Remained the same
20693260440

push

github

pboling
✨ Plugin API for to register backends and languages

### Added

- **External backend registration via `backend_module`** - External gems can now register
  their own pure Ruby backends using the same API as built-in backends. This enables gems
  like rbs-merge to integrate with `TreeHaver.parser_for` without modifying tree_haver:
  ```ruby
  TreeHaver.register_language(
    :rbs,
    backend_module: Rbs::Merge::Backends::RbsBackend,
    backend_type: :rbs,
    gem_name: "rbs"
  )
  # Now TreeHaver.parser_for(:rbs) works!
  ```
- **`Backends::PURE_RUBY_BACKENDS` constant** - Maps pure Ruby backend names to their
  language and module info. Used for auto-registration of built-in backends.
- **`TreeHaver.register_builtin_backends!`** - Registers built-in pure Ruby backends
  (Prism, Psych, Commonmarker, Markly) in the LanguageRegistry using the same API that
  external backends use. Called automatically by `parser_for` on first use.
- **`TreeHaver.ensure_builtin_backends_registered!`** - Idempotent helper that ensures
  built-in backends are registered exactly once.
- **`parser_for` now supports registered `backend_module` backends** - When a language
  has a registered `backend_module`, `parser_for` will use it. This enables external
  gems to provide language support without tree-sitter grammars:
  - Checks LanguageRegistry for registered `backend_module` entries
  - Creates parser from the backend module's `Parser` and `Language` classes
  - Falls back to tree-sitter and Citrus if no backend_module matches
- **RBS dependency tags in `DependencyTags`** - New RSpec tags for RBS parsing:
  - `:rbs_grammar` - tree-sitter-rbs grammar is available and parsing works
  - `:rbs_parsing` - at least one RBS parser (rbs gem OR tree-sitter-rbs) is available
  - `:rbs_gem` - the official rbs gem is available (MRI only)
  - Negated versions: `:not_rbs_grammar`, `:not_rbs_parsing`, `:not_rbs_gem`
  - New availability methods: `tree_sitter_rbs_available?`, `rbs_gem_av... (continued)

786 of 1051 branches covered (74.79%)

Branch coverage included in aggregate %.

44 of 69 new or added lines in 7 files covered. (63.77%)

31 existing lines in 2 files now uncovered.

2230 of 2421 relevant lines covered (92.11%)

37.65 hits per line

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

95.8
/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
275✔
96
      @source = source
275✔
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)
51✔
109
        @inner_node.type.to_s
46✔
110
      elsif @inner_node.respond_to?(:kind)
5✔
111
        @inner_node.kind.to_s
2✔
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
32✔
121
    end
122

123
    # Get the node's end byte offset
124
    # @return [Integer]
125
    def end_byte
3✔
126
      @inner_node.end_byte
23✔
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)
64✔
134
        point = @inner_node.start_point
60✔
135
        # Handle both Point objects and hashes
136
        if point.is_a?(Hash)
60✔
137
          Point.new(point[:row], point[:column])
27✔
138
        else
33✔
139
          Point.new(point.row, point.column)
33✔
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)
55✔
159
        point = @inner_node.end_point
51✔
160
        # Handle both Point objects and hashes
161
        if point.is_a?(Hash)
51✔
162
          Point.new(point[:row], point[:column])
27✔
163
        else
24✔
164
          Point.new(point.row, point.column)
24✔
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
30✔
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
24✔
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,
18✔
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
        # Some backends (like TreeStump) require source as argument
231
        # Check arity to determine how to call
3✔
232
        arity = @inner_node.method(:text).arity
3✔
233
        if arity == 0 || arity == -1
3✔
234
          # No required arguments, or optional arguments only
3✔
235
          @inner_node.text
3!
NEW
236
        elsif arity >= 1 && @source
×
237
          # Has required argument(s) - pass source
×
NEW
238
          @inner_node.text(@source)
×
NEW
239
        elsif @source
×
240
          # Fallback to byte extraction
×
NEW
241
          @source[start_byte...end_byte] || ""
×
242
        else
×
NEW
243
          raise TreeHaver::Error, "Cannot extract text: backend requires source but none provided"
×
244
        end
8✔
245
      elsif @source
8✔
246
        # Fallback: extract from source using byte positions
6✔
247
        @source[start_byte...end_byte] || ""
6✔
248
      else
2✔
249
        raise TreeHaver::Error, "Cannot extract text: node has no text method and no source provided"
2✔
250
      end
251
    end
252

253
    # Check if the node has an error
254
    # @return [Boolean]
255
    def has_error?
3✔
256
      @inner_node.has_error?
4✔
257
    end
258

259
    # Check if the node is missing
260
    # @return [Boolean]
261
    def missing?
3✔
262
      return false unless @inner_node.respond_to?(:missing?)
4✔
263
      @inner_node.missing?
2✔
264
    end
265

266
    # Check if the node is named
267
    # @return [Boolean]
268
    def named?
3✔
269
      if @inner_node.respond_to?(:named?)
45✔
270
        @inner_node.named?
41✔
271
      elsif @inner_node.respond_to?(:is_named?)
4✔
272
        @inner_node.is_named?
2✔
273
      else
2✔
274
        true # Default to true if not supported
2✔
275
      end
276
    end
277

278
    # Check if the node is structural (non-terminal)
279
    #
280
    # In tree-sitter, this is equivalent to being a "named" node.
281
    # Named nodes represent actual syntactic constructs (e.g., table, keyvalue, string)
282
    # while anonymous nodes are syntax/punctuation (e.g., [, =, whitespace).
283
    #
284
    # For Citrus backends, this checks if the node is a non-terminal rule.
285
    #
286
    # @return [Boolean] true if this is a structural (non-terminal) node
287
    def structural?
3✔
288
      # Delegate to inner_node if it has its own structural? method (e.g., Citrus)
289
      if @inner_node.respond_to?(:structural?)
3✔
290
        @inner_node.structural?
2✔
291
      else
292
        # For tree-sitter backends, named? is equivalent to structural?
293
        # Named nodes are syntactic constructs; anonymous nodes are punctuation
1✔
294
        named?
1✔
295
      end
296
    end
297

298
    # Get the number of children
299
    # @return [Integer]
300
    def child_count
3✔
301
      @inner_node.child_count
53✔
302
    end
303

304
    # Get a child by index
305
    #
306
    # @param index [Integer] Child index
307
    # @return [Node, nil] Wrapped child node, or nil if index out of bounds
308
    def child(index)
3✔
309
      child_node = @inner_node.child(index)
71✔
310
      return if child_node.nil?
69✔
311
      Node.new(child_node, source: @source)
66✔
312
    rescue IndexError
313
      # Some backends (e.g., MRI w/ ruby_tree_sitter) raise IndexError for out of bounds
314
      nil
2✔
315
    end
316

317
    # Get a named child by index
318
    #
319
    # Returns the nth named child (skipping unnamed children).
320
    # Uses backend's native named_child if available, otherwise provides fallback.
321
    #
322
    # @param index [Integer] Named child index (0-based)
323
    # @return [Node, nil] Wrapped named child node, or nil if index out of bounds
324
    def named_child(index)
3✔
325
      # Try native implementation first
326
      if @inner_node.respond_to?(:named_child)
12✔
327
        child_node = @inner_node.named_child(index)
2✔
328
        return if child_node.nil?
2✔
329
        return Node.new(child_node, source: @source)
1✔
330
      end
331

332
      # Fallback: manually iterate through children and count named ones
333
      named_count = 0
10✔
334
      (0...child_count).each do |i|
10✔
335
        child_node = @inner_node.child(i)
19✔
336
        next if child_node.nil?
19!
337

338
        # Check if this child is named
339
        is_named = if child_node.respond_to?(:named?)
19✔
340
          child_node.named?
13✔
341
        elsif child_node.respond_to?(:is_named?)
6✔
342
          child_node.is_named?
3✔
343
        else
3✔
344
          true  # Assume named if we can't determine
3✔
345
        end
346

347
        if is_named
19✔
348
          return Node.new(child_node, source: @source) if named_count == index
12✔
349
          named_count += 1
5✔
350
        end
351
      end
352

353
      nil  # Index out of bounds
3✔
354
    end
355

356
    # Get the count of named children
357
    #
358
    # Uses backend's native named_child_count if available, otherwise provides fallback.
359
    #
360
    # @return [Integer] Number of named children
361
    def named_child_count
3✔
362
      # Try native implementation first
363
      if @inner_node.respond_to?(:named_child_count)
6✔
364
        return @inner_node.named_child_count
1✔
365
      end
366

367
      # Fallback: count named children manually
368
      count = 0
5✔
369
      (0...child_count).each do |i|
5✔
370
        child_node = @inner_node.child(i)
11✔
371
        next if child_node.nil?
11✔
372

373
        # Check if this child is named
374
        is_named = if child_node.respond_to?(:named?)
10✔
375
          child_node.named?
6✔
376
        elsif child_node.respond_to?(:is_named?)
4✔
377
          child_node.is_named?
2✔
378
        else
2✔
379
          true  # Assume named if we can't determine
2✔
380
        end
381

382
        count += 1 if is_named
10✔
383
      end
384

385
      count
5✔
386
    end
387

388
    # Get all children as wrapped nodes
389
    #
390
    # @return [Array<Node>] Array of wrapped child nodes
391
    def children
3✔
392
      (0...child_count).map { |i| child(i) }.compact
66✔
393
    end
394

395
    # Get named children only
396
    #
397
    # @return [Array<Node>] Array of named child nodes
398
    def named_children
3✔
399
      children.select(&:named?)
5✔
400
    end
401

402
    # Iterate over children
403
    #
404
    # @yield [Node] Each child node
405
    # @return [Enumerator, nil]
406
    def each(&block)
3✔
407
      return to_enum(__method__) unless block_given?
4✔
408
      children.each(&block)
2✔
409
    end
410

411
    # Get a child by field name
412
    #
413
    # @param name [String, Symbol] Field name
414
    # @return [Node, nil] The child node for that field
415
    def child_by_field_name(name)
3✔
416
      if @inner_node.respond_to?(:child_by_field_name)
4✔
417
        child_node = @inner_node.child_by_field_name(name.to_s)
3✔
418
        return if child_node.nil?
3✔
419
        Node.new(child_node, source: @source)
1✔
420
      else
421
        # Not all backends support field names
1✔
422
        nil
1✔
423
      end
424
    end
425

426
    # Alias for child_by_field_name
427
    alias_method :field, :child_by_field_name
3✔
428

429
    # Get the parent node
430
    #
431
    # @return [Node, nil] The parent node
432
    def parent
3✔
433
      return unless @inner_node.respond_to?(:parent)
6✔
434
      parent_node = @inner_node.parent
5✔
435
      return if parent_node.nil?
5✔
436
      Node.new(parent_node, source: @source)
4✔
437
    end
438

439
    # Get next sibling
440
    #
441
    # @return [Node, nil]
442
    def next_sibling
3✔
443
      return unless @inner_node.respond_to?(:next_sibling)
4✔
444
      sibling = @inner_node.next_sibling
3✔
445
      return if sibling.nil?
3✔
446
      Node.new(sibling, source: @source)
2✔
447
    end
448

449
    # Get previous sibling
450
    #
451
    # @return [Node, nil]
452
    def prev_sibling
3✔
453
      return unless @inner_node.respond_to?(:prev_sibling)
4✔
454
      sibling = @inner_node.prev_sibling
3✔
455
      return if sibling.nil?
3✔
456
      Node.new(sibling, source: @source)
1✔
457
    end
458

459
    # String representation for debugging
460
    # @return [String]
461
    def inspect
3✔
462
      "#<#{self.class} type=#{type} bytes=#{start_byte}..#{end_byte}>"
1✔
463
    end
464

465
    # String representation
466
    # @return [String]
467
    def to_s
3✔
468
      text
1✔
469
    end
470

471
    # Compare nodes for ordering (used by Comparable module)
472
    #
473
    # Nodes are ordered by their position in the source:
474
    # 1. First by start_byte (earlier nodes come first)
475
    # 2. Then by end_byte for tie-breaking (shorter spans come first)
476
    # 3. Then by type for deterministic ordering
477
    #
478
    # This allows nodes to be sorted by position and used in sorted collections.
479
    # The Comparable module provides <, <=, ==, >=, >, and between? based on this.
480
    #
481
    # @param other [Node] node to compare with
482
    # @return [Integer, nil] -1, 0, 1, or nil if not comparable
483
    def <=>(other)
3✔
484
      return unless other.is_a?(Node)
10✔
485

486
      # Compare by position first (start_byte, then end_byte)
487
      cmp = start_byte <=> other.start_byte
7✔
488
      return cmp if cmp.nonzero?
7✔
489

490
      cmp = end_byte <=> other.end_byte
5✔
491
      return cmp if cmp.nonzero?
5✔
492

493
      # For nodes at the same position with same span, compare by type
494
      type <=> other.type
3✔
495
    end
496

497
    # Check equality based on inner_node identity
498
    #
499
    # Two nodes are equal if they wrap the same backend node object.
500
    # This is separate from the <=> comparison which orders by position.
501
    # Nodes at the same position but wrapping different backend nodes are
502
    # equal according to <=> (positional equality) but not equal according to == (identity equality).
503
    #
504
    # Note: We override Comparable's default == behavior to check inner_node identity
505
    # rather than just relying on <=> returning 0, because we want identity-based
506
    # equality for testing and collection membership, not position-based equality.
507
    #
508
    # @param other [Object] object to compare with
509
    # @return [Boolean] true if both nodes wrap the same inner_node
510
    def ==(other)
3✔
511
      return false unless other.is_a?(Node)
7✔
512
      @inner_node == other.inner_node
4✔
513
    end
514

515
    # Alias for == to support both styles
516
    alias_method :eql?, :==
3✔
517

518
    # Generate hash value for this node
519
    #
520
    # Uses the hash of the inner_node to ensure nodes wrapping the same
521
    # backend node have the same hash value.
522
    #
523
    # @return [Integer] hash value
524
    def hash
3✔
525
      @inner_node.hash
3✔
526
    end
527

528
    # Check if node responds to a method (includes delegation to inner_node)
529
    #
530
    # @param method_name [Symbol] method to check
531
    # @param include_private [Boolean] include private methods
532
    # @return [Boolean]
533
    def respond_to_missing?(method_name, include_private = false)
3✔
534
      @inner_node.respond_to?(method_name, include_private) || super
2✔
535
    end
536

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