tree_haver 5.0.0 → 5.0.2

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.
@@ -234,12 +234,12 @@ module TreeHaver
234
234
  private
235
235
 
236
236
  def validate_module_methods(mod, results)
237
- unless mod.respond_to?(:available?)
237
+ unless mod.singleton_class.method_defined?(:available?)
238
238
  results[:errors] << "Missing module method: available?"
239
239
  results[:valid] = false
240
240
  end
241
241
 
242
- unless mod.respond_to?(:capabilities)
242
+ unless mod.singleton_class.method_defined?(:capabilities)
243
243
  results[:warnings] << "Missing module method: capabilities"
244
244
  end
245
245
  end
@@ -248,26 +248,26 @@ module TreeHaver
248
248
  # from_library is REQUIRED for all backends
249
249
  # Language-specific backends should implement it to ignore path/symbol
250
250
  # and return their single language (for API consistency)
251
- unless klass.respond_to?(:from_library)
251
+ unless klass.singleton_class.method_defined?(:from_library)
252
252
  results[:errors] << "Language missing required class method: from_library"
253
253
  results[:valid] = false
254
254
  end
255
255
 
256
256
  # Check for optional convenience methods
257
- optional_methods = LANGUAGE_OPTIONAL_CLASS_METHODS.select { |m| klass.respond_to?(m) }
257
+ optional_methods = LANGUAGE_OPTIONAL_CLASS_METHODS.select { |m| klass.singleton_class.method_defined?(m) }
258
258
  if optional_methods.any?
259
259
  results[:capabilities][:language_shortcuts] = optional_methods
260
260
  end
261
261
 
262
262
  results[:capabilities][:language] = {
263
- class_methods: LANGUAGE_CLASS_METHODS.select { |m| klass.respond_to?(m) } +
263
+ class_methods: LANGUAGE_CLASS_METHODS.select { |m| klass.singleton_class.method_defined?(m) } +
264
264
  optional_methods,
265
265
  }
266
266
  end
267
267
 
268
268
  def validate_parser(klass, results)
269
269
  PARSER_CLASS_METHODS.each do |method|
270
- unless klass.respond_to?(method)
270
+ unless klass.singleton_class.method_defined?(method)
271
271
  results[:errors] << "Parser missing class method: #{method}"
272
272
  results[:valid] = false
273
273
  end
@@ -275,14 +275,14 @@ module TreeHaver
275
275
 
276
276
  # Check instance methods by inspecting the class
277
277
  PARSER_INSTANCE_METHODS.each do |method|
278
- unless klass.instance_methods.include?(method) || klass.private_instance_methods.include?(method)
278
+ unless klass.method_defined?(method) || klass.private_method_defined?(method)
279
279
  results[:errors] << "Parser missing instance method: #{method}"
280
280
  results[:valid] = false
281
281
  end
282
282
  end
283
283
 
284
284
  PARSER_OPTIONAL_METHODS.each do |method|
285
- unless klass.instance_methods.include?(method)
285
+ unless klass.method_defined?(method)
286
286
  results[:warnings] << "Parser missing optional method: #{method}"
287
287
  end
288
288
  end
@@ -290,14 +290,14 @@ module TreeHaver
290
290
 
291
291
  def validate_tree(klass, results)
292
292
  TREE_INSTANCE_METHODS.each do |method|
293
- unless klass.instance_methods.include?(method)
293
+ unless klass.method_defined?(method)
294
294
  results[:errors] << "Tree missing instance method: #{method}"
295
295
  results[:valid] = false
296
296
  end
297
297
  end
298
298
 
299
299
  TREE_OPTIONAL_METHODS.each do |method|
300
- unless klass.instance_methods.include?(method)
300
+ unless klass.method_defined?(method)
301
301
  results[:warnings] << "Tree missing optional method: #{method}"
302
302
  end
303
303
  end
@@ -330,11 +330,11 @@ module TreeHaver
330
330
  end
331
331
 
332
332
  def has_method_or_alias?(klass, method)
333
- return true if klass.instance_methods.include?(method)
333
+ return true if klass.method_defined?(method)
334
334
 
335
335
  # Check aliases
336
336
  aliases = NODE_ALIASES[method] || []
337
- aliases.any? { |alt| klass.instance_methods.include?(alt) }
337
+ aliases.any? { |alt| klass.method_defined?(alt) }
338
338
  end
339
339
 
340
340
  def responds_to_with_aliases?(obj, method)
@@ -22,27 +22,44 @@ module TreeHaver
22
22
  # - **External backends** (commonmarker-merge, markly-merge, rbs-merge) register
23
23
  # their checkers when their backend module is loaded
24
24
  #
