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,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared examples for validating FileAnalyzable integration
4
+ #
5
+ # Usage in your spec:
6
+ # require "ast/merge/rspec/shared_examples/file_analyzable"
7
+ #
8
+ # RSpec.describe MyMerge::FileAnalysis do
9
+ # it_behaves_like "Ast::Merge::FileAnalyzable" do
10
+ # let(:file_analysis_class) { MyMerge::FileAnalysis }
11
+ # let(:freeze_node_class) { MyMerge::FreezeNode }
12
+ # let(:sample_source) { "# Some valid source for this parser" }
13
+ # let(:sample_source_with_freeze) do
14
+ # <<~SOURCE
15
+ # # Some source
16
+ # # my-merge:freeze
17
+ # frozen content
18
+ # # my-merge:unfreeze
19
+ # # More source
20
+ # SOURCE
21
+ # end
22
+ # # Factory to create file analysis instance
23
+ # let(:build_file_analysis) { ->(source, **opts) { file_analysis_class.new(source, **opts) } }
24
+ # end
25
+ # end
26
+ #
27
+ # @note The extending class should include Ast::Merge::FileAnalyzable
28
+
29
+ RSpec.shared_examples("Ast::Merge::FileAnalyzable") do
30
+ # Required let blocks:
31
+ # - file_analysis_class: The class under test (e.g., MyMerge::FileAnalysis)
32
+ # - freeze_node_class: The freeze node class (e.g., MyMerge::FreezeNode)
33
+ # - sample_source: A valid source string for this parser
34
+ # - sample_source_with_freeze: Source containing a freeze block
35
+ # - build_file_analysis: Lambda that creates a file analysis instance
36
+
37
+ describe "module inclusion" do
38
+ it "includes Ast::Merge::FileAnalyzable" do
39
+ expect(file_analysis_class.ancestors).to(include(Ast::Merge::FileAnalyzable))
40
+ end
41
+ end
42
+
43
+ describe "attr_readers from FileAnalyzable" do
44
+ let(:analysis) { build_file_analysis.call(sample_source) }
45
+
46
+ it "has #source reader" do
47
+ expect(analysis).to(respond_to(:source))
48
+ end
49
+
50
+ it "has #lines reader" do
51
+ expect(analysis).to(respond_to(:lines))
52
+ end
53
+
54
+ it "has #freeze_token reader" do
55
+ expect(analysis).to(respond_to(:freeze_token))
56
+ end
57
+
58
+ it "has #signature_generator reader" do
59
+ expect(analysis).to(respond_to(:signature_generator))
60
+ end
61
+
62
+ it "has #statements reader" do
63
+ expect(analysis).to(respond_to(:statements))
64
+ end
65
+ end
66
+
67
+ describe "#freeze_blocks" do
68
+ context "without freeze blocks" do
69
+ let(:analysis) { build_file_analysis.call(sample_source) }
70
+
71
+ it "returns an empty array" do
72
+ expect(analysis.freeze_blocks).to(eq([]))
73
+ end
74
+ end
75
+
76
+ context "with freeze blocks" do
77
+ let(:analysis) { build_file_analysis.call(sample_source_with_freeze) }
78
+
79
+ it "returns an array of FreezeNode instances" do
80
+ expect(analysis.freeze_blocks).to(be_an(Array))
81
+ analysis.freeze_blocks.each do |block|
82
+ expect(block).to(be_a(freeze_node_class))
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ describe "#in_freeze_block?" do
89
+ let(:analysis) { build_file_analysis.call(sample_source_with_freeze) }
90
+
91
+ it "returns false for lines outside freeze blocks" do
92
+ expect(analysis.in_freeze_block?(1)).to(be(false))
93
+ end
94
+
95
+ it "responds to the method" do
96
+ expect(analysis).to(respond_to(:in_freeze_block?))
97
+ end
98
+ end
99
+
100
+ describe "#freeze_block_at" do
101
+ let(:analysis) { build_file_analysis.call(sample_source_with_freeze) }
102
+
103
+ it "returns nil for lines outside freeze blocks" do
104
+ expect(analysis.freeze_block_at(1)).to(be_nil)
105
+ end
106
+
107
+ it "responds to the method" do
108
+ expect(analysis).to(respond_to(:freeze_block_at))
109
+ end
110
+ end
111
+
112
+ describe "#signature_at" do
113
+ let(:analysis) { build_file_analysis.call(sample_source) }
114
+
115
+ it "returns nil for invalid index" do
116
+ expect(analysis.signature_at(-1)).to(be_nil)
117
+ expect(analysis.signature_at(9999)).to(be_nil)
118
+ end
119
+
120
+ it "responds to the method" do
121
+ expect(analysis).to(respond_to(:signature_at))
122
+ end
123
+ end
124
+
125
+ describe "#line_at" do
126
+ let(:analysis) { build_file_analysis.call(sample_source) }
127
+
128
+ it "returns nil for line 0" do
129
+ expect(analysis.line_at(0)).to(be_nil)
130
+ end
131
+
132
+ it "returns nil for negative line" do
133
+ expect(analysis.line_at(-1)).to(be_nil)
134
+ end
135
+
136
+ it "responds to the method" do
137
+ expect(analysis).to(respond_to(:line_at))
138
+ end
139
+ end
140
+
141
+ describe "#normalized_line" do
142
+ let(:analysis) { build_file_analysis.call(sample_source) }
143
+
144
+ it "responds to the method" do
145
+ expect(analysis).to(respond_to(:normalized_line))
146
+ end
147
+ end
148
+
149
+ describe "#generate_signature" do
150
+ let(:analysis) { build_file_analysis.call(sample_source) }
151
+
152
+ it "responds to the method" do
153
+ expect(analysis).to(respond_to(:generate_signature))
154
+ end
155
+
156
+ context "with NodeTyping::Wrapper" do
157
+ let(:analysis) { build_file_analysis.call(sample_source) }
158
+
159
+ it "unwraps NodeTyping::Wrapper and computes signature from underlying node" do
160
+ # Skip if no statements to test with
161
+ skip "No statements available for testing" if analysis.statements.empty?
162
+
163
+ node = analysis.statements.first
164
+
165
+ # Create a NodeTyping::Wrapper around the node
166
+ wrapper = Ast::Merge::NodeTyping::Wrapper.new(node, :test_type)
167
+
168
+ # Create a signature generator that returns the wrapper
169
+ wrapped_analysis = build_file_analysis.call(
170
+ sample_source,
171
+ signature_generator: ->(_n) { wrapper },
172
+ )
173
+
174
+ # The signature should be computed from the unwrapped node, not the wrapper itself
175
+ # Get the expected signature from the unwrapped node
176
+ expected_sig = analysis.generate_signature(node)
177
+
178
+ # The wrapped analysis should produce the same signature
179
+ actual_sig = wrapped_analysis.generate_signature(node)
180
+
181
+ expect(actual_sig).to(eq(expected_sig))
182
+ expect(actual_sig).not_to(be_a(Ast::Merge::NodeTyping::Wrapper))
183
+ end
184
+
185
+ it "recognizes NodeTyping::Wrapper in fallthrough_node?" do
186
+ node = analysis.statements.first || {type: :test}
187
+ wrapper = Ast::Merge::NodeTyping::Wrapper.new(node, :test_type)
188
+
189
+ expect(analysis.send(:fallthrough_node?, wrapper)).to(be(true))
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared examples for validating FreezeNodeBase integration
4
+ #
5
+ # Usage in your spec:
6
+ # require "ast/merge/rspec/shared_examples/freeze_node_base"
7
+ #
8
+ # RSpec.describe MyMerge::FreezeNode do
9
+ # it_behaves_like "Ast::Merge::FreezeNodeBase" do
10
+ # let(:freeze_node_class) { MyMerge::FreezeNode }
11
+ # let(:default_pattern_type) { :hash_comment }
12
+ # # Factory to create a freeze node instance
13
+ # let(:build_freeze_node) do
14
+ # ->(start_line:, end_line:, **opts) {
15
+ # freeze_node_class.new(start_line: start_line, end_line: end_line, **opts)
16
+ # }
17
+ # end
18
+ # end
19
+ # end
20
+ #
21
+ # @note The extending class should inherit from or behave like Ast::Merge::FreezeNodeBase
22
+
23
+ RSpec.shared_examples("Ast::Merge::FreezeNodeBase") do
24
+ # Required let blocks:
25
+ # - freeze_node_class: The class under test (e.g., MyMerge::FreezeNode)
26
+ # - default_pattern_type: The default pattern type (:hash_comment, etc.)
27
+ # - build_freeze_node: Lambda that creates a freeze node instance
28
+
29
+ describe "class methods" do
30
+ describe ".pattern_for" do
31
+ it "returns a hash with :start and :end keys" do
32
+ pattern = freeze_node_class.pattern_for(:hash_comment)
33
+ expect(pattern).to(be_a(Hash))
34
+ expect(pattern).to(have_key(:start))
35
+ expect(pattern).to(have_key(:end))
36
+ end
37
+
38
+ it "returns Regexp patterns" do
39
+ pattern = freeze_node_class.pattern_for(:hash_comment)
40
+ expect(pattern[:start]).to(be_a(Regexp))
41
+ expect(pattern[:end]).to(be_a(Regexp))
42
+ end
43
+
44
+ it "raises ArgumentError for unknown pattern type" do
45
+ expect { freeze_node_class.pattern_for(:unknown_pattern) }
46
+ .to(raise_error(ArgumentError, /unknown pattern type/i))
47
+ end
48
+ end
49
+
50
+ describe ".start_pattern" do
51
+ it "returns a Regexp" do
52
+ expect(freeze_node_class.start_pattern).to(be_a(Regexp))
53
+ end
54
+
55
+ it "accepts pattern_type argument" do
56
+ expect(freeze_node_class.start_pattern(:hash_comment)).to(be_a(Regexp))
57
+ end
58
+ end
59
+
60
+ describe ".end_pattern" do
61
+ it "returns a Regexp" do
62
+ expect(freeze_node_class.end_pattern).to(be_a(Regexp))
63
+ end
64
+
65
+ it "accepts pattern_type argument" do
66
+ expect(freeze_node_class.end_pattern(:hash_comment)).to(be_a(Regexp))
67
+ end
68
+ end
69
+
70
+ describe ".freeze_start?" do
71
+ it "returns true for valid freeze start markers" do
72
+ expect(freeze_node_class.freeze_start?("# token:freeze")).to(be(true))
73
+ expect(freeze_node_class.freeze_start?(" # my-merge:freeze")).to(be(true))
74
+ end
75
+
76
+ it "returns false for invalid markers" do
77
+ expect(freeze_node_class.freeze_start?("not a marker")).to(be(false))
78
+ expect(freeze_node_class.freeze_start?("# unfreeze")).to(be(false))
79
+ end
80
+
81
+ it "returns false for nil" do
82
+ expect(freeze_node_class.freeze_start?(nil)).to(be(false))
83
+ end
84
+ end
85
+
86
+ describe ".freeze_end?" do
87
+ it "returns true for valid freeze end markers" do
88
+ expect(freeze_node_class.freeze_end?("# token:unfreeze")).to(be(true))
89
+ expect(freeze_node_class.freeze_end?(" # my-merge:unfreeze")).to(be(true))
90
+ end
91
+
92
+ it "returns false for invalid markers" do
93
+ expect(freeze_node_class.freeze_end?("not a marker")).to(be(false))
94
+ expect(freeze_node_class.freeze_end?("# freeze")).to(be(false))
95
+ end
96
+
97
+ it "returns false for nil" do
98
+ expect(freeze_node_class.freeze_end?(nil)).to(be(false))
99
+ end
100
+ end
101
+
102
+ describe ".pattern_types" do
103
+ it "returns an array of symbols" do
104
+ types = freeze_node_class.pattern_types
105
+ expect(types).to(be_an(Array))
106
+ expect(types).to(all(be_a(Symbol)))
107
+ end
108
+
109
+ it "includes :hash_comment" do
110
+ expect(freeze_node_class.pattern_types).to(include(:hash_comment))
111
+ end
112
+ end
113
+ end
114
+
115
+ describe "instance methods" do
116
+ let(:freeze_node) { build_freeze_node.call(start_line: 5, end_line: 10) }
117
+
118
+ describe "#start_line" do
119
+ it "returns the start line number" do
120
+ expect(freeze_node.start_line).to(eq(5))
121
+ end
122
+ end
123
+
124
+ describe "#end_line" do
125
+ it "returns the end line number" do
126
+ expect(freeze_node.end_line).to(eq(10))
127
+ end
128
+ end
129
+
130
+ describe "#location" do
131
+ it "returns a location object" do
132
+ expect(freeze_node.location).to(respond_to(:start_line))
133
+ expect(freeze_node.location).to(respond_to(:end_line))
134
+ end
135
+
136
+ it "has correct line numbers" do
137
+ expect(freeze_node.location.start_line).to(eq(5))
138
+ expect(freeze_node.location.end_line).to(eq(10))
139
+ end
140
+ end
141
+
142
+ describe "#freeze_node?" do
143
+ it "returns true" do
144
+ expect(freeze_node.freeze_node?).to(be(true))
145
+ end
146
+ end
147
+
148
+ describe "#signature" do
149
+ it "returns an Array" do
150
+ expect(freeze_node.signature).to(be_an(Array))
151
+ end
152
+
153
+ it "starts with :FreezeNode" do
154
+ expect(freeze_node.signature.first).to(eq(:FreezeNode))
155
+ end
156
+ end
157
+
158
+ describe "#inspect" do
159
+ it "returns a string representation" do
160
+ expect(freeze_node.inspect).to(be_a(String))
161
+ expect(freeze_node.inspect).to(include("5"))
162
+ expect(freeze_node.inspect).to(include("10"))
163
+ end
164
+ end
165
+
166
+ describe "#pattern_type" do
167
+ it "returns the pattern type" do
168
+ expect(freeze_node.pattern_type).to(be_a(Symbol))
169
+ end
170
+ end
171
+ end
172
+
173
+ describe "InvalidStructureError" do
174
+ it "is defined" do
175
+ expect(freeze_node_class::InvalidStructureError).to(be_a(Class))
176
+ end
177
+
178
+ it "is a StandardError" do
179
+ expect(freeze_node_class::InvalidStructureError.ancestors).to(include(StandardError))
180
+ end
181
+
182
+ it "accepts start_line, end_line, and unclosed_nodes" do
183
+ error = freeze_node_class::InvalidStructureError.new(
184
+ "test error",
185
+ start_line: 1,
186
+ end_line: 10,
187
+ unclosed_nodes: [],
188
+ )
189
+ expect(error.start_line).to(eq(1))
190
+ expect(error.end_line).to(eq(10))
191
+ expect(error.unclosed_nodes).to(eq([]))
192
+ end
193
+ end
194
+
195
+ describe "Location struct" do
196
+ let(:location) { freeze_node_class::Location.new(5, 10) }
197
+
198
+ it "has start_line" do
199
+ expect(location.start_line).to(eq(5))
200
+ end
201
+
202
+ it "has end_line" do
203
+ expect(location.end_line).to(eq(10))
204
+ end
205
+
206
+ it "responds to cover?" do
207
+ expect(location).to(respond_to(:cover?))
208
+ end
209
+
210
+ it "#cover? returns true for lines within range" do
211
+ expect(location.cover?(7)).to(be(true))
212
+ end
213
+
214
+ it "#cover? returns false for lines outside range" do
215
+ expect(location.cover?(3)).to(be(false))
216
+ expect(location.cover?(15)).to(be(false))
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared examples for validating MergeResultBase integration
4
+ #
5
+ # Usage in your spec:
6
+ # require "ast/merge/rspec/shared_examples/merge_result_base"
7
+ #
8
+ # RSpec.describe MyMerge::MergeResult do
9
+ # it_behaves_like "Ast::Merge::MergeResultBase" do
10
+ # let(:merge_result_class) { MyMerge::MergeResult }
11
+ # # Factory to create a merge result instance
12
+ # let(:build_merge_result) { -> { merge_result_class.new } }
13
+ # end
14
+ # end
15
+ #
16
+ # @note The extending class should inherit from or behave like Ast::Merge::MergeResultBase
17
+
18
+ RSpec.shared_examples("Ast::Merge::MergeResultBase") do
19
+ # Required let blocks:
20
+ # - merge_result_class: The class under test
21
+ # - build_merge_result: Lambda that creates a merge result instance
22
+
23
+ describe "decision constants" do
24
+ it "has DECISION_KEPT_TEMPLATE" do
25
+ expect(merge_result_class::DECISION_KEPT_TEMPLATE).to(eq(:kept_template))
26
+ end
27
+
28
+ it "has DECISION_KEPT_DEST" do
29
+ expect(merge_result_class::DECISION_KEPT_DEST).to(eq(:kept_destination))
30
+ end
31
+
32
+ it "has DECISION_MERGED" do
33
+ expect(merge_result_class::DECISION_MERGED).to(eq(:merged))
34
+ end
35
+
36
+ it "has DECISION_ADDED" do
37
+ expect(merge_result_class::DECISION_ADDED).to(eq(:added))
38
+ end
39
+
40
+ it "has DECISION_FREEZE_BLOCK" do
41
+ expect(merge_result_class::DECISION_FREEZE_BLOCK).to(eq(:freeze_block))
42
+ end
43
+
44
+ it "has DECISION_REPLACED" do
45
+ expect(merge_result_class::DECISION_REPLACED).to(eq(:replaced))
46
+ end
47
+
48
+ it "has DECISION_APPENDED" do
49
+ expect(merge_result_class::DECISION_APPENDED).to(eq(:appended))
50
+ end
51
+ end
52
+
53
+ describe "instance methods" do
54
+ let(:result) { build_merge_result.call }
55
+
56
+ describe "#lines" do
57
+ it "returns an Array" do
58
+ expect(result.lines).to(be_an(Array))
59
+ end
60
+
61
+ it "is initially empty" do
62
+ expect(result.lines).to(be_empty)
63
+ end
64
+ end
65
+
66
+ describe "#decisions" do
67
+ it "returns an Array" do
68
+ expect(result.decisions).to(be_an(Array))
69
+ end
70
+
71
+ it "is initially empty" do
72
+ expect(result.decisions).to(be_empty)
73
+ end
74
+ end
75
+
76
+ describe "#empty?" do
77
+ it "returns true when no lines" do
78
+ expect(result.empty?).to(be(true))
79
+ end
80
+ end
81
+
82
+ describe "#line_count" do
83
+ it "returns 0 for empty result" do
84
+ expect(result.line_count).to(eq(0))
85
+ end
86
+ end
87
+
88
+ describe "#decision_summary" do
89
+ it "returns a Hash" do
90
+ expect(result.decision_summary).to(be_a(Hash))
91
+ end
92
+
93
+ it "returns empty hash for empty result" do
94
+ expect(result.decision_summary).to(eq({}))
95
+ end
96
+ end
97
+
98
+ describe "#inspect" do
99
+ it "returns a string representation" do
100
+ expect(result.inspect).to(be_a(String))
101
+ expect(result.inspect).to(include("lines="))
102
+ expect(result.inspect).to(include("decisions="))
103
+ end
104
+ end
105
+ end
106
+ end