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,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TreeHaver
4
+ # Factory module for loading language grammars
5
+ #
6
+ # Language is the entry point for loading and using grammars. It provides
7
+ # a unified interface that works across all backends (MRI, Rust, FFI, Java, Citrus, Parslet).
8
+ #
9
+ # This is a module with only module methods (factory pattern), not a class.
10
+ # Backend-specific Language classes (e.g., Backends::Citrus::Language,
11
+ # Backends::Parslet::Language) inherit from Base::Language.
12
+ #
13
+ # For tree-sitter backends, languages are loaded from shared library files (.so/.dylib/.dll).
14
+ # For pure-Ruby backends (Citrus, Parslet, Prism, Psych), languages are built-in or provided by gems.
15
+ #
16
+ # == Loading Languages
17
+ #
18
+ # The primary way to load a language is via registration:
19
+ #
20
+ # TreeHaver.register_language(:toml, path: "/path/to/libtree-sitter-toml.so")
21
+ # language = TreeHaver::Language.toml
22
+ #
23
+ # For explicit loading without registration:
24
+ #
25
+ # language = TreeHaver::Language.from_library(
26
+ # "/path/to/libtree-sitter-toml.so",
27
+ # symbol: "tree_sitter_toml"
28
+ # )
29
+ #
30
+ # For ruby_tree_sitter compatibility:
31
+ #
32
+ # language = TreeHaver::Language.load("toml", "/path/to/libtree-sitter-toml.so")
33
+ #
34
+ # @example Register and load a language
35
+ # TreeHaver.register_language(:toml, path: "/path/to/grammar.so")
36
+ # language = TreeHaver::Language.toml
37
+ #
38
+ # @see Base::Language The base class that backend Language classes inherit from
39
+ module Language
40
+ class << self
41
+ # Load a language grammar from a shared library (ruby_tree_sitter compatibility)
42
+ #
43
+ # This method provides API compatibility with ruby_tree_sitter which uses
44
+ # `Language.load(name, path)`.
45
+ #
46
+ # @param name [String] the language name (e.g., "toml")
47
+ # @param path [String] absolute path to the language shared library
48
+ # @param validate [Boolean] if true, validates the path for safety (default: true)
49
+ # @return [Language] loaded language handle
50
+ # @raise [NotAvailable] if the library cannot be loaded
51
+ # @raise [ArgumentError] if the path fails security validation
52
+ # @example
53
+ # language = TreeHaver::Language.load("toml", "/usr/local/lib/libtree-sitter-toml.so")
54
+ def load(name, path, validate: true)
55
+ from_library(path, symbol: "tree_sitter_#{name}", name: name, validate: validate)
56
+ end
57
+
58
+ # Load a language grammar from a shared library
59
+ #
60
+ # The library must export a function that returns a pointer to a TSLanguage struct.
61
+ # By default, TreeHaver looks for a symbol named "tree_sitter_<name>".
62
+ #
63
+ # == Security
64
+ #
65
+ # By default, paths are validated using {PathValidator} to prevent path traversal
66
+ # and other attacks. Set `validate: false` to skip validation (not recommended
67
+ # unless you've already validated the path).
68
+ #
69
+ # @param path [String] absolute path to the language shared library (.so/.dylib/.dll)
70
+ # @param symbol [String, nil] name of the exported function (defaults to auto-detection)
71
+ # @param name [String, nil] logical name for the language (used in caching)
72
+ # @param validate [Boolean] if true, validates path and symbol for safety (default: true)
73
+ # @param backend [Symbol, String, nil] optional backend to use (overrides context/global)
74
+ # @return [Language] loaded language handle
75
+ # @raise [NotAvailable] if the library cannot be loaded or the symbol is not found
76
+ # @raise [ArgumentError] if path or symbol fails security validation
77
+ # @example
78
+ # language = TreeHaver::Language.from_library(
79
+ # "/usr/local/lib/libtree-sitter-toml.so",
80
+ # symbol: "tree_sitter_toml",
81
+ # name: "toml"
82
+ # )
83
+ # @example With explicit backend
84
+ # language = TreeHaver::Language.from_library(
85
+ # "/usr/local/lib/libtree-sitter-toml.so",
86
+ # symbol: "tree_sitter_toml",
87
+ # backend: :ffi
88
+ # )
89
+ def from_library(path, symbol: nil, name: nil, validate: true, backend: nil)
90
+ if validate
91
+ unless PathValidator.safe_library_path?(path)
92
+ errors = PathValidator.validation_errors(path)
93
+ raise ArgumentError, "Unsafe library path: #{path.inspect}. Errors: #{errors.join('; ')}"
94
+ end
95
+
96
+ if symbol && !PathValidator.safe_symbol_name?(symbol)
97
+ raise ArgumentError, "Unsafe symbol name: #{symbol.inspect}. " \
98
+ 'Symbol names must be valid C identifiers.'
99
+ end
100
+ end
101
+
102
+ # from_library only works with tree-sitter backends that support .so files
103
+ # Pure Ruby backends (Citrus, Prism, Psych, Commonmarker, Markly) don't support from_library
104
+ mod = TreeHaver.resolve_native_backend_module(backend)
105
+
106
+ if mod.nil?
107
+ if backend
108
+ raise NotAvailable,
109
+ "Requested backend #{backend.inspect} is not available or does not support shared libraries"
110
+ else
111
+ raise NotAvailable,
112
+ 'No native tree-sitter backend is available for loading shared libraries. ' \
113
+ 'Available native backends (MRI, Rust, FFI, Java) require platform-specific setup. ' \
114
+ 'For pure-Ruby parsing, use backend-specific Language classes directly (e.g., Prism, Psych, Citrus).'
115
+ end
116
+ end
117
+
118
+ # Backend must implement .from_library; fallback to .from_path for older impls
119
+ # Include effective backend AND ENV vars in cache key since they affect loading
120
+ effective_b = TreeHaver.resolve_effective_backend(backend)
121
+ key = [effective_b, path, symbol, name, ENV['TREE_SITTER_LANG_SYMBOL']]
122
+ LanguageRegistry.fetch(key) do
123
+ if mod::Language.respond_to?(:from_library)
124
+ mod::Language.from_library(path, symbol: symbol, name: name)
125
+ else
126
+ mod::Language.from_path(path)
127
+ end
128
+ end
129
+ end
130
+ # Alias for {from_library}
131
+ # @see from_library
132
+ alias from_path from_library
133
+
134
+ # Dynamic helper to load a registered language by name
135
+ #
136
+ # After registering a language with {TreeHaver.register_language},
137
+ # you can load it using a method call. The appropriate backend will be
138
+ # used based on registration and current backend.
139
+ #
140
+ # @example With tree-sitter
141
+ # TreeHaver.register_language(:toml, path: "/path/to/libtree-sitter-toml.so")
142
+ # language = TreeHaver::Language.toml
143
+ #
144
+ # @example With both backends
145
+ # TreeHaver.register_language(:toml,
146
+ # path: "/path/to/libtree-sitter-toml.so", symbol: "tree_sitter_toml")
147
+ # TreeHaver.register_language(:toml,
148
+ # grammar_module: TomlRB::Document)
149
+ # language = TreeHaver::Language.toml # Uses appropriate grammar for active backend
150
+ #
151
+ # @param method_name [Symbol] the registered language name
152
+ # @param args [Array] positional arguments
153
+ # @param kwargs [Hash] keyword arguments
154
+ # @return [Language] loaded language handle
155
+ # @raise [NoMethodError] if the language name is not registered
156
+ def method_missing(method_name, *args, **kwargs, &block)
157
+ # Resolve only if the language name was registered
158
+ all_backends = TreeHaver.registered_language(method_name)
159
+ return super unless all_backends
160
+
161
+ # Check current backend
162
+ current_backend = TreeHaver.backend_module
163
+
164
+ # Determine which backend type to use
165
+ backend_type = if current_backend == Backends::Citrus
166
+ :citrus
167
+ elsif current_backend == Backends::Parslet
168
+ :parslet
169
+ else
170
+ :tree_sitter # MRI, Rust, FFI, Java all use tree-sitter
171
+ end
172
+
173
+ # Get backend-specific registration
174
+ reg = all_backends[backend_type]
175
+
176
+ # If Citrus backend is active
177
+ if backend_type == :citrus
178
+ return Backends::Citrus::Language.new(reg[:grammar_module]) if reg && reg[:grammar_module]
179
+
180
+ # No Citrus grammar for this language — provide actionable error
181
+ raise NotAvailable,
182
+ "No Citrus grammar registered for :#{method_name}. " \
183
+ 'This language may only be available via tree-sitter. ' \
184
+ 'Check that the correct backend is selected (current: citrus). ' \
185
+ "Registered backends for :#{method_name}: #{all_backends.keys.inspect}"
186
+ end
187
+
188
+ # If Parslet backend is active
189
+ if backend_type == :parslet
190
+ return Backends::Parslet::Language.new(reg[:grammar_class]) if reg && reg[:grammar_class]
191
+
192
+ # No Parslet grammar for this language — provide actionable error
193
+ raise NotAvailable,
194
+ "No Parslet grammar registered for :#{method_name}. " \
195
+ 'This language may only be available via tree-sitter. ' \
196
+ 'Check that the correct backend is selected (current: parslet). ' \
197
+ "Registered backends for :#{method_name}: #{all_backends.keys.inspect}"
198
+ end
199
+
200
+ # For tree-sitter backends, try to load from path
201
+ # If that fails, fall back to Citrus if available
202
+ if reg && reg[:path]
203
+ path = kwargs[:path] || args.first || reg[:path]
204
+ # Symbol priority: kwargs override > registration > derive from method_name
205
+ symbol = if kwargs.key?(:symbol)
206
+ kwargs[:symbol]
207
+ elsif reg[:symbol]
208
+ reg[:symbol]
209
+ else
210
+ "tree_sitter_#{method_name}"
211
+ end
212
+ # Name priority: kwargs override > derive from symbol (strip tree_sitter_ prefix)
213
+ # Using symbol-derived name ensures ruby_tree_sitter gets the correct language name
214
+ # e.g., "toml" not "toml_both" when symbol is "tree_sitter_toml"
215
+ name = kwargs[:name] || symbol&.sub(/\Atree_sitter_/, '')
216
+
217
+ begin
218
+ return from_library(path, symbol: symbol, name: name)
219
+ rescue NotAvailable, ArgumentError, LoadError => e
220
+ # Tree-sitter failed to load - check for Citrus fallback
221
+ # Note: FFI::NotFoundError inherits from LoadError, so it's caught here too
222
+ handle_tree_sitter_load_failure(e, all_backends)
223
+ end
224
+ end
225
+
226
+ # No tree-sitter path registered - check for Citrus or Parslet fallback
227
+ # This enables auto-fallback when tree-sitter grammar is not installed
228
+ # but a pure Ruby grammar (Citrus or Parslet) is available.
229
+ # Only fall back when backend is :auto - explicit native backend requests should fail.
230
+ if TreeHaver.effective_backend == :auto
231
+ citrus_reg = all_backends[:citrus]
232
+ if citrus_reg && citrus_reg[:grammar_module]
233
+ return Backends::Citrus::Language.new(citrus_reg[:grammar_module])
234
+ end
235
+
236
+ parslet_reg = all_backends[:parslet]
237
+ if parslet_reg && parslet_reg[:grammar_class]
238
+ return Backends::Parslet::Language.new(parslet_reg[:grammar_class])
239
+ end
240
+ end
241
+
242
+ # No appropriate registration found
243
+ raise ArgumentError,
244
+ "No grammar registered for :#{method_name} compatible with #{backend_type} backend. " \
245
+ "Registered backends: #{all_backends.keys.inspect}"
246
+ end
247
+
248
+ # @api private
249
+ def respond_to_missing?(method_name, include_private = false)
250
+ !!TreeHaver.registered_language(method_name) || super
251
+ end
252
+
253
+ private
254
+
255
+ # Handle tree-sitter load failure with optional Citrus/Parslet fallback
256
+ #
257
+ # This handles cases where:
258
+ # - The .so file doesn't exist or can't be loaded (NotAvailable, LoadError)
259
+ # - FFI can't find required symbols like ts_parser_new (FFI::NotFoundError inherits from LoadError)
260
+ # - Invalid arguments were provided (ArgumentError)
261
+ #
262
+ # Fallback to Citrus/Parslet ONLY happens when:
263
+ # - The effective backend is :auto (user didn't explicitly request a native backend)
264
+ # - A Citrus or Parslet grammar is registered for the language
265
+ #
266
+ # If the user explicitly requested a native backend (:mri, :rust, :ffi, :java),
267
+ # we should NOT silently fall back to pure Ruby - that would violate the user's intent.
268
+ #
269
+ # @param error [Exception] the original error
270
+ # @param all_backends [Hash] all registered backends for the language
271
+ # @return [Backends::Citrus::Language, Backends::Parslet::Language] if fallback available and allowed
272
+ # @raise [Exception] re-raises original error if no fallback or fallback not allowed
273
+ # @api private
274
+ def handle_tree_sitter_load_failure(error, all_backends)
275
+ # Only fall back to pure Ruby when backend is :auto
276
+ # If user explicitly requested a native backend, respect that choice
277
+ effective = TreeHaver.effective_backend
278
+ if effective == :auto
279
+ citrus_reg = all_backends[:citrus]
280
+ if citrus_reg && citrus_reg[:grammar_module]
281
+ return Backends::Citrus::Language.new(citrus_reg[:grammar_module])
282
+ end
283
+
284
+ parslet_reg = all_backends[:parslet]
285
+ if parslet_reg && parslet_reg[:grammar_class]
286
+ return Backends::Parslet::Language.new(parslet_reg[:grammar_class])
287
+ end
288
+ end
289
+ # No pure Ruby fallback allowed or available, re-raise the original error
290
+ raise error
291
+ end
292
+ end
293
+ end
294
+ end
@@ -1,22 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "tree_sitter_language_pack"
5
-
6
3
  module TreeHaver
