ast-merge 4.0.0 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 871eebc1f2c69aa75278023c250fd90905cf2fb4e23677fbcfd49633bad5ebb1
4
- data.tar.gz: 3804ee9f9f61effc21c49862a96b0092481067813c79b1b4fdb09a748b935570
3
+ metadata.gz: e6af6467980a8e2076bd454a35bea42e18064fed0651704754f459353a85087a
4
+ data.tar.gz: 1d89bad2aa237d11aafd957f68391d8ca83248cc0f7946bad54e788cc1fa05c2
5
5
  SHA512:
6
- metadata.gz: 340c3feee108349b603db2150baa68467b45c7414be0d1fdcfaac10038d3cfca2dc893dc81a489adae66bbfc8d9474f7128e7f0817abc9703ba6694719a48b91
7
- data.tar.gz: 5f135b944efe203fe0f83cf2d141e18de4e64be096a3293c0db09b3f5672790b6cdbad6956ef511c657b43c0e0b162050ec57d46d7da659895554c16e2eac6c4
6
+ metadata.gz: a45b949a5b6b835ba26d0422eb855daa9a62e513131bf06627abe8b68dba2daae28dd835f51fce43758c36f652fb44851885adcec3f82a64519bc2baed6f77f9
7
+ data.tar.gz: c2a7c05cedc23b3552f5eaef980cc890cf034060efe82f51f0c7acc98cba5b5df3bc2441a96bcde188c48391baebe5474dd76f735e5a2dbda053ff85b07a6748
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,46 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [4.0.1] - 2026-01-11
34
+
35
+ - TAG: [v4.0.1][4.0.1t]
36
+ - COVERAGE: 96.45% -- 2553/2647 lines in 51 files
37
+ - BRANCH COVERAGE: 87.41% -- 812/929 branches in 51 files
38
+ - 98.80% documented
39
+
40
+ ### Added
41
+
42
+ - **`Ast::Merge::RSpec::MergeGemRegistry`** - Fully dynamic merge gem registration for RSpec dependency tags
43
+ - `register(tag_name, require_path:, merger_class:, test_source:, category:)` - Register a merge gem
44
+ - `available?(tag_name)` - Check if a merge gem is available and functional
45
+ - `registered_gems` - Get all registered gem tag names
46
+ - `gems_by_category(category)` - Filter gems by category (:markdown, :data, :code, :config, :other)
47
+ - `summary` - Get availability status of all registered gems
48
+ - Automatically defines `*_available?` methods on `DependencyTags` at registration time
49
+ - External merge gems can now get full RSpec tag support without modifying ast-merge
50
+
51
+ ### Changed
52
+
53
+ - Upgrade to [tree_haver v5.0.1](https://github.com/kettle-rb/tree_haver/releases/tag/v5.0.1)
54
+ - **`Ast::Merge::AstNode` now inherits from `TreeHaver::Base::Node`**
55
+ - Ensures synthetic nodes stay in sync with the canonical Node API
56
+ - Inherits `Comparable`, `Enumerable` from base class
57
+ - Retains all existing methods and behavior (Point, Location, signature, etc.)
58
+ - Constructor calls `super(self, source: source)` to properly initialize base class
59
+ - **RSpec Dependency Tags refactored to use MergeGemRegistry**
60
+ - Removed hardcoded merge gem availability checks
61
+ - Removed `MERGE_GEM_TEST_SOURCES` constant
62
+ - `*_available?` methods are now defined dynamically when gems register
63
+ - `any_markdown_merge_available?` now queries registry by category
64
+ - RSpec exclusion filters are configured dynamically from registry
65
+ - `Ast::Merge::Testing::TestableNode` now delegates to `TreeHaver::RSpec::TestableNode`
66
+ - The TestableNode implementation has been moved to tree_haver for sharing across all merge gems
67
+ - `spec/support/testable_node.rb` now requires and re-exports the tree_haver version
68
+ - Backward compatible: existing tests continue to work unchanged
69
+ - `spec/ast/merge/node_wrapper_base_spec.rb` refactored to use `TestableNode` instead of mocks
70
+ - Real TreeHaver::Node behavior for most tests
71
+ - Mocks only retained for edge case testing (e.g., invalid end_line before start_line)
72
+
33
73
  ## [4.0.0] - 2026-01-11
34
74
 
35
75
  - TAG: [v4.0.0][4.0.0t]
@@ -597,7 +637,9 @@ Please file a bug if you notice a violation of semantic versioning.
597
637
 
598
638
  - Initial release
599
639
 
600
- [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v4.0.0...HEAD
640
+ [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v4.0.1...HEAD
641
+ [4.0.1]: https://github.com/kettle-rb/ast-merge/compare/v4.0.0...v4.0.1
642
+ [4.0.1t]: https://github.com/kettle-rb/ast-merge/releases/tag/v4.0.1
601
643
  [4.0.0]: https://github.com/kettle-rb/ast-merge/compare/v3.1.0...v4.0.0
602
644
  [4.0.0t]: https://github.com/kettle-rb/ast-merge/releases/tag/v4.0.0
603
645
  [3.1.0]: https://github.com/kettle-rb/ast-merge/compare/v3.0.0...v3.1.0
@@ -8,9 +8,9 @@ module Ast
8
8
  # created by ast-merge for representing content that doesn't have a native
9
9
  # AST (comments, text lines, env file entries, etc.).
10
10
  #
11
- # This class implements the TreeHaver::Node protocol, making it compatible
12
- # with all code that expects TreeHaver nodes. This allows synthetic nodes
13
- # to be used interchangeably with parser-backed nodes in merge operations.
11
+ # This class inherits from TreeHaver::Base::Node, ensuring it stays in sync
12
+ # with the canonical Node API. This allows synthetic nodes to be used
13
+ # interchangeably with parser-backed nodes in merge operations.
14
14
  #
15
15
  # Implements the TreeHaver::Node protocol:
16
16
  # - type → String node type
@@ -36,12 +36,10 @@ module Ast
36
36
  # end
37
37
  # end
38
38
  #
39
- # @see TreeHaver::Node The protocol this class implements
39
+ # @see TreeHaver::Base::Node The base class defining the canonical Node API
40
40
  # @see Comment::Line Example synthetic node for comments
41
41
  # @see Text::LineNode Example synthetic node for text lines
42
- class AstNode
43
- include Comparable
44
-
42
+ class AstNode < TreeHaver::Base::Node
45
43
  # Point class compatible with TreeHaver::Point
46
44
  # Provides both method and hash-style access to row/column
47
45
  Point = Struct.new(:row, :column, keyword_init: true) do
@@ -83,9 +81,6 @@ module Ast
83
81
  # @return [String] The source text for this node
84
82
  attr_reader :slice
85
83
 
86
- # @return [String, nil] The full source text (for text extraction)
87
- attr_reader :source
88
-
89
84
  # Initialize a new AstNode.
90
85
  #
91
86
  # @param slice [String] The source text for this node
@@ -94,15 +89,14 @@ module Ast
94
89
  def initialize(slice:, location:, source: nil)
95
90
  @slice = slice
96
91
  @location = location
97
- @source = source
92
+ # Call parent constructor with self as inner_node
93
+ super(self, source: source)
98
94
  end
99
95
 
100
- # TreeHaver::Node protocol: inner_node
101
- # For synthetic nodes, this returns self (no wrapping layer)
102
- #
103
- # @return [AstNode] self
104
- def inner_node
105
- self
96
+ # Override source to return stored value (not parent's)
97
+ # @return [String, nil] The full source text (for text extraction)
98
+ def source
99
+ @source || super
106
100
  end
107
101
 
108
102
  # TreeHaver::Node protocol: type
@@ -132,10 +126,11 @@ module Ast
132
126
  #
133
127
  # @return [Integer] Starting byte offset
134
128
  def start_byte
135
- return 0 unless source && location
129
+ src = source
130
+ return 0 unless src && location
136
131
 
137
132
  # Calculate byte offset from line/column
138
- lines = source.lines
133
+ lines = src.lines
139
134
  byte_offset = 0
140
135
  (0...(location.start_line - 1)).each do |i|
141
136
  byte_offset += lines[i]&.bytesize || 0
@@ -249,6 +244,7 @@ module Ast
249
244
  end
250
245
 
251
246
  # Comparable: compare nodes by position
247
+ # Note: Inherits Comparable from TreeHaver::Base::Node
252
248
  #
253
249
  # @param other [AstNode] node to compare with
254
250
  # @return [Integer, nil] -1, 0, 1, or nil if not comparable
@@ -227,7 +227,7 @@ module Ast
227
227
  next unless node.respond_to?(:start_line) && node.respond_to?(:end_line)
228
228
  next unless node.start_line && node.end_line
229
229
 
230
- line_num >= node.start_line && line_num <= node.end_line
230
+ line_num.between?(node.start_line, node.end_line)
231
231
  end
232
232
  end
233
233
 
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "merge_gem_registry"
4
+
3
5
  # Ast::Merge RSpec Dependency Tags
4
6
  #
5
7
  # This module provides dependency detection helpers for conditional test execution
6
- # in the ast-merge gem family. It extends tree_haver's dependency tags with
7
- # merge-gem-specific checks.
8
+ # in the ast-merge gem family. It uses MergeGemRegistry for dynamic merge gem detection.
8
9
  #
9
10
  # @example Loading in spec_helper.rb
10
11
  # require "ast/merge/rspec/dependency_tags"
@@ -18,50 +19,26 @@
18
19
  # # This test only runs when prism-merge is available
19
20
  # end
20
21
  #
21
- # == Available Tags
22
- #
23
- # === Merge Gem Tags (run when dependency IS available)
24
- #
25
- # [:markly_merge]
26
- # markly-merge gem is available and functional.
27
- #
28
- # [:commonmarker_merge]
29
- # commonmarker-merge gem is available and functional.
30
- #
31
- # [:markdown_merge]
32
- # markdown-merge gem is available and functional.
33
- #
34
- # [:prism_merge]
35
- # prism-merge gem is available and functional.
36
- #
37
- # [:json_merge]
38
- # json-merge gem is available and functional.
22
+ # == Dynamic Tag Registration
39
23
  #
40
- # [:jsonc_merge]
41
- # jsonc-merge gem is available and functional.
24
+ # Merge gems register themselves with MergeGemRegistry, which automatically:
25
+ # - Defines `*_available?` methods on DependencyTags
26
+ # - Configures RSpec exclusion filters for the tag
27
+ # - Supports negated tags (`:not_*`)
42
28
  #
43
- # [:toml_merge]
44
- # toml-merge gem is available and functional.
29
+ # @example How merge gems register (in their lib file)
30
+ # Ast::Merge::RSpec::MergeGemRegistry.register(
31
+ # :markly_merge,
32
+ # require_path: "markly/merge",
33
+ # merger_class: "Markly::Merge::SmartMerger",
34
+ # test_source: "# Test\n\nParagraph",
35
+ # category: :markdown
36
+ # )
45
37
  #
46
- # [:bash_merge]
47
- # bash-merge gem is available and functional.
48
- #
49
- # [:psych_merge]
50
- # psych-merge gem is available and functional.
51
- #
52
- # [:rbs_merge]
53
- # rbs-merge gem is available and functional.
38
+ # == Built-in Composite Tags
54
39
  #
55
40
  # [:any_markdown_merge]
56
- # At least one markdown merge gem (markly-merge or commonmarker-merge) is available.
57
- #
58
- # === Negated Tags (run when dependency is NOT available)
59
- #
60
- # All positive tags have negated versions prefixed with `not_`:
61
- # - :not_markly_merge, :not_commonmarker_merge, :not_markdown_merge
62
- # - :not_prism_merge, :not_json_merge, :not_jsonc_merge
63
- # - :not_toml_merge, :not_bash_merge, :not_psych_merge, :not_rbs_merge
64
- # - :not_any_markdown_merge
41
+ # At least one markdown merge gem is available (category: :markdown).
65
42
 
66
43
  module Ast
67
44
  module Merge
@@ -70,96 +47,16 @@ module Ast
70
47
  module DependencyTags
71
48
  class << self
72
49
  # ============================================================
73
- # Merge Gem Availability
50
+ # Composite Availability Checks
74
51
  # ============================================================
75
52
 
76
- # rubocop:disable ThreadSafety/ClassInstanceVariable
77
- # Check if markly-merge is available and functional
78
- #
79
- # @return [Boolean] true if markly-merge works
80
- def markly_merge_available?
81
- return @markly_merge_available if defined?(@markly_merge_available)
82
- @markly_merge_available = merge_gem_works?("markly/merge", "Markly::Merge::SmartMerger", "# Test\n\nParagraph")
83
- end
84
-
85
- # Check if commonmarker-merge is available and functional
86
- #
87
- # @return [Boolean] true if commonmarker-merge works
88
- def commonmarker_merge_available?
89
- return @commonmarker_merge_available if defined?(@commonmarker_merge_available)
90
- @commonmarker_merge_available = merge_gem_works?("commonmarker/merge", "Commonmarker::Merge::SmartMerger", "# Test\n\nParagraph")
91
- end
92
-
93
- # Check if markdown-merge is available and functional
94
- #
95
- # @return [Boolean] true if markdown-merge works
96
- def markdown_merge_available?
97
- return @markdown_merge_available if defined?(@markdown_merge_available)
98
- @markdown_merge_available = merge_gem_works?("markdown/merge", "Markdown::Merge::SmartMerger", "# Test\n\nParagraph")
99
- end
100
-
101
- # Check if prism-merge is available and functional
102
- #
103
- # @return [Boolean] true if prism-merge works
104
- def prism_merge_available?
105
- return @prism_merge_available if defined?(@prism_merge_available)
106
- @prism_merge_available = merge_gem_works?("prism/merge", "Prism::Merge::SmartMerger", "puts 1")
107
- end
108
-
109
- # Check if json-merge is available and functional
110
- #
111
- # @return [Boolean] true if json-merge works
112
- def json_merge_available?
113
- return @json_merge_available if defined?(@json_merge_available)
114
- @json_merge_available = merge_gem_works?("json/merge", "Json::Merge::SmartMerger", '{"key": "value"}')
115
- end
116
-
117
- # Check if jsonc-merge is available and functional
118
- #
119
- # @return [Boolean] true if jsonc-merge works
120
- def jsonc_merge_available?
121
- return @jsonc_merge_available if defined?(@jsonc_merge_available)
122
- @jsonc_merge_available = merge_gem_works?("jsonc/merge", "Jsonc::Merge::SmartMerger", '{"key": "value" /* comment */}')
123
- end
124
-
125
- # Check if toml-merge is available and functional
126
- #
127
- # @return [Boolean] true if toml-merge works
128
- def toml_merge_available?
129
- return @toml_merge_available if defined?(@toml_merge_available)
130
- @toml_merge_available = merge_gem_works?("toml/merge", "Toml::Merge::SmartMerger", 'key = "value"')
131
- end
132
-
133
- # Check if bash-merge is available and functional
134
- #
135
- # @return [Boolean] true if bash-merge works
136
- def bash_merge_available?
137
- return @bash_merge_available if defined?(@bash_merge_available)
138
- @bash_merge_available = merge_gem_works?("bash/merge", "Bash::Merge::SmartMerger", "echo hello")
139
- end
140
-
141
- # Check if psych-merge is available and functional
142
- #
143
- # @return [Boolean] true if psych-merge works
144
- def psych_merge_available?
145
- return @psych_merge_available if defined?(@psych_merge_available)
146
- @psych_merge_available = merge_gem_works?("psych/merge", "Psych::Merge::SmartMerger", "key: value")
147
- end
148
-
149
- # Check if rbs-merge is available and functional
150
- #
151
- # @return [Boolean] true if rbs-merge works
152
- def rbs_merge_available?
153
- return @rbs_merge_available if defined?(@rbs_merge_available)
154
- @rbs_merge_available = merge_gem_works?("rbs/merge", "Rbs::Merge::SmartMerger", "class Foo end")
155
- end
156
- # rubocop:enable ThreadSafety/ClassInstanceVariable
157
-
158
53
  # Check if at least one markdown merge gem is available
159
54
  #
160
55
  # @return [Boolean] true if any markdown merge gem works
161
56
  def any_markdown_merge_available?
162
- markly_merge_available? || commonmarker_merge_available? || markdown_merge_available?
57
+ MergeGemRegistry.gems_by_category(:markdown).any? do |tag|
58
+ MergeGemRegistry.available?(tag)
59
+ end
163
60
  end
164
61
 
165
62
  # ============================================================
@@ -170,45 +67,16 @@ module Ast
170
67
  #
171
68
  # @return [Hash{Symbol => Boolean}] map of dependency name to availability
172
69
  def summary
173
- {
174
- markly_merge: markly_merge_available?,
175
- commonmarker_merge: commonmarker_merge_available?,
176
- markdown_merge: markdown_merge_available?,
177
- prism_merge: prism_merge_available?,
178
- json_merge: json_merge_available?,
179
- jsonc_merge: jsonc_merge_available?,
180
- toml_merge: toml_merge_available?,
181
- bash_merge: bash_merge_available?,
182
- psych_merge: psych_merge_available?,
183
- rbs_merge: rbs_merge_available?,
184
- any_markdown_merge: any_markdown_merge_available?,
185
- }
70
+ result = MergeGemRegistry.summary
71
+ result[:any_markdown_merge] = any_markdown_merge_available?
72
+ result
186
73
  end
187
74
 
188
75
  # Reset all memoized availability checks
189
76
  #
190
77
  # @return [void]
191
78
  def reset!
192
- instance_variables.each do |ivar|
193
- remove_instance_variable(ivar) if ivar.to_s.end_with?("_available")
194
- end
195
- end
196
-
197
- private
198
-
199
- # Generic helper to check if a merge gem is available and functional
200
- #
201
- # @param require_path [String] the require path for the gem
202
- # @param merger_class [String] the full class name of the SmartMerger
203
- # @param test_source [String] sample source code to test merging
204
- # @return [Boolean] true if the merger can be instantiated
205
- def merge_gem_works?(require_path, merger_class, test_source)
206
- require require_path
207
- klass = Object.const_get(merger_class)
208
- klass.new(test_source, test_source)
209
- true
210
- rescue LoadError, StandardError
211
- false
79
+ MergeGemRegistry.reset_availability!
212
80
  end
213
81
  end
214
82
  end
@@ -219,10 +87,11 @@ end
219
87
  # Configure RSpec with dependency-based exclusion filters
220
88
  RSpec.configure do |config|
221
89
  deps = Ast::Merge::RSpec::DependencyTags
90
+ registry = Ast::Merge::RSpec::MergeGemRegistry
222
91
 
223
92
  config.before(:suite) do
224
93
  # Print dependency summary if AST_MERGE_DEBUG is set
225
- if ENV["AST_MERGE_DEBUG"]
94
+ unless ENV.fetch("AST_MERGE_DEBUG", "false").casecmp?("false")
226
95
  puts "\n=== Ast::Merge Test Dependencies ==="
227
96
  deps.summary.each do |dep, available|
228
97
  status = available ? "✓ available" : "✗ not available"
@@ -233,34 +102,24 @@ RSpec.configure do |config|
233
102
  end
234
103
 
235
104
  # ============================================================
236
- # Merge Gem Tags
105
+ # Dynamic Merge Gem Tags
237
106
  # ============================================================
107
+ # Tags are configured dynamically based on what's registered in MergeGemRegistry.
108
+ # Each merge gem registers itself, and exclusion filters are set up automatically.
238
109
 
239
- config.filter_run_excluding(markly_merge: true) unless deps.markly_merge_available?
240
- config.filter_run_excluding(commonmarker_merge: true) unless deps.commonmarker_merge_available?
241
- config.filter_run_excluding(markdown_merge: true) unless deps.markdown_merge_available?
242
- config.filter_run_excluding(prism_merge: true) unless deps.prism_merge_available?
243
- config.filter_run_excluding(json_merge: true) unless deps.json_merge_available?
244
- config.filter_run_excluding(jsonc_merge: true) unless deps.jsonc_merge_available?
245
- config.filter_run_excluding(toml_merge: true) unless deps.toml_merge_available?
246
- config.filter_run_excluding(bash_merge: true) unless deps.bash_merge_available?
247
- config.filter_run_excluding(psych_merge: true) unless deps.psych_merge_available?
248
- config.filter_run_excluding(rbs_merge: true) unless deps.rbs_merge_available?
249
- config.filter_run_excluding(any_markdown_merge: true) unless deps.any_markdown_merge_available?
110
+ registry.registered_gems.each do |tag|
111
+ # Positive tag: run when gem IS available
112
+ config.filter_run_excluding(tag => true) unless registry.available?(tag)
113
+
114
+ # Negated tag: run when gem is NOT available
115
+ negated_tag = :"not_#{tag}"
116
+ config.filter_run_excluding(negated_tag => true) if registry.available?(tag)
117
+ end
250
118
 
251
119
  # ============================================================
252
- # Negated Tags (run when dependency is NOT available)
120
+ # Composite Tags
253
121
  # ============================================================
254
122
 
255
- config.filter_run_excluding(not_markly_merge: true) if deps.markly_merge_available?
256
- config.filter_run_excluding(not_commonmarker_merge: true) if deps.commonmarker_merge_available?
257
- config.filter_run_excluding(not_markdown_merge: true) if deps.markdown_merge_available?
258
- config.filter_run_excluding(not_prism_merge: true) if deps.prism_merge_available?
259
- config.filter_run_excluding(not_json_merge: true) if deps.json_merge_available?
260
- config.filter_run_excluding(not_jsonc_merge: true) if deps.jsonc_merge_available?
261
- config.filter_run_excluding(not_toml_merge: true) if deps.toml_merge_available?
262
- config.filter_run_excluding(not_bash_merge: true) if deps.bash_merge_available?
263
- config.filter_run_excluding(not_psych_merge: true) if deps.psych_merge_available?
264
- config.filter_run_excluding(not_rbs_merge: true) if deps.rbs_merge_available?
123
+ config.filter_run_excluding(any_markdown_merge: true) unless deps.any_markdown_merge_available?
265
124
  config.filter_run_excluding(not_any_markdown_merge: true) if deps.any_markdown_merge_available?
266
125
  end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module RSpec
6
+ # Registry for merge gem dependency tag availability checkers
7
+ #
8
+ # This module allows merge gems (like markly-merge, prism-merge, json-merge)
9
+ # to register their availability checker for RSpec dependency tags without
10
+ # ast-merge needing to know about them directly.
11
+ #
12
+ # == Purpose
13
+ #
14
+ # When running RSpec tests with dependency tags (e.g., `:markly_merge`),
15
+ # ast-merge needs to know if each merge gem is available. The MergeGemRegistry
16
+ # provides a way for gems to register their availability checkers, and also
17
+ # pre-configures known merge gems so they can be checked before being loaded.
18
+ #
19
+ # == Pre-configured Gems
20
+ #
21
+ # The following merge gems are pre-configured so that their availability can
22
+ # be checked before they are loaded (e.g., during RSpec setup):
23
+ # - :markly_merge, :commonmarker_merge, :markdown_merge (markdown)
24
+ # - :prism_merge, :bash_merge, :rbs_merge (code)
25
+ # - :json_merge, :jsonc_merge (data)
26
+ # - :toml_merge, :psych_merge, :dotenv_merge (config)
27
+ #
28
+ # External merge gems can also register themselves by calling {register}
29
+ # when loaded.
30
+ #
31
+ # == Registration
32
+ #
33
+ # Each merge gem registers itself when loaded using {register}:
34
+ # - Tag name (e.g., :markly_merge)
35
+ # - Require path (e.g., "markly/merge")
36
+ # - Merger class name (e.g., "Markly::Merge::SmartMerger")
37
+ # - Test source code to verify the merger works
38
+ # - Optional category for grouping (e.g., :markdown, :data, :code)
39
+ #
40
+ # When a tag is registered, an availability method is automatically defined
41
+ # on `Ast::Merge::RSpec::DependencyTags`.
42
+ #
43
+ # == Thread Safety
44
+ #
45
+ # All operations are thread-safe using a Mutex for synchronization.
46
+ # Results are cached after first check for performance.
47
+ #
48
+ # @example Registering a merge gem (in your gem's lib file)
49
+ # # In markly-merge/lib/markly/merge.rb
50
+ # if defined?(Ast::Merge::RSpec::MergeGemRegistry)
51
+ # Ast::Merge::RSpec::MergeGemRegistry.register(
52
+ # :markly_merge,
53
+ # require_path: "markly/merge",
54
+ # merger_class: "Markly::Merge::SmartMerger",
55
+ # test_source: "# Test\n\nParagraph",
56
+ # category: :markdown
57
+ # )
58
+ # end
59
+ #
60
+ # @example Checking availability
61
+ # Ast::Merge::RSpec::MergeGemRegistry.available?(:markly_merge) # => true/false
62
+ #
63
+ # @example Getting all registered gems
64
+ # Ast::Merge::RSpec::MergeGemRegistry.registered_gems # => [:markly_merge, :prism_merge, ...]
65
+ #
66
+ # @see Ast::Merge::RSpec::DependencyTags Uses MergeGemRegistry for dynamic gem detection
67
+ # @api public
68
+ module MergeGemRegistry
69
+ @mutex = Mutex.new
70
+ @registry = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable
71
+ @availability_cache = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable
72
+
73
+ # Valid categories for merge gems
74
+ CATEGORIES = %i[markdown data code config other].freeze
75
+
76
+ # Pre-configured known merge gems
77
+ # These can be checked before the gems are actually loaded
78
+ KNOWN_GEMS = {
79
+ # Markdown gems
80
+ markly_merge: {
81
+ require_path: "markly/merge",
82
+ merger_class: "Markly::Merge::SmartMerger",
83
+ test_source: "# Test\n\nParagraph",
84
+ category: :markdown,
85
+ skip_instantiation: false,
86
+ },
87
+ commonmarker_merge: {
88
+ require_path: "commonmarker/merge",
89
+ merger_class: "Commonmarker::Merge::SmartMerger",
90
+ test_source: "# Test\n\nParagraph",
91
+ category: :markdown,
92
+ skip_instantiation: false,
93
+ },
94
+ markdown_merge: {
95
+ require_path: "markdown/merge",
96
+ merger_class: "Markdown::Merge::SmartMerger",
97
+ test_source: "# Test\n\nParagraph",
98
+ category: :markdown,
99
+ skip_instantiation: true, # Requires backend
100
+ },
101
+ # Code gems
102
+ prism_merge: {
103
+ require_path: "prism/merge",
104
+ merger_class: "Prism::Merge::SmartMerger",
105
+ test_source: "def foo; end",
106
+ category: :code,
107
+ skip_instantiation: false,
108
+ },
109
+ bash_merge: {
110
+ require_path: "bash/merge",
111
+ merger_class: "Bash::Merge::SmartMerger",
112
+ test_source: "#!/bin/bash\necho hello",
113
+ category: :code,
114
+ skip_instantiation: false,
115
+ },
116
+ rbs_merge: {
117
+ require_path: "rbs/merge",
118
+ merger_class: "Rbs::Merge::SmartMerger",
119
+ test_source: "class Foo\nend",
120
+ category: :code,
121
+ skip_instantiation: false,
122
+ },
123
+ # Data gems
124
+ json_merge: {
125
+ require_path: "json/merge",
126
+ merger_class: "Json::Merge::SmartMerger",
127
+ test_source: '{"key": "value"}',
128
+ category: :data,
129
+ skip_instantiation: false,
130
+ },
131
+ jsonc_merge: {
132
+ require_path: "jsonc/merge",
133
+ merger_class: "Jsonc::Merge::SmartMerger",
134
+ test_source: "// comment\n{\"key\": \"value\"}",
135
+ category: :data,
136
+ skip_instantiation: false,
137
+ },
138
+ # Config gems
139
+ toml_merge: {
140
+ require_path: "toml/merge",
141
+ merger_class: "Toml::Merge::SmartMerger",
142
+ test_source: "[section]\nkey = \"value\"",
143
+ category: :config,
144
+ skip_instantiation: false,
145
+ },
146
+ psych_merge: {
147
+ require_path: "psych/merge",
148
+ merger_class: "Psych::Merge::SmartMerger",
149
+ test_source: "key: value",
150
+ category: :config,
151
+ skip_instantiation: false,
152
+ },
153
+ dotenv_merge: {
154
+ require_path: "dotenv/merge",
155
+ merger_class: "Dotenv::Merge::SmartMerger",
156
+ test_source: "KEY=value",
157
+ category: :config,
158
+ skip_instantiation: false,
159
+ },
160
+ }.freeze
161
+
162
+ module_function
163
+
164
+ # Register a merge gem for dependency tag support
165
+ #
166
+ # When a gem is registered, this also dynamically defines a `*_available?` method
167
+ # on `Ast::Merge::RSpec::DependencyTags` if it doesn't already exist.
168
+ #
169
+ # @param tag_name [Symbol] the RSpec tag name (e.g., :markly_merge)
170
+ # @param require_path [String] the require path for the gem (e.g., "markly/merge")
171
+ # @param merger_class [String] the full class name of the SmartMerger
172
+ # @param test_source [String] sample source code to test merging
173
+ # @param category [Symbol] category for grouping (:markdown, :data, :code, :config, :other)
174
+ # @param skip_instantiation [Boolean] if true, only check class exists (for gems requiring backends)
175
+ # @return [void]
176
+ #
177
+ # @example Register a merge gem
178
+ # Ast::Merge::RSpec::MergeGemRegistry.register(
179
+ # :markly_merge,
180
+ # require_path: "markly/merge",
181
+ # merger_class: "Markly::Merge::SmartMerger",
182
+ # test_source: "# Test\n\nParagraph",
183
+ # category: :markdown
184
+ # )
185
+ def register(tag_name, require_path:, merger_class:, test_source:, category: :other, skip_instantiation: false)
186
+ raise ArgumentError, "Invalid category: #{category}" unless CATEGORIES.include?(category)
187
+
188
+ tag_sym = tag_name.to_sym
189
+
190
+ @mutex.synchronize do
191
+ @registry[tag_sym] = {
192
+ require_path: require_path,
193
+ merger_class: merger_class,
194
+ test_source: test_source,
195
+ category: category,
196
+ skip_instantiation: skip_instantiation,
197
+ }
198
+ # Clear cache when re-registering
199
+ @availability_cache.delete(tag_sym)
200
+ end
201
+
202
+ # Define availability method on DependencyTags
203
+ define_availability_method(tag_sym)
204
+
205
+ nil
206
+ end
207
+
208
+ # Check if a merge gem is available and functional
209
+ #
210
+ # This method will try to load the gem if it's not yet registered but
211
+ # is known (in KNOWN_GEMS). This allows availability checking before
212
+ # the gem is explicitly loaded.
213
+ #
214
+ # @param tag_name [Symbol] the tag name to check
215
+ # @return [Boolean] true if the merge gem is available and works
216
+ def available?(tag_name)
217
+ tag_sym = tag_name.to_sym
218
+
219
+ # Check cache first
220
+ @mutex.synchronize do
221
+ return @availability_cache[tag_sym] if @availability_cache.key?(tag_sym)
222
+ end
223
+
224
+ # Get registration info (from registry or known gems)
225
+ info = @mutex.synchronize { @registry[tag_sym] }
226
+ info ||= KNOWN_GEMS[tag_sym]
227
+
228
+ return false unless info
229
+
230
+ # Check if gem works
231
+ result = gem_works?(
232
+ info[:require_path],
233
+ info[:merger_class],
234
+ info[:test_source],
235
+ info[:skip_instantiation],
236
+ )
237
+
238
+ # Cache result
239
+ @mutex.synchronize do
240
+ @availability_cache[tag_sym] = result
241
+ end
242
+
243
+ result
244
+ end
245
+
246
+ # Check if a tag is registered
247
+ #
248
+ # @param tag_name [Symbol] the tag name
249
+ # @return [Boolean] true if the tag is registered
250
+ def registered?(tag_name)
251
+ @mutex.synchronize do
252
+ @registry.key?(tag_name.to_sym)
253
+ end
254
+ end
255
+
256
+ # Get all registered gem tag names (including pre-configured known gems)
257
+ #
258
+ # @return [Array<Symbol>] list of registered tag names
259
+ def registered_gems
260
+ @mutex.synchronize do
261
+ (KNOWN_GEMS.keys + @registry.keys).uniq
262
+ end
263
+ end
264
+
265
+ # Get gems filtered by category
266
+ #
267
+ # @param category [Symbol] one of :markdown, :data, :code, :config, :other
268
+ # @return [Array<Symbol>] list of tag names in that category
269
+ def gems_by_category(category)
270
+ @mutex.synchronize do
271
+ known = KNOWN_GEMS.select { |_, info| info[:category] == category }.keys
272
+ registered = @registry.select { |_, info| info[:category] == category }.keys
273
+ (known + registered).uniq
274
+ end
275
+ end
276
+
277
+ # Get registration info for a gem
278
+ #
279
+ # @param tag_name [Symbol] the tag name
280
+ # @return [Hash, nil] registration info or nil if not registered/known
281
+ def info(tag_name)
282
+ tag_sym = tag_name.to_sym
283
+ @mutex.synchronize do
284
+ @registry[tag_sym]&.dup || KNOWN_GEMS[tag_sym]&.dup
285
+ end
286
+ end
287
+
288
+ # Get a summary of all registered gems and their availability
289
+ #
290
+ # @return [Hash{Symbol => Boolean}] map of tag name to availability
291
+ def summary
292
+ registered_gems.each_with_object({}) do |tag, result|
293
+ result[tag] = available?(tag)
294
+ end
295
+ end
296
+
297
+ # Clear the availability cache
298
+ #
299
+ # @return [void]
300
+ def clear_cache!
301
+ @mutex.synchronize do
302
+ @availability_cache.clear
303
+ end
304
+ nil
305
+ end
306
+
307
+ # Clear all registrations and cache
308
+ #
309
+ # @return [void]
310
+ def clear!
311
+ @mutex.synchronize do
312
+ @registry.clear
313
+ @availability_cache.clear
314
+ end
315
+ nil
316
+ end
317
+
318
+ # Reset memoized availability on DependencyTags
319
+ #
320
+ # @return [void]
321
+ def reset_availability!
322
+ clear_cache!
323
+ return unless defined?(DependencyTags)
324
+
325
+ registered_gems.each do |tag|
326
+ ivar = :"@#{tag}_available"
327
+ DependencyTags.remove_instance_variable(ivar) if DependencyTags.instance_variable_defined?(ivar)
328
+ end
329
+ end
330
+
331
+ # ============================================================
332
+ # Private Helpers
333
+ # ============================================================
334
+
335
+ # Check if a merge gem is available and functional
336
+ #
337
+ # @param require_path [String] the require path for the gem
338
+ # @param merger_class [String] the full class name of the SmartMerger
339
+ # @param test_source [String] sample source code to test merging
340
+ # @param skip_instantiation [Boolean] if true, only check class exists
341
+ # @return [Boolean] true if the merger can be loaded/instantiated
342
+ # @api private
343
+ def gem_works?(require_path, merger_class, test_source, skip_instantiation)
344
+ require require_path
345
+ klass = Object.const_get(merger_class)
346
+
347
+ if skip_instantiation
348
+ # Just check that the class exists and looks like a SmartMerger
349
+ klass.is_a?(Class) && klass.ancestors.any? { |a| a.name&.include?("SmartMergerBase") }
350
+ else
351
+ klass.new(test_source, test_source)
352
+ true
353
+ end
354
+ rescue LoadError, StandardError
355
+ false
356
+ end
357
+ private_class_method :gem_works?
358
+
359
+ # Dynamically define an availability method on DependencyTags
360
+ #
361
+ # @param tag_name [Symbol] the tag name (e.g., :markly_merge)
362
+ # @return [void]
363
+ # @api private
364
+ def define_availability_method(tag_name)
365
+ method_name = :"#{tag_name}_available?"
366
+
367
+ # Only define if DependencyTags is loaded
368
+ return unless defined?(DependencyTags)
369
+
370
+ # Don't override existing methods
371
+ return if DependencyTags.respond_to?(method_name)
372
+
373
+ # Define the method dynamically - MergeGemRegistry.available? handles caching
374
+ DependencyTags.define_singleton_method(method_name) do
375
+ MergeGemRegistry.available?(tag_name)
376
+ end
377
+ end
378
+ private_class_method :define_availability_method
379
+ end
380
+ end
381
+ end
382
+ end
@@ -5,7 +5,7 @@ module Ast
5
5
  # Version information for Ast::Merge
6
6
  module Version
7
7
  # Current version of the ast-merge gem
8
- VERSION = "4.0.0"
8
+ VERSION = "4.0.1"
9
9
  end
10
10
  VERSION = Version::VERSION # traditional location
11
11
  end
data/lib/ast/merge.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  # External gems
4
4
  require "version_gem"
5
5
 
6
+ # Normalized AST for all languages, parsers, and platforms
7
+ require "tree_haver"
8
+
6
9
  # This gem - only version can be required (never autoloaded)
7
10
  require_relative "merge/version"
8
11
 
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ast-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -66,7 +66,7 @@ dependencies:
66
66
  version: '5.0'
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: 5.0.0
69
+ version: 5.0.1
70
70
  type: :runtime
71
71
  prerelease: false
72
72
  version_requirements: !ruby/object:Gem::Requirement
@@ -76,7 +76,7 @@ dependencies:
76
76
  version: '5.0'
77
77
  - - ">="
78
78
  - !ruby/object:Gem::Version
79
- version: 5.0.0
79
+ version: 5.0.1
80
80
  - !ruby/object:Gem::Dependency
81
81
  name: kettle-dev
82
82
  requirement: !ruby/object:Gem::Requirement
@@ -355,6 +355,7 @@ files:
355
355
  - lib/ast/merge/recipe/script_loader.rb
356
356
  - lib/ast/merge/rspec.rb
357
357
  - lib/ast/merge/rspec/dependency_tags.rb
358
+ - lib/ast/merge/rspec/merge_gem_registry.rb
358
359
  - lib/ast/merge/rspec/shared_examples.rb
359
360
  - lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb
360
361
  - lib/ast/merge/rspec/shared_examples/debug_logger.rb
@@ -381,10 +382,10 @@ licenses:
381
382
  - MIT
382
383
  metadata:
383
384
  homepage_uri: https://ast-merge.galtzo.com/
384
- source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v4.0.0
385
- changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v4.0.0/CHANGELOG.md
385
+ source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v4.0.1
386
+ changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v4.0.1/CHANGELOG.md
386
387
  bug_tracker_uri: https://github.com/kettle-rb/ast-merge/issues
387
- documentation_uri: https://www.rubydoc.info/gems/ast-merge/4.0.0
388
+ documentation_uri: https://www.rubydoc.info/gems/ast-merge/4.0.1
388
389
  funding_uri: https://github.com/sponsors/pboling
389
390
  wiki_uri: https://github.com/kettle-rb/ast-merge/wiki
390
391
  news_uri: https://www.railsbling.com/tags/ast-merge
metadata.gz.sig CHANGED
Binary file