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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/ast/merge/ast_node.rb +87 -0
- data/lib/ast/merge/comment/block.rb +195 -0
- data/lib/ast/merge/comment/empty.rb +78 -0
- data/lib/ast/merge/comment/line.rb +138 -0
- data/lib/ast/merge/comment/parser.rb +278 -0
- data/lib/ast/merge/comment/style.rb +282 -0
- data/lib/ast/merge/comment.rb +36 -0
- data/lib/ast/merge/conflict_resolver_base.rb +399 -0
- data/lib/ast/merge/debug_logger.rb +271 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
- data/lib/ast/merge/file_analyzable.rb +307 -0
- data/lib/ast/merge/freezable.rb +82 -0
- data/lib/ast/merge/freeze_node_base.rb +434 -0
- data/lib/ast/merge/match_refiner_base.rb +312 -0
- data/lib/ast/merge/match_score_base.rb +135 -0
- data/lib/ast/merge/merge_result_base.rb +169 -0
- data/lib/ast/merge/merger_config.rb +258 -0
- data/lib/ast/merge/node_typing.rb +373 -0
- data/lib/ast/merge/region.rb +124 -0
- data/lib/ast/merge/region_detector_base.rb +114 -0
- data/lib/ast/merge/region_mergeable.rb +364 -0
- data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
- data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
- data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
- data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
- data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
- data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
- data/lib/ast/merge/rspec/shared_examples.rb +26 -0
- data/lib/ast/merge/rspec.rb +4 -0
- data/lib/ast/merge/section_typing.rb +303 -0
- data/lib/ast/merge/smart_merger_base.rb +417 -0
- data/lib/ast/merge/text/conflict_resolver.rb +161 -0
- data/lib/ast/merge/text/file_analysis.rb +168 -0
- data/lib/ast/merge/text/line_node.rb +142 -0
- data/lib/ast/merge/text/merge_result.rb +42 -0
- data/lib/ast/merge/text/section.rb +93 -0
- data/lib/ast/merge/text/section_splitter.rb +397 -0
- data/lib/ast/merge/text/smart_merger.rb +141 -0
- data/lib/ast/merge/text/word_node.rb +86 -0
- data/lib/ast/merge/text.rb +35 -0
- data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
- data/lib/ast/merge/version.rb +12 -0
- data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
- data/lib/ast/merge.rb +165 -0
- data/lib/ast-merge.rb +4 -0
- data/sig/ast/merge.rbs +195 -0
- data.tar.gz.sig +0 -0
- metadata +326 -0
- 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,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
|