25
+ # == Full Tag Registration
26
+ #
27
+ # External gems can register complete tag support using {register_tag}:
28
+ # - Tag name (e.g., :commonmarker_backend)
29
+ # - Category (:backend, :gem, :parsing, :grammar)
30
+ # - Availability checker
31
+ # - Optional require path for lazy loading
32
+ #
33
+ # This enables tree_haver/rspec/dependency_tags to automatically configure
34
+ # RSpec exclusion filters for any registered tag without hardcoded knowledge.
35
+ #
25
36
  # == Thread Safety
26
37
  #
27
38
  # All operations are thread-safe using a Mutex for synchronization.
28
39
  # Results are cached after first check for performance.
29
40
  #
30
- # @example Registering a backend availability checker (in your gem)
41
+ # @example Registering a backend availability checker (simple form)
31
42
  # # In commonmarker-merge/lib/commonmarker/merge/backend.rb
32
43
  # TreeHaver::BackendRegistry.register_availability_checker(:commonmarker) do
33
44
  # available?
34
45
  # end
35
46
  #
47
+ # @example Registering a full tag with require path (preferred for external gems)
48
+ # TreeHaver::BackendRegistry.register_tag(
49
+ # :commonmarker_backend,
50
+ # category: :backend,
51
+ # backend_name: :commonmarker,
52
+ # require_path: "commonmarker/merge"
53
+ # ) { Commonmarker::Merge::Backend.available? }
54
+ #
36
55
  # @example Checking backend availability
37
56
  # TreeHaver::BackendRegistry.available?(:commonmarker) # => true/false
38
57
  # TreeHaver::BackendRegistry.available?(:markly) # => true/false
39
58
  # TreeHaver::BackendRegistry.available?(:rbs) # => true/false
40
59
  #
41
- # @example Checking if a checker is registered
42
- # TreeHaver::BackendRegistry.registered?(:commonmarker) # => true/false
43
- #
44
- # @example Getting all registered backends
45
- # TreeHaver::BackendRegistry.registered_backends # => [:mri, :rust, :ffi, ...]
60
+ # @example Getting all registered tags
61
+ # TreeHaver::BackendRegistry.registered_tags # => [:commonmarker_backend, :markly_backend, ...]
62
+ # TreeHaver::BackendRegistry.tags_by_category(:backend) # => [...]
46
63
  #
47
64
  # @see TreeHaver::RSpec::DependencyTags Uses BackendRegistry for dynamic backend detection
48
65
  # @api public
@@ -50,15 +67,83 @@ module TreeHaver
50
67
  @mutex = Mutex.new
51
68
  @availability_checkers = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable
52
69
  @availability_cache = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable
70
+ @tag_registry = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable
71
+
72
+ # Tag categories for organizing dependency tags
73
+ # @api private
74
+ CATEGORIES = %i[backend gem parsing grammar engine other].freeze
53
75
 
54
76
  module_function
55
77
 
56
- # Register an availability checker for a backend
78
+ # Register a full dependency tag with all metadata
79
+ #
80
+ # This is the preferred method for external gems to register their availability
81
+ # with complete tag support. It registers both the availability checker and
82
+ # the tag metadata needed for RSpec configuration.
83
+ #
84
+ # When a tag is registered, this also dynamically defines a `*_available?` method
85
+ # on `TreeHaver::RSpec::DependencyTags` if it doesn't already exist.
86
+ #
87
+ # @param tag_name [Symbol] the RSpec tag name (e.g., :commonmarker_backend)
88
+ # @param category [Symbol] one of :backend, :gem, :parsing, :grammar, :engine, :other
89
+ # @param backend_name [Symbol, nil] the backend name for availability checks (defaults to tag without suffix)
90
+ # @param require_path [String, nil] optional require path to load before checking availability
91
+ # @param checker [#call, nil] a callable that returns true if available
92
+ # @yield Block form of checker (alternative to passing a callable)
93
+ # @yieldreturn [Boolean] true if the tag's dependency is available
94
+ # @return [void]
95
+ #
96
+ # @example Register a backend tag with require path
97
+ # TreeHaver::BackendRegistry.register_tag(
98
+ # :commonmarker_backend,
99
+ # category: :backend,
100
+ # require_path: "commonmarker/merge"
101
+ # ) { Commonmarker::Merge::Backend.available? }
102
+ #
103
+ # @example Register a gem tag
104
+ # TreeHaver::BackendRegistry.register_tag(
105
+ # :toml_gem,
106
+ # category: :gem,
107
+ # require_path: "toml"
108
+ # ) { defined?(TOML) }
109
+ def register_tag(tag_name, category:, backend_name: nil, require_path: nil, checker: nil, &block)
110
+ callable = checker || block
111
+ raise ArgumentError, "Must provide a checker callable or block" unless callable
112
+ raise ArgumentError, "Checker must respond to #call" unless callable.respond_to?(:call)
113
+ raise ArgumentError, "Invalid category: #{category}" unless CATEGORIES.include?(category)
114
+
115
+ tag_sym = tag_name.to_sym
116
+ # Derive backend_name from tag_name if not provided (e.g., :commonmarker_backend -> :commonmarker)
117
+ derived_backend = backend_name || tag_sym.to_s.sub(/_backend$/, "").to_sym
118
+
119
+ @mutex.synchronize do
120
+ @tag_registry[tag_sym] = {
121
+ category: category,
122
+ backend_name: derived_backend,
123
+ require_path: require_path,
124
+ checker: callable,
125
+ }
126
+ # Also register as availability checker for the backend name
127
+ @availability_checkers[derived_backend] = callable
128
+ # Clear caches
129
+ @availability_cache.delete(derived_backend)
130
+ end
131
+
132
+ # Dynamically define the availability method on DependencyTags
133
+ # This happens outside the mutex to avoid potential deadlock
134
+ define_availability_method(derived_backend, tag_sym)
135
+
136
+ nil
137
+ end
138
+
139
+ # Register an availability checker for a backend (simple form)
57
140
  #
