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,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TreeHaver
4
+ # Unified Parser facade providing a consistent API across all backends
5
+ #
6
+ # This class acts as a facade/adapter that delegates to backend-specific
7
+ # parser implementations. It automatically selects the appropriate backend
8
+ # and provides a unified interface regardless of which parser is being used.
9
+ #
10
+ # == Backend Selection
11
+ #
12
+ # The parser automatically selects a backend based on:
13
+ # 1. Explicit `backend:` parameter in constructor
14
+ # 2. `TreeHaver.backend` global setting
15
+ # 3. `TREE_HAVER_BACKEND` environment variable
16
+ # 4. Auto-detection (tries available backends in order)
17
+ #
18
+ # == Supported Backends
19
+ #
20
+ # **Tree-sitter backends** (native, high-performance):
21
+ # - `:mri` - ruby_tree_sitter gem (C extension, MRI only)
22
+ # - `:rust` - tree_stump gem (Rust via magnus, MRI only)
23
+ # - `:ffi` - FFI bindings to libtree-sitter (MRI, JRuby)
24
+ # - `:java` - java-tree-sitter (JRuby only)
25
+ #
26
+ # **Pure Ruby backends** (portable, no native dependencies):
27
+ # - `:citrus` - Citrus PEG parser (e.g., toml-rb)
28
+ # - `:parslet` - Parslet PEG parser (e.g., toml gem)
29
+ # - `:prism` - Ruby's official parser (Ruby only)
30
+ # - `:psych` - YAML parser (stdlib)
31
+ #
32
+ # == Wrapping/Unwrapping Responsibility
33
+ #
34
+ # TreeHaver::Parser handles ALL object wrapping and unwrapping:
35
+ #
36
+ # **Language objects:**
37
+ # - Unwraps Language wrappers before passing to backend.language=
38
+ # - MRI backend receives ::TreeSitter::Language
39
+ # - Rust backend receives String (language name)
40
+ # - FFI backend receives wrapped Language (needs to_ptr)
41
+ # - Citrus backend receives grammar module
42
+ # - Parslet backend receives grammar class
43
+ #
44
+ # **Tree objects:**
45
+ # - parse() receives raw source, backend returns raw tree, Parser wraps it
46
+ # - parse_string() unwraps old_tree before passing to backend, wraps returned tree
47
+ # - Backends always work with raw backend trees, never TreeHaver::Tree
48
+ #
49
+ # **Node objects:**
50
+ # - Backends return raw nodes, TreeHaver::Tree and TreeHaver::Node wrap them
51
+ #
52
+ # This design ensures:
53
+ # - Principle of Least Surprise: wrapping happens at boundaries, consistently
54
+ # - Backends are simple: they don't need to know about TreeHaver wrappers
55
+ # - Single Responsibility: wrapping logic is only in TreeHaver::Parser
56
+ #
57
+ # @example Basic parsing
58
+ # parser = TreeHaver::Parser.new
59
+ # parser.language = TreeHaver::Language.toml
60
+ # tree = parser.parse("[package]\nname = \"foo\"")
61
+ #
62
+ # @example Explicit backend selection
63
+ # parser = TreeHaver::Parser.new(backend: :citrus)
64
+ # parser.language = TreeHaver::Language.toml
65
+ # tree = parser.parse(toml_source)
66
+ #
67
+ # @see Base::Parser The base class defining the parser interface
68
+ # @see Backends::Citrus::Parser Citrus backend implementation
69
+ # @see Backends::Parslet::Parser Parslet backend implementation
70
+ # @see Backends::Prism::Parser Prism backend implementation
71
+ class Parser < Base::Parser
72
+ # Create a new parser instance
73
+ #
74
+ # The parser automatically selects the best available backend unless
75
+ # explicitly specified. Use the `backend:` parameter to force a specific backend.
76
+ #
77
+ # @param backend [Symbol, String, nil] optional backend to use (overrides context/global)
78
+ # Valid values: :auto, :mri, :rust, :ffi, :java, :citrus, :parslet, :prism, :psych
79
+ # @raise [NotAvailable] if no backend is available or requested backend is unavailable
80
+ # @example Default (auto-selects best available backend)
81
+ # parser = TreeHaver::Parser.new
82
+ # @example Explicit backend
83
+ # parser = TreeHaver::Parser.new(backend: :citrus)
84
+ def initialize(backend: nil)
85
+ super() # Initialize @language from Base::Parser
86
+
87
+ # Convert string backend names to symbols for consistency
88
+ backend = backend.to_sym if backend.is_a?(String)
89
+
90
+ mod = TreeHaver.resolve_backend_module(backend)
91
+
92
+ if mod.nil?
93
+ raise NotAvailable, "Requested backend #{backend.inspect} is not available" if backend
94
+
95
+ raise NotAvailable, 'No TreeHaver backend is available'
96
+
97
+ end
98
+
99
+ # Try to create the parser, with fallback to pure Ruby if tree-sitter fails
100
+ # This enables auto-fallback when tree-sitter runtime isn't available
101
+ begin
102
+ @impl = mod::Parser.new
103
+ @explicit_backend = backend # Remember for introspection (always a Symbol or nil)
104
+ rescue NoMethodError, LoadError => e
105
+ # NOTE: FFI::NotFoundError inherits from LoadError, so it's caught here too
106
+ handle_parser_creation_failure(e, backend)
107
+ end
108
+ end
109
+
110
+ # Handle parser creation failure with optional Citrus/Parslet fallback
111
+ #
112
+ # @param error [Exception] the error that caused parser creation to fail
113
+ # @param backend [Symbol, nil] the requested backend
114
+ # @raise [NotAvailable] if no fallback is available
115
+ # @api private
116
+ def handle_parser_creation_failure(error, backend)
117
+ # Tree-sitter backend failed (likely missing runtime library)
118
+ # Try Citrus or Parslet as fallback if we weren't explicitly asked for a specific backend
119
+ raise error unless backend.nil? || backend == :auto
120
+
121
+ if Backends::Citrus.available?
122
+ @impl = Backends::Citrus::Parser.new
123
+ @explicit_backend = :citrus
124
+ elsif Backends::Parslet.available?
125
+ @impl = Backends::Parslet::Parser.new
126
+ @explicit_backend = :parslet
127
+ else
128
+ # No fallback available, re-raise original error
129
+ raise NotAvailable, "Tree-sitter backend failed: #{error.message}. " \
130
+ 'Citrus/Parslet fallback not available. Install tree-sitter runtime, citrus gem, or parslet gem.'
131
+ end
132
+
133
+ # Explicit backend was requested, don't fallback
134
+ end
135
+
136
+ # Get the backend this parser is using (for introspection)
137
+ #
138
+ # Returns the actual backend in use, resolving :auto to the concrete backend.
139
+ #
140
+ # @return [Symbol] the backend name (:mri, :rust, :ffi, :java, :citrus, or :parslet)
141
+ def backend
142
+ if @explicit_backend && @explicit_backend != :auto
143
+ @explicit_backend
144
+ else
145
+ # Determine actual backend from the implementation class
146
+ case @impl.class.name
147
+ when /MRI/
148
+ :mri
149
+ when /Rust/
150
+ :rust
151
+ when /FFI/
152
+ :ffi
153
+ when /Java/
154
+ :java
155
+ when /Citrus/
156
+ :citrus
157
+ when /Parslet/
158
+ :parslet
159
+ else
160
+ # Fallback to effective_backend if we can't determine from class name
161
+ TreeHaver.effective_backend
162
+ end
163
+ end
164
+ end
165
+
166
+ # Set the language grammar for this parser
167
+ #
168
+ # The language must be compatible with the parser's backend. If a mismatch
169
+ # is detected (e.g., Citrus language on tree-sitter parser), the parser
170
+ # will automatically switch to the correct backend.
171
+ #
172
+ # @param lang [Language] the language to use for parsing
173
+ # @return [Language] the language that was set
174
+ # @example
175
+ # parser.language = TreeHaver::Language.from_library("/path/to/grammar.so")
176
+ def language=(lang)
177
+ # Auto-switch backend if language type doesn't match current parser
178
+ # This handles the case where Language.toml returns a Citrus/Parslet language
179
+ # but the parser was initialized with a tree-sitter backend
180
+ switch_backend_for_language(lang)
181
+
182
+ # Unwrap the language before passing to backend
183
+ # Backends receive raw language objects, never TreeHaver wrappers
184
+ inner_lang = unwrap_language(lang)
185
+ @impl.language = inner_lang
186
+
187
+ # Store on base class for API compatibility
188
+ @language = lang
189
+ end
190
+
191
+ # Parse source code into a syntax tree
192
+ #
193
+ # @param source [String] the source code to parse (should be UTF-8)
194
+ # @return [Tree] the parsed syntax tree
195
+ # @example
196
+ # tree = parser.parse("x = 1")
197
+ # puts tree.root_node.type
198
+ def parse(source)
199
+ tree_impl = @impl.parse(source)
200
+ # Wrap backend tree with source so Node#text works
201
+ Tree.new(tree_impl, source: source)
202
+ end
203
+
204
+ # Parse source code into a syntax tree (with optional incremental parsing)
205
+ #
206
+ # This method provides API compatibility with ruby_tree_sitter which uses
207
+ # `parse_string(old_tree, source)`.
208
+ #
209
+ # == Incremental Parsing
210
+ #
211
+ # tree-sitter supports **incremental parsing** where you can pass a previously
212
+ # parsed tree along with edit information to efficiently re-parse only the
213
+ # changed portions of source code. This is a major performance optimization
214
+ # for editors and IDEs that need to re-parse on every keystroke.
215
+ #
216
+ # The workflow for incremental parsing is:
217
+ # 1. Parse the initial source: `tree = parser.parse_string(nil, source)`
218
+ # 2. User edits the source (e.g., inserts a character)
219
+ # 3. Call `tree.edit(...)` to update the tree's position data
220
+ # 4. Re-parse with the old tree: `new_tree = parser.parse_string(tree, new_source)`
221
+ # 5. tree-sitter reuses unchanged nodes, only re-parsing affected regions
222
+ #
223
+ # TreeHaver passes through to the underlying backend if it supports incremental
224
+ # parsing (MRI and Rust backends do). Check `TreeHaver.capabilities[:incremental]`
225
+ # to see if the current backend supports it.
226
+ #
227
+ # @param old_tree [Tree, nil] previously parsed tree for incremental parsing, or nil for fresh parse
228
+ # @param source [String] the source code to parse (should be UTF-8)
229
+ # @return [Tree] the parsed syntax tree
230
+ # @see https://tree-sitter.github.io/tree-sitter/using-parsers#editing tree-sitter incremental parsing docs
231
+ # @see Tree#edit For marking edits before incremental re-parsing
232
+ # @example First parse (no old tree)
233
+ # tree = parser.parse_string(nil, "x = 1")
234
+ # @example Incremental parse
235
+ # tree.edit(
236
+ # start_byte: 4,
237
+ # old_end_byte: 5,
238
+ # new_end_byte: 6,
239
+ # start_point: {row: 0, column: 4},
240
+ # old_end_point: {row: 0, column: 5},
241
+ # new_end_point: {row: 0, column: 6}
242
+ # )
243
+ # new_tree = parser.parse_string(tree, "x = 42")
244
+ def parse_string(old_tree, source)
245
+ # Pass through to backend if it supports incremental parsing
246
+ if old_tree && @impl.respond_to?(:parse_string)
247
+ # Extract the underlying implementation from our Tree wrapper
248
+ old_impl = if old_tree.respond_to?(:inner_tree)
249
+ old_tree.inner_tree
250
+ elsif old_tree.respond_to?(:instance_variable_get)
251
+ # Fallback for compatibility
252
+ old_tree.instance_variable_get(:@inner_tree) || old_tree.instance_variable_get(:@impl) || old_tree
253
+ else
254
+ old_tree
255
+ end
256
+ tree_impl = @impl.parse_string(old_impl, source)
257
+ # Wrap backend tree with source so Node#text works
258
+ Tree.new(tree_impl, source: source)
259
+ elsif @impl.respond_to?(:parse_string)
260
+ tree_impl = @impl.parse_string(nil, source)
261
+ # Wrap backend tree with source so Node#text works
262
+ Tree.new(tree_impl, source: source)
263
+ else
264
+ # Fallback for backends that don't support parse_string
265
+ parse(source)
266
+ end
267
+ end
268
+
269
+ private
270
+
271
+ # Switch backend if language type doesn't match current parser
272
+ #
273
+ # This is necessary because TreeHaver.parser_for may return a Language
274
+ # from a different backend than the Parser was initialized with.
275
+ # For example, Language.toml might return a Citrus::Language when
276
+ # tree-sitter-toml is not available, but Parser was initialized with :auto.
277
+ #
278
+ # @param lang [Object] The language object
279
+ # @api private
280
+ def switch_backend_for_language(lang)
281
+ return unless lang.respond_to?(:backend)
282
+
283
+ lang_backend = lang.backend
284
+ parser_backend = backend
285
+
286
+ # No switch needed if backends match
287
+ return if lang_backend == parser_backend
288
+
289
+ # Switch to matching backend parser
290
+ case lang_backend
291
+ when :citrus
292
+ unless @impl.is_a?(Backends::Citrus::Parser)
293
+ @impl = Backends::Citrus::Parser.new
294
+ @explicit_backend = :citrus
295
+ end
296
+ when :parslet
297
+ unless @impl.is_a?(Backends::Parslet::Parser)
298
+ @impl = Backends::Parslet::Parser.new
299
+ @explicit_backend = :parslet
300
+ end
301
+ when :prism
302
+ unless @impl.is_a?(Backends::Prism::Parser)
303
+ @impl = Backends::Prism::Parser.new
304
+ @explicit_backend = :prism
305
+ end
306
+ when :psych
307
+ unless @impl.is_a?(Backends::Psych::Parser)
308
+ @impl = Backends::Psych::Parser.new
309
+ @explicit_backend = :psych
310
+ end
311
+ # Tree-sitter backends (:mri, :rust, :ffi, :java) - don't auto-switch between them
312
+ # as that would require reloading the language from the .so file
313
+ end
314
+ end
315
+
316
+ # Unwrap a language object to extract the raw backend language
317
+ #
318
+ # This method is smart about backend compatibility:
319
+ # 1. If language has a backend attribute, checks if it matches current backend
320
+ # 2. If mismatch detected, attempts to reload language for correct backend
321
+ # 3. If reload successful, uses new language; otherwise continues with original
322
+ # 4. Unwraps the language wrapper to get raw backend object
323
+ #
324
+ # @param lang [Object] wrapped or raw language object
325
+ # @return [Object] raw backend language object appropriate for current backend
326
+ # @api private
327
+ def unwrap_language(lang)
328
+ # Check if this is a TreeHaver language wrapper with backend info
329
+ if lang.respond_to?(:backend)
330
+ # Verify backend compatibility FIRST
331
+ # This prevents passing languages from wrong backends to native code
332
+ # Exception: :auto backend is permissive - accepts any language
333
+ current_backend = backend
334
+
335
+ if lang.backend != current_backend && current_backend != :auto
336
+ # Backend mismatch! Try to reload for correct backend
337
+ reloaded = try_reload_language_for_backend(lang, current_backend)
338
+ if reloaded
339
+ lang = reloaded
340
+ else
341
+ # Couldn't reload - this is an error
342
+ raise TreeHaver::Error,
343
+ "Language backend mismatch: language is for #{lang.backend}, parser is #{current_backend}. " \
344
+ 'Cannot reload language for correct backend. ' \
345
+ "Create a new language with TreeHaver::Language.from_library when backend is #{current_backend}."
346
+ end
347
+ end
348
+
349
+ # Get the current parser's language (if set)
350
+ current_lang = @impl.respond_to?(:language) ? @impl.language : nil
351
+
352
+ # Language mismatch detected! The parser might have a different language set
353
+ # Compare the actual language objects using Comparable
354
+ if current_lang && lang != current_lang
355
+ # Different language being set (e.g., switching from TOML to JSON)
356
+ # This is fine, just informational
357
+ end
358
+ end
359
+
360
+ # Unwrap based on backend type
361
+ # All TreeHaver Language wrappers have the backend attribute
362
+ unless lang.respond_to?(:backend)
363
+ # This shouldn't happen - all our wrappers have backend attribute
364
+ # If we get here, it's likely a raw backend object that was passed directly
365
+ raise TreeHaver::Error,
366
+ "Expected TreeHaver Language wrapper with backend attribute, got #{lang.class}. " \
367
+ 'Use TreeHaver::Language.from_library to create language objects.'
368
+ end
369
+
370
+ case lang.backend
371
+ when :mri
372
+ return lang.to_language if lang.respond_to?(:to_language)
373
+ return lang.inner_language if lang.respond_to?(:inner_language)
374
+
375
+ lang
376
+ when :rust
377
+ return lang.name if lang.respond_to?(:name)
378
+
379
+ lang
380
+ when :ffi
381
+ lang # FFI needs wrapper for to_ptr
382
+ when :java
383
+ lang.impl if lang.respond_to?(:impl)
384
+ when :citrus
385
+ lang # Citrus backend accepts Language wrapper (handles both)
386
+ when :parslet
387
+ lang # Parslet backend accepts Language wrapper (handles both)
388
+ when :prism
389
+ lang # Prism backend expects the Language wrapper
390
+ when :psych
391
+ lang # Psych backend expects the Language wrapper
392
+ when :commonmarker
393
+ lang # Commonmarker backend expects the Language wrapper
394
+ when :markly
395
+ lang # Markly backend expects the Language wrapper
396
+ else
397
+ # Unknown backend (e.g., test backend)
398
+ # Try generic unwrapping methods for flexibility in testing
399
+ return lang.to_language if lang.respond_to?(:to_language)
400
+ return lang.inner_language if lang.respond_to?(:inner_language)
401
+ return lang.impl if lang.respond_to?(:impl)
402
+ return lang.grammar_module if lang.respond_to?(:grammar_module)
403
+ return lang.grammar_class if lang.respond_to?(:grammar_class)
404
+ return lang.name if lang.respond_to?(:name)
405
+
406
+ # If nothing works, pass through as-is
407
+ # This allows test languages to be passed directly
408
+ lang
409
+ end
410
+ end
411
+
412
+ # Try to reload a language for the current backend
413
+ #
414
+ # This handles the case where a language was loaded for one backend,
415
+ # but is now being used with a different backend (e.g., after backend switch).
416
+ #
417
+ # @param lang [Object] language object with metadata
418
+ # @param target_backend [Symbol] backend to reload for
419
+ # @return [Object, nil] reloaded language or nil if reload not possible
420
+ # @api private
421
+ def try_reload_language_for_backend(lang, target_backend)
422
+ # Can't reload without path information
423
+ return unless lang.respond_to?(:path) || lang.respond_to?(:grammar_module)
424
+
425
+ # For tree-sitter backends, reload from path
426
+ if lang.respond_to?(:path) && lang.path
427
+ begin
428
+ # Use Language.from_library which respects current backend
429
+ return Language.from_library(
430
+ lang.path,
431
+ symbol: lang.respond_to?(:symbol) ? lang.symbol : nil,
432
+ name: lang.respond_to?(:name) ? lang.name : nil
433
+ )
434
+ rescue StandardError => e
435
+ # Reload failed, continue with original
436
+ warn("TreeHaver: Failed to reload language for backend #{target_backend}: #{e.message}") if $VERBOSE
437
+ return
438
+ end
439
+ end
440
+
441
+ # For Citrus, can't really reload as it's just a module reference
442
+ nil
443
+ end
444
+ end
445
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TreeHaver
4
+ # Utility for finding and registering Parslet grammar gems.
5
+ #
6
+ # ParsletGrammarFinder provides language-agnostic discovery of Parslet grammar
7
+ # gems. Given a language name and gem information, it attempts to load the
8
+ # grammar and register it with tree_haver.
9
+ #
10
+ # Unlike tree-sitter grammars (which are .so files), Parslet grammars are
11
+ # Ruby classes that inherit from Parslet::Parser. This class handles the
12
+ # discovery and registration of these grammars.
13
+ #
14
+ # @example Basic usage with toml gem
15
+ # finder = TreeHaver::ParsletGrammarFinder.new(
16
+ # language: :toml,
17
+ # gem_name: "toml",
18
+ # grammar_const: "TOML::Parslet"
19
+ # )
20
+ # finder.register! if finder.available?
21
+ #
22
+ # @example With custom require path
23
+ # finder = TreeHaver::ParsletGrammarFinder.new(
24
+ # language: :json,
25
+ # gem_name: "json-parslet",
26
+ # grammar_const: "JsonParslet::Grammar",
27
+ # require_path: "json/parslet"
28
+ # )
29
+ #
30
+ # @see GrammarFinder For tree-sitter grammar discovery
31
+ # @see CitrusGrammarFinder For Citrus grammar discovery
32
+ class ParsletGrammarFinder
33
+ # @return [Symbol] the language identifier
34
+ attr_reader :language_name
35
+
36
+ # @return [String] the gem name to require
37
+ attr_reader :gem_name
38
+
39
+ # @return [String] the constant path to the grammar class (e.g., "TOML::Parslet")
40
+ attr_reader :grammar_const
41
+
42
+ # @return [String, nil] custom require path (defaults to gem_name)
43
+ attr_reader :require_path
44
+
45
+ # Initialize a Parslet grammar finder
46
+ #
47
+ # @param language [Symbol, String] the language name (e.g., :toml, :json)
48
+ # @param gem_name [String] the gem name (e.g., "toml")
49
+ # @param grammar_const [String] constant path to grammar class (e.g., "TOML::Parslet")
50
+ # @param require_path [String, nil] custom require path (defaults to gem_name as-is)
51
+ def initialize(language:, gem_name:, grammar_const:, require_path: nil)
52
+ @language_name = language.to_sym
53
+ @gem_name = gem_name
54
+ @grammar_const = grammar_const
55
+ @require_path = require_path || gem_name
56
+ @load_attempted = false
57
+ @available = false
58
+ @grammar_class = nil
59
+ end
60
+
61
+ # Check if the Parslet grammar is available
62
+ #
63
+ # Attempts to require the gem and resolve the grammar constant.
64
+ # Result is cached after first call.
65
+ #
66
+ # @return [Boolean] true if grammar is available
67
+ def available?
68
+ return @available if @load_attempted
69
+
70
+ @load_attempted = true
71
+ debug = ENV['TREE_HAVER_DEBUG']
72
+
73
+ # Guard against nil require_path (can happen if gem_name was nil)
74
+ if @require_path.nil? || @require_path.empty?
75
+ warn("ParsletGrammarFinder: require_path is nil or empty for #{@language_name}") if debug
76
+ @available = false
77
+ return false
78
+ end
79
+
80
+ begin
81
+ # Try to require the gem
82
+ require @require_path
83
+
84
+ # Try to resolve the constant
85
+ @grammar_class = resolve_constant(@grammar_const)
86
+
87
+ # Verify it can create a parser instance with a parse method
88
+ unless valid_grammar_class?(@grammar_class)
89
+ if debug
90
+ warn("ParsletGrammarFinder: #{@grammar_const} is not a valid Parslet grammar class")
91
+ warn("ParsletGrammarFinder: #{@grammar_const}.class = #{@grammar_class.class}")
92
+ end
93
+ @available = false
94
+ return false
95
+ end
96
+
97
+ @available = true
98
+ rescue LoadError => e
99
+ # simplecov:disable defensive - requires gem to not be installed
100
+ if debug
101
+ warn("ParsletGrammarFinder: Failed to load '#{@require_path}': #{e.class}: #{e.message}")
102
+ warn("ParsletGrammarFinder: LoadError backtrace:\n #{e.backtrace&.first(10)&.join("\n ")}")
103
+ end
104
+ @available = false
105
+ # simplecov:enable
106
+ rescue NameError => e
107
+ # simplecov:disable defensive - requires gem with missing constant
108
+ if debug
109
+ warn("ParsletGrammarFinder: Failed to resolve '#{@grammar_const}': #{e.class}: #{e.message}")
110
+ warn("ParsletGrammarFinder: NameError backtrace:\n #{e.backtrace&.first(10)&.join("\n ")}")
111
+ end
112
+ @available = false
113
+ # simplecov:enable
114
+ rescue TypeError => e
115
+ # simplecov:disable defensive - TruffleRuby-specific edge case
116
+ warn("ParsletGrammarFinder: TypeError during load of '#{@require_path}': #{e.class}: #{e.message}")
117
+ warn('ParsletGrammarFinder: This may be a TruffleRuby bundled_gems.rb issue')
118
+ warn("ParsletGrammarFinder: TypeError backtrace:\n #{e.backtrace&.first(10)&.join("\n ")}") if debug
119
+ @available = false
120
+ # simplecov:enable
121
+ rescue StandardError => e
122
+ # simplecov:disable defensive - catch-all for unexpected errors
123
+ warn("ParsletGrammarFinder: Unexpected error: #{e.class}: #{e.message}")
124
+ warn("ParsletGrammarFinder: backtrace:\n #{e.backtrace&.first(10)&.join("\n ")}") if debug
125
+ @available = false
126
+ # simplecov:enable
127
+ end
128
+
129
+ @available
130
+ end
131
+
132
+ # Get the resolved grammar class
133
+ #
134
+ # @return [Class, nil] the grammar class if available
135
+ def grammar_class
136
+ available? # Ensure we've tried to load
137
+ @grammar_class
138
+ end
139
+
140
+ # Register this Parslet grammar with TreeHaver
141
+ #
142
+ # After registration, the language can be used via:
143
+ # TreeHaver::Language.{language_name}
144
+ #
145
+ # @param raise_on_missing [Boolean] if true, raises when grammar not available
146
+ # @return [Boolean] true if registration succeeded
147
+ # @raise [NotAvailable] if grammar not available and raise_on_missing is true
148
+ def register!(raise_on_missing: false)
149
+ unless available?
150
+ raise NotAvailable, not_found_message if raise_on_missing
151
+
152
+ return false
153
+ end
154
+
155
+ TreeHaver.register_language(
156
+ @language_name,
157
+ grammar_class: @grammar_class,
158
+ gem_name: @gem_name
159
+ )
160
+ true
161
+ end
162
+
163
+ # Get debug information about the search
164
+ #
165
+ # @return [Hash] diagnostic information
166
+ def search_info
167
+ {
168
+ language: @language_name,
169
+ gem_name: @gem_name,
170
+ grammar_const: @grammar_const,
171
+ require_path: @require_path,
172
+ available: available?,
173
+ grammar_class: @grammar_class&.name
174
+ }
175
+ end
176
+
177
+ # Get a human-readable error message when grammar is not found
178
+ #
179
+ # @return [String] error message with installation hints
180
+ def not_found_message
181
+ "Parslet grammar for #{@language_name} not found. " \
182
+ "Install #{@gem_name} gem: gem install #{@gem_name}"
183
+ end
184
+
185
+ private
186
+
187
+ # Resolve a constant path like "TOML::Parslet"
188
+ #
189
+ # @param const_path [String] constant path
190
+ # @return [Object] the constant
191
+ # @raise [NameError] if constant not found
192
+ def resolve_constant(const_path)
193
+ const_path.split('::').reduce(Object) do |mod, const_name|
194
+ mod.const_get(const_name)
195
+ end
196
+ end
197
+
198
+ # Check if the class is a valid Parslet grammar
199
+ #
200
+ # @param klass [Class] the class to check
201
+ # @return [Boolean] true if valid
202
+ def valid_grammar_class?(klass)
203
+ return false unless klass.respond_to?(:new)
204
+
205
+ # Check if it's a Parslet::Parser subclass
206
+ return true if defined?(::Parslet::Parser) && (klass < ::Parslet::Parser)
207
+
208
+ # Fallback: check if it can create an instance that responds to parse
209
+ begin
210
+ instance = klass.new
211
+ instance.respond_to?(:parse)
212
+ rescue StandardError
213
+ false
214
+ end
215
+ end
216
+ end
217
+ end