tree_haver 4.0.5 → 5.0.1

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.
@@ -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
@@ -45,8 +45,10 @@ module TreeHaver
45
45
  @loaded = true # rubocop:disable ThreadSafety/ClassInstanceVariable
46
46
  rescue LoadError
47
47
  @loaded = false # rubocop:disable ThreadSafety/ClassInstanceVariable
48
+ # :nocov: defensive code - StandardError during require is extremely rare
48
49
  rescue StandardError
49
50
  @loaded = false # rubocop:disable ThreadSafety/ClassInstanceVariable
51
+ # :nocov:
50
52
  end
51
53
  @loaded # rubocop:disable ThreadSafety/ClassInstanceVariable
52
54
  end
@@ -108,6 +110,22 @@ module TreeHaver
108
110
  @backend = :citrus
109
111
  end
110
112
 
113
+ # Get the language name
114
+ #
115
+ # Derives a name from the grammar module name.
116
+ #
117
+ # @return [Symbol] language name
118
+ def language_name
119
+ # Derive name from grammar module (e.g., TomlRB::Document -> :toml)
120
+ return :unknown unless @grammar_module.respond_to?(:name) && @grammar_module.name
121
+
122
+ name = @grammar_module.name.to_s.split("::").first.downcase
123
+ name.sub(/rb$/, "").to_sym
124
+ end
125
+
126
+ # Alias for language_name (API compatibility)
127
+ alias_method :name, :language_name
128
+
111
129
  # Compare languages for equality
112
130
  #
113
131
  # Citrus languages are equal if they have the same backend and grammar_module.
@@ -192,23 +210,31 @@ module TreeHaver
192
210
 
193
211
  # Set the grammar for this parser
194
212
  #
195
- # Note: TreeHaver::Parser unwraps language objects before calling this method.
196
- # This backend receives the raw Citrus grammar module (unwrapped), not the Language wrapper.
213
+ # Accepts either a Citrus::Language wrapper or a raw Citrus grammar module.
214
+ # When passed a Language wrapper, extracts the grammar_module from it.
215
+ # When passed a raw grammar module, uses it directly.
197
216
  #
198
- # @param grammar [Module] Citrus grammar module with a parse method
217
+ # This flexibility allows both patterns:
218
+ # parser.language = TreeHaver::Backends::Citrus::Language.new(TomlRB::Document)
219
+ # parser.language = TomlRB::Document # Also works
220
+ #
221
+ # @param grammar [Language, Module] Citrus Language wrapper or grammar module
199
222
  # @return [void]
200
- # @example
201
- # require "toml-rb"
202
- # # TreeHaver::Parser unwraps Language.new(TomlRB::Document) to just TomlRB::Document
203
- # parser.language = TomlRB::Document # Backend receives unwrapped module
204
223
  def language=(grammar)
205
- # grammar is already unwrapped by TreeHaver::Parser
206
- unless grammar.respond_to?(:parse)
224
+ # Accept Language wrapper or raw grammar module
225
+ actual_grammar = case grammar
226
+ when Language
227
+ grammar.grammar_module
228
+ else
229
+ grammar
230
+ end
231
+
232
+ unless actual_grammar.respond_to?(:parse)
207
233
  raise ArgumentError,
208
- "Expected Citrus grammar module with parse method, " \
234
+ "Expected Citrus grammar module with parse method or Language wrapper, " \
209
235
  "got #{grammar.class}"
210
236
  end
211
- @grammar = grammar
237
+ @grammar = actual_grammar
212
238
  end
213
239
 
214
240
  # Parse source code
@@ -247,13 +273,18 @@ module TreeHaver
247
273
  # Wraps a Citrus::Match (which represents the parse tree) to provide
248
274
  # tree-sitter-compatible API.
249
275
  #
276
+ # Inherits from Base::Tree to get shared methods like #errors, #warnings,
277
+ # #comments, #has_error?, and #inspect.
278
+ #
250
279
  # @api private
251
- class Tree
252
- attr_reader :root_match, :source
280
+ class Tree < TreeHaver::Base::Tree
281
+ # The raw Citrus::Match root
282
+ # @return [Citrus::Match] The root match
283
+ attr_reader :root_match
253
284
 
254
285
  def initialize(root_match, source)
255
286
  @root_match = root_match
256
- @source = source
287
+ super(root_match, source: source)
257
288
  end
258
289
 
259
290
  def root_node
@@ -273,22 +304,24 @@ module TreeHaver
273
304
  # - matches: child matches
274
305
  # - captures: named groups
275
306
  #
307
+ # Inherits from Base::Node to get shared methods like #first_child, #last_child,
308
+ # #to_s, #inspect, #==, #<=>, #source_position, #start_line, #end_line, etc.
309
+ #
276
310
  # Language-specific helpers can be mixed in for convenience:
277
311
  # require "tree_haver/backends/citrus/toml_helpers"
278
312
  # TreeHaver::Backends::Citrus::Node.include(TreeHaver::Backends::Citrus::TomlHelpers)
279
313
  #
280
314
  # @api private
