ast-merge 1.0.0

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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +852 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/ast/merge/ast_node.rb +87 -0
  14. data/lib/ast/merge/comment/block.rb +195 -0
  15. data/lib/ast/merge/comment/empty.rb +78 -0
  16. data/lib/ast/merge/comment/line.rb +138 -0
  17. data/lib/ast/merge/comment/parser.rb +278 -0
  18. data/lib/ast/merge/comment/style.rb +282 -0
  19. data/lib/ast/merge/comment.rb +36 -0
  20. data/lib/ast/merge/conflict_resolver_base.rb +399 -0
  21. data/lib/ast/merge/debug_logger.rb +271 -0
  22. data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
  23. data/lib/ast/merge/file_analyzable.rb +307 -0
  24. data/lib/ast/merge/freezable.rb +82 -0
  25. data/lib/ast/merge/freeze_node_base.rb +434 -0
  26. data/lib/ast/merge/match_refiner_base.rb +312 -0
  27. data/lib/ast/merge/match_score_base.rb +135 -0
  28. data/lib/ast/merge/merge_result_base.rb +169 -0
  29. data/lib/ast/merge/merger_config.rb +258 -0
  30. data/lib/ast/merge/node_typing.rb +373 -0
  31. data/lib/ast/merge/region.rb +124 -0
  32. data/lib/ast/merge/region_detector_base.rb +114 -0
  33. data/lib/ast/merge/region_mergeable.rb +364 -0
  34. data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
  35. data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
  36. data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
  37. data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
  38. data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
  39. data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
  40. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
  41. data/lib/ast/merge/rspec/shared_examples.rb +26 -0
  42. data/lib/ast/merge/rspec.rb +4 -0
  43. data/lib/ast/merge/section_typing.rb +303 -0
  44. data/lib/ast/merge/smart_merger_base.rb +417 -0
  45. data/lib/ast/merge/text/conflict_resolver.rb +161 -0
  46. data/lib/ast/merge/text/file_analysis.rb +168 -0
  47. data/lib/ast/merge/text/line_node.rb +142 -0
  48. data/lib/ast/merge/text/merge_result.rb +42 -0
  49. data/lib/ast/merge/text/section.rb +93 -0
  50. data/lib/ast/merge/text/section_splitter.rb +397 -0
  51. data/lib/ast/merge/text/smart_merger.rb +141 -0
  52. data/lib/ast/merge/text/word_node.rb +86 -0
  53. data/lib/ast/merge/text.rb +35 -0
  54. data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
  55. data/lib/ast/merge/version.rb +12 -0
  56. data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
  57. data/lib/ast/merge.rb +165 -0
  58. data/lib/ast-merge.rb +4 -0
  59. data/sig/ast/merge.rbs +195 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +326 -0
  62. metadata.gz.sig +0 -0
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared examples for validating MergerConfig usage
4
+ #
5
+ # Usage in your spec:
6
+ # require "ast/merge/rspec/shared_examples/merger_config"
7
+ #
8
+ # RSpec.describe Ast::Merge::MergerConfig do
9
+ # it_behaves_like "Ast::Merge::MergerConfig" do
10
+ # let(:merger_config_class) { Ast::Merge::MergerConfig }
11
+ # # Factory to create merger config instance
12
+ # let(:build_merger_config) { ->(**opts) { merger_config_class.new(**opts) } }
13
+ # end
14
+ # end
15
+ #
16
+ # @note This is for testing the MergerConfig class itself or custom subclasses
17
+
18
+ RSpec.shared_examples("Ast::Merge::MergerConfig") do
19
+ # Required let blocks:
20
+ # - merger_config_class: The class under test (usually Ast::Merge::MergerConfig)
21
+ # - build_merger_config: Lambda that creates a merger config instance
22
+
23
+ describe "constants" do
24
+ it "has VALID_PREFERENCES" do
25
+ expect(merger_config_class::VALID_PREFERENCES).to(eq(%i[destination template]))
26
+ end
27
+ end
28
+
29
+ describe "default configuration" do
30
+ let(:config) { build_merger_config.call }
31
+
32
+ it "defaults preference to :destination" do
33
+ expect(config.preference).to(eq(:destination))
34
+ end
35
+
36
+ it "defaults add_template_only_nodes to false" do
37
+ expect(config.add_template_only_nodes).to(eq(false))
38
+ end
39
+
40
+ it "defaults freeze_token to nil" do
41
+ expect(config.freeze_token).to(be_nil)
42
+ end
43
+
44
+ it "defaults signature_generator to nil" do
45
+ expect(config.signature_generator).to(be_nil)
46
+ end
47
+ end
48
+
49
+ describe "custom configuration" do
50
+ it "accepts preference: :template" do
51
+ config = build_merger_config.call(preference: :template)
52
+ expect(config.preference).to(eq(:template))
53
+ end
54
+
55
+ it "accepts add_template_only_nodes: true" do
56
+ config = build_merger_config.call(add_template_only_nodes: true)
57
+ expect(config.add_template_only_nodes).to(eq(true))
58
+ end
59
+
60
+ it "accepts freeze_token option" do
61
+ config = build_merger_config.call(freeze_token: "my-token")
62
+ expect(config.freeze_token).to(eq("my-token"))
63
+ end
64
+
65
+ it "accepts signature_generator proc" do
66
+ generator = ->(node) { [:custom, node] }
67
+ config = build_merger_config.call(signature_generator: generator)
68
+ expect(config.signature_generator).to(eq(generator))
69
+ end
70
+ end
71
+
72
+ describe "validation" do
73
+ it "raises ArgumentError for invalid preference" do
74
+ expect { build_merger_config.call(preference: :invalid) }
75
+ .to(raise_error(ArgumentError, /invalid.*preference/i))
76
+ end
77
+
78
+ it "accepts :destination preference" do
79
+ expect { build_merger_config.call(preference: :destination) }
80
+ .not_to(raise_error)
81
+ end
82
+
83
+ it "accepts :template preference" do
84
+ expect { build_merger_config.call(preference: :template) }
85
+ .not_to(raise_error)
86
+ end
87
+
88
+ it "accepts Hash preference" do
89
+ expect { build_merger_config.call(preference: {default: :destination}) }
90
+ .not_to(raise_error)
91
+ end
92
+
93
+ it "validates node_typing if provided" do
94
+ expect { build_merger_config.call(node_typing: "not a hash") }
95
+ .to(raise_error(ArgumentError, /must be a Hash/))
96
+ end
97
+ end
98
+
99
+ describe "#prefer_destination?" do
100
+ it "returns true when preference is :destination" do
101
+ config = build_merger_config.call(preference: :destination)
102
+ expect(config.prefer_destination?).to(be(true))
103
+ end
104
+
105
+ it "returns false when preference is :template" do
106
+ config = build_merger_config.call(preference: :template)
107
+ expect(config.prefer_destination?).to(be(false))
108
+ end
109
+ end
110
+
111
+ describe "#prefer_template?" do
112
+ it "returns true when preference is :template" do
113
+ config = build_merger_config.call(preference: :template)
114
+ expect(config.prefer_template?).to(be(true))
115
+ end
116
+
117
+ it "returns false when preference is :destination" do
118
+ config = build_merger_config.call(preference: :destination)
119
+ expect(config.prefer_template?).to(be(false))
120
+ end
121
+ end
122
+
123
+ describe "#to_h" do
124
+ it "returns a hash with configuration options" do
125
+ config = build_merger_config.call(
126
+ preference: :template,
127
+ add_template_only_nodes: true,
128
+ )
129
+ hash = config.to_h
130
+
131
+ expect(hash).to(be_a(Hash))
132
+ expect(hash[:preference]).to(eq(:template))
133
+ expect(hash[:add_template_only_nodes]).to(eq(true))
134
+ end
135
+
136
+ it "includes freeze_token when set" do
137
+ config = build_merger_config.call(freeze_token: "my-token")
138
+ hash = config.to_h
139
+
140
+ expect(hash[:freeze_token]).to(eq("my-token"))
141
+ end
142
+
143
+ it "uses default_freeze_token when none specified" do
144
+ config = build_merger_config.call
145
+ hash = config.to_h(default_freeze_token: "default-token")
146
+
147
+ expect(hash[:freeze_token]).to(eq("default-token"))
148
+ end
149
+
150
+ it "includes signature_generator when set" do
151
+ generator = ->(_node) { [:custom] }
152
+ config = build_merger_config.call(signature_generator: generator)
153
+ hash = config.to_h
154
+
155
+ expect(hash[:signature_generator]).to(eq(generator))
156
+ end
157
+
158
+ it "includes node_typing when set" do
159
+ typing = {CallNode: ->(_node) { nil }}
160
+ config = build_merger_config.call(node_typing: typing)
161
+ hash = config.to_h
162
+
163
+ expect(hash[:node_typing]).to(eq(typing))
164
+ end
165
+ end
166
+
167
+ describe "#with" do
168
+ it "creates a new config with updated values" do
169
+ original = build_merger_config.call(preference: :destination)
170
+ updated = original.with(preference: :template)
171
+
172
+ expect(original.preference).to(eq(:destination))
173
+ expect(updated.preference).to(eq(:template))
174
+ end
175
+
176
+ it "preserves unmodified values" do
177
+ original = build_merger_config.call(
178
+ preference: :destination,
179
+ add_template_only_nodes: true,
180
+ )
181
+ updated = original.with(preference: :template)
182
+
183
+ expect(updated.add_template_only_nodes).to(eq(true))
184
+ end
185
+
186
+ it "preserves node_typing" do
187
+ typing = {CallNode: ->(_node) { nil }}
188
+ original = build_merger_config.call(node_typing: typing)
189
+ updated = original.with(preference: :template)
190
+
191
+ expect(updated.node_typing).to(eq(typing))
192
+ end
193
+ end
194
+
195
+ describe "#inspect" do
196
+ it "returns a string representation" do
197
+ config = build_merger_config.call
198
+ expect(config.inspect).to(be_a(String))
199
+ expect(config.inspect).to(include("MergerConfig"))
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared example for testing merge scenarios with idempotency
4
+ #
5
+ # This example tests that:
6
+ # 1. Merging template + destination produces the expected result
7
+ # 2. The merge is idempotent (merging again produces the same result)
8
+ #
9
+ # Required let blocks that must be defined by the including spec:
10
+ # - fixtures_path: Path to the fixtures directory
11
+ # - merger_class: The SmartMerger class to use (e.g., Ast::Merge::Text::SmartMerger)
12
+ #
13
+ # Optional let blocks:
14
+ # - file_extension: File extension for fixtures (default: "" for no extension)
15
+ # Set to "rb" for Ruby, "txt" for text, etc.
16
+ #
17
+ # The shared example accepts:
18
+ # - scenario: Name of the fixture subdirectory (e.g., "01_top_level_removed")
19
+ # - options: Hash of merge options to pass to the merger (default: {})
20
+ #
21
+ # Fixture directory structure:
22
+ # fixtures_path/
23
+ # scenario/
24
+ # template.{ext} - The template file
25
+ # destination.{ext} - The destination file
26
+ # result.{ext} - The expected merge result
27
+ #
28
+ # @example Basic usage with .txt files
29
+ # let(:fixtures_path) { File.expand_path("../fixtures/text", __dir__) }
30
+ # let(:merger_class) { Ast::Merge::Text::SmartMerger }
31
+ #
32
+ # context "when a top-level node is removed in destination" do
33
+ # it_behaves_like "a reproducible merge", "01_top_level_removed"
34
+ # end
35
+ #
36
+ # @example With Ruby files
37
+ # let(:fixtures_path) { File.expand_path("../fixtures/ruby", __dir__) }
38
+ # let(:merger_class) { Prism::Merge::SmartMerger }
39
+ # let(:file_extension) { "rb" }
40
+ #
41
+ # @example With no extension
42
+ # let(:file_extension) { "" }
43
+ #
44
+ # @example With merge options
45
+ # context "with preference: :template" do
46
+ # it_behaves_like "a reproducible merge", "config_preference_template", {
47
+ # preference: :template
48
+ # }
49
+ # end
50
+ #
51
+ RSpec.shared_examples("a reproducible merge") do |scenario, options = {}|
52
+ let(:scenario_name) { scenario }
53
+ let(:merge_options) { options }
54
+ # file_extension should be defined by the including spec
55
+ # Default: "" (no extension). Override with let(:file_extension) { "rb" } etc.
56
+ let(:file_extension) do
57
+ super()
58
+ rescue NoMethodError
59
+ ""
60
+ end
61
+ let(:fixture_filename) do
62
+ ->(name) { file_extension.to_s.empty? ? name : "#{name}.#{file_extension}" }
63
+ end
64
+ let(:fixture) do
65
+ template = File.read(File.join(fixtures_path, scenario_name, fixture_filename.call("template")))
66
+ destination = File.read(File.join(fixtures_path, scenario_name, fixture_filename.call("destination")))
67
+ expected_result = File.read(File.join(fixtures_path, scenario_name, fixture_filename.call("result")))
68
+ {template: template, destination: destination, expected: expected_result}
69
+ end
70
+
71
+ it "produces the expected result" do
72
+ merger = merger_class.new(
73
+ fixture[:template],
74
+ fixture[:destination],
75
+ **merge_options,
76
+ )
77
+ result = merger.merge
78
+
79
+ # Normalize trailing newlines for comparison
80
+ expected = fixture[:expected]
81
+ actual = result.to_s
82
+
83
+ expect(actual).to(
84
+ eq(expected),
85
+ "Merge result did not match expected.\n" \
86
+ "Expected:\n#{expected.inspect}\n" \
87
+ "Got:\n#{actual.inspect}",
88
+ )
89
+ end
90
+
91
+ it "is idempotent (merging again produces same result)" do
92
+ # First merge
93
+ merger1 = merger_class.new(
94
+ fixture[:template],
95
+ fixture[:destination],
96
+ **merge_options,
97
+ )
98
+ result1 = merger1.merge
99
+
100
+ # Second merge: use result1 as new destination
101
+ merger2 = merger_class.new(
102
+ fixture[:template],
103
+ result1,
104
+ **merge_options,
105
+ )
106
+ result2 = merger2.merge
107
+
108
+ expect(result2).to(
109
+ eq(result1),
110
+ "Merge is not idempotent!\n" \
111
+ "First merge:\n#{result1.inspect}\n" \
112
+ "Second merge:\n#{result2.inspect}",
113
+ )
114
+ end
115
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load all Ast::Merge shared examples for RSpec
4
+ #
5
+ # Usage:
6
+ # require "ast/merge/rspec/shared_examples"
7
+ #
8
+ # This will load all shared examples provided by ast-merge,
9
+ # making them available for use in any *-merge gem's test suite.
10
+ #
11
+ # Available shared examples:
12
+ # - "Ast::Merge::ConflictResolverBase" - validates conflict resolver base implementation
13
+ # - "Ast::Merge::DebugLogger" - validates debug logging integration
14
+ # - "Ast::Merge::FileAnalyzable" - validates file analysis mixin integration
15
+ # - "Ast::Merge::FreezeNodeBase" - validates freeze node base implementation
16
+ # - "Ast::Merge::MergeResultBase" - validates merge result implementation
17
+ # - "Ast::Merge::MergerConfig" - validates merger configuration
18
+ # - "a reproducible merge" - validates merge scenarios with fixtures and idempotency
19
+
20
+ require_relative "shared_examples/conflict_resolver_base"
21
+ require_relative "shared_examples/debug_logger"
22
+ require_relative "shared_examples/file_analyzable"
23
+ require_relative "shared_examples/freeze_node_base"
24
+ require_relative "shared_examples/merge_result_base"
25
+ require_relative "shared_examples/merger_config"
26
+ require_relative "shared_examples/reproducible_merge"
@@ -0,0 +1,4 @@
1
+ # Ensure the main ast-merge library is loaded first
2
+ require "ast/merge"
3
+
4
+ require_relative "rspec/shared_examples"
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # AST-aware section typing for identifying logical sections within parsed trees.
6
+ #
7
+ # Unlike text-based splitting (see `Ast::Merge::Text::SectionSplitter`), SectionTyping
8
+ # works with already-parsed AST nodes where the parser has already identified
9
+ # structural boundaries. This eliminates the need for regex pattern matching.
10
+ #
11
+ # ## Use Cases
12
+ #
13
+ # - Identifying `appraise` blocks in Appraisals files
14
+ # - Identifying `group` blocks in Gemfiles
15
+ # - Identifying method definitions in Ruby files
16
+ # - Any case where the AST parser provides structural information
17
+ #
18
+ # ## How It Works
19
+ #
20
+ # 1. **Classifier**: A callable that inspects an AST node and returns section info
21
+ # 2. **Typed Node**: The node wrapped with its section classification
22
+ # 3. **Merge Logic**: Section-aware merging based on classifications
23
+ #
24
+ # @example Defining an Appraisals block classifier
25
+ # AppraisalClassifier = ->(node) do
26
+ # return nil unless node.is_a?(Prism::CallNode)
27
+ # return nil unless node.name == :appraise
28
+ #
29
+ # # Extract the block name from the first argument
30
+ # block_name = node.arguments&.arguments&.first
31
+ # return nil unless block_name.is_a?(Prism::StringNode)
32
+ #
33
+ # {
34
+ # type: :appraise_block,
35
+ # name: block_name.unescaped,
36
+ # node: node
37
+ # }
38
+ # end
39
+ #
40
+ # @example Using the classifier
41
+ # typing = SectionTyping.new(classifier: AppraisalClassifier)
42
+ # sections = typing.classify_children(parsed_tree.statements)
43
+ #
44
+ # sections.each do |section|
45
+ # puts "#{section.type}: #{section.name}"
46
+ # end
47
+ #
48
+ # @api public
49
+ module SectionTyping
50
+ # Represents a classified section from an AST node.
51
+ #
52
+ # Unlike `Text::Section` which is text-based, this wraps actual AST nodes
53
+ # with their classification information.
54
+ #
55
+ # @api public
56
+ TypedSection = Struct.new(
57
+ # @return [Symbol] The section type (e.g., :appraise_block, :gem_group)
58
+ :type,
59
+
60
+ # @return [String, Symbol] Unique identifier for matching (e.g., block name)
61
+ :name,
62
+
63
+ # @return [Object] The original AST node
64
+ :node,
65
+
66
+ # @return [Hash, nil] Additional metadata from classification
67
+ :metadata,
68
+ keyword_init: true,
69
+ ) do
70
+ # Normalize the section name for matching.
71
+ #
72
+ # @return [String] Normalized name
73
+ def normalized_name
74
+ return "" if name.nil?
75
+ return name.to_s if name.is_a?(Symbol)
76
+ name.to_s.strip.downcase
77
+ end
78
+
79
+ # Check if this is an unclassified/preamble section.
80
+ #
81
+ # @return [Boolean]
82
+ def unclassified?
83
+ type == :unclassified || type == :preamble
84
+ end
85
+ end
86
+
87
+ # Base class for AST-aware section classifiers.
88
+ #
89
+ # Subclasses implement `classify(node)` to identify section boundaries
90
+ # and extract section names from AST nodes.
91
+ #
92
+ # @abstract Subclass and implement {#classify}
93
+ # @api public
94
+ class Classifier
95
+ # Classify a single AST node.
96
+ #
97
+ # @param node [Object] An AST node to classify
98
+ # @return [TypedSection, nil] Section info if node starts a section, nil otherwise
99
+ # @abstract Subclasses must implement this method
100
+ def classify(node)
101
+ raise NotImplementedError, "#{self.class}#classify must be implemented"
102
+ end
103
+
104
+ # Classify all children of a parent node.
105
+ #
106
+ # Iterates through child nodes and classifies each, grouping consecutive
107
+ # unclassified nodes into preamble/interstitial sections.
108
+ #
109
+ # @param children [Array, Enumerable] Child nodes to classify
110
+ # @return [Array<TypedSection>] Classified sections
111
+ def classify_all(children)
112
+ sections = []
113
+ unclassified_buffer = []
114
+
115
+ children.each do |child|
116
+ if (section = classify(child))
117
+ # Flush unclassified buffer as preamble/interstitial
118
+ if unclassified_buffer.any?
119
+ sections << build_unclassified_section(unclassified_buffer)
120
+ unclassified_buffer = []
121
+ end
122
+ sections << section
123
+ else
124
+ unclassified_buffer << child
125
+ end
126
+ end
127
+
128
+ # Flush remaining unclassified nodes
129
+ if unclassified_buffer.any?
130
+ sections << build_unclassified_section(unclassified_buffer)
131
+ end
132
+
133
+ sections
134
+ end
135
+
136
+ # Check if a node can be classified by this classifier.
137
+ #
138
+ # @param node [Object] Node to check
139
+ # @return [Boolean]
140
+ def classifies?(node)
141
+ !classify(node).nil?
142
+ end
143
+
144
+ private
145
+
146
+ # Build a section for unclassified nodes.
147
+ #
148
+ # @param nodes [Array] Unclassified nodes
149
+ # @return [TypedSection]
150
+ def build_unclassified_section(nodes)
151
+ TypedSection.new(
152
+ type: :unclassified,
153
+ name: :unclassified,
154
+ node: (nodes.length == 1) ? nodes.first : nodes,
155
+ metadata: {node_count: nodes.length},
156
+ )
157
+ end
158
+ end
159
+
160
+ # A classifier that uses a callable (proc/lambda) for classification.
161
+ #
162
+ # This allows defining classifiers without creating a subclass.
163
+ #
164
+ # @example Using a lambda classifier
165
+ # classifier = CallableClassifier.new(->(node) {
166
+ # return nil unless node.respond_to?(:name) && node.name == :appraise
167
+ # TypedSection.new(type: :appraise, name: extract_name(node), node: node)
168
+ # })
169
+ #
170
+ class CallableClassifier < Classifier
171
+ # @return [#call] The callable used for classification
172
+ attr_reader :callable
173
+
174
+ # Initialize with a callable.
175
+ #
176
+ # @param callable [#call] A callable that takes a node and returns TypedSection or nil
177
+ def initialize(callable)
178
+ @callable = callable
179
+ end
180
+
181
+ # Classify using the callable.
182
+ #
183
+ # @param node [Object] Node to classify
184
+ # @return [TypedSection, nil]
185
+ def classify(node)
186
+ result = callable.call(node)
187
+ return if result.nil?
188
+
189
+ # Allow callable to return a Hash and convert to TypedSection
190
+ if result.is_a?(Hash)
191
+ TypedSection.new(**result)
192
+ else
193
+ result
194
+ end
195
+ end
196
+ end
197
+
198
+ # A composite classifier that tries multiple classifiers in order.
199
+ #
200
+ # Useful when a file may contain multiple types of sections
201
+ # (e.g., both `appraise` blocks and `group` blocks).
202
+ #
203
+ # @example Combining classifiers
204
+ # composite = CompositeClassifier.new(
205
+ # AppraisalClassifier.new,
206
+ # GemGroupClassifier.new
207
+ # )
208
+ # sections = composite.classify_all(children)
209
+ #
210
+ class CompositeClassifier < Classifier
211
+ # @return [Array<Classifier>] Classifiers to try in order
212
+ attr_reader :classifiers
213
+
214
+ # Initialize with multiple classifiers.
215
+ #
216
+ # @param classifiers [Array<Classifier>] Classifiers to try
217
+ def initialize(*classifiers)
218
+ @classifiers = classifiers.flatten
219
+ end
220
+
221
+ # Try each classifier until one matches.
222
+ #
223
+ # @param node [Object] Node to classify
224
+ # @return [TypedSection, nil]
225
+ def classify(node)
226
+ classifiers.each do |classifier|
227
+ if (section = classifier.classify(node))
228
+ return section
229
+ end
230
+ end
231
+ nil
232
+ end
233
+ end
234
+
235
+ # Merge typed sections from template and destination.
236
+ #
237
+ # Similar to `Text::SectionSplitter#merge_section_lists` but works with
238
+ # TypedSection objects wrapping AST nodes.
239
+ #
240
+ # @param template_sections [Array<TypedSection>] Sections from template
241
+ # @param dest_sections [Array<TypedSection>] Sections from destination
242
+ # @param preference [Symbol, Hash] Merge preference (:template, :destination, or per-section Hash)
243
+ # @param add_template_only [Boolean] Whether to add sections only in template
244
+ # @return [Array<TypedSection>] Merged sections
245
+ def self.merge_sections(template_sections, dest_sections, preference: :destination, add_template_only: false)
246
+ dest_by_name = dest_sections.each_with_object({}) do |section, hash|
247
+ key = section.normalized_name
248
+ hash[key] = section unless section.unclassified?
249
+ end
250
+
251
+ merged = []
252
+ seen_names = Set.new
253
+
254
+ template_sections.each do |template_section|
255
+ if template_section.unclassified?
256
+ # Unclassified sections are typically kept as-is or merged specially
257
+ merged << template_section if add_template_only
258
+ next
259
+ end
260
+
261
+ key = template_section.normalized_name
262
+ seen_names << key
263
+
264
+ dest_section = dest_by_name[key]
265
+
266
+ if dest_section
267
+ # Section exists in both - choose based on preference
268
+ section_pref = preference_for(template_section.name, preference)
269
+ merged << ((section_pref == :template) ? template_section : dest_section)
270
+ elsif add_template_only
271
+ merged << template_section
272
+ end
273
+ end
274
+
275
+ # Append destination-only sections
276
+ dest_sections.each do |dest_section|
277
+ next if dest_section.unclassified?
278
+ key = dest_section.normalized_name
279
+ next if seen_names.include?(key)
280
+ merged << dest_section
281
+ end
282
+
283
+ merged
284
+ end
285
+
286
+ # Get preference for a specific section.
287
+ #
288
+ # @param section_name [String, Symbol] The section name
289
+ # @param preference [Symbol, Hash] Overall preference
290
+ # @return [Symbol] :template or :destination
291
+ def self.preference_for(section_name, preference)
292
+ return preference unless preference.is_a?(Hash)
293
+
294
+ normalized = section_name.to_s.strip.downcase
295
+ preference.each do |key, value|
296
+ return value if key.to_s.strip.downcase == normalized
297
+ end
298
+
299
+ preference.fetch(:default, :destination)
300
+ end
301
+ end
302
+ end
303
+ end