58
141
  # The checker should be a callable (lambda/proc/block) that returns true if
59
142
  # the backend is available and can be used. The checker is called lazily
60
143
  # (only when {available?} is first called for this backend).
61
144
  #
145
+ # For full tag support including require paths, use {register_tag} instead.
146
+ #
62
147
  # @param backend_name [Symbol, String] the backend name (e.g., :commonmarker, :markly)
63
148
  # @param checker [#call, nil] a callable that returns true if the backend is available
64
149
  # @yield Block form of checker (alternative to passing a callable)
@@ -206,10 +291,118 @@ module TreeHaver
206
291
  @mutex.synchronize do
207
292
  @availability_checkers.clear
208
293
  @availability_cache.clear
294
+ @tag_registry.clear
209
295
  end
210
296
  nil
211
297
  end
212
298
 
299
+ # ============================================================
300
+ # Tag Registry Methods
301
+ # ============================================================
302
+
303
+ # Get all registered tag names
304
+ #
305
+ # @return [Array<Symbol>] list of registered tag names
306
+ #
307
+ # @example
308
+ # TreeHaver::BackendRegistry.registered_tags
309
+ # # => [:commonmarker_backend, :markly_backend, :toml_gem, ...]
310
+ def registered_tags
311
+ @mutex.synchronize do
312
+ @tag_registry.keys.dup
313
+ end
314
+ end
315
+
316
+ # Get tags filtered by category
317
+ #
318
+ # @param category [Symbol] one of :backend, :gem, :parsing, :grammar, :engine, :other
319
+ # @return [Array<Symbol>] list of tag names in that category
320
+ #
321
+ # @example
322
+ # TreeHaver::BackendRegistry.tags_by_category(:backend)
323
+ # # => [:commonmarker_backend, :markly_backend, :mri_backend, ...]
324
+ def tags_by_category(category)
325
+ @mutex.synchronize do
326
+ @tag_registry.select { |_, meta| meta[:category] == category }.keys
327
+ end
328
+ end
329
+
330
+ # Get tag metadata
331
+ #
332
+ # @param tag_name [Symbol] the tag name
333
+ # @return [Hash, nil] tag metadata or nil if not registered
334
+ #
335
+ # @example
336
+ # TreeHaver::BackendRegistry.tag_metadata(:commonmarker_backend)
337
+ # # => { category: :backend, backend_name: :commonmarker, require_path: "commonmarker/merge", checker: #<Proc> }
338
+ def tag_metadata(tag_name)
339
+ @mutex.synchronize do
340
+ @tag_registry[tag_name.to_sym]&.dup
341
+ end
342
+ end
343
+
344
+ # Check if a tag is registered
345
+ #
346
+ # @param tag_name [Symbol] the tag name
347
+ # @return [Boolean] true if the tag is registered
348
+ def tag_registered?(tag_name)
349
+ @mutex.synchronize do
350
+ @tag_registry.key?(tag_name.to_sym)
351
+ end
352
+ end
353
+
354
+ # Check if a tag's dependency is available
355
+ #
356
+ # This method handles require paths: if the tag has a require_path, it will
357
+ # attempt to load the gem before checking availability. This enables lazy
358
+ # loading of external gems.
359
+ #
360
+ # @param tag_name [Symbol] the tag name to check
361
+ # @return [Boolean] true if the tag's dependency is available
362
+ #
363
+ # @example
364
+ # TreeHaver::BackendRegistry.tag_available?(:commonmarker_backend) # => true/false
365
+ def tag_available?(tag_name)
366
+ tag_sym = tag_name.to_sym
367
+
368
+ # Get tag metadata
369
+ meta = @mutex.synchronize { @tag_registry[tag_sym] }
370
+
371
+ # If tag not registered, check if it's a backend name with _backend suffix
372
+ unless meta
373
+ # Try to derive backend name (e.g., :commonmarker_backend -> :commonmarker)
374
+ backend_name = tag_sym.to_s.sub(/_backend$/, "").to_sym
375
+ return available?(backend_name) if backend_name != tag_sym
376
+ return false
377
+ end
378
+
379
+ # Try to load the gem if require_path is specified
380
+ if meta[:require_path]
381
+ begin
382
+ require meta[:require_path]
383
+ rescue LoadError
384
+ # Gem not available
385
+ return false
386
+ end
387
+ end
388
+
389
+ # Check availability using the backend name
390
+ available?(meta[:backend_name])
391
+ end
392
+
393
+ # Get a summary of all registered tags and their availability
394
+ #
395
+ # @return [Hash{Symbol => Boolean}] map of tag name to availability
396
+ #
397
+ # @example
398
+ # TreeHaver::BackendRegistry.tag_summary
399
+ # # => { commonmarker_backend: true, markly_backend: false, ... }
400
+ def tag_summary
401
+ @mutex.synchronize { @tag_registry.keys.dup }.each_with_object({}) do |tag, result|
402
+ result[tag] = tag_available?(tag)
403
+ end
404
+ end
405
+
213
406
  # Check a built-in TreeHaver backend