281
- class Node
282
- include Comparable
283
- include Enumerable
284
-
285
- attr_reader :match, :source
315
+ class Node < TreeHaver::Base::Node
316
+ attr_reader :match
286
317
 
287
318
  def initialize(match, source)
288
319
  @match = match
289
- @source = source
320
+ super(match, source: source)
290
321
  end
291
322
 
323
+ # -- Required API Methods (from Base::Node) ----------------------------
324
+
292
325
  # Get node type from Citrus rule name
293
326
  #
294
327
  # Uses Citrus grammar introspection to dynamically determine node types.
@@ -308,6 +341,50 @@ module TreeHaver
308
341
  extract_type_from_event(@match.events.first)
309
342
  end
310
343
 
344
+ def start_byte
345
+ @match.offset
346
+ end
347
+
348
+ def end_byte
349
+ @match.offset + @match.length
350
+ end
351
+
352
+ def children
353
+ return [] unless @match.respond_to?(:matches)
354
+ @match.matches.map { |m| Node.new(m, @source) }
355
+ end
356
+
357
+ # -- Overridden Methods ------------------------------------------------
358
+
359
+ # Override start_point to calculate from source
360
+ def start_point
361
+ calculate_point(@match.offset)
362
+ end
363
+
364
+ # Override end_point to calculate from source
365
+ def end_point
366
+ calculate_point(@match.offset + @match.length)
367
+ end
368
+
369
+ # Override text to use Citrus match string
370
+ def text
371
+ @match.string
372
+ end
373
+
374
+ # Override child_count for efficiency (avoid building full children array)
375
+ def child_count
376
+ @match.respond_to?(:matches) ? @match.matches.size : 0
377
+ end
378
+
379
+ # Override child to handle negative indices properly
380
+ def child(index)
381
+ return if index.negative?
382
+ return unless @match.respond_to?(:matches)
383
+ return if index >= @match.matches.size
384
+
385
+ Node.new(@match.matches[index], @source)
386
+ end
387
+
311
388
  # Check if this node represents a structural element vs a terminal/token
312
389
  #
313
390
  # Uses Citrus grammar's terminal? method to determine if this is
@@ -387,99 +464,6 @@ module TreeHaver
387
464
  "unknown"
388
465
  end
389
466
 
390
- public
391
-
392
- def start_byte
393
- @match.offset
394
- end
395
-
396
- def end_byte
397
- @match.offset + @match.length
398
- end
399
-
400
- def start_point
401
- calculate_point(@match.offset)
402
- end
403
-
404
- def end_point
405
- calculate_point(@match.offset + @match.length)
406
- end
407
-
408
- # Get the 1-based line number where this node starts
409
- #
410
- # @return [Integer] 1-based line number
411
- def start_line
412
- start_point[:row] + 1
413
- end
414
-
415
- # Get the 1-based line number where this node ends
416
- #
417
- # @return [Integer] 1-based line number
418
- def end_line
419
- end_point[:row] + 1
420
- end
421
-
422
- # Get position information as a hash
423
- #
424
- # Returns a hash with 1-based line numbers and 0-based columns.
425
- # Compatible with *-merge gems' FileAnalysisBase.
426
- #
427
- # @return [Hash{Symbol => Integer}] Position hash
428
- def source_position
429
- {
430
- start_line: start_line,
431
- end_line: end_line,
432
- start_column: start_point[:column],
433
- end_column: end_point[:column],
434
- }
435
- end
436
-
437
- # Get the first child node
438
- #
439
- # @return [Node, nil] First child or nil
440
- def first_child
441
- child(0)
442
- end
443
-
444
- def text
445
- @match.string
446
- end
447
-
448
- def child_count
449
- @match.respond_to?(:matches) ? @match.matches.size : 0
450
- end
451
-
452
- def child(index)
453
- return unless @match.respond_to?(:matches)
454
- return if index >= @match.matches.size
455
-
456
- Node.new(@match.matches[index], @source)
457
- end
458
-
459
- def children
460
- return [] unless @match.respond_to?(:matches)
461
- @match.matches.map { |m| Node.new(m, @source) }
462
- end
463
-
464
- def each(&block)
465
- return to_enum(__method__) unless block_given?
466
- children.each(&block)
467
- end
468
-
469
- def has_error?
470
- false # Citrus raises on parse error, so successful parse has no errors
471
- end
472
-
473
- def missing?
474
- false # Citrus doesn't have the concept of missing nodes
475
- end
476
-
477
- def named?
478
- true # Citrus matches are typically "named" in tree-sitter terminology
479
- end
480
-
481
- private
482
-
483
467
  def calculate_point(offset)
484
468
  return {row: 0, column: 0} if offset <= 0
485
469
 
@@ -494,7 +478,7 @@ module TreeHaver
494
478
  end
495
479
  end
496
480
 
497
- # Register availability checker for RSpec dependency tags
481
+ # Register the availability checker for RSpec dependency tags
498
482
  TreeHaver::BackendRegistry.register_availability_checker(:citrus) do
499
483
  available?
500
484
  end