ast-merge 2.0.6 → 2.0.8

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: 86f9ef0462fcd809046dfc7c0486d9ab739badaab3faf4dfa898089479b7b5b1
4
- data.tar.gz: f77f27ebd8b050a209c2d94283b93f1282b4f5e3c689229c3ea19432e1d3bafa
3
+ metadata.gz: ca82f3d67dd5ed8339d2ebad3032875db7c22bb5446747c7f6cf9a03080a1da4
4
+ data.tar.gz: c523f74b6be2660bd2c41c63d1d3afd895d4e849d5b1858813b252000e71093f
5
5
  SHA512:
6
- metadata.gz: de6c91e8cb0a86dd1d13a52a2dbda3cd52951f2d4cfd45c171344412a992d59e5db7593c861467df256a798184774c978ddd47abf3c157e5a73b3c994ad337b5
7
- data.tar.gz: 3ec3274c3366c5bbcd2362ebc0abf71e54292afefa42022fa673996141a43f5385b54c87ca5bf5c15ee2d5267a73544e8316d81bae47ff0ec37dd7772d990566
6
+ metadata.gz: a41a7321e1fafe4882c59b77b16822eabc70f2d255ed6de92ef373bbbbc98a2855895c22175a0ad72270b97a6de9ff8e8246f72110f98ddf7fecc8682799048a
7
+ data.tar.gz: 30bc1323ae070faf65765549aeb479cb2e4c6b060676997ca1a35f31cf45658dab6f1d3adb55f70bf43ac103748b34f0b87e41d8349372f6cea811e55547fc83
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,55 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.0.8] - 2026-01-01
34
+
35
+ - TAG: [v2.0.8][2.0.8t]
36
+ - COVERAGE: 97.09% -- 2636/2715 lines in 48 files
37
+ - BRANCH COVERAGE: 89.73% -- 882/983 branches in 48 files
38
+ - 98.71% documented
39
+
40
+ ### Added
41
+
42
+ - `Ast::Merge::NodeWrapperBase` abstract base class for format-specific node wrappers
43
+ - Provides common functionality shared by `*::Merge::NodeWrapper` classes across gems
44
+ - Handles source context (lines, source string), line info, comments, content extraction
45
+ - Defines abstract `#compute_signature` that subclasses must implement
46
+ - Includes `#node_wrapper?` to distinguish from `NodeTyping::Wrapper`
47
+ - Includes `#underlying_node` to access the raw TreeHaver node (NOT `#unwrap` to avoid
48
+ conflict with `NodeTyping::Wrapper#unwrap` semantics in `FileAnalyzable`)
49
+ - Documents relationship between `NodeWrapperBase` and `NodeTyping::Wrapper`:
50
+ - `NodeWrapperBase`: Format-specific functionality (line info, signatures, type predicates)
51
+ - `NodeTyping::Wrapper`: Custom merge classification (`merge_type` attribute)
52
+ - Nodes can be double-wrapped: `NodeTyping::Wrapper(Format::Merge::NodeWrapper(tree_sitter_node))`
53
+ - Accepts `**options` in initialize for subclass-specific parameters (e.g., `backend`, `document_root`)
54
+
55
+ ## [2.0.7] - 2026-01-01
56
+
57
+ - TAG: [v2.0.7][2.0.7t]
58
+ - COVERAGE: 97.31% -- 2569/2640 lines in 47 files
59
+ - BRANCH COVERAGE: 89.87% -- 869/967 branches in 47 files
60
+ - 98.84% documented
61
+
62
+ ### Added
63
+
64
+ - `Ast::Merge::NodeTyping::Normalizer` module for thread-safe backend type normalization
65
+ - Provides shared infrastructure for format-specific normalizers (toml-merge, markdown-merge)
66
+ - Thread-safe backend registration via mutex-protected operations
67
+ - `configure_normalizer` for initial backend mappings configuration
68
+ - `register_backend` for runtime registration of new backends
69
+ - `canonical_type` for mapping backend-specific types to canonical types
70
+ - `wrap` for wrapping nodes with canonical merge_type
71
+ - `registered_backends`, `backend_registered?`, `mappings_for`, `canonical_types` query methods
72
+ - `Ast::Merge::NodeTyping::FrozenWrapper` class for frozen AST nodes
73
+ - Includes `Freezable` behavior for freeze marker support
74
+ - `frozen_node?`, `slice`, `signature` methods
75
+ - Split `NodeTyping` module into separate files following autoload pattern:
76
+ - `ast/merge/node_typing/wrapper.rb`
77
+ - `ast/merge/node_typing/frozen_wrapper.rb`
78
+ - `ast/merge/node_typing/normalizer.rb`
79
+ - Comprehensive specs for `NodeTyping::Normalizer` including thread-safety tests
80
+ - RBS type signatures for `NodeTyping::Normalizer` and `NodeTyping::FrozenWrapper`
81
+
33
82
  ## [2.0.6] - 2026-01-01
34
83
 
35
84
  - TAG: [v2.0.6][2.0.6t]
@@ -380,7 +429,11 @@ Please file a bug if you notice a violation of semantic versioning.
380
429
 
381
430
  - Initial release
382
431
 
