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,1013 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TreeHaver
4
+ module Backends
5
+ # FFI-based backend for calling libtree-sitter directly
6
+ #
7
+ # This backend uses Ruby FFI (JNR-FFI on JRuby) to call the native tree-sitter
8
+ # C library without requiring MRI C extensions.
9
+ #
10
+ # The FFI backend currently supports:
11
+ # - Parsing source code
12
+ # - AST node traversal
13
+ # - Accessing node types and children
14
+ #
15
+ # Not yet supported:
16
+ # - Query API (tree-sitter queries/patterns)
17
+ #
18
+ # == Tree/Node Architecture
19
+ #
20
+ # This backend defines raw `FFI::Tree` and `FFI::Node` wrapper classes that
21
+ # provide minimal FFI bindings to the tree-sitter C structs. These are **not**
22
+ # intended for direct use by application code.
23
+ #
24
+ # The wrapping hierarchy is:
25
+ # FFI::Tree/Node (raw FFI wrappers) → TreeHaver::Tree/Node → Base::Tree/Node
26
+ #
27
+ # When you use `TreeHaver::Parser#parse`:
28
+ # 1. `FFI::Parser#parse` returns an `FFI::Tree` (raw pointer wrapper)
29
+ # 2. `TreeHaver::Parser` wraps it in `TreeHaver::Tree` (adds source storage)
30
+ # 3. `TreeHaver::Tree#root_node` wraps `FFI::Node` in `TreeHaver::Node`
31
+ #
32
+ # The `TreeHaver::Tree` and `TreeHaver::Node` wrappers provide the full unified
33
+ # API including `#children`, `#text`, `#source`, `#source_position`, etc.
34
+ #
35
+ # This differs from pure-Ruby backends (Citrus, Parslet, Prism, Psych) which
36
+ # define Tree/Node classes that directly inherit from Base::Tree/Base::Node.
37
+ #
38
+ # @see TreeHaver::Tree The wrapper class users should interact with
39
+ # @see TreeHaver::Node The wrapper class users should interact with
40
+ # @see TreeHaver::Base::Tree Base class documenting the Tree API contract
41
+ # @see TreeHaver::Base::Node Base class documenting the Node API contract
42
+ #
43
+ # == Platform Compatibility
44
+ #
45
+ # - MRI Ruby: ✓ Full support
46
+ # - JRuby: ✓ Full support (uses JNR-FFI)
47
+ # - TruffleRuby: ✗ TruffleRuby's FFI doesn't support STRUCT_BY_VALUE return types
48
+ # (used by ts_tree_root_node, ts_node_child, ts_node_start_point, etc.)
49
+ #
50
+ # @note Requires the `ffi` gem and libtree-sitter shared library to be installed
51
+ # @see https://github.com/ffi/ffi Ruby FFI
52
+ # @see https://tree-sitter.github.io/tree-sitter/ tree-sitter
53
+ module FFI
54
+ # Module-level availability and capability methods
55
+ #
56
+ # These methods provide API consistency with other backends.
57
+ class << self
58
+ # Check if the FFI backend is available
59
+ #
60
+ # The FFI backend requires:
61
+ # - The ffi gem to be installed
62
+ # - NOT running on TruffleRuby (STRUCT_BY_VALUE limitation)
63
+ # - MRI backend (ruby_tree_sitter) not already loaded (symbol conflicts)
64
+ #
65
+ # @return [Boolean] true if FFI backend can be used
66
+ # @example
67
+ # if TreeHaver::Backends::FFI.available?
68
+ # puts "FFI backend is ready"
69
+ # end
70
+ def available?
71
+ return false unless ffi_gem_available?
72
+
73
+ # Check if MRI backend has been loaded (which blocks FFI)
74
+ !defined?(::TreeSitter::Parser)
75
+ end
76
+
77
+ # Check if the FFI gem can be loaded and is usable for tree-sitter
78
+ #
79
+ # @return [Boolean] true if FFI gem can be loaded and works with tree-sitter
80
+ # @api private
81
+ # @note Returns false on TruffleRuby because TruffleRuby's FFI doesn't support
82
+ # STRUCT_BY_VALUE return types (used by ts_tree_root_node, ts_node_child, etc.)
83
+ def ffi_gem_available?
84
+ return @loaded if @load_attempted
85
+
86
+ @load_attempted = true
87
+
88
+ @loaded = begin
89
+ # TruffleRuby's FFI doesn't support STRUCT_BY_VALUE return types
90
+ # which tree-sitter uses extensively (ts_tree_root_node, ts_node_child, etc.)
91
+ # simplecov:disable TruffleRuby returns false early - subsequent FFI code paths unreachable on TruffleRuby
92
+ if RUBY_ENGINE == 'truffleruby'
93
+ false
94
+ # simplecov:enable
95
+ else
96
+ require 'ffi'
97
+ true
98
+ end
99
+ rescue LoadError
100
+ false
101
+ # simplecov:disable defensive code - StandardError during require is extremely rare
102
+ rescue StandardError
103
+ false
104
+ # simplecov:enable
105
+ end
106
+ @loaded
107
+ end
108
+
109
+ # Reset the load state (primarily for testing)
110
+ #
111
+ # @return [void]
112
+ # @api private
113
+ def reset!
114
+ @load_attempted = false
115
+ @loaded = false
116
+ end
117
+
118
+ # Get capabilities supported by this backend
119
+ #
120
+ # @return [Hash{Symbol => Object}] capability map
121
+ # @example
122
+ # TreeHaver::Backends::FFI.capabilities
123
+ # # => { backend: :ffi, parse: true, query: false, bytes_field: true, comment_support: :nodes_only }
124
+ def capabilities
125
+ return {} unless available?
126
+
127
+ {
128
+ backend: :ffi,
129
+ parse: true,
130
+ query: false, # Query API not yet implemented in FFI backend
131
+ bytes_field: true,
132
+ incremental: false,
133
+ comment_support: :nodes_only
134
+ }
135
+ end
136
+ end
137
+
138
+ # Native FFI bindings to libtree-sitter
139
+ #
140
+ # This module handles loading the tree-sitter runtime library and defining
141
+ # FFI function attachments for the core tree-sitter API.
142
+ #
143
+ # All FFI operations are lazy - nothing is loaded until actually needed.
144
+ # This prevents polluting the Ruby environment at require time.
145
+ #
146
+ # @api private
147
+ module Native
148
+ class << self
149
+ # Lazily extend with FFI::Library only when needed
150
+ #
151
+ # @return [Boolean] true if FFI was successfully extended
152
+ def ensure_ffi_extended!
153
+ return true if @ffi_extended
154
+
155
+ raise TreeHaver::NotAvailable, 'FFI gem is not available' unless FFI.ffi_gem_available?
156
+
157
+ extend(::FFI::Library)
158
+
159
+ define_ts_point_struct!
160
+ define_ts_node_struct!
161
+ @ffi_extended = true
162
+ end
163
+
164
+ # Define the TSPoint struct lazily
165
+ # @api private
166
+ def define_ts_point_struct!
167
+ return if const_defined?(:TSPoint, false)
168
+
169
+ # FFI struct representation of TSPoint
170
+ # Mirrors the C struct layout: struct { uint32_t row; uint32_t column; }
171
+ ts_point_class = Class.new(::FFI::Struct) do
172
+ layout :row,
173
+ :uint32,
174
+ :column,
175
+ :uint32
176
+ end
177
+ const_set(:TSPoint, ts_point_class)
178
+ typedef(ts_point_class.by_value, :ts_point)
179
+ end
180
+
181
+ # Define the TSNode struct lazily
182
+ # @api private
183
+ def define_ts_node_struct!
184
+ return if const_defined?(:TSNode, false)
185
+
186
+ # FFI struct representation of TSNode
187
+ # Mirrors the C struct layout used by tree-sitter
188
+ ts_node_class = Class.new(::FFI::Struct) do
189
+ layout :context,
190
+ [:uint32, 4],
191
+ :id,
192
+ :pointer,
193
+ :tree,
194
+ :pointer
195
+ end
196
+ const_set(:TSNode, ts_node_class)
197
+ typedef(ts_node_class.by_value, :ts_node)
198
+ end
199
+
200
+ # Get the TSNode class, ensuring it's defined
201
+ # @return [Class] the TSNode FFI struct class
202
+ def ts_node_class
203
+ ensure_ffi_extended!
204
+ const_get(:TSNode)
205
+ end
206
+
207
+ # Get list of candidate library names for loading libtree-sitter
208
+ #
209
+ # The list is built dynamically to respect environment variables set at runtime.
210
+ # If TREE_SITTER_RUNTIME_LIB is set, it is tried first.
211
+ #
212
+ # @note TREE_SITTER_LIB is intentionally NOT supported
213
+ # @return [Array<String>] list of library names to try
214
+ def lib_candidates
215
+ [
216
+ ENV['TREE_SITTER_RUNTIME_LIB'],
217
+ 'tree-sitter',
218
+ 'libtree-sitter.so.0',
219
+ 'libtree-sitter.so',
220
+ 'libtree-sitter.dylib',
221
+ 'libtree-sitter.dll'
222
+ ].compact
223
+ end
224
+
225
+ # Load the tree-sitter runtime library
226
+ #
227
+ # Tries each candidate library name in order until one succeeds.
228
+ # After loading, attaches FFI function definitions for the tree-sitter API.
229
+ #
230
+ # @raise [TreeHaver::NotAvailable] if no library can be loaded
231
+ # @return [void]
232
+ def try_load!
233
+ return if @loaded
234
+
235
+ ensure_ffi_extended!
236
+
237
+ # Warn about potential conflicts with MRI backend
238
+ if defined?(::TreeSitter) && defined?(::TreeSitter::Parser) && $VERBOSE
239
+ warn('TreeHaver: FFI backend loading after ruby_tree_sitter (MRI backend). ' \
240
+ 'This may cause symbol conflicts due to different libtree-sitter versions. ' \
241
+ 'Consider using only one backend per process, or set TREE_SITTER_RUNTIME_LIB ' \
242
+ 'to match the version used by ruby_tree_sitter.')
243
+ end
244
+
245
+ last_error = nil
246
+ candidates = lib_candidates
247
+ lib_loaded = false
248
+ candidates.each do |name|
249
+ ffi_lib(name)
250
+ lib_loaded = true
251
+ break
252
+ rescue LoadError => e
253
+ # NOTE: FFI::NotFoundError inherits from LoadError, so it's caught here too
254
+ last_error = e
255
+ end
256
+
257
+ unless lib_loaded
258
+ # simplecov:disable
259
+ tried = candidates.join(', ')
260
+ env_hint = ENV['TREE_SITTER_RUNTIME_LIB'] ? " TREE_SITTER_RUNTIME_LIB=#{ENV['TREE_SITTER_RUNTIME_LIB']}." : ''
261
+ msg = if last_error
262
+ "Could not load libtree-sitter (tried: #{tried}).#{env_hint} #{last_error.class}: #{last_error.message}"
263
+ else
264
+ "Could not load libtree-sitter (tried: #{tried}).#{env_hint}"
265
+ end
266
+ raise TreeHaver::NotAvailable, msg
267
+ # simplecov:enable
268
+ end
269
+
270
+ # Attach functions after lib is selected
271
+ # Note: TruffleRuby's FFI doesn't support STRUCT_BY_VALUE return types,
272
+ # so these attach_function calls will fail on TruffleRuby.
273
+ attach_function(:ts_parser_new, [], :pointer)
274
+ attach_function(:ts_parser_delete, [:pointer], :void)
275
+ attach_function(:ts_parser_set_language, %i[pointer pointer], :bool)
276
+ attach_function(:ts_parser_parse_string, %i[pointer pointer string uint32], :pointer)
277
+
278
+ attach_function(:ts_tree_delete, [:pointer], :void)
279
+ attach_function(:ts_tree_root_node, [:pointer], :ts_node)
280
+
281
+ attach_function(:ts_node_type, [:ts_node], :string)
282
+ attach_function(:ts_node_child_count, [:ts_node], :uint32)
283
+ attach_function(:ts_node_child, %i[ts_node uint32], :ts_node)
284
+ attach_function(:ts_node_child_by_field_name, %i[ts_node string uint32], :ts_node)
285
+ attach_function(:ts_node_start_byte, [:ts_node], :uint32)
286
+ attach_function(:ts_node_end_byte, [:ts_node], :uint32)
287
+ attach_function(:ts_node_start_point, [:ts_node], :ts_point)
288
+ attach_function(:ts_node_end_point, [:ts_node], :ts_point)
289
+ attach_function(:ts_node_is_null, [:ts_node], :bool)
290
+ attach_function(:ts_node_is_named, [:ts_node], :bool)
291
+ attach_function(:ts_node_is_missing, [:ts_node], :bool)
292
+ attach_function(:ts_node_has_error, [:ts_node], :bool)
293
+
294
+ # Node navigation functions
295
+ attach_function(:ts_node_parent, [:ts_node], :ts_node)
296
+ attach_function(:ts_node_next_sibling, [:ts_node], :ts_node)
297
+ attach_function(:ts_node_prev_sibling, [:ts_node], :ts_node)
298
+ attach_function(:ts_node_next_named_sibling, [:ts_node], :ts_node)
299
+ attach_function(:ts_node_prev_named_sibling, [:ts_node], :ts_node)
300
+ attach_function(:ts_node_named_child, %i[ts_node uint32], :ts_node)
301
+ attach_function(:ts_node_named_child_count, [:ts_node], :uint32)
302
+
303
+ # Descendant lookup functions
304
+ attach_function(:ts_node_descendant_for_byte_range, %i[ts_node uint32 uint32], :ts_node)
305
+ attach_function(:ts_node_descendant_for_point_range, %i[ts_node ts_point ts_point], :ts_node)
306
+ attach_function(:ts_node_named_descendant_for_byte_range, %i[ts_node uint32 uint32], :ts_node)
307
+ attach_function(:ts_node_named_descendant_for_point_range, %i[ts_node ts_point ts_point], :ts_node)
308
+
309
+ # Only mark as fully loaded after all attach_function calls succeed
310
+ @loaded = true
311
+ end
312
+
313
+ def loaded?
314
+ !!@loaded
315
+ end
316
+ end
317
+ end
318
+
319
+ # Represents a tree-sitter language loaded via FFI
320
+ #
321
+ # Holds a pointer to a TSLanguage struct from a loaded shared library.
322
+ class Language
323
+ include Comparable
324
+
325
+ # The FFI pointer to the TSLanguage struct
326
+ # @return [FFI::Pointer]
327
+ attr_reader :pointer
328
+
329
+ # The backend this language is for
330
+ # @return [Symbol]
331
+ attr_reader :backend
332
+
333
+ # The path this language was loaded from (if known)
334
+ # @return [String, nil]
335
+ attr_reader :path
336
+
337
+ # The symbol name (if known)
338
+ # @return [String, nil]
339
+ attr_reader :symbol
340
+
341
+ # @api private
342
+ # @param ptr [FFI::Pointer] pointer to TSLanguage
343
+ # @param lib [FFI::DynamicLibrary, nil] the opened dynamic library
344
+ # (kept as an instance variable to prevent it being GC'd/unloaded)
345
+ # @param path [String, nil] path language was loaded from
346
+ # @param symbol [String, nil] symbol name
347
+ def initialize(ptr, lib = nil, path: nil, symbol: nil)
348
+ @pointer = ptr
349
+ @backend = :ffi
350
+ @path = path
351
+ @symbol = symbol
352
+ # Keep a reference to the DynamicLibrary that produced the language
353
+ # pointer so it isn't garbage-collected and unloaded while the
354
+ # pointer is still in use by the parser. Not keeping this reference
355
+ # can lead to the language pointer becoming invalid and causing
356
+ # segmentation faults when passed to native functions.
357
+ @library = lib
358
+ end
359
+
360
+ # Compare languages for equality
361
+ #
362
+ # FFI languages are equal if they have the same backend, path, and symbol.
363
+ # Path and symbol uniquely identify a loaded language.
364
+ #
365
+ # @param other [Object] object to compare with
366
+ # @return [Integer, nil] -1, 0, 1, or nil if not comparable
367
+ def <=>(other)
368
+ return unless other.is_a?(Language)
369
+ return unless other.backend == @backend
370
+
371
+ # Compare by path first, then symbol
372
+ cmp = (@path || '') <=> (other.path || '')
373
+ return cmp if cmp.nonzero?
374
+
375
+ (@symbol || '') <=> (other.symbol || '')
376
+ end
377
+
378
+ # Hash value for this language (for use in Sets/Hashes)
379
+ # @return [Integer]
380
+ def hash
381
+ [@backend, @path, @symbol].hash
382
+ end
383
+
384
+ # Alias eql? to ==
385
+ alias eql? ==
386
+
387
+ # Get the language name
388
+ #
389
+ # Derives a name from the symbol or path.
390
+ #
391
+ # @return [Symbol] language name
392
+ def language_name
393
+ # Try to derive from symbol (e.g., "tree_sitter_toml" -> :toml)
394
+ if @symbol
395
+ name = @symbol.to_s.sub(/^tree_sitter_/, '')
396
+ return name.to_sym
397
+ end
398
+
399
+ # Try to derive from path (e.g., "/path/to/libtree-sitter-toml.so" -> :toml)
400
+ if @path
401
+ name = LibraryPathUtils.derive_language_name_from_path(@path)
402
+ return name.to_sym if name
403
+ end
404
+
405
+ :unknown
406
+ end
407
+
408
+ # Alias for language_name (API compatibility)
409
+ alias name language_name
410
+
411
+ # Convert to FFI pointer for passing to native functions
412
+ #
413
+ # @return [FFI::Pointer]
414
+ def to_ptr
415
+ @pointer
416
+ end
417
+
418
+ # Load a language from a shared library
419
+ #
420
+ # The library must export a function that returns a pointer to a TSLanguage struct.
421
+ # Symbol resolution uses this precedence (when symbol: not provided):
422
+ # 1. ENV["TREE_SITTER_LANG_SYMBOL"]
423
+ # 2. Guessed from filename (e.g., "libtree-sitter-toml.so" → "tree_sitter_toml")
424
+ # 3. Default fallback ("tree_sitter_toml")
425
+ #
426
+ # @param path [String] absolute path to the language shared library
427
+ # @param symbol [String, nil] explicit exported function name (highest precedence)
428
+ # @param name [String, nil] optional logical name (accepted for compatibility, not used)
429
+ # @return [Language] loaded language handle
430
+ # @raise [TreeHaver::NotAvailable] if FFI not available or library cannot be loaded
431
+ # @example
432
+ # lang = TreeHaver::Backends::FFI::Language.from_library(
433
+ # "/usr/local/lib/libtree-sitter-toml.so",
434
+ # symbol: "tree_sitter_toml"
435
+ # )
436
+ class << self
437
+ def from_library(path, symbol: nil, name: nil)
438
+ raise TreeHaver::NotAvailable, 'FFI not available' unless Backends::FFI.available?
439
+
440
+ # Check for MRI backend conflict BEFORE loading the grammar
441
+ # If ruby_tree_sitter has already loaded this grammar file, the dynamic
442
+ # linker will return the cached library with symbols resolved against
443
+ # MRI's statically-linked tree-sitter, causing segfaults when FFI
444
+ # tries to use the pointer with its dynamically-linked libtree-sitter.
445
+ # MRI backend has been loaded - check if it might have loaded this grammar
446
+ # We can't reliably detect which grammars MRI loaded, so we warn and
447
+ # attempt to proceed. The segfault will occur when setting language on parser.
448
+ if defined?(::TreeSitter::Language) && $VERBOSE
449
+ warn('TreeHaver: FFI backend loading grammar after ruby_tree_sitter (MRI backend). ' \
450
+ 'This may cause segfaults due to tree-sitter symbol conflicts. ' \
451
+ 'For reliable operation, use only one backend per process.')
452
+ end
453
+
454
+ # Ensure the core libtree-sitter runtime is loaded first so
455
+ # the language shared library resolves its symbols against the
456
+ # same runtime. This prevents cases where the language pointer
457
+ # is incompatible with the parser (different lib instances).
458
+ Native.try_load!
459
+
460
+ begin
461
+ # Prefer resolving symbols immediately and globally so the
462
+ # language library links to the already-loaded libtree-sitter
463
+ # (RTLD_NOW | RTLD_GLOBAL). If those constants are not present
464
+ # fall back to RTLD_LAZY for maximum compatibility.
465
+ flags = if defined?(::FFI::DynamicLibrary::RTLD_NOW) && defined?(::FFI::DynamicLibrary::RTLD_GLOBAL)
466
+ ::FFI::DynamicLibrary::RTLD_NOW | ::FFI::DynamicLibrary::RTLD_GLOBAL
467
+ else
468
+ ::FFI::DynamicLibrary::RTLD_LAZY
469
+ end
470
+
471
+ dl = ::FFI::DynamicLibrary.open(path, flags)
472
+ rescue LoadError, RuntimeError => e
473
+ # TruffleRuby raises RuntimeError instead of LoadError when a shared library cannot be opened
474
+ raise TreeHaver::NotAvailable, "Could not open language library at #{path}: #{e.message}"
475
+ end
476
+
477
+ requested = symbol || ENV['TREE_SITTER_LANG_SYMBOL']
478
+ # Use shared utility for consistent symbol derivation across backends
479
+ guessed_symbol = LibraryPathUtils.derive_symbol_from_path(path)
480
+ # If an override was provided (arg or ENV), treat it as strict and do not fall back.
481
+ # Only when no override is provided do we attempt guessed and default candidates.
482
+ candidates = if requested && !requested.to_s.empty?
483
+ [requested]
484
+ else
485
+ [guessed_symbol, 'tree_sitter_toml'].compact.uniq
486
+ end
487
+
488
+ func = nil
489
+ last_err = nil
490
+ candidates.each do |name|
491
+ addr = dl.find_function(name)
492
+ func = ::FFI::Function.new(:pointer, [], addr)
493
+ break
494
+ rescue StandardError => e
495
+ last_err = e
496
+ end
497
+ unless func
498
+ env_used = []
499
+ env_used << "TREE_SITTER_LANG_SYMBOL=#{ENV['TREE_SITTER_LANG_SYMBOL']}" if ENV['TREE_SITTER_LANG_SYMBOL']
500
+ detail = env_used.empty? ? '' : " Env overrides: #{env_used.join(', ')}."
501
+ raise TreeHaver::NotAvailable,
502
+ "Could not resolve language symbol in #{path} (tried: #{candidates.join(', ')}).#{detail} #{last_err&.message}"
503
+ end
504
+
505
+ # Only ensure the core lib is loaded when we actually need to interact with it
506
+ # (e.g., during parsing). Creating the Language handle does not require core to be loaded.
507
+ ptr = func.call
508
+ raise TreeHaver::NotAvailable, "Language factory returned NULL for #{path}" if ptr.null?
509
+
510
+ # Pass the opened DynamicLibrary into the Language instance so the
511
+ # library handle remains alive for the lifetime of the Language.
512
+ new(ptr, dl, path: path, symbol: symbol)
513
+ end
514
+
515
+ # Backward-compatible alias
516
+ alias from_path from_library
517
+ end
518
+ end
519
+
520
+ # FFI-based tree-sitter parser
521
+ #
522
+ # Wraps a TSParser pointer and manages its lifecycle with a finalizer.
523
+ class Parser
524
+ # Create a new parser instance
525
+ #
526
+ # @raise [TreeHaver::NotAvailable] if FFI not available or parser creation fails
527
+ def initialize
528
+ raise TreeHaver::NotAvailable, 'FFI not available' unless Backends::FFI.available?
529
+
530
+ Native.try_load!
531
+ @parser = Native.ts_parser_new
532
+ raise TreeHaver::NotAvailable, 'Failed to create ts_parser' if @parser.null?
533
+
534
+ # NOTE: We intentionally do NOT register a finalizer here because:
535
+ # 1. ts_parser_delete can segfault if called during certain GC scenarios
536
+ # 2. The native library may be unloaded before finalizers run
537
+ # 3. Parser cleanup happens automatically on process exit
538
+ # 4. Long-running processes should explicitly manage parser lifecycle
539
+ #
540
+ # If you need explicit cleanup in long-running processes, store the
541
+ # parser in an instance variable and call a cleanup method explicitly
542
+ # when done, rather than relying on GC finalizers.
543
+ end
544
+
545
+ # Set the language for this parser
546
+ #
547
+ # Note: FFI backend is special - it receives the wrapped Language object
548
+ # because it needs to call to_ptr to get the FFI pointer. TreeHaver::Parser
549
+ # detects FFI Language wrappers (respond_to?(:to_ptr)) and passes them through.
550
+ #
551
+ # @param lang [Language] the FFI language wrapper (not unwrapped)
552
+ # @return [Language] the language that was set
553
+ # @raise [TreeHaver::NotAvailable] if setting the language fails
554
+ def language=(lang)
555
+ # Defensive check: ensure we received an FFI Language wrapper
556
+ unless lang.is_a?(Language)
557
+ raise TreeHaver::NotAvailable,
558
+ "FFI backend expected FFI::Language wrapper, got #{lang.class}. " \
559
+ 'This usually means TreeHaver::Parser#unwrap_language passed the wrong type. ' \
560
+ 'Check that language caching respects backend boundaries.'
561
+ end
562
+
563
+ # Additional check: verify the language is actually for FFI backend
564
+ if lang.respond_to?(:backend) && lang.backend != :ffi
565
+ raise TreeHaver::NotAvailable,
566
+ "FFI backend received Language for wrong backend: #{lang.backend}. " \
567
+ "Expected :ffi backend. Class: #{lang.class}. " \
568
+ "Path: #{lang.path.inspect}, Symbol: #{lang.symbol.inspect}"
569
+ end
570
+
571
+ # Verify the DynamicLibrary is still valid (not GC'd)
572
+ # The Language stores @library to prevent this, but let's verify
573
+ lib = lang.instance_variable_get(:@library)
574
+ if lib.nil?
575
+ raise TreeHaver::NotAvailable,
576
+ 'FFI Language has no library reference. The dynamic library may have been unloaded. ' \
577
+ "Path: #{lang.path.inspect}, Symbol: #{lang.symbol.inspect}"
578
+ end
579
+
580
+ # Verify the language has a valid pointer
581
+ ptr = lang.to_ptr
582
+
583
+ # Check ptr is actually an FFI::Pointer
584
+ unless ptr.is_a?(::FFI::Pointer)
585
+ raise TreeHaver::NotAvailable,
586
+ "FFI Language#to_ptr returned #{ptr.class}, expected FFI::Pointer. " \
587
+ "Language class: #{lang.class}. " \
588
+ "Path: #{lang.path.inspect}, Symbol: #{lang.symbol.inspect}"
589
+ end
590
+
591
+ ptr_address = ptr.address
592
+
593
+ # Check for NULL (0x0)
594
+ if ptr.nil? || ptr_address.zero?
595
+ raise TreeHaver::NotAvailable,
596
+ 'FFI Language has NULL pointer. Language may not have loaded correctly. ' \
597
+ "Path: #{lang.path.inspect}, Symbol: #{lang.symbol.inspect}"
598
+ end
599
+
600
+ # Check for small invalid addresses (< 4KB are typically unmapped memory)
601
+ # Common invalid addresses like 0x40 (64) indicate corrupted or uninitialized pointers
602
+ if ptr_address < 4096
603
+ raise TreeHaver::NotAvailable,
604
+ "FFI Language has invalid pointer (address 0x#{ptr_address.to_s(16)}). " \
605
+ 'This usually indicates the language library was unloaded or never loaded correctly. ' \
606
+ "Path: #{lang.path.inspect}, Symbol: #{lang.symbol.inspect}"
607
+ end
608
+
609
+ # NOTE: MRI backend conflict is now handled by TreeHaver::BackendConflict
610
+ # at a higher level (in TreeHaver.resolve_backend_module)
611
+
612
+ # lang is a wrapped FFI::Language that has to_ptr method
613
+ ok = Native.ts_parser_set_language(@parser, ptr)
614
+ raise TreeHaver::NotAvailable, 'Failed to set language on parser' unless ok
615
+
616
+ lang # rubocop:disable Lint/Void (intentional return value)
617
+ end
618
+
619
+ # Parse source code into a syntax tree
620
+ #
621
+ # @param source [String] the source code to parse (should be UTF-8)
622
+ # @return [Tree] raw backend tree (wrapping happens in TreeHaver::Parser)
623
+ # @raise [TreeHaver::NotAvailable] if parsing fails
624
+ def parse(source)
625
+ src = String(source)
626
+ tree_ptr = Native.ts_parser_parse_string(@parser, ::FFI::Pointer::NULL, src, src.bytesize)
627
+ raise TreeHaver::NotAvailable, 'Parse returned NULL' if tree_ptr.null?
628
+
629
+ # Return raw FFI::Tree - TreeHaver::Parser will wrap it
630
+ Tree.new(tree_ptr)
631
+ end
632
+ end
633
+
634
+ # FFI-based tree-sitter tree
635
+ #
636
+ # Wraps a TSTree pointer and manages its lifecycle with a finalizer.
637
+ #
638
+ # Note: Tree objects DO use finalizers (unlike Parser objects) because:
639
+ # 1. Trees are typically short-lived and numerous (one per parse)
640
+ # 2. ts_tree_delete is safer than ts_parser_delete during GC
641
+ # 3. Memory leaks from accumulated trees are more problematic
642
+ # 4. The finalizer silently ignores errors for safety
643
+ class Tree
644
+ # @api private
645
+ # @param ptr [FFI::Pointer] pointer to TSTree
646
+ def initialize(ptr)
647
+ @ptr = ptr
648
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@ptr))
649
+ end
650
+
651
+ # @api private
652
+ # @param ptr [FFI::Pointer] pointer to TSTree
653
+ class << self
654
+ # Returns a finalizer proc that deletes the tree
655
+ #
656
+ # This is public API for testing purposes, but not intended for
657
+ # direct use. The finalizer is automatically registered when
658
+ # creating a Tree object.
659
+ #
660
+ # @return [Proc] finalizer that deletes the tree
661
+ def finalizer(ptr)
662
+ proc {
663
+ begin
664
+ Native.ts_tree_delete(ptr)
665
+ rescue StandardError
666
+ # Silently ignore errors during finalization to prevent crashes
667
+ # during GC. If the library is unloaded or ptr is invalid, we
668
+ # don't want to crash the entire process.
669
+ nil
670
+ end
671
+ }
672
+ end
673
+ end
674
+
675
+ # Get the root node of the syntax tree
676
+ #
677
+ # @return [Node] the root node
678
+ def root_node
679
+ node_val = Native.ts_tree_root_node(@ptr)
680
+ Node.new(node_val)
681
+ end
682
+ end
683
+
684
+ # FFI-based tree-sitter node (raw backend node)
685
+ #
686
+ # This is a **raw backend node** that wraps a TSNode by-value struct from the
687
+ # tree-sitter C API. It provides the minimal interface needed for tree-sitter
688
+ # operations but is NOT intended for direct use by application code.
689
+ #
690
+ # == Architecture Note
691
+ #
692
+ # Unlike pure-Ruby backends (Citrus, Parslet, Prism, Psych) which define Node
693
+ # classes that inherit from `TreeHaver::Base::Node`, tree-sitter backends (MRI,
694
+ # Rust, FFI, Java) define raw wrapper classes that get wrapped by `TreeHaver::Node`.
695
+ #
696
+ # The wrapping hierarchy is:
697
+ # FFI::Node (this class) → TreeHaver::Node → Base::Node
698
+ #
699
+ # When you use `TreeHaver::Parser#parse`, the returned tree's nodes are already
700
+ # wrapped in `TreeHaver::Node`, which provides the full unified API including:
701
+ # - `#children` - Array of child nodes
702
+ # - `#text` - Extract text from source
703
+ # - `#first_child`, `#last_child` - Convenience accessors
704
+ # - `#start_line`, `#end_line` - 1-based line numbers
705
+ # - `#source_position` - Hash with position info
706
+ # - `#each`, `#map`, etc. - Enumerable methods
707
+ # - `#to_s`, `#inspect` - String representations
708
+ #
709
+ # This raw class only implements methods that require direct FFI calls to the
710
+ # tree-sitter C library. The wrapper adds Ruby-level conveniences.
711
+ #
712
+ # @api private
713
+ # @see TreeHaver::Node The wrapper class users should interact with
714
+ # @see TreeHaver::Base::Node The base class documenting the full Node API
715
+ class Node
716
+ include Enumerable
717
+
718
+ # @api private
719
+ # @param ts_node_value [Native::TSNode] the TSNode struct (by value)
720
+ def initialize(ts_node_value)
721
+ # Store by-value struct (FFI will copy); methods pass it back by value
722
+ @val = ts_node_value
723
+ end
724
+
725
+ # Get the type name of this node
726
+ #
727
+ # @return [String] the node type (e.g., "document", "table", "pair")
728
+ def type
729
+ Native.ts_node_type(@val)
730
+ end
731
+
732
+ # Get the number of children
733
+ #
734
+ # @return [Integer] child count
735
+ def child_count
736
+ Native.ts_node_child_count(@val)
737
+ end
738
+
739
+ # Get a child by index
740
+ #
741
+ # @param index [Integer] child index
742
+ # @return [Node, nil] child node or nil if index out of bounds
743
+ def child(index)
744
+ return if index >= child_count || index < 0
745
+
746
+ child_node = Native.ts_node_child(@val, index)
747
+ Node.new(child_node)
748
+ end
749
+
750
+ # Get a child node by field name
751
+ #
752
+ # Tree-sitter grammars define named fields for certain child positions.
753
+ # For example, in JSON, a "pair" node has "key" and "value" fields.
754
+ #
755
+ # @param field_name [String] the field name to look up
756
+ # @return [Node, nil] the child node, or nil if no child has that field
757
+ # @example Get the key from a JSON pair
758
+ # pair.child_by_field_name("key") #=> Node (type: "string")
759
+ # pair.child_by_field_name("value") #=> Node (type: "string" or "number", etc.)
760
+ def child_by_field_name(field_name)
761
+ name = String(field_name)
762
+ child_node = Native.ts_node_child_by_field_name(@val, name, name.bytesize)
763
+ # ts_node_child_by_field_name returns a null node if field not found
764
+ return if Native.ts_node_is_null(child_node)
765
+
766
+ Node.new(child_node)
767
+ end
768
+
769
+ # Get start byte offset
770
+ #
771
+ # @return [Integer]
772
+ def start_byte
773
+ Native.ts_node_start_byte(@val)
774
+ end
775
+
776
+ # Get end byte offset
777
+ #
778
+ # @return [Integer]
779
+ def end_byte
780
+ Native.ts_node_end_byte(@val)
781
+ end
782
+
783
+ # Get start point
784
+ #
785
+ # @return [TreeHaver::Point] with row and column
786
+ def start_point
787
+ point = Native.ts_node_start_point(@val)
788
+ # TSPoint is returned by value as an FFI::Struct with :row and :column fields
789
+ TreeHaver::Point.new(point[:row], point[:column])
790
+ end
791
+
792
+ # Get end point
793
+ #
794
+ # @return [TreeHaver::Point] with row and column
795
+ def end_point
796
+ point = Native.ts_node_end_point(@val)
797
+ # TSPoint is returned by value as an FFI::Struct with :row and :column fields
798
+ TreeHaver::Point.new(point[:row], point[:column])
799
+ end
800
+
801
+ # Check if node has error
802
+ #
803
+ # Returns true if this node or any of its descendants have a syntax error.
804
+ # This is the FFI equivalent of tree-sitter's ts_node_has_error.
805
+ #
806
+ # @return [Boolean] true if node subtree contains errors
807
+ def has_error?
808
+ # Explicit boolean conversion ensures consistent behavior across Ruby versions
809
+ # FFI :bool return type may behave differently on some platforms
810
+ !!Native.ts_node_has_error(@val)
811
+ end
812
+
813
+ # Check if this is a MISSING node
814
+ #
815
+ # A MISSING node represents a token that was expected by the grammar
816
+ # but was not found in the source. Tree-sitter inserts MISSING nodes
817
+ # to allow parsing to continue despite syntax errors.
818
+ #
819
+ # @return [Boolean] true if this is a MISSING node
820
+ def missing?
821
+ !!Native.ts_node_is_missing(@val)
822
+ end
823
+
824
+ # Check if this is a named node
825
+ #
826
+ # Named nodes represent syntactic constructs (e.g., "pair", "object").
827
+ # Anonymous nodes represent syntax/punctuation (e.g., "{", ",").
828
+ #
829
+ # @return [Boolean] true if this is a named node
830
+ def named?
831
+ !!Native.ts_node_is_named(@val)
832
+ end
833
+
834
+ # Get the parent node
835
+ #
836
+ # @return [Node, nil] parent node or nil if this is the root
837
+ def parent
838
+ parent_node = Native.ts_node_parent(@val)
839
+ return if Native.ts_node_is_null(parent_node)
840
+
841
+ Node.new(parent_node)
842
+ end
843
+
844
+ # Get the next sibling node
845
+ #
846
+ # @return [Node, nil] next sibling or nil if none
847
+ def next_sibling
848
+ sibling = Native.ts_node_next_sibling(@val)
849
+ return if Native.ts_node_is_null(sibling)
850
+
851
+ Node.new(sibling)
852
+ end
853
+
854
+ # Get the previous sibling node
855
+ #
856
+ # @return [Node, nil] previous sibling or nil if none
857
+ def prev_sibling
858
+ sibling = Native.ts_node_prev_sibling(@val)
859
+ return if Native.ts_node_is_null(sibling)
860
+
861
+ Node.new(sibling)
862
+ end
863
+
864
+ # Get the next named sibling node
865
+ #
866
+ # @return [Node, nil] next named sibling or nil if none
867
+ def next_named_sibling
868
+ sibling = Native.ts_node_next_named_sibling(@val)
869
+ return if Native.ts_node_is_null(sibling)
870
+
871
+ Node.new(sibling)
872
+ end
873
+
874
+ # Get the previous named sibling node
875
+ #
876
+ # @return [Node, nil] previous named sibling or nil if none
877
+ def prev_named_sibling
878
+ sibling = Native.ts_node_prev_named_sibling(@val)
879
+ return if Native.ts_node_is_null(sibling)
880
+
881
+ Node.new(sibling)
882
+ end
883
+
884
+ # Get a named child by index
885
+ #
886
+ # @param index [Integer] named child index (0-based)
887
+ # @return [Node, nil] named child or nil if index out of bounds
888
+ def named_child(index)
889
+ return if index < 0 || index >= named_child_count
890
+
891
+ child_node = Native.ts_node_named_child(@val, index)
892
+ return if Native.ts_node_is_null(child_node)
893
+
894
+ Node.new(child_node)
895
+ end
896
+
897
+ # Get the count of named children
898
+ #
899
+ # @return [Integer] number of named children
900
+ def named_child_count
901
+ Native.ts_node_named_child_count(@val)
902
+ end
903
+
904
+ # Find the smallest descendant that spans the given byte range
905
+ #
906
+ # @param start_byte [Integer] start byte offset
907
+ # @param end_byte [Integer] end byte offset
908
+ # @return [Node, nil] descendant node or nil if not found
909
+ def descendant_for_byte_range(start_byte, end_byte)
910
+ node = Native.ts_node_descendant_for_byte_range(@val, start_byte, end_byte)
911
+ return if Native.ts_node_is_null(node)
912
+
913
+ Node.new(node)
914
+ end
915
+
916
+ # Find the smallest named descendant that spans the given byte range
917
+ #
918
+ # @param start_byte [Integer] start byte offset
919
+ # @param end_byte [Integer] end byte offset
920
+ # @return [Node, nil] named descendant node or nil if not found
921
+ def named_descendant_for_byte_range(start_byte, end_byte)
922
+ node = Native.ts_node_named_descendant_for_byte_range(@val, start_byte, end_byte)
923
+ return if Native.ts_node_is_null(node)
924
+
925
+ Node.new(node)
926
+ end
927
+
928
+ # Find the smallest descendant that spans the given point range
929
+ #
930
+ # @param start_point [TreeHaver::Point, Hash] start point with :row and :column
931
+ # @param end_point [TreeHaver::Point, Hash] end point with :row and :column
932
+ # @return [Node, nil] descendant node or nil if not found
933
+ def descendant_for_point_range(start_point, end_point)
934
+ start_pt = Native::TSPoint.new
935
+ start_pt[:row] = start_point.respond_to?(:row) ? start_point.row : start_point[:row]
936
+ start_pt[:column] = start_point.respond_to?(:column) ? start_point.column : start_point[:column]
937
+
938
+ end_pt = Native::TSPoint.new
939
+ end_pt[:row] = end_point.respond_to?(:row) ? end_point.row : end_point[:row]
940
+ end_pt[:column] = end_point.respond_to?(:column) ? end_point.column : end_point[:column]
941
+
942
+ node = Native.ts_node_descendant_for_point_range(@val, start_pt, end_pt)
943
+ return if Native.ts_node_is_null(node)
944
+
945
+ Node.new(node)
946
+ end
947
+
948
+ # Find the smallest named descendant that spans the given point range
949
+ #
950
+ # @param start_point [TreeHaver::Point, Hash] start point with :row and :column
951
+ # @param end_point [TreeHaver::Point, Hash] end point with :row and :column
952
+ # @return [Node, nil] named descendant node or nil if not found
953
+ def named_descendant_for_point_range(start_point, end_point)
954
+ start_pt = Native::TSPoint.new
955
+ start_pt[:row] = start_point.respond_to?(:row) ? start_point.row : start_point[:row]
956
+ start_pt[:column] = start_point.respond_to?(:column) ? start_point.column : start_point[:column]
957
+
958
+ end_pt = Native::TSPoint.new
959
+ end_pt[:row] = end_point.respond_to?(:row) ? end_point.row : end_point[:row]
960
+ end_pt[:column] = end_point.respond_to?(:column) ? end_point.column : end_point[:column]
961
+
962
+ node = Native.ts_node_named_descendant_for_point_range(@val, start_pt, end_pt)
963
+ return if Native.ts_node_is_null(node)
964
+
965
+ Node.new(node)
966
+ end
967
+
968
+ # Iterate over child nodes
969
+ #
970
+ # @yieldparam child [Node] each child node
971
+ # @return [Enumerator, nil] an enumerator if no block given, nil otherwise
972
+ def each
973
+ return enum_for(:each) unless block_given?
974
+
975
+ count = child_count
976
+ i = 0
977
+ while i < count
978
+ child = Native.ts_node_child(@val, i)
979
+ yield Node.new(child)
980
+ i += 1
981
+ end
982
+ nil
983
+ end
984
+
985
+ # Compare nodes for ordering (used by Comparable module)
986
+ #
987
+ # Nodes are ordered by their position in the source:
988
+ # 1. First by start_byte (earlier nodes come first)
989
+ # 2. Then by end_byte for tie-breaking (shorter spans come first)
990
+ # 3. Then by type for deterministic ordering
991
+ #
992
+ # @param other [Node] node to compare with
993
+ # @return [Integer, nil] -1, 0, 1, or nil if not comparable
994
+ def <=>(other)
995
+ return unless other.is_a?(Node)
996
+
997
+ cmp = start_byte <=> other.start_byte
998
+ return cmp if cmp.nonzero?
999
+
1000
+ cmp = end_byte <=> other.end_byte
1001
+ return cmp if cmp.nonzero?
1002
+
1003
+ type <=> other.type
1004
+ end
1005
+ end
1006
+
1007
+ # Register the availability checker for RSpec dependency tags
1008
+ TreeHaver::BackendRegistry.register_availability_checker(:ffi) do
1009
+ available?
1010
+ end
1011
+ end
1012
+ end
1013
+ end