tree_haver 7.0.0 → 7.1.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +1959 -0
  5. data/lib/tree_haver/backend_api.rb +392 -0
  6. data/lib/tree_haver/backend_registry.rb +153 -3
  7. data/lib/tree_haver/backends/citrus.rb +489 -0
  8. data/lib/tree_haver/backends/ffi.rb +1013 -0
  9. data/lib/tree_haver/backends/java.rb +909 -0
  10. data/lib/tree_haver/backends/mri.rb +367 -0
  11. data/lib/tree_haver/backends/parslet.rb +565 -0
  12. data/lib/tree_haver/backends/prism.rb +568 -0
  13. data/lib/tree_haver/backends/psych.rb +379 -0
  14. data/lib/tree_haver/backends/rust.rb +243 -0
  15. data/lib/tree_haver/backends/tslp.rb +274 -0
  16. data/lib/tree_haver/base/comment.rb +320 -0
  17. data/lib/tree_haver/base/language.rb +98 -0
  18. data/lib/tree_haver/base/node.rb +330 -0
  19. data/lib/tree_haver/base/parser.rb +28 -0
  20. data/lib/tree_haver/base/point.rb +48 -0
  21. data/lib/tree_haver/base/tree.rb +128 -0
  22. data/lib/tree_haver/citrus_grammar_finder.rb +213 -0
  23. data/lib/tree_haver/contracts.rb +661 -96
  24. data/lib/tree_haver/grammar_finder.rb +429 -0
  25. data/lib/tree_haver/kaitai_backend.rb +2 -2
  26. data/lib/tree_haver/language.rb +294 -0
  27. data/lib/tree_haver/language_pack.rb +17 -166
  28. data/lib/tree_haver/language_registry.rb +221 -0
  29. data/lib/tree_haver/library_path_utils.rb +80 -0
  30. data/lib/tree_haver/node.rb +588 -0
  31. data/lib/tree_haver/parser.rb +445 -0
  32. data/lib/tree_haver/parslet_grammar_finder.rb +217 -0
  33. data/lib/tree_haver/path_validator.rb +356 -0
  34. data/lib/tree_haver/peg_backends.rb +7 -7
  35. data/lib/tree_haver/point.rb +27 -0
  36. data/lib/tree_haver/rspec/dependency_tags.rb +52 -0
  37. data/lib/tree_haver/rspec.rb +3 -0
  38. data/lib/tree_haver/tree.rb +267 -0
  39. data/lib/tree_haver/version.rb +5 -3
  40. data/lib/tree_haver.rb +613 -8
  41. data/sig/tree_haver.rbs +6 -0
  42. data.tar.gz.sig +0 -0
  43. metadata +314 -13
  44. metadata.gz.sig +0 -0