383
- [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v2.0.6...HEAD
432
+ [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v2.0.8...HEAD
433
+ [2.0.8]: https://github.com/kettle-rb/ast-merge/compare/v2.0.7...v2.0.8
434
+ [2.0.8t]: https://github.com/kettle-rb/ast-merge/releases/tag/v2.0.8
435
+ [2.0.7]: https://github.com/kettle-rb/ast-merge/compare/v2.0.6...v2.0.7
436
+ [2.0.7t]: https://github.com/kettle-rb/ast-merge/releases/tag/v2.0.7
384
437
  [2.0.6]: https://github.com/kettle-rb/ast-merge/compare/v2.0.5...v2.0.6
385
438
  [2.0.6t]: https://github.com/kettle-rb/ast-merge/releases/tag/v2.0.6
386
439
  [2.0.5]: https://github.com/kettle-rb/ast-merge/compare/v2.0.4...v2.0.5
data/README.md CHANGED
@@ -1032,7 +1032,7 @@ Thanks for RTFM. ☺️
1032
1032
  [📌gitmoji]: https://gitmoji.dev
1033
1033
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1034
1034
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1035
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.595-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1035
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.715-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1036
1036
  [🔐security]: SECURITY.md
1037
1037
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1038
1038
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module NodeTyping
6
+ # Wrapper for frozen AST nodes that includes Freezable behavior.
7
+ #
8
+ # FrozenWrapper extends Wrapper to add freeze node semantics, making the
9
+ # wrapped node satisfy both the NodeTyping API and the Freezable API.
10
+ # This enables composition where frozen nodes are:
11
+ # - Wrapped AST nodes (can unwrap to get original)
12
+ # - Typed nodes (have merge_type)
13
+ # - Freeze nodes (satisfy is_a?(Freezable) and freeze_node?)
14
+ #
15
+ # ## Key Distinction from FreezeNodeBase
16
+ #
17
+ # FrozenWrapper and FreezeNodeBase both include Freezable, but they represent
18
+ # fundamentally different concepts:
19
+ #
20
+ # ### FrozenWrapper (this class)
21
+ # - Wraps an AST node that has a freeze marker in its leading comments
22
+ # - The node is still a structural AST node (e.g., a `gem` call in a gemspec)
23
+ # - During matching, we want to match by the underlying node's IDENTITY
24
+ # (e.g., the gem name), NOT by the full content
25
+ # - Signature generation should unwrap and use the underlying node's structure
26
+ # - Example: `# token:freeze\ngem "example_gem", "~> 1.0"` wraps a CallNode
27
+ #
28
+ # ### FreezeNodeBase
29
+ # - Represents an explicit freeze block with `# token:freeze ... # token:unfreeze`
30
+ # - The entire block is opaque content that should be preserved verbatim
31
+ # - During matching, we match by the full CONTENT of the block
32
+ # - Signature generation uses freeze_signature (content-based)
33
+ # - Example: A multi-line comment block with custom formatting
34
+ #
35
+ # ## Signature Generation Behavior
36
+ #
37
+ # When FileAnalyzable#generate_signature encounters a FrozenWrapper:
38
+ # 1. It unwraps to get the underlying AST node
39
+ # 2. Passes the unwrapped node to the signature_generator
40
+ # 3. This allows the signature generator to recognize the node type
41
+ # (e.g., Prism::CallNode) and generate appropriate signatures
42
+ #
43
+ # This is critical because signature generators check for specific AST types.
44
+ # If we passed the wrapper, the generator wouldn't recognize it as a CallNode
45
+ # and would fall back to a generic signature, breaking matching.
46
+ #
47
+ # @example Creating a frozen wrapper
48
+ # frozen = NodeTyping::FrozenWrapper.new(prism_node, :frozen)
49
+ # frozen.freeze_node? # => true
50
+ # frozen.is_a?(Ast::Merge::Freezable) # => true
51
+ # frozen.unwrap # => prism_node
52
+ #
53
+ # @see Wrapper
54
+ # @see Ast::Merge::Freezable
55
+ # @see FreezeNodeBase
56
+ # @see FileAnalyzable#generate_signature
57
+ class FrozenWrapper < Wrapper
58
+ include Ast::Merge::Freezable
59
+
60
+ # Create a frozen wrapper for an AST node.
61
+ #
62
+ # @param node [Object] The AST node to wrap
63
+ # @param merge_type [Symbol] The merge type (defaults to :frozen)
64
+ def initialize(node, merge_type = :frozen)
65
+ super(node, merge_type)
66
+ end
67
+
68
+ # Returns true to indicate this is a frozen node.
69
+ # Overrides both Wrapper#typed_node? context and provides freeze_node? from Freezable.
70
+ #
71
+ # @return [Boolean] true
72
+ def frozen_node?
73
+ true
74
+ end
75
+
76
+ # Returns the content of this frozen node.
77
+ # Delegates to the wrapped node's slice method.
78
+ #
79
+ # @return [String] The node content
80
+ def slice
81
+ @node.slice
82
+ end
83
+
84
+ # Returns the signature for this frozen node.
85
+ # Uses the freeze_signature from Freezable module.
86
+ #
87
+ # @return [Array] Signature in the form [:FreezeNode, content]
88
+ def signature
89
+ freeze_signature
90
+ end
91
+
92
+ # Forward inspect to show frozen status.
93
+ def inspect
94
+ "#<NodeTyping::FrozenWrapper merge_type=#{@merge_type.inspect} node=#{@node.inspect}>"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module NodeTyping
6
+ # Thread-safe backend registration and type normalization for AST merge libraries.
7
+ #
8
+ # Normalizer provides a shared, thread-safe mechanism for registering backend-specific
9
+ # node type mappings and normalizing them to canonical types. This enables portable
10
+ # merge rules across different parsers/backends for the same file format.
11
+ #
12
+ # ## Thread Safety
13
+ #
14
+ # All registration and lookup operations are protected by a mutex to ensure
15
+ # thread-safe access to the backend mappings. This follows the same pattern
16
+ # used in TreeHaver::LanguageRegistry and TreeHaver::PathValidator.
17
+ #
18
+ # ## Usage Pattern
19
+ #
20
+ # File-format-specific merge libraries (e.g., toml-merge, markdown-merge) should:
21
+ # 1. Create their own NodeTypeNormalizer module
22
+ # 2. Include or extend Ast::Merge::NodeTyping::Normalizer
23
+ # 3. Define their canonical types and default backend mappings
24
+ # 4. Call `configure_normalizer` with their initial mappings
25
+ #
26
+ # @example Creating a format-specific normalizer
27
+ # module Toml
28
+ # module Merge
29
+ # module NodeTypeNormalizer
30
+ # extend Ast::Merge::NodeTyping::Normalizer
31
+ #
32
+ # configure_normalizer(
33
+ # tree_sitter_toml: {
34
+ # table_array_element: :array_of_tables,
35
+ # pair: :pair,
36
+ # # ...
37
+ # }.freeze,
38
+ # citrus_toml: {
39
+ # table_array_element: :array_of_tables,
40
+ # pair: :pair,
41
+ # # ...
42
+ # }.freeze
43
+ # )
44
+ #
45
+ # # Optional: Add format-specific helper methods
46
+ # def self.table_type?(type)
47
+ # %i[table array_of_tables].include?(type.to_sym)
48
+ # end
49
+ # end
50
+ # end
51
+ # end
52
+ #
53
+ # @see TreeHaver::LanguageRegistry
54
+ # @see TreeHaver::PathValidator
55
+ module Normalizer
56
+ # Called when this module is extended into another module.
57
+ # Sets up the mutex and backend mappings storage.
58
+ #
59
+ # @param base [Module] The module extending this one
60
+ class << self
61
+ def extended(base)
62
+ base.instance_variable_set(:@normalizer_mutex, Mutex.new)
63
+ base.instance_variable_set(:@backend_mappings, {})
64
+ end
65
+ end
66
+
67
+ # Configure the normalizer with initial backend mappings.
68
+ #
69
+ # This should be called once when defining the format-specific normalizer,
70
+ # providing the default backend mappings. Additional backends can be
71
+ # registered later via `register_backend`.
72
+ #
73
+ # @param mappings [Hash{Symbol => Hash{Symbol => Symbol}}] Initial backend mappings
74
+ # Keys are backend identifiers, values are hashes mapping backend types to canonical types
75
+ # @return [void]
76
+ #
77
+ # @example
78
+ # configure_normalizer(
79
+ # tree_sitter_toml: { table_array_element: :array_of_tables }.freeze,
80
+ # citrus_toml: { table_array_element: :array_of_tables }.freeze
81
+ # )
82
+ def configure_normalizer(**mappings)
83
+ @normalizer_mutex.synchronize do
84
+ mappings.each do |backend, type_mappings|
85
+ @backend_mappings[backend.to_sym] = type_mappings.frozen? ? type_mappings : type_mappings.freeze
86
+ end
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Register type mappings for a new backend.
92
+ #
93
+ # This allows extending the normalizer to support additional parsers
94
+ # beyond those configured initially. Thread-safe for runtime registration.
95
+ #
96
+ # @param backend [Symbol] Backend identifier (e.g., :my_parser)
97
+ # @param mappings [Hash{Symbol => Symbol}] Backend type → canonical type mappings
98
+ # @return [void]
99
+ #
100
+ # @example
101
+ # NodeTypeNormalizer.register_backend(:my_parser, {
102
+ # my_table: :table,
103
+ # my_pair: :pair,
104
+ # })
105
+ def register_backend(backend, mappings)
106
+ @normalizer_mutex.synchronize do
107
+ @backend_mappings[backend.to_sym] = mappings.frozen? ? mappings : mappings.freeze
108
+ end
109
+ nil
110
+ end
111
+
112
+ # Get the canonical type for a backend-specific type.
113
+ #
114
+ # If no mapping exists, returns the original type unchanged (passthrough).
115
+ # This allows backend-specific types to pass through for backend-specific
116
+ # merge rules.
117
+ #
118
+ # @param backend_type [Symbol, String, nil] The backend's node type
119
+ # @param backend [Symbol] The backend identifier
120
+ # @return [Symbol, nil] Canonical type (or original if no mapping), nil if input was nil
121
+ #
122
+ # @example
123
+ # NodeTypeNormalizer.canonical_type(:table_array_element, :tree_sitter_toml)
124
+ # # => :array_of_tables
125
+ #
126
+ # NodeTypeNormalizer.canonical_type(:unknown_type, :tree_sitter_toml)
127
+ # # => :unknown_type (passthrough)
128
+ def canonical_type(backend_type, backend = nil)
129
+ return backend_type if backend_type.nil?
130
+
131
+ type_sym = backend_type.to_sym
132
+ @normalizer_mutex.synchronize do
133
+ @backend_mappings.dig(backend, type_sym) || type_sym
134
+ end
135
+ end
136
+
137
+ # Wrap a node with its canonical type as merge_type.
138
+ #
139
+ # Uses Ast::Merge::NodeTyping.with_merge_type to create a wrapper
140
+ # that delegates all methods to the underlying node while adding
141
+ # a canonical merge_type attribute.
142
+ #
143
+ # @param node [Object] The backend node to wrap (must respond to #type)
144
+ # @param backend [Symbol] The backend identifier
145
+ # @return [Ast::Merge::NodeTyping::Wrapper] Wrapped node with canonical merge_type
146
+ #
147
+ # @example
148
+ # wrapped = NodeTypeNormalizer.wrap(node, :tree_sitter_toml)
149
+ # wrapped.type # => :table_array_element (original)
150
+ # wrapped.merge_type # => :array_of_tables (canonical)
151
+ # wrapped.unwrap # => node (original node)
152
+ def wrap(node, backend)
153
+ canonical = canonical_type(node.type, backend)
154
+ Ast::Merge::NodeTyping.with_merge_type(node, canonical)
155
+ end
156
+
157
+ # Get all registered backends.
158
+ #
159
+ # @return [Array<Symbol>] Backend identifiers
160
+ def registered_backends
161
+ @normalizer_mutex.synchronize do
162
+ @backend_mappings.keys
163
+ end
164
+ end
165
+
166
+ # Check if a backend is registered.
167
+ #
168
+ # @param backend [Symbol] Backend identifier
169
+ # @return [Boolean]
170
+ def backend_registered?(backend)
171
+ @normalizer_mutex.synchronize do
172
+ @backend_mappings.key?(backend.to_sym)
173
+ end
174
+ end
175
+
176
+ # Get the mappings for a specific backend.
177
+ #
178
+ # @param backend [Symbol] Backend identifier
179
+ # @return [Hash{Symbol => Symbol}, nil] The mappings or nil if not registered
180
+ def mappings_for(backend)
181
+ @normalizer_mutex.synchronize do
182
+ @backend_mappings[backend.to_sym]
183
+ end
184
+ end
185
+
186
+ # Get all canonical types across all backends.
187
+ #
188
+ # @return [Array<Symbol>] Unique canonical type symbols
189
+ def canonical_types
190
+ @normalizer_mutex.synchronize do
191
+ @backend_mappings.values.flat_map(&:values).uniq
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module NodeTyping
6
+ # Node wrapper that adds a merge_type attribute to an existing node.
7
+ # This uses a simple delegation pattern to preserve all original node
8
+ # behavior while adding the merge_type.
9
+ class Wrapper
10
+ # @return [Object] The original node being wrapped
11
+ attr_reader :node
12
+
13
+ # @return [Symbol] The custom merge type for this node
14
+ attr_reader :merge_type
15
+
16
+ # Create a new node type wrapper.
17
+ #
18
+ # @param node [Object] The original node to wrap
19
+ # @param merge_type [Symbol] The custom merge type
20
+ def initialize(node, merge_type)
21
+ @node = node
22
+ @merge_type = merge_type
23
+ end
24
+
25
+ # Delegate all unknown methods to the wrapped node.
26
+ # This allows the wrapper to be used transparently in place of the node.
27
+ def method_missing(method, *args, &block)
28
+ if @node.respond_to?(method)
29
+ @node.send(method, *args, &block)
30
+ else
31
+ super
32
+ end
33
+ end
34
+
35
+ # Check if the wrapped node responds to a method.
36
+ def respond_to_missing?(method, include_private = false)
37
+ @node.respond_to?(method, include_private) || super
38
+ end
39
+
40
+ # Returns true to indicate this is a node type wrapper.
41
+ def typed_node?
42
+ true
43
+ end
44
+
45
+ # Unwrap to get the original node.
46
+ # @return [Object] The original unwrapped node
47
+ def unwrap
48
+ @node
49
+ end
50
+
51
+ # Forward equality check to the wrapped node.
52
+ def ==(other)
53
+ if other.is_a?(Wrapper)
54
+ @node == other.node && @merge_type == other.merge_type
55
+ else
56
+ @node == other
57
+ end
58
+ end
59
+
60
+ # Forward hash to the wrapped node.
61
+ def hash
62
+ [@node, @merge_type].hash
63
+ end
64
+
65
+ # Forward eql? to the wrapped node.
66
+ def eql?(other)
67
+ self == other
68
+ end
69
+
70
+ # Forward inspect to show both the type and node.
71
+ def inspect
72
+ "#<NodeTyping::Wrapper merge_type=#{@merge_type.inspect} node=#{@node.inspect}>"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -46,167 +46,9 @@ module Ast
46
46
  # @see MergerConfig
47
47
  # @see ConflictResolverBase
48
48
  module NodeTyping
49
- # Node wrapper that adds a merge_type attribute to an existing node.
50
- # This uses a simple delegation pattern to preserve all original node
51
- # behavior while adding the merge_type.
52
- class Wrapper
53
- # @return [Object] The original node being wrapped
54
- attr_reader :node
55
-
56
- # @return [Symbol] The custom merge type for this node
57
- attr_reader :merge_type
58
-
59
- # Create a new node type wrapper.
60
- #
61
- # @param node [Object] The original node to wrap
62
- # @param merge_type [Symbol] The custom merge type
63
- def initialize(node, merge_type)
64
- @node = node
65
- @merge_type = merge_type
66
- end
67
-
68
- # Delegate all unknown methods to the wrapped node.
69
- # This allows the wrapper to be used transparently in place of the node.
70
- def method_missing(method, *args, &block)
71
- if @node.respond_to?(method)
72
- @node.send(method, *args, &block)
73
- else
74
- super
75
- end
76
- end
77
-
78
- # Check if the wrapped node responds to a method.
79
- def respond_to_missing?(method, include_private = false)
80
- @node.respond_to?(method, include_private) || super
81
- end
82
-
83
- # Returns true to indicate this is a node type wrapper.
84
- def typed_node?
85
- true
86
- end
87
-
88
- # Unwrap to get the original node.
89
- # @return [Object] The original unwrapped node
90
- def unwrap
91
- @node
92
- end
93
-
94
- # Forward equality check to the wrapped node.
95
- def ==(other)
96
- if other.is_a?(Wrapper)
97
- @node == other.node && @merge_type == other.merge_type
98
- else
99
- @node == other
100
- end
101
- end
102
-
103
- # Forward hash to the wrapped node.
104
- def hash
105
- [@node, @merge_type].hash
106
- end
107
-
108
- # Forward eql? to the wrapped node.
109
- def eql?(other)
110
- self == other
111
- end
112
-
113
- # Forward inspect to show both the type and node.
114
- def inspect
115
- "#<NodeTyping::Wrapper merge_type=#{@merge_type.inspect} node=#{@node.inspect}>"
116
- end
117
- end
118
-
119
- # Wrapper for frozen AST nodes that includes Freezable behavior.
120
- #
121
- # FrozenWrapper extends Wrapper to add freeze node semantics, making the
122
- # wrapped node satisfy both the NodeTyping API and the Freezable API.
123
- # This enables composition where frozen nodes are:
124
- # - Wrapped AST nodes (can unwrap to get original)
125
- # - Typed nodes (have merge_type)
126
- # - Freeze nodes (satisfy is_a?(Freezable) and freeze_node?)
127
- #
128
- # ## Key Distinction from FreezeNodeBase
129
- #
130
- # FrozenWrapper and FreezeNodeBase both include Freezable, but they represent
131
- # fundamentally different concepts:
132
- #
133
- # ### FrozenWrapper (this class)
134
- # - Wraps an AST node that has a freeze marker in its leading comments
135
- # - The node is still a structural AST node (e.g., a `gem` call in a gemspec)
136
- # - During matching, we want to match by the underlying node's IDENTITY
137
- # (e.g., the gem name), NOT by the full content
138
- # - Signature generation should unwrap and use the underlying node's structure
139
- # - Example: `# token:freeze\ngem "example_gem", "~> 1.0"` wraps a CallNode
140
- #
141
- # ### FreezeNodeBase
142
- # - Represents an explicit freeze block with `# token:freeze ... # token:unfreeze`
143
- # - The entire block is opaque content that should be preserved verbatim
144
- # - During matching, we match by the full CONTENT of the block
145
- # - Signature generation uses freeze_signature (content-based)
146
- # - Example: A multi-line comment block with custom formatting
147
- #
148
- # ## Signature Generation Behavior
149
- #
150
- # When FileAnalyzable#generate_signature encounters a FrozenWrapper:
151
- # 1. It unwraps to get the underlying AST node
152
- # 2. Passes the unwrapped node to the signature_generator
153
- # 3. This allows the signature generator to recognize the node type
154
- # (e.g., Prism::CallNode) and generate appropriate signatures
155
- #
156
- # This is critical because signature generators check for specific AST types.
157
- # If we passed the wrapper, the generator wouldn't recognize it as a CallNode
158
- # and would fall back to a generic signature, breaking matching.
159
- #
160
- # @example Creating a frozen wrapper
161
- # frozen = NodeTyping::FrozenWrapper.new(prism_node, :frozen)
162
- # frozen.freeze_node? # => true
163
- # frozen.is_a?(Ast::Merge::Freezable) # => true
164
- # frozen.unwrap # => prism_node
165
- #
166
- # @see Wrapper
167
- # @see Ast::Merge::Freezable
168
- # @see FreezeNodeBase
169
- # @see FileAnalyzable#generate_signature
170
- class FrozenWrapper < Wrapper
171
- include Ast::Merge::Freezable
172
-
173
- # Create a frozen wrapper for an AST node.
174
- #
175
- # @param node [Object] The AST node to wrap
176
- # @param merge_type [Symbol] The merge type (defaults to :frozen)
177
- def initialize(node, merge_type = :frozen)
178
- super(node, merge_type)
179
- end
180
-
181
- # Returns true to indicate this is a frozen node.
182
- # Overrides both Wrapper#typed_node? context and provides freeze_node? from Freezable.
183
- #
184
- # @return [Boolean] true
185
- def frozen_node?
186
- true
187
- end
188
-
189
- # Returns the content of this frozen node.
190
- # Delegates to the wrapped node's slice method.
191
- #
192
- # @return [String] The node content
193
- def slice
194
- @node.slice
195
- end
196
-
197
- # Returns the signature for this frozen node.
198
- # Uses the freeze_signature from Freezable module.
199
- #
200
- # @return [Array] Signature in the form [:FreezeNode, content]
201
- def signature
202
- freeze_signature
203
- end
204
-
205
- # Forward inspect to show frozen status.
206
- def inspect
207
- "#<NodeTyping::FrozenWrapper merge_type=#{@merge_type.inspect} node=#{@node.inspect}>"
208
- end
209
- end
49
+ autoload :FrozenWrapper, "ast/merge/node_typing/frozen_wrapper"
50
+ autoload :Normalizer, "ast/merge/node_typing/normalizer"
51
+ autoload :Wrapper, "ast/merge/node_typing/wrapper"
210
52
 
211
53
  class << self
212
54
  # Wrap a node with a custom merge_type.
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for format-specific node wrappers used in *-merge gems.
6
+ #
7
+ # This provides common functionality for wrapping TreeHaver nodes with:
8
+ # - Source context (lines, source string)
9
+ # - Line information (start_line, end_line)
10
+ # - Comment associations (leading_comments, inline_comment)
11
+ # - Content extraction (text, content)
12
+ # - Signature generation (abstract)
13
+ #
14
+ # ## Relationship to NodeTyping::Wrapper
15
+ #
16
+ # This class is DIFFERENT from `Ast::Merge::NodeTyping::Wrapper`:
17
+ #
18
+ # - **NodeWrapperBase**: Provides format-specific functionality (line info,
19
+ # signatures, comments, type predicates). Used to wrap raw TreeHaver nodes
20
+ # with rich context needed for merging.
21
+ #
22
+ # - **NodeTyping::Wrapper**: Adds a custom `merge_type` attribute for merge
23
+ # classification. Used by SmartMergerBase to apply custom typing rules.
24
+ #
25
+ # A node CAN be wrapped by both:
26
+ # ```
27
+ # NodeTyping::Wrapper(Toml::Merge::NodeWrapper(tree_sitter_node))
28
+ # ```
29
+ #
30
+ # The `NodeTyping.unwrap` method handles unwrapping `NodeTyping::Wrapper`,
31
+ # while `NodeWrapperBase#node` provides access to the underlying TreeHaver node.
32
+ #
33
+ # ## Subclass Responsibilities
34
+ #
35
+ # Subclasses MUST implement:
36
+ # - `#compute_signature(node)` - Generate a signature for node matching
37
+ #
38
+ # Subclasses SHOULD implement format-specific type predicates:
39
+ # - TOML: `#table?`, `#pair?`, `#array_of_tables?`, etc.
40
+ # - JSON: `#object?`, `#array?`, `#pair?`, etc.
41
+ # - Bash: `#function_definition?`, `#variable_assignment?`, etc.
42
+ #
43
+ # @example Creating a format-specific wrapper
44
+ # class NodeWrapper < Ast::Merge::NodeWrapperBase
45
+ # def table?
46
+ # type == :table
47
+ # end
48
+ #
49
+ # private
50
+ #
51
+ # def compute_signature(node)
52
+ # case node.type.to_sym
53
+ # when :table
54
+ # [:table, table_name]
55
+ # else
56
+ # [node.type.to_sym]
57
+ # end
58
+ # end
59
+ # end
60
+ #
61
+ # @abstract Subclass and implement `#compute_signature`
62
+ class NodeWrapperBase
63
+ # @return [Object] The wrapped TreeHaver node
64
+ attr_reader :node
65
+
66
+ # @return [Array<String>] Source lines for content extraction
67
+ attr_reader :lines
68
+
69
+ # @return [String] The original source string
70
+ attr_reader :source
71
+
72
+ # @return [Array<Hash>] Leading comments associated with this node
73
+ attr_reader :leading_comments
74
+
75
+ # @return [Hash, nil] Inline/trailing comment on the same line
76
+ attr_reader :inline_comment
77
+
78
+ # @return [Integer, nil] Start line (1-based)
79
+ attr_reader :start_line
80
+
81
+ # @return [Integer, nil] End line (1-based)
82
+ attr_reader :end_line
83
+
84
+ # Initialize the node wrapper with source context.
85
+ #
86
+ # @param node [Object] TreeHaver node to wrap
87
+ # @param lines [Array<String>] Source lines for content extraction
88
+ # @param source [String, nil] Original source string for byte-based text extraction
89
+ # @param leading_comments [Array<Hash>] Comments before this node
90
+ # @param inline_comment [Hash, nil] Inline comment on the node's line
91
+ # @param options [Hash] Additional options for subclasses (forward compatibility)
92
+ def initialize(node, lines:, source: nil, leading_comments: [], inline_comment: nil, **options)
93
+ @node = node
94
+ @lines = lines
95
+ @source = source || lines.join("\n")
96
+ @leading_comments = leading_comments
97
+ @inline_comment = inline_comment
98
+
99
+ # Store additional options for subclasses to use
100
+ process_additional_options(options)
101
+
102
+ # Extract line information from the node (0-indexed to 1-indexed)
103
+ extract_line_info(node)
104
+
105
+ # Handle edge case where end_line might be before start_line
106
+ @end_line = @start_line if @start_line && @end_line && @end_line < @start_line
107
+ end
108
+
109
+ # Process additional options. Override in subclasses to handle format-specific options.
110
+ # @param options [Hash] Additional options
111
+ def process_additional_options(options)
112
+ # Default: no-op. Subclasses can override to process options like :backend, :document_root
113
+ end
114
+
115
+ # Generate a signature for this node for matching purposes.
116
+ # Signatures are used to identify corresponding nodes between template and destination.
117
+ #
118
+ # @return [Array, nil] Signature array or nil if not signaturable
119
+ def signature
120
+ compute_signature(@node)
121
+ end
122
+
123
+ # Get the node type as a symbol.
124
+ # @return [Symbol]
125
+ def type
126
+ @node.type.to_sym
127
+ end
128
+
129
+ # Check if this node has a specific type.
130
+ # @param type_name [Symbol, String] Type to check
131
+ # @return [Boolean]
132
+ def type?(type_name)
133
+ @node.type.to_s == type_name.to_s
134
+ end
135
+
136
+ # Check if this is a freeze node.
137
+ # Override in subclasses if freeze node detection differs.
138
+ # @return [Boolean]
139
+ def freeze_node?
140
+ false
141
+ end
142
+
143
+ # Get the text content for this node by extracting from source using byte positions.
144
+ # @return [String]
145
+ def text
146
+ node_text(@node)
147
+ end
148
+
149
+ # Extract text from a node using byte positions.
150
+ # @param ts_node [Object] The TreeHaver node
151
+ # @return [String]
152
+ def node_text(ts_node)
153
+ return "" unless ts_node.respond_to?(:start_byte) && ts_node.respond_to?(:end_byte)
154
+
155
+ @source[ts_node.start_byte...ts_node.end_byte] || ""
156
+ end
157
+
158
+ # Get the content for this node from source lines.
159
+ # @return [String]
160
+ def content
161
+ return "" unless @start_line && @end_line
162
+
163
+ (@start_line..@end_line).map { |ln| @lines[ln - 1] }.compact.join("\n")
164
+ end
165
+
166
+ # Check if this node is a container (has children for merging).
167
+ # Override in subclasses to define container types.
168
+ # @return [Boolean]
169
+ def container?
170
+ false
171
+ end
172
+
173
+ # Check if this node is a leaf (no mergeable children).
174
+ # @return [Boolean]
175
+ def leaf?
176
+ !container?
177
+ end
178
+
179
+ # Get children wrapped as NodeWrappers.
180
+ # Override in subclasses to return wrapped children.
181
+ # @return [Array<NodeWrapperBase>]
182
+ def children
183
+ return [] unless @node.respond_to?(:each)
184
+
185
+ result = []
186
+ @node.each do |child|
187
+ result << wrap_child(child)
188
+ end
189
+ result
190
+ end
191
+
192
+ # Get mergeable children - the semantically meaningful children for tree merging.
193
+ # Override in subclasses to return format-specific mergeable children.
194
+ # @return [Array<NodeWrapperBase>]
195
+ def mergeable_children
196
+ children
197
+ end
198
+
199
+ # String representation for debugging.
200
+ # @return [String]
201
+ def inspect
202
+ "#<#{self.class.name} type=#{@node.type} lines=#{@start_line}..#{@end_line}>"
203
+ end
204
+
205
+ # Returns true to indicate this is a node wrapper.
206
+ # Used to distinguish from NodeTyping::Wrapper.
207
+ # @return [Boolean]
208
+ def node_wrapper?
209
+ true
210
+ end
211
+
212
+ # Get the underlying TreeHaver node.
213
+ # Note: This is NOT the same as NodeTyping::Wrapper#unwrap which removes
214
+ # the typing wrapper. This method provides access to the raw parser node.
215
+ # @return [Object] The underlying TreeHaver node
216
+ def underlying_node
217
+ @node
218
+ end
219
+
220
+ protected
221
+
222
+ # Wrap a child node. Override in subclasses to use the specific wrapper class.
223
+ # @param child [Object] Child node to wrap
224
+ # @return [NodeWrapperBase]
225
+ def wrap_child(child)
226
+ self.class.new(child, lines: @lines, source: @source)
227
+ end
228
+
229
+ # Compute signature for a node. Subclasses MUST implement this.
230
+ # @param node [Object] The node to compute signature for
231
+ # @return [Array, nil] Signature array
232
+ # @abstract
233
+ def compute_signature(node)
234
+ raise NotImplementedError, "#{self.class} must implement #compute_signature"
235
+ end
236
+
237
+ private
238
+
239
+ # Extract line information from the node.
240
+ # @param node [Object] The node to extract line info from
241
+ def extract_line_info(node)
242
+ if node.respond_to?(:start_point)
243
+ point = node.start_point
244
+ @start_line = extract_row(point) + 1
245
+ end
246
+
247
+ if node.respond_to?(:end_point)
248
+ point = node.end_point
249
+ @end_line = extract_row(point) + 1
250
+ end
251
+ end
252
+
253
+ # Extract row from a point, handling different point implementations.
254
+ # @param point [Object] The point object
255
+ # @return [Integer]
256
+ def extract_row(point)
257
+ if point.respond_to?(:row)
258
+ point.row
259
+ elsif point.is_a?(Hash)
260
+ point[:row]
261
+ else
262
+ 0
263
+ end
264
+ end
265
+ end
266
+ end
267
+ 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 = "2.0.6"
8
+ VERSION = "2.0.8"
9
9
  end
10
10
  VERSION = Version::VERSION # traditional location
11
11
  end
data/lib/ast/merge.rb CHANGED
@@ -153,6 +153,7 @@ module Ast
153
153
  autoload :MergerConfig, "ast/merge/merger_config"
154
154
  autoload :NavigableStatement, "ast/merge/navigable_statement"
155
155
  autoload :NodeTyping, "ast/merge/node_typing"
156
+ autoload :NodeWrapperBase, "ast/merge/node_wrapper_base"
156
157
  autoload :PartialTemplateMerger, "ast/merge/partial_template_merger"
157
158
  autoload :SectionTyping, "ast/merge/section_typing"
158
159
  autoload :SmartMergerBase, "ast/merge/smart_merger_base"
data/sig/ast/merge.rbs CHANGED
@@ -574,9 +574,60 @@ module Ast
574
574
  def inspect: () -> String
575
575
  end
576
576
 
577
+ # Wrapper for frozen AST nodes that includes Freezable behavior
578
+ class FrozenWrapper < Wrapper
579
+ include Freezable
580
+
581
+ def initialize: (untyped node, ?Symbol merge_type) -> void
582
+ def frozen_node?: () -> bool
583
+ def slice: () -> String
584
+ def signature: () -> Array[untyped]
585
+ def inspect: () -> String
586
+ end
587
+
588
+ # Thread-safe backend registration and type normalization module.
589
+ # Extended by format-specific normalizers (e.g., Toml::Merge::NodeTypeNormalizer).
590
+ module Normalizer
591
+ @normalizer_mutex: Mutex
592
+ @backend_mappings: Hash[Symbol, Hash[Symbol, Symbol]]
593
+
594
+ # Called when this module is extended into another module
595
+ def self.extended: (Module base) -> void
596
+
597
+ # Configure initial backend mappings
598
+ # @param mappings Keyword args where keys are backend symbols and values are type mapping hashes
599
+ def configure_normalizer: (**untyped) -> void
600
+
601
+ # Register type mappings for a new backend (thread-safe)
602
+ def register_backend: (Symbol backend, Hash[Symbol, Symbol] mappings) -> void
603
+
604
+ # Get the canonical type for a backend-specific type
605
+ def canonical_type: (Symbol | String | nil backend_type, ?Symbol? backend) -> Symbol?
606
+
607
+ # Wrap a node with its canonical type as merge_type
608
+ def wrap: (untyped node, Symbol backend) -> Wrapper
609
+
610
+ # Get all registered backends
611
+ def registered_backends: () -> Array[Symbol]
612
+
613
+ # Check if a backend is registered
614
+ def backend_registered?: (Symbol | String backend) -> bool
615
+
616
+ # Get the mappings for a specific backend
617
+ def mappings_for: (Symbol backend) -> Hash[Symbol, Symbol]?
618
+
619
+ # Get all canonical types across all backends
620
+ def canonical_types: () -> Array[Symbol]
621
+ end
622
+
577
623
  def self.with_merge_type: (untyped node, Symbol merge_type) -> Wrapper
578
- def self.validate!: (node_typing_hash? node_typing) -> void
579
- def self.apply: (untyped node, node_typing_hash? node_typing) -> untyped
624
+ def self.frozen: (untyped node, ?Symbol merge_type) -> FrozenWrapper
625
+ def self.frozen_node?: (untyped node) -> bool
626
+ def self.typed_node?: (untyped node) -> bool
627
+ def self.merge_type_for: (untyped node) -> Symbol?
628
+ def self.unwrap: (untyped node) -> untyped
629
+ def self.process: (untyped node, node_typing_hash? typing_config) -> untyped?
630
+ def self.validate!: (node_typing_hash? typing_config) -> void
580
631
  end
581
632
  end
582
633
  end
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: 2.0.6
4
+ version: 2.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -318,6 +318,10 @@ files:
318
318
  - lib/ast/merge/merger_config.rb
319
319
  - lib/ast/merge/navigable_statement.rb
320
320
  - lib/ast/merge/node_typing.rb
321
+ - lib/ast/merge/node_typing/frozen_wrapper.rb
322
+ - lib/ast/merge/node_typing/normalizer.rb
323
+ - lib/ast/merge/node_typing/wrapper.rb
324
+ - lib/ast/merge/node_wrapper_base.rb
321
325
  - lib/ast/merge/partial_template_merger.rb
322
326
  - lib/ast/merge/recipe.rb
323
327
  - lib/ast/merge/recipe/config.rb
@@ -352,10 +356,10 @@ licenses:
352
356
  - MIT
353
357
  metadata:
354
358
  homepage_uri: https://ast-merge.galtzo.com/
355
- source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v2.0.6
356
- changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v2.0.6/CHANGELOG.md
359
+ source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v2.0.8
360
+ changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v2.0.8/CHANGELOG.md
357
361
  bug_tracker_uri: https://github.com/kettle-rb/ast-merge/issues
358
- documentation_uri: https://www.rubydoc.info/gems/ast-merge/2.0.6
362
+ documentation_uri: https://www.rubydoc.info/gems/ast-merge/2.0.8
359
363
  funding_uri: https://github.com/sponsors/pboling
360
364
  wiki_uri: https://github.com/kettle-rb/ast-merge/wiki
361
365
  news_uri: https://www.railsbling.com/tags/ast-merge
metadata.gz.sig CHANGED
Binary file