4
+ TSLP_BACKEND = BackendReference.new(
5
+ id: 'tslp',
6
+ family: 'tree-sitter'
7
+ ).freeze
7
8
  KREUZBERG_LANGUAGE_PACK_BACKEND = BackendReference.new(
8
- id: "kreuzberg-language-pack",
9
- family: "tree-sitter"
9
+ id: 'kreuzberg-language-pack',
10
+ family: 'tree-sitter'
10
11
  ).freeze
11
12
 
13
+ BackendRegistry.register(TSLP_BACKEND)
12
14
  BackendRegistry.register(KREUZBERG_LANGUAGE_PACK_BACKEND)
15
+ BackendRegistry.register_availability_checker(:tslp) do
16
+ Backends::Tslp.available?
17
+ end
18
+ BackendRegistry.register_availability_checker(:"kreuzberg-language-pack") do
19
+ BackendRegistry.available?(:tslp)
20
+ end
13
21
 
14
22
  module_function
15
23
 
16
24
  def language_pack_adapter_info
17
25
  AdapterInfo.new(
18
- backend: KREUZBERG_LANGUAGE_PACK_BACKEND.id,
19
- backend_ref: KREUZBERG_LANGUAGE_PACK_BACKEND,
26
+ backend: TSLP_BACKEND.id,
27
+ backend_ref: TSLP_BACKEND,
20
28
  supports_dialects: false,
21
29
  supported_policies: []
22
30
  )
