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,429 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ module TreeHaver
6
+ # Registration-first utility for finding tree-sitter grammar shared libraries.
7
+ #
8
+ # GrammarFinder resolves tree-sitter grammars in a constrained order:
9
+ #
10
+ # 1. explicit environment override
11
+ # 2. existing TreeHaver registration
12
+ # 3. explicit extra paths
13
+ # 4. tree_sitter_language_pack parser backend registration
14
+ #
15
+ # This class is designed to be used by language-specific merge gems
16
+ # without requiring TreeHaver to own parser- or grammar-specific policy.
17
+ #
18
+ # == Security Considerations
19
+ #
20
+ # Loading shared libraries is inherently dangerous as it executes arbitrary
21
+ # native code. GrammarFinder performs the following security validations:
22
+ #
23
+ # - Language names are validated to contain only safe characters
24
+ # - Paths from environment variables are validated before use
25
+ # - Path traversal attempts (../) are rejected
26
+ # - Only files with expected extensions (.so, .dylib, .dll) are accepted
27
+ #
28
+ # For additional security, use {#find_library_path_safe} which only returns
29
+ # paths from trusted system directories.
30
+ #
31
+ # @example Basic usage
32
+ # finder = TreeHaver::GrammarFinder.new(:toml)
33
+ # path = finder.find_library_path
34
+ # # => "/usr/local/lib/libtree_sitter_toml.so"
35
+ #
36
+ # @example Check availability
37
+ # finder = TreeHaver::GrammarFinder.new(:json)
38
+ # if finder.available?
39
+ # language = TreeHaver::Language.load(finder.language_name, finder.find_library_path)
40
+ # end
41
+ #
42
+ # @example Register with TreeHaver
43
+ # finder = TreeHaver::GrammarFinder.new(:bash)
44
+ # finder.register! if finder.available?
45
+ # # Now you can use: TreeHaver::Language.bash
46
+ #
47
+ # @example With custom search paths
48
+ # finder = TreeHaver::GrammarFinder.new(:toml, extra_paths: ["/opt/custom/lib"])
49
+ #
50
+ # @example Secure mode (trusted directories only)
51
+ # finder = TreeHaver::GrammarFinder.new(:toml)
52
+ # path = finder.find_library_path_safe # Only returns paths in trusted dirs
53
+ #
54
+ # @see PathValidator For details on security validations
55
+ class GrammarFinder
56
+ # @return [Symbol] the language identifier
57
+ attr_reader :language_name
58
+
59
+ # @return [Array<String>] additional search paths provided at initialization
60
+ attr_reader :extra_paths
61
+
62
+ # Initialize a grammar finder for a specific language
63
+ #
64
+ # @param language_name [Symbol, String] the tree-sitter language name (e.g., :toml, :json, :bash)
65
+ # @param extra_paths [Array<String>] additional paths to search (searched first after ENV)
66
+ # @param validate [Boolean] if true, validates the language name (default: true)
67
+ # @raise [ArgumentError] if language_name is invalid and validate is true
68
+ def initialize(language_name, extra_paths: [], validate: true)
69
+ name_str = language_name.to_s.downcase
70
+
71
+ if validate && !PathValidator.safe_language_name?(name_str)
72
+ raise ArgumentError, "Invalid language name: #{language_name.inspect}. " \
73
+ 'Language names must start with a letter and contain only lowercase letters, numbers, and underscores.'
74
+ end
75
+
76
+ @language_name = name_str.to_sym
77
+ @extra_paths = Array(extra_paths)
78
+ end
79
+
80
+ # Get the environment variable name for this language
81
+ #
82
+ # @return [String] the ENV var name (e.g., "TREE_SITTER_TOML_PATH")
83
+ def env_var_name
84
+ "TREE_SITTER_#{@language_name.to_s.upcase}_PATH"
85
+ end
86
+
87
+ # Get the expected symbol name exported by the grammar library
88
+ #
89
+ # @return [String] the symbol name (e.g., "tree_sitter_toml")
90
+ def symbol_name
91
+ "tree_sitter_#{@language_name}"
92
+ end
93
+
94
+ # Get the canonical tree-sitter-language-pack filename for the current platform
95
+ #
96
+ # @return [String] the library filename (e.g., "libtree_sitter_toml.so")
97
+ def library_filename
98
+ library_filenames.first
99
+ end
100
+
101
+ # Get all accepted library filenames for this language
102
+ #
103
+ # Accept both the tree-sitter-language-pack naming convention and the
104
+ # historical hyphenated form used by some standalone grammar builds.
105
+ #
106
+ # @return [Array<String>]
107
+ def library_filenames
108
+ ext = platform_extension
109
+ [
110
+ "libtree_sitter_#{@language_name}#{ext}",
111
+ "libtree-sitter-#{@language_name}#{ext}"
112
+ ]
113
+ end
114
+
115
+ # Generate the full list of search paths for this language
116
+ #
117
+ # Order: registered path, then explicit extra paths.
118
+ #
119
+ # @return [Array<String>] all paths to search
120
+ def search_paths
121
+ paths = []
122
+
123
+ registration = registered_tree_sitter_registration
124
+ paths << registration[:path] if registration&.dig(:path)
125
+
126
+ @extra_paths.each do |dir|
127
+ library_filenames.each do |filename|
128
+ paths << File.join(dir, filename)
129
+ end
130
+ end
131
+
132
+ paths.uniq
133
+ end
134
+
135
+ # Find the grammar library path
136
+ #
137
+ # Searches in order:
138
+ # 1. Environment variable override (validated for safety)
139
+ # 2. Existing TreeHaver tree-sitter registration
140
+ # 3. Extra paths provided at initialization
141
+ # tree_sitter_language_pack is intentionally not exposed as a shared-library
142
+ # path fallback here. It is registered as a TreeHaver backend module when
143
+ # its parser API is available.
144
+ #
145
+ # @note Paths from ENV are validated using {PathValidator.safe_library_path?}
146
+ # to prevent path traversal and other attacks. Invalid ENV paths cause
147
+ # an error to be raised (Principle of Least Surprise - explicit paths must work).
148
+ #
149
+ # @note Setting the ENV variable to an empty string explicitly disables
150
+ # this grammar. This allows fallback to alternative backends (e.g., Citrus).
151
+ #
152
+ # @return [String, nil] the path to the library, or nil if not found
153
+ # @raise [TreeHaver::NotAvailable] if ENV variable is set to an invalid path
154
+ # @see #find_library_path_safe For stricter validation (trusted directories only)
155
+ def find_library_path
156
+ # Check environment variable first (highest priority)
157
+ # Use key? to distinguish between "not set" and "set to empty"
158
+ env_var = env_var_name
159
+ if ENV[env_var] || ENV.key?(env_var)
160
+ env_path = ENV[env_var]
161
+
162
+ # simplecov:disable defensive - ENV.key? true with nil value is rare edge case
163
+ if env_path.nil?
164
+ @env_rejection_reason = 'explicitly disabled (set to nil)'
165
+ return
166
+ end
167
+ # simplecov:enable
168
+
169
+ # Empty string means "explicitly skip this grammar"
170
+ # This allows users to disable tree-sitter for specific languages
171
+ # and fall back to alternative backends like Citrus
172
+ if env_path.empty?
173
+ @env_rejection_reason = 'explicitly disabled (set to empty string)'
174
+ return
175
+ end
176
+
177
+ # Store why env path was rejected for better error messages
178
+ @env_rejection_reason = validate_env_path(env_path)
179
+
180
+ # Principle of Least Surprise: If user explicitly sets an ENV variable
181
+ # to a path, that path MUST work. Don't silently fall back to auto-discovery.
182
+ if @env_rejection_reason
183
+ raise TreeHaver::NotAvailable,
184
+ "#{env_var_name} is set to #{env_path.inspect} but #{@env_rejection_reason}. " \
185
+ 'Either fix the path, unset the variable to use auto-discovery, ' \
186
+ 'or set it to empty string to explicitly disable this grammar.'
187
+ end
188
+
189
+ return env_path
190
+ end
191
+
192
+ registered_path = registered_tree_sitter_path
193
+ return registered_path if registered_path
194
+
195
+ explicit_path = explicit_search_path
196
+ return explicit_path if explicit_path
197
+
198
+ nil
199
+ end
200
+
201
+ # Validate an environment variable path and return reason if invalid
202
+ # @return [String, nil] rejection reason or nil if valid
203
+ def validate_env_path(path)
204
+ # Check for leading/trailing whitespace
205
+ return "contains leading or trailing whitespace (use #{path.strip.inspect})" if path != path.strip
206
+
207
+ # Check if path is safe
208
+ unless PathValidator.safe_library_path?(path)
209
+ return 'failed security validation (may contain path traversal or suspicious characters)'
210
+ end
211
+
212
+ # Check if file exists
213
+ return 'file does not exist' unless File.exist?(path)
214
+
215
+ nil # Valid!
216
+ end
217
+
218
+ # Find the grammar library path with strict security validation
219
+ #
220
+ # This method only returns paths that are in trusted system directories.
221
+ # Use this when you want maximum security and don't need to support
222
+ # custom installation locations.
223
+ #
224
+ # @return [String, nil] the path to the library, or nil if not found
225
+ # @see PathValidator::TRUSTED_DIRECTORIES For the list of trusted directories
226
+ def find_library_path_safe
227
+ search_paths.find do |path|
228
+ File.exist?(path) && PathValidator.in_trusted_directory?(path)
229
+ end
230
+ end
231
+
232
+ # Check if the grammar library is available AND usable
233
+ #
234
+ # This checks:
235
+ # 1. The grammar library file exists
236
+ # 2. The tree-sitter runtime is functional (can create a parser)
237
+ #
238
+ # This prevents registering grammars when tree-sitter isn't actually usable,
239
+ # allowing clean fallback to alternative backends like Citrus.
240
+ #
241
+ # @return [Boolean] true if the library can be found AND tree-sitter runtime works
242
+ def available?
243
+ return true if tree_sitter_language_pack_parser_available?
244
+
245
+ path = find_library_path
246
+ return false if path.nil?
247
+
248
+ # Check if tree-sitter runtime is actually functional
249
+ # This is cached at the class level since it's the same for all grammars
250
+ self.class.tree_sitter_runtime_usable?
251
+ end
252
+
253
+ # Backends that use tree-sitter (require native runtime libraries)
254
+ # Other backends (Citrus, Prism, Psych, etc.) don't use tree-sitter
255
+ TREE_SITTER_BACKENDS = [
256
+ TreeHaver::Backends::MRI,
257
+ TreeHaver::Backends::FFI,
258
+ TreeHaver::Backends::Rust,
259
+ TreeHaver::Backends::Java
260
+ ].freeze
261
+
262
+ class << self
263
+ # Check if the tree-sitter runtime is usable
264
+ #
265
+ # Tests whether we can actually create a tree-sitter parser.
266
+ # Result is cached since this is expensive and won't change during runtime.
267
+ #
268
+ # @return [Boolean] true if tree-sitter runtime is functional
269
+ def tree_sitter_runtime_usable?
270
+ return @tree_sitter_runtime_usable if defined?(@tree_sitter_runtime_usable)
271
+
272
+ @tree_sitter_runtime_usable = begin
273
+ # Try to create a parser using the current backend
274
+ mod = TreeHaver.resolve_backend_module(nil)
275
+
276
+ # Only tree-sitter backends are relevant here
277
+ # Non-tree-sitter backends (Citrus, Prism, Psych, etc.) don't use grammar files
278
+ if mod.nil? || !TREE_SITTER_BACKENDS.include?(mod)
279
+ false
280
+ else
281
+ # Try to instantiate a parser - this will fail if runtime isn't available
282
+ mod::Parser.new
283
+ true
284
+ end
285
+ rescue NoMethodError, LoadError, NotAvailable => _e
286
+ # NOTE: FFI::NotFoundError inherits from LoadError, so it's caught here too
287
+ false
288
+ end
289
+ end
290
+
291
+ # Reset the cached tree-sitter runtime check (for testing)
292
+ #
293
+ # @api private
294
+ def reset_runtime_check!
295
+ remove_instance_variable(:@tree_sitter_runtime_usable) if defined?(@tree_sitter_runtime_usable)
296
+ end
297
+ end
298
+
299
+ # Check if the grammar library is available in a trusted directory
300
+ #
301
+ # @return [Boolean] true if the library can be found in a trusted directory
302
+ # @see #find_library_path_safe
303
+ def available_safe?
304
+ !find_library_path_safe.nil?
305
+ end
306
+
307
+ # Register this language with TreeHaver
308
+ #
309
+ # After registration, the language can be loaded via dynamic method
310
+ # (e.g., `TreeHaver::Language.toml`).
311
+ #
312
+ # @param raise_on_missing [Boolean] if true, raises when library not found
313
+ # @return [Boolean] true if registration succeeded
314
+ # @raise [NotAvailable] if library not found and raise_on_missing is true
315
+ def register!(raise_on_missing: false)
316
+ if tree_sitter_language_pack_parser_available?
317
+ TreeHaver.register_language(
318
+ @language_name,
319
+ backend_module: TreeHaver::Backends::Tslp,
320
+ backend_type: :tslp,
321
+ gem_name: 'tree_sitter_language_pack'
322
+ )
323
+ return true
324
+ end
325
+
326
+ path = find_library_path
327
+ unless path
328
+ raise NotAvailable, not_found_message if raise_on_missing
329
+
330
+ return false
331
+ end
332
+
333
+ TreeHaver.register_language(@language_name, path: path, symbol: symbol_name)
334
+ true
335
+ end
336
+
337
+ # Get debug information about the search
338
+ #
339
+ # @return [Hash] diagnostic information
340
+ def search_info
341
+ found = find_library_path # This populates @env_rejection_reason
342
+ {
343
+ language: @language_name,
344
+ env_var: env_var_name,
345
+ env_value: ENV[env_var_name],
346
+ env_rejection_reason: @env_rejection_reason,
347
+ tree_sitter_language_pack_parser_available: tree_sitter_language_pack_parser_available?,
348
+ symbol: symbol_name,
349
+ library_filename: library_filename,
350
+ library_filenames: library_filenames,
351
+ search_paths: search_paths,
352
+ found_path: found,
353
+ available: tree_sitter_language_pack_parser_available? || !found.nil?
354
+ }
355
+ end
356
+
357
+ # Get a human-readable error message when library is not found
358
+ #
359
+ # @return [String] error message with installation hints
360
+ def not_found_message
361
+ msg = "tree-sitter #{@language_name} grammar not found."
362
+
363
+ # Check if env var is set but rejected
364
+ env_value = ENV[env_var_name]
365
+ msg += if env_value && @env_rejection_reason
366
+ " #{env_var_name} is set to #{env_value.inspect} but #{@env_rejection_reason}."
367
+ elsif env_value && File.exist?(env_value) && !self.class.tree_sitter_runtime_usable?
368
+ " #{env_var_name} is set and file exists, but no tree-sitter runtime is available. " \
369
+ 'Add ruby_tree_sitter, ffi, or tree_stump gem to your Gemfile.'
370
+ else
371
+ " Searched: #{search_paths.join(', ')}."
372
+ end
373
+
374
+ msg + ' Register the grammar, install tree_sitter_language_pack with parser API support, ' \
375
+ "or set #{env_var_name} to a valid path."
376
+ end
377
+
378
+ private
379
+
380
+ def registered_tree_sitter_registration
381
+ TreeHaver::LanguageRegistry.registered(@language_name, :tree_sitter)
382
+ end
383
+
384
+ def registered_tree_sitter_path
385
+ registration = registered_tree_sitter_registration
386
+ path = registration&.dig(:path)
387
+ return unless path
388
+ return path if File.exist?(path)
389
+
390
+ @registered_rejection_reason = "registered path does not exist: #{path}"
391
+ nil
392
+ end
393
+
394
+ def explicit_search_path
395
+ search_paths.find { |path| File.exist?(path) }
396
+ end
397
+
398
+ def tree_sitter_language_pack_parser_available?
399
+ return @tree_sitter_language_pack_parser_available if defined?(@tree_sitter_language_pack_parser_available)
400
+
401
+ @tree_sitter_language_pack_parser_available = begin
402
+ require 'tree_sitter_language_pack' unless defined?(::TreeSitterLanguagePack)
403
+ TreeHaver::Backends::Tslp.available? &&
404
+ ::TreeSitterLanguagePack.respond_to?(:has_language) &&
405
+ ::TreeSitterLanguagePack.has_language(@language_name.to_s) &&
406
+ TreeHaver::Backends::Tslp.parser_available_for?(@language_name)
407
+ rescue LoadError
408
+ false
409
+ rescue StandardError => e
410
+ @tree_sitter_language_pack_rejection_reason = e.message
411
+ false
412
+ end
413
+ end
414
+
415
+ # Get the platform-appropriate shared library extension
416
+ #
417
+ # @return [String] ".so" on Linux, ".dylib" on macOS
418
+ def platform_extension
419
+ case RbConfig::CONFIG['host_os']
420
+ when /darwin/i
421
+ '.dylib'
422
+ when /mswin|mingw|cygwin/i
423
+ '.dll'
424
+ else
425
+ '.so'
426
+ end
427
+ end
428
+ end
429
+ end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module TreeHaver
4
4
  KAITAI_STRUCT_BACKEND = BackendReference.new(
5
- id: "kaitai-struct",
6
- family: "kaitai"
5
+ id: 'kaitai-struct',
6
+ family: 'kaitai'
7
7
  ).freeze
8
8
 
9
9
  BackendRegistry.register(KAITAI_STRUCT_BACKEND)