ast-merge 4.0.0 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 871eebc1f2c69aa75278023c250fd90905cf2fb4e23677fbcfd49633bad5ebb1
4
- data.tar.gz: 3804ee9f9f61effc21c49862a96b0092481067813c79b1b4fdb09a748b935570
3
+ metadata.gz: 29e3c1eb54812e59b30a8ae0dc42792486ef2e4992dbd43234452cfce0f2b1ab
4
+ data.tar.gz: bb1b200aa2a2d8a48c127952510984cf2ccd71e2517ced1d422fe542a7969c78
5
5
  SHA512:
6
- metadata.gz: 340c3feee108349b603db2150baa68467b45c7414be0d1fdcfaac10038d3cfca2dc893dc81a489adae66bbfc8d9474f7128e7f0817abc9703ba6694719a48b91
7
- data.tar.gz: 5f135b944efe203fe0f83cf2d141e18de4e64be096a3293c0db09b3f5672790b6cdbad6956ef511c657b43c0e0b162050ec57d46d7da659895554c16e2eac6c4
6
+ metadata.gz: b0823b5b64b77947faa3213463c18289a4275b5b8fd7d5a0dcf258c1ae4088dcc50a34923f459944473cfae67e7c18dd646e3236f7ff6eb0898edfdb8b5d9ab2
7
+ data.tar.gz: 0f8dafa6c932a75504c898793fb0f51dfa9977d288f70e6e8e9fc8aff9f0f3452742a0302dbdd23aee33db8ab4528834048322af37d7c4d44ec9fb473e682855
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,73 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [4.0.2] - 2026-01-12
34
+
35
+ - TAG: [v4.0.2][4.0.2t]
36
+ - COVERAGE: 97.30% -- 2739/2815 lines in 53 files
37
+ - BRANCH COVERAGE: 89.84% -- 893/994 branches in 53 files
38
+ - 98.81% documented
39
+
40
+ ### Added
41
+
42
+ - **`Recipe::Runner` target file override**: Accept `target_files` parameter to override recipe targets
43
+ - `Runner.new(recipe, target_files: ["file1.md", "file2.md"])` - Process only specified files
44
+ - Paths are expanded relative to `base_dir`
45
+ - When not specified, falls back to recipe's configured targets
46
+ - **`exe/ast-merge-recipe` file arguments**: Accept target files on command line
47
+ - `ast-merge-recipe recipe.yml file1.md file2.md` - Override recipe targets
48
+ - Updated help text and banner to document new usage
49
+ - **`bin/update_gem_family_section` file arguments**: Accept target files on command line
50
+ - `bin/update_gem_family_section vendor/my-gem/README.md` - Process specific file(s)
51
+ - If no files specified, defaults to `README.md` + `vendor/*/README.md`
52
+ - Added `--skip-fix` option to skip the formatting fix step
53
+
54
+ ### Changed
55
+
56
+ - **`bin/update_gem_family_section`**: Refactored to use `OptionParser` for clean option handling
57
+ - Consistent with `bin/fix_readme_formatting` style
58
+ - Properly separates options from file arguments
59
+
60
+ ## [4.0.1] - 2026-01-11
61
+
62
+ - TAG: [v4.0.1][4.0.1t]
63
+ - COVERAGE: 96.45% -- 2553/2647 lines in 51 files
64
+ - BRANCH COVERAGE: 87.41% -- 812/929 branches in 51 files
65
+ - 98.80% documented
66
+
67
+ ### Added
68
+
69
+ - **`Ast::Merge::RSpec::MergeGemRegistry`** - Fully dynamic merge gem registration for RSpec dependency tags
70
+ - `register(tag_name, require_path:, merger_class:, test_source:, category:)` - Register a merge gem
71
+ - `available?(tag_name)` - Check if a merge gem is available and functional
72
+ - `registered_gems` - Get all registered gem tag names
73
+ - `gems_by_category(category)` - Filter gems by category (:markdown, :data, :code, :config, :other)
74
+ - `summary` - Get availability status of all registered gems
75
+ - Automatically defines `*_available?` methods on `DependencyTags` at registration time
76
+ - External merge gems can now get full RSpec tag support without modifying ast-merge
77
+
78
+ ### Changed
79
+
80
+ - Upgrade to [tree_haver v5.0.1](https://github.com/kettle-rb/tree_haver/releases/tag/v5.0.1)
81
+ - **`Ast::Merge::AstNode` now inherits from `TreeHaver::Base::Node`**
82
+ - Ensures synthetic nodes stay in sync with the canonical Node API
83
+ - Inherits `Comparable`, `Enumerable` from base class
84
+ - Retains all existing methods and behavior (Point, Location, signature, etc.)
85
+ - Constructor calls `super(self, source: source)` to properly initialize base class
86
+ - **RSpec Dependency Tags refactored to use MergeGemRegistry**
87
+ - Removed hardcoded merge gem availability checks
88
+ - Removed `MERGE_GEM_TEST_SOURCES` constant
89
+ - `*_available?` methods are now defined dynamically when gems register
90
+ - `any_markdown_merge_available?` now queries registry by category
91
+ - RSpec exclusion filters are configured dynamically from registry
92
+ - `Ast::Merge::Testing::TestableNode` now delegates to `TreeHaver::RSpec::TestableNode`
93
+ - The TestableNode implementation has been moved to tree_haver for sharing across all merge gems
94
+ - `spec/support/testable_node.rb` now requires and re-exports the tree_haver version
95
+ - Backward compatible: existing tests continue to work unchanged
96
+ - `spec/ast/merge/node_wrapper_base_spec.rb` refactored to use `TestableNode` instead of mocks
97
+ - Real TreeHaver::Node behavior for most tests
98
+ - Mocks only retained for edge case testing (e.g., invalid end_line before start_line)
99
+
33
100
  ## [4.0.0] - 2026-01-11
34
101
 
35
102
  - TAG: [v4.0.0][4.0.0t]
@@ -597,7 +664,13 @@ Please file a bug if you notice a violation of semantic versioning.
597
664
 
598
665
  - Initial release
599
666
 
600
- [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v4.0.0...HEAD
667
+ [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v4.0.3...HEAD
668
+ [4.0.3]: https://github.com/kettle-rb/ast-merge/compare/v4.0.2...v4.0.3
669
+ [4.0.3t]: https://github.com/kettle-rb/ast-merge/releases/tag/v4.0.3
670
+ [4.0.2]: https://github.com/kettle-rb/ast-merge/compare/v4.0.1...v4.0.2
671
+ [4.0.2t]: https://github.com/kettle-rb/ast-merge/releases/tag/v4.0.2
672
+ [4.0.1]: https://github.com/kettle-rb/ast-merge/compare/v4.0.0...v4.0.1
673
+ [4.0.1t]: https://github.com/kettle-rb/ast-merge/releases/tag/v4.0.1
601
674
  [4.0.0]: https://github.com/kettle-rb/ast-merge/compare/v3.1.0...v4.0.0
602
675
  [4.0.0t]: https://github.com/kettle-rb/ast-merge/releases/tag/v4.0.0
603
676
  [3.1.0]: https://github.com/kettle-rb/ast-merge/compare/v3.0.0...v3.1.0
data/README.md CHANGED
@@ -208,24 +208,24 @@ The `*-merge` gem family is built on a two-layer architecture:
208
208
 
209
209
  [tree\_haver][tree_haver] provides cross-Ruby parsing capabilities:
210
210
 
211
- - **Universal Backend Support**: Automatically selects the best parsing backend for your Ruby implementation (MRI, JRuby, TruffleRuby)
212
- - **10 Backend Options**: MRI C extensions, Rust bindings, FFI, Java (JRuby), language-specific parsers (Prism, Psych, Commonmarker, Markly), and pure Ruby fallback (Citrus)
213
- - **Unified API**: Write parsing code once, run on any Ruby implementation
214
- - **Grammar Discovery**: Built-in `GrammarFinder` for platform-aware grammar library discovery
215
- - **Thread-Safe**: Language registry with thread-safe caching
211
+ - **Universal Backend Support**: Automatically selects the best parsing backend for your Ruby implementation (MRI, JRuby, TruffleRuby)
212
+ - **10 Backend Options**: MRI C extensions, Rust bindings, FFI, Java (JRuby), language-specific parsers (Prism, Psych, Commonmarker, Markly), and pure Ruby fallback (Citrus)
213
+ - **Unified API**: Write parsing code once, run on any Ruby implementation
214
+ - **Grammar Discovery**: Built-in `GrammarFinder` for platform-aware grammar library discovery
215
+ - **Thread-Safe**: Language registry with thread-safe caching
216
216
 
217
217
  #### Layer 2: ast-merge (Merge Infrastructure)
218
218
 
219
219
  Ast::Merge builds on tree\_haver to provide:
220
220
 
221
- - **Base Classes**: `FreezeNode`, `MergeResult` base classes with unified constructors
222
- - **Shared Modules**: `FileAnalysisBase`, `FileAnalyzable`, `MergerConfig`, `DebugLogger`
223
- - **Freeze Block Support**: Configurable marker patterns for multiple comment syntaxes (preserve sections during merge)
224
- - **Node Typing System**: `NodeTyping` for canonical node type identification across different parsers
225
- - **Conflict Resolution**: `ConflictResolverBase` with pluggable strategies
226
- - **Error Classes**: `ParseError`, `TemplateParseError`, `DestinationParseError`
227
- - **Region Detection**: `RegionDetectorBase`, `FencedCodeBlockDetector` for text-based analysis
228
- - **RSpec Shared Examples**: Test helpers for implementing new merge gems
221
+ - **Base Classes**: `FreezeNode`, `MergeResult` base classes with unified constructors
222
+ - **Shared Modules**: `FileAnalysisBase`, `FileAnalyzable`, `MergerConfig`, `DebugLogger`
223
+ - **Freeze Block Support**: Configurable marker patterns for multiple comment syntaxes (preserve sections during merge)
224
+ - **Node Typing System**: `NodeTyping` for canonical node type identification across different parsers
225
+ - **Conflict Resolution**: `ConflictResolverBase` with pluggable strategies
226
+ - **Error Classes**: `ParseError`, `TemplateParseError`, `DestinationParseError`
227
+ - **Region Detection**: `RegionDetectorBase`, `FencedCodeBlockDetector` for text-based analysis
228
+ - **RSpec Shared Examples**: Test helpers for implementing new merge gems
229
229
 
230
230
  ### Creating a New Merge Gem
231
231
 
@@ -372,10 +372,10 @@ merger = MyFormat::SmartMerger.new(
372
372
 
373
373
  This is particularly useful for:
374
374
 
375
- - Paragraphs with minor edits (typos, rewording)
376
- - Headings with slight changes
377
- - Comments with updated text
378
- - Any text-based node that may have been slightly modified
375
+ - Paragraphs with minor edits (typos, rewording)
376
+ - Headings with slight changes
377
+ - Comments with updated text
378
+ - Any text-based node that may have been slightly modified
379
379
 
380
380
  ### Namespace Reference
381
381
 
@@ -391,11 +391,11 @@ The `Ast::Merge` module is organized into several namespaces, each with detailed
391
391
 
392
392
  **Key Classes by Namespace:**
393
393
 
394
- - **Detector**: `Region`, `Base`, `Mergeable`, `FencedCodeBlock`, `YamlFrontmatter`, `TomlFrontmatter`
395
- - **Recipe**: `Config`, `Runner`, `ScriptLoader`
396
- - **Comment**: `Line`, `Block`, `Empty`, `Parser`, `Style`
397
- - **Text**: `SmartMerger`, `FileAnalysis`, `LineNode`, `WordNode`, `Section`
398
- - **RSpec**: Shared examples and dependency tags for testing `*-merge` implementations
394
+ - **Detector**: `Region`, `Base`, `Mergeable`, `FencedCodeBlock`, `YamlFrontmatter`, `TomlFrontmatter`
395
+ - **Recipe**: `Config`, `Runner`, `ScriptLoader`
396
+ - **Comment**: `Line`, `Block`, `Empty`, `Parser`, `Style`
397
+ - **Text**: `SmartMerger`, `FileAnalysis`, `LineNode`, `WordNode`, `Section`
398
+ - **RSpec**: Shared examples and dependency tags for testing `*-merge` implementations
399
399
 
400
400
  ## 💡 Info you can shake a stick at
401
401
 
@@ -447,18 +447,13 @@ The maintainers of this and thousands of other packages are working with Tidelif
447
447
 
448
448
  [![Get help from me on Tidelift][🏙️entsup-tidelift-img]][🏙️entsup-tidelift]
449
449
 
450
- - 💡Subscribe for support guarantees covering *all* your FLOSS dependencies
451
-
452
- - 💡Tidelift is part of [Sonar][🏙️entsup-tidelift-sonar]
453
-
454
- - 💡Tidelift pays maintainers to maintain the software you depend on\!<br/>📊`@`Pointy Haired Boss: An [enterprise support][🏙️entsup-tidelift] subscription is "[never gonna let you down][🧮kloc]", and *supports* open source maintainers
455
- Alternatively:
456
-
457
- - [![Live Chat on Discord][✉️discord-invite-img-ftb]][🖼️galtzo-discord]
458
-
459
- - [![Get help from me on Upwork][👨🏼‍🏫expsup-upwork-img]][👨🏼‍🏫expsup-upwork]
460
-
461
- - [![Get help from me on Codementor][👨🏼‍🏫expsup-codementor-img]][👨🏼‍🏫expsup-codementor]
450
+ - 💡Subscribe for support guarantees covering *all* your FLOSS dependencies
451
+ - 💡Tidelift is part of [Sonar][🏙️entsup-tidelift-sonar]
452
+ - 💡Tidelift pays maintainers to maintain the software you depend on\!<br/>📊`@`Pointy Haired Boss: An [enterprise support][🏙️entsup-tidelift] subscription is "[never gonna let you down][🧮kloc]", and *supports* open source maintainers
453
+ Alternatively:
454
+ - [![Live Chat on Discord][✉️discord-invite-img-ftb]][🖼️galtzo-discord]
455
+ - [![Get help from me on Upwork][👨🏼‍🏫expsup-upwork-img]][👨🏼‍🏫expsup-upwork]
456
+ - [![Get help from me on Codementor][👨🏼‍🏫expsup-codementor-img]][👨🏼‍🏫expsup-codementor]
462
457
 
463
458
  </details>
464
459
 
@@ -537,17 +532,17 @@ merger = SomeFormat::Merge::SmartMerger.new(
537
532
 
538
533
  Control which source wins when both files have the same structural element:
539
534
 
540
- - **`:template`** - Template values replace destination values
541
- - **`:destination`** (default) - Destination values are preserved
542
- - **Hash** - Per-node-type preference (see Advanced Configuration)
535
+ - **`:template`** - Template values replace destination values
536
+ - **`:destination`** (default) - Destination values are preserved
537
+ - **Hash** - Per-node-type preference (see Advanced Configuration)
543
538
 
544
539
  ### Template-Only Nodes
545
540
 
546
541
  Control whether to add nodes that only exist in the template:
547
542
 
548
- - **`true`** - Add all template-only nodes
549
- - **`false`** (default) - Skip template-only nodes
550
- - **Callable** - Filter which template-only nodes to add
543
+ - **`true`** - Add all template-only nodes
544
+ - **`false`** (default) - Skip template-only nodes
545
+ - **Callable** - Filter which template-only nodes to add
551
546
 
552
547
  #### Callable Filter
553
548
 
@@ -577,10 +572,10 @@ merger = Markly::Merge::SmartMerger.new(
577
572
 
578
573
  The `entry` hash contains:
579
574
 
580
- - `:template_node` - The node being considered for addition
581
- - `:signature` - The node's signature (Array or other value)
582
- - `:template_index` - Index in the template statements
583
- - `:dest_index` - Always `nil` for template-only nodes
575
+ - `:template_node` - The node being considered for addition
576
+ - `:signature` - The node's signature (Array or other value)
577
+ - `:template_index` - Index in the template statements
578
+ - `:dest_index` - Always `nil` for template-only nodes
584
579
 
585
580
  ## 🔧 Basic Usage
586
581
 
@@ -606,11 +601,11 @@ end
606
601
 
607
602
  ### Available Shared Examples
608
603
 
609
- - `"Ast::Merge::FreezeNode"` - Tests for FreezeNode implementations
610
- - `"Ast::Merge::MergeResult"` - Tests for MergeResult implementations
611
- - `"Ast::Merge::DebugLogger"` - Tests for DebugLogger implementations
612
- - `"Ast::Merge::FileAnalysisBase"` - Tests for FileAnalysis implementations
613
- - `"Ast::Merge::MergerConfig"` - Tests for SmartMerger implementations
604
+ - `"Ast::Merge::FreezeNode"` - Tests for FreezeNode implementations
605
+ - `"Ast::Merge::MergeResult"` - Tests for MergeResult implementations
606
+ - `"Ast::Merge::DebugLogger"` - Tests for DebugLogger implementations
607
+ - `"Ast::Merge::FileAnalysisBase"` - Tests for FileAnalysis implementations
608
+ - `"Ast::Merge::MergerConfig"` - Tests for SmartMerger implementations
614
609
 
615
610
  ## 🎛️ Advanced Configuration
616
611
 
@@ -622,9 +617,9 @@ This is useful for hand-edited customizations you never want overwritten.
622
617
 
623
618
  A freeze block consists of:
624
619
 
625
- - A **start marker** comment (e.g., `# mytoken:freeze`)
626
- - The protected content
627
- - An **end marker** comment (e.g., `# mytoken:unfreeze`)
620
+ - A **start marker** comment (e.g., `# mytoken:freeze`)
621
+ - The protected content
622
+ - An **end marker** comment (e.g., `# mytoken:unfreeze`)
628
623
 
629
624
  ```ruby
630
625
  # In a Ruby file with prism-merge:
@@ -669,13 +664,13 @@ preferences for different types of nodes (e.g., prefer template for linter confi
669
664
 
670
665
  1. **Define a `node_typing`**: A Hash mapping node type symbols to callables that receive a node and return either:
671
666
 
672
- - The original node (no special handling)
673
- - A wrapped node with a `merge_type` attribute (via `Ast::Merge::NodeTyping::Wrapper`)
667
+ - The original node (no special handling)
668
+ - A wrapped node with a `merge_type` attribute (via `Ast::Merge::NodeTyping::Wrapper`)
674
669
 
675
670
  2. **Use a Hash-based preference**: Instead of a simple `:destination` or `:template` Symbol, pass a Hash with:
676
671
 
677
- - `:default` key for the fallback preference
678
- - Custom keys matching the `merge_type` values from your `node_typing`
672
+ - `:default` key for the fallback preference
673
+ - Custom keys matching the `merge_type` values from your `node_typing`
679
674
 
680
675
  ```ruby
681
676
  # Example: Prefer template for lint gem configs, destination for everything else
@@ -903,7 +898,7 @@ is a *breaking change* to an API, and for that reason the bike shedding is endle
903
898
  To get a better understanding of how SemVer is intended to work over a project's lifetime,
904
899
  read this article from the creator of SemVer:
905
900
 
906
- - ["Major Version Numbers are Not Sacred"][📌major-versions-not-sacred]
901
+ - ["Major Version Numbers are Not Sacred"][📌major-versions-not-sacred]
907
902
 
908
903
  </details>
909
904
 
@@ -1108,7 +1103,7 @@ Thanks for RTFM. ☺️
1108
1103
  [📌gitmoji]: https://gitmoji.dev
1109
1104
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1110
1105
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1111
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.647-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1106
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.815-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1112
1107
  [🔐security]: SECURITY.md
1113
1108
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1114
1109
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/exe/ast-merge-recipe CHANGED
@@ -171,9 +171,10 @@ class AstMergeRecipeCLI
171
171
 
172
172
  def parse_options(argv)
173
173
  @option_parser = OptionParser.new do |opts|
174
- opts.banner = "Usage: #{File.basename($0)} RECIPE_FILE [options]"
174
+ opts.banner = "Usage: #{File.basename($0)} RECIPE_FILE [FILES...] [options]"
175
175
  opts.separator("")
176
176
  opts.separator("Run a YAML-based merge recipe against target files.")
177
+ opts.separator("If FILES are specified, they override the recipe's targets.")
177
178
  opts.separator("")
178
179
  opts.separator("Options:")
179
180
 
@@ -225,9 +226,15 @@ class AstMergeRecipeCLI
225
226
  opts.on("-h", "--help", "Show this help message") do
226
227
  puts opts
227
228
  puts
229
+ puts "Arguments:"
230
+ puts " RECIPE_FILE Path to the YAML recipe file (required)"
231
+ puts " [FILES...] Target files to process (optional, overrides recipe targets)"
232
+ puts
228
233
  puts "Examples:"
229
234
  puts " #{File.basename($0)} .merge-recipes/gem_family_section.yml --dry-run"
230
235
  puts " #{File.basename($0)} recipe.yml --verbose --parser=commonmarker"
236
+ puts " #{File.basename($0)} recipe.yml vendor/my-gem/README.md"
237
+ puts " #{File.basename($0)} recipe.yml README.md vendor/*/README.md"
231
238
  puts
232
239
  puts "Recipe YAML format:"
233
240
  puts " See lib/ast/merge/recipe/README.md for full documentation"
@@ -241,10 +248,8 @@ class AstMergeRecipeCLI
241
248
  # First non-option argument is the recipe file
242
249
  @options[:recipe_file] = remaining.shift
243
250
 
244
- # Warn about extra arguments
245
- if remaining.any?
246
- $stderr.puts Colors.yellow("WARNING: Ignoring extra arguments: #{remaining.join(", ")}")
247
- end
251
+ # Remaining arguments are target files (override recipe targets)
252
+ @options[:target_files] = remaining if remaining.any?
248
253
  end
249
254
 
250
255
  def validate_options!
@@ -271,14 +276,19 @@ class AstMergeRecipeCLI
271
276
  recipe = Ast::Merge::Recipe::Config.load(@options[:recipe_file])
272
277
  print_recipe_info(recipe)
273
278
 
274
- # Create runner
275
- runner = Ast::Merge::Recipe::Runner.new(
276
- recipe,
279
+ # Build runner options
280
+ runner_opts = {
277
281
  dry_run: @options[:dry_run],
278
282
  base_dir: @options[:base_dir],
279
283
  parser: @options[:parser],
280
284
  verbose: @options[:verbose],
281
- )
285
+ }
286
+
287
+ # If target files specified on command line, override recipe targets
288
+ runner_opts[:target_files] = @options[:target_files] if @options[:target_files]
289
+
290
+ # Create runner
291
+ runner = Ast::Merge::Recipe::Runner.new(recipe, **runner_opts)
282
292
 
283
293
  # Run and display results
284
294
  puts Colors.cyan("Processing files...")
@@ -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
 
@@ -40,6 +40,9 @@ module Ast
40
40
  # @return [Symbol] Parser to use (:markly, :commonmarker, :prism, :psych, etc.)
41
41
  attr_reader :parser
42
42
 
43
+ # @return [Array<String>, nil] Target files override (from command line)
44
+ attr_reader :target_files
45
+
43
46
  # @return [Array<Result>] Results from the last run
44
47
  attr_reader :results
45
48
 
@@ -50,12 +53,14 @@ module Ast
50
53
  # @param base_dir [String, nil] Base directory for path resolution
51
54
  # @param parser [Symbol] Which parser to use
52
55
  # @param verbose [Boolean] Enable verbose output
53
- def initialize(recipe, dry_run: false, base_dir: nil, parser: :markly, verbose: false)
56
+ # @param target_files [Array<String>, nil] Override recipe targets with these files
57
+ def initialize(recipe, dry_run: false, base_dir: nil, parser: :markly, verbose: false, target_files: nil, **options)
54
58
  @recipe = recipe
55
59
  @dry_run = dry_run
56
60
  @base_dir = base_dir || Dir.pwd
57
61
  @parser = parser
58
62
  @verbose = verbose
63
+ @target_files = target_files
59
64
  @results = []
60
65
  end
61
66
 
@@ -66,10 +71,17 @@ module Ast
66
71
  @results = []
67
72
 
68
73
  template_content = load_template
69
- # Let the recipe expand targets from its own location
70
- target_files = recipe.expand_targets
71
74
 
72
- target_files.each do |target_path|
75
+ # Use command-line targets if provided, otherwise expand from recipe
76
+ files_to_process = if @target_files && !@target_files.empty?
77
+ # Expand paths relative to base_dir
78
+ @target_files.map { |f| File.expand_path(f, @base_dir) }
79
+ else
80
+ # Let the recipe expand targets from its own location
81
+ recipe.expand_targets
82
+ end
83
+
84
+ files_to_process.each do |target_path|
73
85
  result = process_file(target_path, template_content)
74
86
  @results << result
75
87
  yield result if block_given?
@@ -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.2"
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.2
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.2
386
+ changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v4.0.2/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.2
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