rbs-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 +936 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/rbs/merge/conflict_resolver.rb +99 -0
- data/lib/rbs/merge/debug_logger.rb +24 -0
- data/lib/rbs/merge/file_aligner.rb +148 -0
- data/lib/rbs/merge/file_analysis.rb +264 -0
- data/lib/rbs/merge/freeze_node.rb +117 -0
- data/lib/rbs/merge/merge_result.rb +171 -0
- data/lib/rbs/merge/smart_merger.rb +266 -0
- data/lib/rbs/merge/version.rb +12 -0
- data/lib/rbs/merge.rb +82 -0
- data/lib/rbs-merge.rb +4 -0
- data/sig/rbs/merge.rbs +203 -0
- data.tar.gz.sig +0 -0
- metadata +318 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbs
|
|
4
|
+
module Merge
|
|
5
|
+
# Wrapper to represent freeze blocks as first-class nodes in RBS files.
|
|
6
|
+
# A freeze block is a section marked with freeze/unfreeze comment markers that
|
|
7
|
+
# should be preserved from the destination during merges.
|
|
8
|
+
#
|
|
9
|
+
# Inherits from Ast::Merge::FreezeNodeBase for shared functionality including
|
|
10
|
+
# the Location struct, InvalidStructureError, and configurable marker patterns.
|
|
11
|
+
#
|
|
12
|
+
# Uses the `:hash_comment` pattern type by default for RBS type signature files.
|
|
13
|
+
#
|
|
14
|
+
# @example Freeze block in RBS
|
|
15
|
+
# # rbs-merge:freeze
|
|
16
|
+
# # Custom type definitions
|
|
17
|
+
# type custom_config = { key: String, value: untyped }
|
|
18
|
+
# # rbs-merge:unfreeze
|
|
19
|
+
#
|
|
20
|
+
# @example Freeze block with reason
|
|
21
|
+
# # rbs-merge:freeze Project-specific types
|
|
22
|
+
# type project_result = success | failure
|
|
23
|
+
# # rbs-merge:unfreeze
|
|
24
|
+
class FreezeNode < Ast::Merge::FreezeNodeBase
|
|
25
|
+
# Inherit InvalidStructureError from base class
|
|
26
|
+
InvalidStructureError = Ast::Merge::FreezeNodeBase::InvalidStructureError
|
|
27
|
+
|
|
28
|
+
# Inherit Location from base class
|
|
29
|
+
Location = Ast::Merge::FreezeNodeBase::Location
|
|
30
|
+
|
|
31
|
+
# @param start_line [Integer] Line number of freeze marker
|
|
32
|
+
# @param end_line [Integer] Line number of unfreeze marker
|
|
33
|
+
# @param analysis [FileAnalysis] The file analysis containing this block
|
|
34
|
+
# @param nodes [Array<RBS::AST::Declarations::Base, RBS::AST::Members::Base>] Nodes fully contained within the freeze block
|
|
35
|
+
# @param overlapping_nodes [Array] All nodes that overlap with freeze block (for validation)
|
|
36
|
+
# @param start_marker [String, nil] The freeze start marker text
|
|
37
|
+
# @param end_marker [String, nil] The freeze end marker text
|
|
38
|
+
# @param pattern_type [Symbol] Pattern type for marker matching (defaults to :hash_comment)
|
|
39
|
+
def initialize(start_line:, end_line:, analysis:, nodes: [], overlapping_nodes: nil, start_marker: nil, end_marker: nil, pattern_type: Ast::Merge::FreezeNodeBase::DEFAULT_PATTERN)
|
|
40
|
+
super(
|
|
41
|
+
start_line: start_line,
|
|
42
|
+
end_line: end_line,
|
|
43
|
+
analysis: analysis,
|
|
44
|
+
nodes: nodes,
|
|
45
|
+
overlapping_nodes: overlapping_nodes || nodes,
|
|
46
|
+
start_marker: start_marker,
|
|
47
|
+
end_marker: end_marker,
|
|
48
|
+
pattern_type: pattern_type
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Validate structure
|
|
52
|
+
validate_structure!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns a stable signature for this freeze block
|
|
56
|
+
# Signature includes the normalized content to detect changes
|
|
57
|
+
# @return [Array] Signature array
|
|
58
|
+
def signature
|
|
59
|
+
normalized = (@start_line..@end_line).map do |ln|
|
|
60
|
+
@analysis.normalized_line(ln)
|
|
61
|
+
end.compact.join("\n")
|
|
62
|
+
|
|
63
|
+
[:FreezeNode, normalized]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# String representation for debugging
|
|
67
|
+
# @return [String]
|
|
68
|
+
def inspect
|
|
69
|
+
"#<Rbs::Merge::FreezeNode lines=#{@start_line}..#{@end_line} nodes=#{@nodes.length}>"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Validate that the freeze block has proper structure:
|
|
75
|
+
# - All nodes must be either fully contained or fully outside
|
|
76
|
+
# - No partial overlaps allowed
|
|
77
|
+
def validate_structure!
|
|
78
|
+
unclosed = []
|
|
79
|
+
|
|
80
|
+
@overlapping_nodes.each do |node|
|
|
81
|
+
node_start = node.location.start_line
|
|
82
|
+
node_end = node.location.end_line
|
|
83
|
+
|
|
84
|
+
# Check if node is fully contained (valid)
|
|
85
|
+
fully_contained = node_start >= @start_line && node_end <= @end_line
|
|
86
|
+
|
|
87
|
+
# Check if node completely encompasses the freeze block
|
|
88
|
+
# This is valid for container nodes like classes/modules
|
|
89
|
+
encompasses = node_start < @start_line && node_end > @end_line
|
|
90
|
+
|
|
91
|
+
# Check if node is fully outside (valid)
|
|
92
|
+
fully_outside = node_end < @start_line || node_start > @end_line
|
|
93
|
+
|
|
94
|
+
# If none of the above, it's a partial overlap (invalid)
|
|
95
|
+
next if fully_contained || encompasses || fully_outside
|
|
96
|
+
|
|
97
|
+
unclosed << node
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return if unclosed.empty?
|
|
101
|
+
|
|
102
|
+
node_names = unclosed.map do |n|
|
|
103
|
+
name = n.respond_to?(:name) ? n.name.to_s : n.class.name.split("::").last
|
|
104
|
+
"#{name} (lines #{n.location.start_line}-#{n.location.end_line})"
|
|
105
|
+
end.join(", ")
|
|
106
|
+
|
|
107
|
+
raise InvalidStructureError.new(
|
|
108
|
+
"Freeze block at lines #{@start_line}-#{@end_line} has partial overlap with: #{node_names}. " \
|
|
109
|
+
"Freeze blocks must fully contain declarations or be fully contained within them.",
|
|
110
|
+
start_line: @start_line,
|
|
111
|
+
end_line: @end_line,
|
|
112
|
+
unclosed_nodes: unclosed,
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbs
|
|
4
|
+
module Merge
|
|
5
|
+
# Result container for RBS file merge operations.
|
|
6
|
+
# Inherits from Ast::Merge::MergeResultBase for shared functionality.
|
|
7
|
+
#
|
|
8
|
+
# Tracks merged content, decisions made during merge, and provides
|
|
9
|
+
# methods to reconstruct the final merged RBS file.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# result = MergeResult.new(template_analysis, dest_analysis)
|
|
13
|
+
# result.add_from_template(0)
|
|
14
|
+
# result.add_from_destination(1)
|
|
15
|
+
# merged_content = result.to_s
|
|
16
|
+
#
|
|
17
|
+
# @see Ast::Merge::MergeResultBase
|
|
18
|
+
class MergeResult < Ast::Merge::MergeResultBase
|
|
19
|
+
# Decision indicating content was preserved from a freeze block
|
|
20
|
+
# @return [Symbol]
|
|
21
|
+
DECISION_FREEZE_BLOCK = :freeze_block
|
|
22
|
+
|
|
23
|
+
# Decision indicating content came from the template
|
|
24
|
+
# @return [Symbol]
|
|
25
|
+
DECISION_TEMPLATE = :template
|
|
26
|
+
|
|
27
|
+
# Decision indicating content came from the destination (customization preserved)
|
|
28
|
+
# @return [Symbol]
|
|
29
|
+
DECISION_DESTINATION = :destination
|
|
30
|
+
|
|
31
|
+
# Decision indicating content was added from template (new in template)
|
|
32
|
+
# @return [Symbol]
|
|
33
|
+
DECISION_ADDED = :added
|
|
34
|
+
|
|
35
|
+
# Decision indicating content was recursively merged
|
|
36
|
+
# @return [Symbol]
|
|
37
|
+
DECISION_RECURSIVE = :recursive
|
|
38
|
+
|
|
39
|
+
# Initialize a new merge result
|
|
40
|
+
# @param template_analysis [FileAnalysis] Analysis of the template file
|
|
41
|
+
# @param dest_analysis [FileAnalysis] Analysis of the destination file
|
|
42
|
+
def initialize(template_analysis, dest_analysis)
|
|
43
|
+
super(template_analysis: template_analysis, dest_analysis: dest_analysis)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Add content from the template at the given statement index
|
|
47
|
+
# @param index [Integer] Statement index in template
|
|
48
|
+
# @param decision [Symbol] Decision type (default: DECISION_TEMPLATE)
|
|
49
|
+
# @return [void]
|
|
50
|
+
def add_from_template(index, decision: DECISION_TEMPLATE)
|
|
51
|
+
statement = @template_analysis.statements[index]
|
|
52
|
+
return unless statement
|
|
53
|
+
|
|
54
|
+
lines = extract_lines(statement, @template_analysis)
|
|
55
|
+
@lines.concat(lines)
|
|
56
|
+
@decisions << {decision: decision, source: :template, index: index, lines: lines.length}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Add content from the destination at the given statement index
|
|
60
|
+
# @param index [Integer] Statement index in destination
|
|
61
|
+
# @param decision [Symbol] Decision type (default: DECISION_DESTINATION)
|
|
62
|
+
# @return [void]
|
|
63
|
+
def add_from_destination(index, decision: DECISION_DESTINATION)
|
|
64
|
+
statement = @dest_analysis.statements[index]
|
|
65
|
+
return unless statement
|
|
66
|
+
|
|
67
|
+
lines = extract_lines(statement, @dest_analysis)
|
|
68
|
+
@lines.concat(lines)
|
|
69
|
+
@decisions << {decision: decision, source: :destination, index: index, lines: lines.length}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add content from a freeze block
|
|
73
|
+
# @param freeze_node [FreezeNode] The freeze block to add
|
|
74
|
+
# @return [void]
|
|
75
|
+
def add_freeze_block(freeze_node)
|
|
76
|
+
lines = (freeze_node.start_line..freeze_node.end_line).map do |ln|
|
|
77
|
+
@dest_analysis.line_at(ln)
|
|
78
|
+
end
|
|
79
|
+
@lines.concat(lines)
|
|
80
|
+
@decisions << {
|
|
81
|
+
decision: DECISION_FREEZE_BLOCK,
|
|
82
|
+
source: :destination,
|
|
83
|
+
start_line: freeze_node.start_line,
|
|
84
|
+
end_line: freeze_node.end_line,
|
|
85
|
+
lines: lines.length,
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Add recursively merged content
|
|
90
|
+
# @param merged_content [String] The merged content string
|
|
91
|
+
# @param template_index [Integer] Template statement index
|
|
92
|
+
# @param dest_index [Integer] Destination statement index
|
|
93
|
+
# @return [void]
|
|
94
|
+
def add_recursive_merge(merged_content, template_index:, dest_index:)
|
|
95
|
+
# Split without trailing newlines for consistency with other methods
|
|
96
|
+
lines = merged_content.split("\n", -1)
|
|
97
|
+
# Remove trailing empty element if content ended with newline
|
|
98
|
+
lines.pop if lines.last == ""
|
|
99
|
+
@lines.concat(lines)
|
|
100
|
+
@decisions << {
|
|
101
|
+
decision: DECISION_RECURSIVE,
|
|
102
|
+
source: :merged,
|
|
103
|
+
template_index: template_index,
|
|
104
|
+
dest_index: dest_index,
|
|
105
|
+
lines: lines.length,
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add raw content lines
|
|
110
|
+
# @param lines [Array<String>] Lines to add
|
|
111
|
+
# @param decision [Symbol] Decision type
|
|
112
|
+
# @return [void]
|
|
113
|
+
def add_raw(lines, decision:)
|
|
114
|
+
@lines.concat(lines)
|
|
115
|
+
@decisions << {decision: decision, source: :raw, lines: lines.length}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Convert the merged result to a string
|
|
119
|
+
# @return [String] The merged RBS content
|
|
120
|
+
def to_s
|
|
121
|
+
return "" if @lines.empty?
|
|
122
|
+
|
|
123
|
+
# Lines are stored without trailing newlines, so join with newlines
|
|
124
|
+
result = @lines.join("\n")
|
|
125
|
+
# Ensure file ends with newline if content is non-empty
|
|
126
|
+
result += "\n" unless result.end_with?("\n")
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if any content has been added
|
|
131
|
+
# @return [Boolean]
|
|
132
|
+
def empty?
|
|
133
|
+
@lines.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Get summary of merge decisions
|
|
137
|
+
# @return [Hash] Summary with counts by decision type
|
|
138
|
+
def summary
|
|
139
|
+
counts = @decisions.group_by { |d| d[:decision] }.transform_values(&:count)
|
|
140
|
+
{
|
|
141
|
+
total_decisions: @decisions.length,
|
|
142
|
+
total_lines: @lines.length,
|
|
143
|
+
by_decision: counts,
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Extract lines for a statement from analysis
|
|
150
|
+
# @param statement [Object] The statement (declaration, member, or FreezeNode)
|
|
151
|
+
# @param analysis [FileAnalysis] The file analysis
|
|
152
|
+
# @return [Array<String>] Lines for the statement
|
|
153
|
+
def extract_lines(statement, analysis)
|
|
154
|
+
if statement.is_a?(FreezeNode)
|
|
155
|
+
(statement.start_line..statement.end_line).map { |ln| analysis.line_at(ln) }
|
|
156
|
+
else
|
|
157
|
+
start_line = statement.location.start_line
|
|
158
|
+
end_line = statement.location.end_line
|
|
159
|
+
|
|
160
|
+
# Include leading comment if present
|
|
161
|
+
if statement.respond_to?(:comment) && statement.comment
|
|
162
|
+
comment_start = statement.comment.location.start_line
|
|
163
|
+
start_line = comment_start if comment_start < start_line
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
(start_line..end_line).map { |ln| analysis.line_at(ln) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbs
|
|
4
|
+
module Merge
|
|
5
|
+
# Orchestrates the smart merge process for RBS type signature files.
|
|
6
|
+
# Uses FileAnalysis, FileAligner, ConflictResolver, and MergeResult to
|
|
7
|
+
# merge two RBS files intelligently.
|
|
8
|
+
#
|
|
9
|
+
# SmartMerger provides flexible configuration for different merge scenarios.
|
|
10
|
+
# When matching class or module definitions are found in both files, the merger
|
|
11
|
+
# can perform recursive merging of their members.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic merge (destination customizations preserved)
|
|
14
|
+
# merger = SmartMerger.new(template_content, dest_content)
|
|
15
|
+
# result = merger.merge
|
|
16
|
+
#
|
|
17
|
+
# @example Template updates win
|
|
18
|
+
# merger = SmartMerger.new(
|
|
19
|
+
# template_content,
|
|
20
|
+
# dest_content,
|
|
21
|
+
# preference: :template,
|
|
22
|
+
# add_template_only_nodes: true
|
|
23
|
+
# )
|
|
24
|
+
# result = merger.merge
|
|
25
|
+
#
|
|
26
|
+
# @example Custom signature matching
|
|
27
|
+
# sig_gen = ->(node) { [:decl, node.name.to_s] }
|
|
28
|
+
# merger = SmartMerger.new(
|
|
29
|
+
# template_content,
|
|
30
|
+
# dest_content,
|
|
31
|
+
# signature_generator: sig_gen
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# @see FileAnalysis
|
|
35
|
+
# @see FileAligner
|
|
36
|
+
# @see ConflictResolver
|
|
37
|
+
# @see MergeResult
|
|
38
|
+
class SmartMerger
|
|
39
|
+
# @return [FileAnalysis] Analysis of the template file
|
|
40
|
+
attr_reader :template_analysis
|
|
41
|
+
|
|
42
|
+
# @return [FileAnalysis] Analysis of the destination file
|
|
43
|
+
attr_reader :dest_analysis
|
|
44
|
+
|
|
45
|
+
# @return [FileAligner] Aligner for finding matches and differences
|
|
46
|
+
attr_reader :aligner
|
|
47
|
+
|
|
48
|
+
# @return [ConflictResolver] Resolver for handling conflicting content
|
|
49
|
+
attr_reader :resolver
|
|
50
|
+
|
|
51
|
+
# @return [MergeResult] Result object tracking merged content
|
|
52
|
+
attr_reader :result
|
|
53
|
+
|
|
54
|
+
# Creates a new SmartMerger for intelligent RBS file merging.
|
|
55
|
+
#
|
|
56
|
+
# @param template_content [String] Template RBS source code
|
|
57
|
+
# @param dest_content [String] Destination RBS source code
|
|
58
|
+
#
|
|
59
|
+
# @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
|
|
60
|
+
# The proc receives an RBS declaration and should return one of:
|
|
61
|
+
# - An array representing the node's signature
|
|
62
|
+
# - `nil` to indicate the node should have no signature
|
|
63
|
+
# - The original node to fall through to default signature computation
|
|
64
|
+
#
|
|
65
|
+
# @param preference [Symbol] Controls which version to use when nodes
|
|
66
|
+
# have matching signatures but different content:
|
|
67
|
+
# - `:destination` (default) - Use destination version (preserves customizations)
|
|
68
|
+
# - `:template` - Use template version (applies updates)
|
|
69
|
+
#
|
|
70
|
+
# @param add_template_only_nodes [Boolean] Controls whether to add nodes that only
|
|
71
|
+
# exist in template:
|
|
72
|
+
# - `false` (default) - Skip template-only nodes
|
|
73
|
+
# - `true` - Add template-only nodes to result
|
|
74
|
+
#
|
|
75
|
+
# @param freeze_token [String] Token to use for freeze block markers.
|
|
76
|
+
# Default: "rbs-merge" (looks for # rbs-merge:freeze / # rbs-merge:unfreeze)
|
|
77
|
+
#
|
|
78
|
+
# @param max_recursion_depth [Integer, Float] Maximum depth for recursive body merging.
|
|
79
|
+
# Default: Float::INFINITY (no limit)
|
|
80
|
+
#
|
|
81
|
+
# @raise [TemplateParseError] If template has syntax errors
|
|
82
|
+
# @raise [DestinationParseError] If destination has syntax errors
|
|
83
|
+
def initialize(
|
|
84
|
+
template_content,
|
|
85
|
+
dest_content,
|
|
86
|
+
signature_generator: nil,
|
|
87
|
+
preference: :destination,
|
|
88
|
+
add_template_only_nodes: false,
|
|
89
|
+
freeze_token: FileAnalysis::DEFAULT_FREEZE_TOKEN,
|
|
90
|
+
max_recursion_depth: Float::INFINITY
|
|
91
|
+
)
|
|
92
|
+
@preference = preference
|
|
93
|
+
@add_template_only_nodes = add_template_only_nodes
|
|
94
|
+
@max_recursion_depth = max_recursion_depth
|
|
95
|
+
|
|
96
|
+
# Parse template
|
|
97
|
+
begin
|
|
98
|
+
@template_analysis = FileAnalysis.new(
|
|
99
|
+
template_content,
|
|
100
|
+
freeze_token: freeze_token,
|
|
101
|
+
signature_generator: signature_generator,
|
|
102
|
+
)
|
|
103
|
+
rescue RBS::ParsingError => e
|
|
104
|
+
raise TemplateParseError.new([e])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Parse destination
|
|
108
|
+
begin
|
|
109
|
+
@dest_analysis = FileAnalysis.new(
|
|
110
|
+
dest_content,
|
|
111
|
+
freeze_token: freeze_token,
|
|
112
|
+
signature_generator: signature_generator,
|
|
113
|
+
)
|
|
114
|
+
rescue RBS::ParsingError => e
|
|
115
|
+
raise DestinationParseError.new([e])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@aligner = FileAligner.new(@template_analysis, @dest_analysis)
|
|
119
|
+
@resolver = ConflictResolver.new(
|
|
120
|
+
preference: @preference,
|
|
121
|
+
template_analysis: @template_analysis,
|
|
122
|
+
dest_analysis: @dest_analysis,
|
|
123
|
+
)
|
|
124
|
+
@result = MergeResult.new(@template_analysis, @dest_analysis)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Perform the merge operation
|
|
128
|
+
#
|
|
129
|
+
# @return [String] The merged content as a string
|
|
130
|
+
def merge
|
|
131
|
+
merge_result.to_s
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Perform the merge operation and return the full result object
|
|
135
|
+
#
|
|
136
|
+
# @return [MergeResult] The merge result containing merged content
|
|
137
|
+
def merge_result
|
|
138
|
+
return @merge_result if @merge_result
|
|
139
|
+
|
|
140
|
+
@merge_result = DebugLogger.time("SmartMerger#merge") do
|
|
141
|
+
alignment = @aligner.align
|
|
142
|
+
|
|
143
|
+
DebugLogger.debug("Alignment complete", {
|
|
144
|
+
total_entries: alignment.size,
|
|
145
|
+
matches: alignment.count { |e| e[:type] == :match },
|
|
146
|
+
template_only: alignment.count { |e| e[:type] == :template_only },
|
|
147
|
+
dest_only: alignment.count { |e| e[:type] == :dest_only },
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
process_alignment(alignment)
|
|
151
|
+
@result
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# Process alignment entries and build result
|
|
158
|
+
# @param alignment [Array<Hash>] Alignment entries
|
|
159
|
+
# @return [void]
|
|
160
|
+
def process_alignment(alignment)
|
|
161
|
+
alignment.each do |entry|
|
|
162
|
+
case entry[:type]
|
|
163
|
+
when :match
|
|
164
|
+
process_match(entry)
|
|
165
|
+
when :template_only
|
|
166
|
+
process_template_only(entry)
|
|
167
|
+
when :dest_only
|
|
168
|
+
process_dest_only(entry)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Process a matched declaration pair
|
|
174
|
+
# @param entry [Hash] Alignment entry
|
|
175
|
+
# @return [void]
|
|
176
|
+
def process_match(entry)
|
|
177
|
+
resolution = @resolver.resolve(
|
|
178
|
+
entry[:template_decl],
|
|
179
|
+
entry[:dest_decl],
|
|
180
|
+
template_index: entry[:template_index],
|
|
181
|
+
dest_index: entry[:dest_index],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
case resolution[:source]
|
|
185
|
+
when :template
|
|
186
|
+
@result.add_from_template(entry[:template_index], decision: resolution[:decision])
|
|
187
|
+
when :destination
|
|
188
|
+
if entry[:dest_decl].is_a?(FreezeNode)
|
|
189
|
+
@result.add_freeze_block(entry[:dest_decl])
|
|
190
|
+
else
|
|
191
|
+
@result.add_from_destination(entry[:dest_index], decision: resolution[:decision])
|
|
192
|
+
end
|
|
193
|
+
when :recursive
|
|
194
|
+
process_recursive_merge(entry, resolution)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Process a template-only declaration
|
|
199
|
+
# @param entry [Hash] Alignment entry
|
|
200
|
+
# @return [void]
|
|
201
|
+
def process_template_only(entry)
|
|
202
|
+
return unless @add_template_only_nodes
|
|
203
|
+
|
|
204
|
+
@result.add_from_template(entry[:template_index], decision: MergeResult::DECISION_ADDED)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Process a destination-only declaration
|
|
208
|
+
# @param entry [Hash] Alignment entry
|
|
209
|
+
# @return [void]
|
|
210
|
+
def process_dest_only(entry)
|
|
211
|
+
if entry[:dest_decl].is_a?(FreezeNode)
|
|
212
|
+
@result.add_freeze_block(entry[:dest_decl])
|
|
213
|
+
else
|
|
214
|
+
@result.add_from_destination(entry[:dest_index], decision: MergeResult::DECISION_DESTINATION)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Process recursive merge for container declarations
|
|
219
|
+
# @param entry [Hash] Alignment entry
|
|
220
|
+
# @param resolution [Hash] Resolution info
|
|
221
|
+
# @return [void]
|
|
222
|
+
def process_recursive_merge(entry, resolution)
|
|
223
|
+
template_decl = resolution[:template_declaration]
|
|
224
|
+
dest_decl = resolution[:dest_declaration]
|
|
225
|
+
|
|
226
|
+
# For now, just use the destination version for complex recursive merges
|
|
227
|
+
# A full recursive implementation would merge members individually
|
|
228
|
+
merged_content = reconstruct_declaration_with_merged_members(
|
|
229
|
+
template_decl,
|
|
230
|
+
dest_decl,
|
|
231
|
+
entry[:template_index],
|
|
232
|
+
entry[:dest_index],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
@result.add_recursive_merge(
|
|
236
|
+
merged_content,
|
|
237
|
+
template_index: entry[:template_index],
|
|
238
|
+
dest_index: entry[:dest_index],
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Reconstruct a declaration with merged members
|
|
243
|
+
# @param template_decl [Object] Template declaration
|
|
244
|
+
# @param dest_decl [Object] Destination declaration
|
|
245
|
+
# @param template_index [Integer] Template index
|
|
246
|
+
# @param dest_index [Integer] Destination index
|
|
247
|
+
# @return [String] Merged declaration source
|
|
248
|
+
def reconstruct_declaration_with_merged_members(template_decl, dest_decl, template_index, dest_index)
|
|
249
|
+
# Choose which declaration to use based on preference
|
|
250
|
+
decl = (@preference == :template) ? template_decl : dest_decl
|
|
251
|
+
analysis = (@preference == :template) ? @template_analysis : @dest_analysis
|
|
252
|
+
|
|
253
|
+
start_line = decl.location.start_line
|
|
254
|
+
end_line = decl.location.end_line
|
|
255
|
+
|
|
256
|
+
# Include leading comment if present
|
|
257
|
+
if decl.respond_to?(:comment) && decl.comment
|
|
258
|
+
comment_start = decl.comment.location.start_line
|
|
259
|
+
start_line = comment_start if comment_start < start_line
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
(start_line..end_line).map { |ln| analysis.line_at(ln) }.join("\n") + "\n"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
data/lib/rbs/merge.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# External gems
|
|
4
|
+
require "rbs"
|
|
5
|
+
require "version_gem"
|
|
6
|
+
require "set"
|
|
7
|
+
|
|
8
|
+
# Shared merge infrastructure
|
|
9
|
+
require "ast/merge"
|
|
10
|
+
|
|
11
|
+
# This gem
|
|
12
|
+
require_relative "merge/version"
|
|
13
|
+
|
|
14
|
+
module Rbs
|
|
15
|
+
module Merge
|
|
16
|
+
# Base error class for rbs-merge errors
|
|
17
|
+
# Inherits from Ast::Merge::Error for consistency across merge gems.
|
|
18
|
+
# @api public
|
|
19
|
+
class Error < Ast::Merge::Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when an RBS file has parsing errors.
|
|
22
|
+
# Inherits from Ast::Merge::ParseError for consistency across merge gems.
|
|
23
|
+
#
|
|
24
|
+
# @example Handling parse errors
|
|
25
|
+
# begin
|
|
26
|
+
# analysis = FileAnalysis.new(rbs_content)
|
|
27
|
+
# rescue ParseError => e
|
|
28
|
+
# puts "RBS syntax error: #{e.message}"
|
|
29
|
+
# e.errors.each { |error| puts " #{error}" }
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @api public
|
|
33
|
+
class ParseError < Ast::Merge::ParseError
|
|
34
|
+
# @param message [String, nil] Error message (auto-generated if nil)
|
|
35
|
+
# @param content [String, nil] The RBS source that failed to parse
|
|
36
|
+
# @param errors [Array] Parse errors from RBS
|
|
37
|
+
def initialize(message = nil, content: nil, errors: [])
|
|
38
|
+
super(message, errors: errors, content: content)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Raised when the template RBS file has syntax errors.
|
|
43
|
+
#
|
|
44
|
+
# @example Handling template parse errors
|
|
45
|
+
# begin
|
|
46
|
+
# merger = SmartMerger.new(template, destination)
|
|
47
|
+
# result = merger.merge
|
|
48
|
+
# rescue TemplateParseError => e
|
|
49
|
+
# puts "Template syntax error: #{e.message}"
|
|
50
|
+
# e.errors.each { |error| puts " #{error.message}" }
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# @api public
|
|
54
|
+
class TemplateParseError < ParseError; end
|
|
55
|
+
|
|
56
|
+
# Raised when the destination RBS file has syntax errors.
|
|
57
|
+
#
|
|
58
|
+
# @example Handling destination parse errors
|
|
59
|
+
# begin
|
|
60
|
+
# merger = SmartMerger.new(template, destination)
|
|
61
|
+
# result = merger.merge
|
|
62
|
+
# rescue DestinationParseError => e
|
|
63
|
+
# puts "Destination syntax error: #{e.message}"
|
|
64
|
+
# e.errors.each { |error| puts " #{error.message}" }
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# @api public
|
|
68
|
+
class DestinationParseError < ParseError; end
|
|
69
|
+
|
|
70
|
+
autoload :DebugLogger, "rbs/merge/debug_logger"
|
|
71
|
+
autoload :FreezeNode, "rbs/merge/freeze_node"
|
|
72
|
+
autoload :MergeResult, "rbs/merge/merge_result"
|
|
73
|
+
autoload :FileAnalysis, "rbs/merge/file_analysis"
|
|
74
|
+
autoload :ConflictResolver, "rbs/merge/conflict_resolver"
|
|
75
|
+
autoload :FileAligner, "rbs/merge/file_aligner"
|
|
76
|
+
autoload :SmartMerger, "rbs/merge/smart_merger"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Rbs::Merge::Version.class_eval do
|
|
81
|
+
extend VersionGem::Basic
|
|
82
|
+
end
|
data/lib/rbs-merge.rb
ADDED