@@ -24,167 +32,10 @@ module TreeHaver
24
32
 
25
33
  def language_pack_feature_profile
26
34
  FeatureProfile.new(
27
- backend: KREUZBERG_LANGUAGE_PACK_BACKEND.id,
28
- backend_ref: KREUZBERG_LANGUAGE_PACK_BACKEND,
35
+ backend: TSLP_BACKEND.id,
36
+ backend_ref: TSLP_BACKEND,
29
37
  supports_dialects: false,
30
38
  supported_policies: []
31
39
  )
32
40
  end
33
-
34
- def parse_with_language_pack(request)
35
- ensure_language_pack_language(request.language)
36
- raw = TreeSitterLanguagePack.process(
37
- request.source,
38
- JSON.generate(language: request.language, diagnostics: true)
39
- )
40
- diagnostics = Array(raw["diagnostics"])
41
- return parse_error_result(request.language) unless diagnostics.empty?
42
-
43
- analysis = LanguagePackAnalysis.new(
44
- language: request.language,
45
- dialect: request.dialect,
46
- root_type: inferred_root_type(request),
47
- has_error: false,
48
- backend_ref: KREUZBERG_LANGUAGE_PACK_BACKEND
49
- )
50
- parse_result(ok: true, analysis: analysis, diagnostics: [])
51
- rescue StandardError => e
52
- parse_result(
53
- ok: false,
54
- diagnostics: [diagnostic("error", "unsupported_feature", e.message)]
55
- )
56
- end
57
-
58
- def process_with_language_pack(request)
59
- ensure_language_pack_language(request.language)
60
- raw = TreeSitterLanguagePack.process(
61
- request.source,
62
- JSON.generate(language: request.language, structure: true, imports: true, diagnostics: true)
63
- )
64
- analysis = LanguagePackProcessAnalysis.new(
65
- language: raw.fetch("language"),
66
- structure: Array(raw["structure"]).map do |item|
67
- ProcessStructureItem.new(
68
- kind: item.fetch("kind").downcase,
69
- name: item["name"],
70
- span: process_span(item.fetch("span"))
71
- )
72
- end,
73
- imports: normalize_imports(request.language, Array(raw["imports"])),
74
- diagnostics: Array(raw["diagnostics"]).map do |item|
75
- ProcessDiagnostic.new(
76
- message: item.fetch("message"),
77
- severity: item.fetch("severity")
78
- )
79
- end,
80
- backend_ref: KREUZBERG_LANGUAGE_PACK_BACKEND
81
- )
82
- parse_result(ok: true, analysis: analysis, diagnostics: [])
83
- rescue StandardError => e
84
- parse_result(
85
- ok: false,
86
- diagnostics: [diagnostic("error", "unsupported_feature", e.message)]
87
- )
88
- end
89
-
90
- def ensure_language_pack_language(language)
91
- return if TreeSitterLanguagePack.has_language(language)
92
-
93
- TreeSitterLanguagePack.init(JSON.generate(languages: [language]))
94
- end
95
- private_class_method :ensure_language_pack_language
96
-
97
- def parse_error_result(language)
98
- parse_result(
99
- ok: false,
100
- diagnostics: [
101
- diagnostic(
102
- "error",
103
- "parse_error",
104
- "tree-sitter-language-pack reported syntax errors for #{language}."
105
- )
106
- ]
107
- )
108
- end
109
- private_class_method :parse_error_result
110
-
111
- def process_span(raw)
112
- ProcessSpan.new(
113
- start_byte: raw.fetch("start_byte"),
114
- end_byte: raw.fetch("end_byte"),
115
- start_row: raw["start_row"] || raw.fetch("start_line"),
116
- start_col: raw["start_col"] || raw.fetch("start_column"),
117
- end_row: raw["end_row"] || raw.fetch("end_line"),
118
- end_col: raw["end_col"] || raw.fetch("end_column")
119
- )
120
- end
121
- private_class_method :process_span
122
-
123
- def inferred_root_type(request)
124
- stripped = request.source.lstrip
125
- case request.language
126
- when "json"
127
- return "object" if stripped.start_with?("{")
128
- return "array" if stripped.start_with?("[")
129
-
130
- "scalar"
131
- else
132
- request.language
133
- end
134
- end
135
- private_class_method :inferred_root_type
136
-
137
- def normalize_imports(language, raw_imports)
138
- raw_imports.map do |item|
139
- source, items =
140
- if language == "typescript"
141
- normalize_typescript_import(item)
142
- else
143
- [item["module"] || item["source"] || "", Array(item["names"] || item["items"])]
144
- end
145
-
146
- ProcessImportInfo.new(
147
- source: source,
148
- items: items,
149
- span: process_span(item.fetch("span"))
150
- )
151
- end
152
- end
153
- private_class_method :normalize_imports
154
-
155
- def normalize_typescript_import(item)
156
- raw_source = item["module"] || item["source"] || ""
157
- source_match = raw_source.match(/from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/)
158
- source = source_match&.captures&.compact&.first || raw_source.strip
159
- names = if (named_items = raw_source.match(/\{([^}]+)\}/))
160
- named_items[1]
161
- .split(",")
162
- .map { |part| part.gsub(/\btype\b/, "").strip }
163
- .reject(&:empty?)
164
- else
165
- Array(item["names"] || item["items"])
166
- end
167
-
168
- [source, names]
169
- end
170
- private_class_method :normalize_typescript_import
171
-
172
- def parse_result(ok:, diagnostics:, analysis: nil, policies: [])
173
- {
174
- ok: ok,
175
- diagnostics: diagnostics,
176
- **(analysis ? { analysis: analysis } : {}),
177
- policies: policies
178
- }
179
- end
180
- private_class_method :parse_result
181
-
182
- def diagnostic(severity, category, message)
183
- {
184
- severity: severity,
185
- category: category,
186
- message: message
187
- }
188
- end
189
- private_class_method :diagnostic
190
41
  end