214
407
  #
215
408
  # Attempts to find the backend module at `TreeHaver::Backends::<Name>` and
@@ -230,5 +423,35 @@ module TreeHaver
230
423
  false
231
424
  end
232
425
  private_class_method :check_builtin_backend
426
+
427
+ # Dynamically define an availability method on DependencyTags
428
+ #
429
+ # This creates a `*_available?` method that checks tag_available? with
430
+ # memoization. The method is only defined if DependencyTags is loaded
431
+ # and doesn't already have a method with that name.
432
+ #
433
+ # @param backend_name [Symbol] the backend name (e.g., :commonmarker)
434
+ # @param tag_name [Symbol] the tag name (e.g., :commonmarker_backend)
435
+ # @return [void]
436
+ # @api private
437
+ def define_availability_method(backend_name, tag_name)
438
+ method_name = :"#{backend_name}_available?"
439
+
440
+ # Only define if DependencyTags is loaded
441
+ return unless defined?(TreeHaver::RSpec::DependencyTags)
442
+
443
+ deps = TreeHaver::RSpec::DependencyTags
444
+
445
+ # Don't override existing methods (built-in backends have explicit methods)
446
+ return if deps.respond_to?(method_name)
447
+
448
+ # Define the method dynamically
449
+ ivar = :"@#{backend_name}_available"
450
+ deps.define_singleton_method(method_name) do
451
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
452
+ instance_variable_set(ivar, TreeHaver::BackendRegistry.tag_available?(tag_name))
453
+ end
454
+ end
455
+ private_class_method :define_availability_method
233
456
  end
234
457
  end
@@ -264,12 +264,13 @@ module TreeHaver
264
264
 
265
265
  # Only tree-sitter backends are relevant here
266
266
  # Non-tree-sitter backends (Citrus, Prism, Psych, etc.) don't use grammar files
267
- return false if mod.nil?
268
- return false unless TREE_SITTER_BACKENDS.include?(mod)
269
-
270
- # Try to instantiate a parser - this will fail if runtime isn't available
271
- mod::Parser.new
272
- true
267
+ if mod.nil? || !TREE_SITTER_BACKENDS.include?(mod)
268
+ false
269
+ else
270
+ # Try to instantiate a parser - this will fail if runtime isn't available
271
+ mod::Parser.new
272
+ true
273
+ end
273
274
  rescue NoMethodError, LoadError, NotAvailable => _e
274
275
  # Note: FFI::NotFoundError inherits from LoadError, so it's caught here too
275
276
  false
@@ -364,10 +364,12 @@ module TreeHaver
364
364
 
365
365
  case lang.backend
366
366
  when :mri
367
- lang.to_language if lang.respond_to?(:to_language)
368
- lang.inner_language if lang.respond_to?(:inner_language)
367
+ return lang.to_language if lang.respond_to?(:to_language)
368
+ return lang.inner_language if lang.respond_to?(:inner_language)
369
+ lang
369
370
  when :rust
370
- lang.name if lang.respond_to?(:name)
371
+ return lang.name if lang.respond_to?(:name)
372
+ lang
371
373
  when :ffi
372
374
  lang # FFI needs wrapper for to_ptr
373
375
  when :java