@@ -0,0 +1,588 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TreeHaver
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 < Base::Node
63
+ # The wrapped backend-specific node object
64
+ #
65
+ # This provides direct access to the underlying backend node for advanced usage
66
+ # when you need backend-specific features not exposed by the unified API.
67
+ #
68
+ # @return [Object] The underlying node (TreeSitter::Node, TreeStump::Node, etc.)
69
+ # @example Accessing backend-specific methods
70
+ # # TreeStump-specific: grammar information
71
+ # if node.inner_node.respond_to?(:grammar_name)
72
+ # puts node.inner_node.grammar_name # => "toml"
73
+ # puts node.inner_node.grammar_id # => Integer
74
+ # end
75
+ #
76
+ # # Check backend type
77
+ # case node.inner_node.class.name
78
+ # when /TreeStump/
79
+ # # TreeStump-specific code
80
+ # when /TreeSitter/
81
+ # # ruby_tree_sitter-specific code
82
+ # end
83
+ # NOTE: inner_node is inherited from Base::Node
84
+
85
+ # The source text for text extraction
86
+ # @return [String]
87
+ # NOTE: source is inherited from Base::Node
88
+
89
+ # @param node [Object] Backend-specific node object
90
+ # @param source [String] Source text for text extraction
91
+ def initialize(node, source: nil)
92
+ super(node, source: source)
93
+ end
94
+
95
+ # Get the node's type/kind as a string
96
+ #
97
+ # Maps backend-specific methods to a unified API:
98
+ # - ruby_tree_sitter: node.type
99
+ # - tree_stump: node.kind
100
+ # - FFI: node.type
101
+ #
102
+ # @return [String] The node type
103
+ def type
104
+ if @inner_node.respond_to?(:type)
105
+ @inner_node.type.to_s
106
+ elsif @inner_node.respond_to?(:kind)
107
+ @inner_node.kind.to_s
108
+ else
109
+ raise TreeHaver::Error, 'Backend node does not support type/kind'
110
+ end
111
+ end
112
+
113
+ # Alias for type (tree_stump compatibility)
114
+ #
115
+ # tree_stump uses `kind` instead of `type` for node types.
116
+ # This method delegates to `type` so either can be used.
117
+ #
118
+ # @return [String] The node type
119
+ def kind
120
+ type
121
+ end
122
+
123
+ def start_byte
124
+ @inner_node.start_byte
125
+ end
126
+
127
+ # Get the node's end byte offset
128
+ # @return [Integer]
129
+ def end_byte
130
+ @inner_node.end_byte
131
+ end
132
+
133
+ # Get the node's start position (row, column)
134
+ #
135
+ # @return [Point] with row and column accessors (also works as Hash)
136
+ def start_point
137
+ if @inner_node.respond_to?(:start_point)
138
+ point = @inner_node.start_point
139
+ # Handle both Point objects and hashes
140
+ if point.is_a?(Hash)
141
+ Point.new(point[:row], point[:column])
142
+ else
143
+ Point.new(point.row, point.column)
144
+ end
145
+ elsif @inner_node.respond_to?(:start_position)
146
+ point = @inner_node.start_position
147
+ # Handle both Point objects and hashes
148
+ if point.is_a?(Hash)
149
+ Point.new(point[:row], point[:column])
150
+ else
151
+ Point.new(point.row, point.column)
152
+ end
153
+ else
154
+ raise TreeHaver::Error, 'Backend node does not support start_point/start_position'
155
+ end
156
+ end
157
+
158
+ # Get the node's end position (row, column)
159
+ #
160
+ # @return [Point] with row and column accessors (also works as Hash)
161
+ def end_point
162
+ if @inner_node.respond_to?(:end_point)
163
+ point = @inner_node.end_point
164
+ # Handle both Point objects and hashes
165
+ if point.is_a?(Hash)
166
+ Point.new(point[:row], point[:column])
167
+ else
168
+ Point.new(point.row, point.column)
169
+ end
170
+ elsif @inner_node.respond_to?(:end_position)
171
+ point = @inner_node.end_position
172
+ # Handle both Point objects and hashes
173
+ if point.is_a?(Hash)
174
+ Point.new(point[:row], point[:column])
175
+ else
176
+ Point.new(point.row, point.column)
177
+ end
178
+ else
179
+ raise TreeHaver::Error, 'Backend node does not support end_point/end_position'
180
+ end
181
+ end
182
+
183
+ # Get the 1-based line number where this node starts
184
+ #
185
+ # Convenience method that converts 0-based row to 1-based line number.
186
+ # This is useful for error messages and matching with editor line numbers.
187
+ #
188
+ # @return [Integer] 1-based line number
189
+ def start_line
190
+ start_point.row + 1
191
+ end
192
+
193
+ # Get the 1-based line number where this node ends
194
+ #
195
+ # Convenience method that converts 0-based row to 1-based line number.
196
+ #
197
+ # @return [Integer] 1-based line number
198
+ def end_line
199
+ end_point.row + 1
200
+ end
201
+
202
+ # Get position information as a hash
203
+ #
204
+ # Returns a hash with 1-based line numbers and 0-based columns.
205
+ # This format is compatible with *-merge gems' FileAnalysisBase.
206
+ #
207
+ # @return [Hash{Symbol => Integer}] Position hash
208
+ # @example
209
+ # node.source_position
210
+ # # => { start_line: 1, end_line: 3, start_column: 0, end_column: 10 }
211
+ def source_position
212
+ {
213
+ start_line: start_line,
214
+ end_line: end_line,
215
+ start_column: start_point.column,
216
+ end_column: end_point.column
217
+ }
218
+ end
219
+
220
+ # Get the first child node
221
+ #
222
+ # Convenience method for iteration patterns that expect first_child.
223
+ #
224
+ # @return [Node, nil] First child node or nil if no children
225
+ def first_child
226
+ child(0)
227
+ end
228
+
229
+ # Get the node's text content
230
+ #
231
+ # @return [String]
232
+ def text
233
+ if @inner_node.respond_to?(:text)
234
+ # Some backends (like TreeStump) require source as argument
235
+ # Check arity to determine how to call
236
+ arity = @inner_node.method(:text).arity
237
+ if [0, -1].include?(arity)
238
+ # No required arguments, or optional arguments only
239
+ @inner_node.text
240
+ elsif arity >= 1 && @source
241
+ # Has required argument(s) - pass source
242
+ @inner_node.text(@source)
243
+ elsif @source
244
+ # Fallback to byte extraction
245
+ @source[start_byte...end_byte] || ''
246
+ else
247
+ raise TreeHaver::Error, 'Cannot extract text: backend requires source but none provided'
248
+ end
249
+ elsif @source
250
+ # Fallback: extract from source using byte positions
251
+ @source[start_byte...end_byte] || ''
252
+ else
253
+ raise TreeHaver::Error, 'Cannot extract text: node has no text method and no source provided'
254
+ end
255
+ end
256
+
257
+ # Check if the node has an error
258
+ # @return [Boolean]
259
+ def has_error?
260
+ @inner_node.has_error?
261
+ end
262
+
263
+ # Check if the node is missing
264
+ # @return [Boolean]
265
+ def missing?
266
+ @inner_node.missing?
267
+ end
268
+
269
+ # Check if the node is named
270
+ # @return [Boolean]
271
+ def named?
272
+ if @inner_node.respond_to?(:named?)
273
+ @inner_node.named?
274
+ elsif @inner_node.respond_to?(:is_named?)
275
+ @inner_node.is_named?
276
+ else
277
+ true # Default to true if not supported
278
+ end
279
+ end
280
+
281
+ # Check if the node is structural (non-terminal)
282
+ #
283
+ # In tree-sitter, this is equivalent to being a "named" node.
284
+ # Named nodes represent actual syntactic constructs (e.g., table, keyvalue, string)
285
+ # while anonymous nodes are syntax/punctuation (e.g., [, =, whitespace).
286
+ #
287
+ # For Citrus backends, this checks if the node is a non-terminal rule.
288
+ #
289
+ # @return [Boolean] true if this is a structural (non-terminal) node
290
+ def structural?
291
+ # Delegate to inner_node if it has its own structural? method (e.g., Citrus)
292
+ if @inner_node.respond_to?(:structural?)
293
+ @inner_node.structural?
294
+ else
295
+ # For tree-sitter backends, named? is equivalent to structural?
296
+ # Named nodes are syntactic constructs; anonymous nodes are punctuation
297
+ named?
298
+ end
299
+ end
300
+
301
+ # Get the number of children
302
+ # @return [Integer]
303
+ def child_count
304
+ @inner_node.child_count
305
+ end
306
+
307
+ # Get a child by index
308
+ #
309
+ # @param index [Integer] Child index
310
+ # @return [Node, nil] Wrapped child node, or nil if index out of bounds
311
+ def child(index)
312
+ child_node = @inner_node.child(index)
313
+ return if child_node.nil?
314
+
315
+ Node.new(child_node, source: @source)
316
+ rescue IndexError
317
+ # Some backends (e.g., MRI w/ ruby_tree_sitter) raise IndexError for out of bounds
318
+ nil
319
+ end
320
+
321
+ # Get a named child by index
322
+ #
323
+ # Returns the nth named child (skipping unnamed children).
324
+ # Uses backend's native named_child if available, otherwise provides fallback.
325
+ #
326
+ # @param index [Integer] Named child index (0-based)
327
+ # @return [Node, nil] Wrapped named child node, or nil if index out of bounds
328
+ def named_child(index)
329
+ # Try native implementation first
330
+ if @inner_node.respond_to?(:named_child)
331
+ child_node = @inner_node.named_child(index)
332
+ return if child_node.nil?
333
+
334
+ return Node.new(child_node, source: @source)
335
+ end
336
+
337
+ # Fallback: manually iterate through children and count named ones
338
+ named_count = 0
339
+ (0...child_count).each do |i|
340
+ child_node = @inner_node.child(i)
341
+ next if child_node.nil?
342
+
343
+ # Check if this child is named
344
+ is_named = if child_node.respond_to?(:named?)
345
+ child_node.named?
346
+ elsif child_node.respond_to?(:is_named?)
347
+ child_node.is_named?
348
+ else
349
+ true # Assume named if we can't determine
350
+ end
351
+
352
+ next unless is_named
353
+ return Node.new(child_node, source: @source) if named_count == index
354
+
355
+ named_count += 1
356
+ end
357
+
358
+ nil # Index out of bounds
359
+ end
360
+
361
+ # Get the count of named children
362
+ #
363
+ # Uses backend's native named_child_count if available, otherwise provides fallback.
364
+ #
365
+ # @return [Integer] Number of named children
366
+ def named_child_count
367
+ # Try native implementation first
368
+ return @inner_node.named_child_count if @inner_node.respond_to?(:named_child_count)
369
+
370
+ # Fallback: count named children manually
371
+ count = 0
372
+ (0...child_count).each do |i|
373
+ child_node = @inner_node.child(i)
374
+ next if child_node.nil?
375
+
376
+ # Check if this child is named
377
+ is_named = if child_node.respond_to?(:named?)
378
+ child_node.named?
379
+ elsif child_node.respond_to?(:is_named?)
380
+ child_node.is_named?
381
+ else
382
+ true # Assume named if we can't determine
383
+ end
384
+
385
+ count += 1 if is_named
386
+ end
387
+
388
+ count
389
+ end
390
+
391
+ # Get all children as wrapped nodes
392
+ #
393
+ # @return [Array<Node>] Array of wrapped child nodes
394
+ def children
395
+ (0...child_count).map { |i| child(i) }.compact
396
+ end
397
+
398
+ # Get named children only
399
+ #
400
+ # @return [Array<Node>] Array of named child nodes
401
+ def named_children
402
+ children.select(&:named?)
403
+ end
404
+
405
+ # Iterate over children
406
+ #
407
+ # @yield [Node] Each child node
408
+ # @return [Enumerator, nil]
409
+ def each(&block)
410
+ return to_enum(__method__) unless block_given?
411
+
412
+ children.each(&block)
413
+ end
414
+
415
+ # Get a child by field name
416
+ #
417
+ # @param name [String, Symbol] Field name
418
+ # @return [Node, nil] The child node for that field
419
+ def child_by_field_name(name)
420
+ if @inner_node.respond_to?(:child_by_field_name)
421
+ child_node = @inner_node.child_by_field_name(name.to_s)
422
+ return if child_node.nil?
423
+
424
+ Node.new(child_node, source: @source)
425
+ else
426
+ # Not all backends support field names
427
+ nil
428
+ end
429
+ end
430
+
431
+ # Alias for child_by_field_name
432
+ alias field child_by_field_name
433
+
434
+ # Get the parent node
435
+ #
436
+ # @return [Node, nil] The parent node
437
+ def parent
438
+ return unless @inner_node.respond_to?(:parent)
439
+
440
+ parent_node = @inner_node.parent
441
+ return if parent_node.nil?
442
+
443
+ Node.new(parent_node, source: @source)
444
+ end
445
+
446
+ # Get next sibling
447
+ #
448
+ # @return [Node, nil]
449
+ def next_sibling
450
+ return unless @inner_node.respond_to?(:next_sibling)
451
+
452
+ sibling = @inner_node.next_sibling
453
+ return if sibling.nil?
454
+
455
+ Node.new(sibling, source: @source)
456
+ end
457
+
458
+ # Get previous sibling
459
+ #
460
+ # @return [Node, nil]
461
+ def prev_sibling
462
+ return unless @inner_node.respond_to?(:prev_sibling)
463
+
464
+ sibling = @inner_node.prev_sibling
465
+ return if sibling.nil?
466
+
467
+ Node.new(sibling, source: @source)
468
+ end
469
+
470
+ # String representation for debugging
471
+ # @return [String]
472
+ def inspect
473
+ "#<#{self.class} type=#{type} bytes=#{start_byte}..#{end_byte}>"
474
+ end
475
+
476
+ # String representation
477
+ # @return [String]
478
+ def to_s
479
+ text
480
+ end
481
+
482
+ # Compare nodes for ordering (used by Comparable module)
483
+ #
484
+ # Nodes are ordered by their position in the source:
485
+ # 1. First by start_byte (earlier nodes come first)
486
+ # 2. Then by end_byte for tie-breaking (shorter spans come first)
487
+ # 3. Then by type for deterministic ordering
488
+ #
489
+ # This allows nodes to be sorted by position and used in sorted collections.
490
+ # The Comparable module provides <, <=, ==, >=, >, and between? based on this.
491
+ #
492
+ # @param other [Node] node to compare with
493
+ # @return [Integer, nil] -1, 0, 1, or nil if not comparable
494
+ def <=>(other)
495
+ return unless other.is_a?(Node)
496
+
497
+ # Compare by position first (start_byte, then end_byte)
498
+ cmp = start_byte <=> other.start_byte
499
+ return cmp if cmp.nonzero?
500
+
501
+ cmp = end_byte <=> other.end_byte
502
+ return cmp if cmp.nonzero?
503
+
504
+ # For nodes at the same position with same span, compare by type
505
+ type <=> other.type
506
+ end
507
+
508
+ # Check equality based on inner_node identity
509
+ #
510
+ # Two nodes are equal if they wrap the same backend node object.
511
+ # This is separate from the <=> comparison which orders by position.
512
+ # Nodes at the same position but wrapping different backend nodes are
513
+ # equal according to <=> (positional equality) but not equal according to == (identity equality).
514
+ #
515
+ # Note: We override Comparable's default == behavior to check inner_node identity
516
+ # rather than just relying on <=> returning 0, because we want identity-based
517
+ # equality for testing and collection membership, not position-based equality.
518
+ #
519
+ # @param other [Object] object to compare with
520
+ # @return [Boolean] true if both nodes wrap the same inner_node
521
+ def ==(other)
522
+ return false unless other.is_a?(Node)
523
+
524
+ @inner_node == other.inner_node
525
+ end
526
+
527
+ # Alias for == to support both styles
528
+ alias eql? ==
529
+
530
+ # Generate hash value for this node
531
+ #
532
+ # Uses the hash of the inner_node to ensure nodes wrapping the same
533
+ # backend node have the same hash value.
534
+ #
535
+ # @return [Integer] hash value
536
+ def hash
537
+ @inner_node.hash
538
+ end
539
+
540
+ # Check if node responds to a method (includes delegation to inner_node)
541
+ #
542
+ # @param method_name [Symbol] method to check
543
+ # @param include_private [Boolean] include private methods
544
+ # @return [Boolean]
545
+ def respond_to_missing?(method_name, include_private = false)
546
+ @inner_node.respond_to?(method_name, include_private) || super
547
+ end
548
+
549
+ # Delegate unknown methods to the underlying backend-specific node
550
+ #
551
+ # This provides passthrough access for advanced usage when you need
552
+ # backend-specific features not exposed by TreeHaver's unified API.
553
+ #
554
+ # The delegation is automatic and transparent - you can call backend-specific
555
+ # methods directly on the TreeHaver::Node and they'll be forwarded to the
556
+ # underlying node implementation.
557
+ #
558
+ # @param method_name [Symbol] method to call
559
+ # @param args [Array] arguments to pass
560
+ # @param block [Proc] block to pass
561
+ # @return [Object] result from the underlying node
562
+ #
563
+ # @example Using TreeStump-specific methods
564
+ # # These methods don't exist in the unified API but are in TreeStump
565
+ # node.grammar_name # => "toml" (delegated to inner_node)
566
+ # node.grammar_id # => Integer (delegated to inner_node)
567
+ # node.kind_id # => Integer (delegated to inner_node)
568
+ #
569
+ # @example Safe usage with respond_to? check
570
+ # if node.respond_to?(:grammar_name)
571
+ # puts "Using #{node.grammar_name} grammar"
572
+ # end
573
+ #
574
+ # @example Equivalent explicit access
575
+ # node.grammar_name # Via passthrough (method_missing)
576
+ # node.inner_node.grammar_name # Explicit access (same result)
577
+ #
578
+ # @note This maintains backward compatibility with code written for
579
+ # specific backends while providing the benefits of the unified API
580
+ def method_missing(method_name, *args, **kwargs, &block)
581
+ if @inner_node.respond_to?(method_name)
582
+ @inner_node.public_send(method_name, *args, **kwargs, &block)
583
+ else
584
+ super
585
+ end
586
+ end
587
+ end
588
+ end