json-merge 7.0.0 → 7.1.3
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +600 -0
- data/lib/json/merge/comment_tracker.rb +27 -0
- data/lib/json/merge/conflict_resolver.rb +1379 -0
- data/lib/json/merge/debug_logger.rb +41 -0
- data/lib/json/merge/emitter.rb +227 -0
- data/lib/json/merge/file_analysis.rb +355 -0
- data/lib/json/merge/freeze_node.rb +62 -0
- data/lib/json/merge/merge_result.rb +156 -0
- data/lib/json/merge/node_wrapper.rb +258 -0
- data/lib/json/merge/object_match_refiner.rb +338 -0
- data/lib/json/merge/provider.rb +696 -0
- data/lib/json/merge/smart_merger.rb +276 -0
- data/lib/json/merge/source_locator.rb +68 -0
- data/lib/json/merge/three_way_decision.rb +168 -0
- data/lib/json/merge/version.rb +5 -3
- data/lib/json/merge.rb +393 -266
- data/lib/json-merge.rb +7 -1
- data/sig/json/merge.rbs +54 -0
- data.tar.gz.sig +0 -0
- metadata +264 -15
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# Tracks the result of a merge operation, including the merged content,
|
|
6
|
+
# decisions made, and statistics.
|
|
7
|
+
#
|
|
8
|
+
# Inherits decision constants and base functionality from Ast::Merge::MergeResultBase.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# result = MergeResult.new
|
|
12
|
+
# result.add_line('"key": "value"', decision: :kept_template, source: :template)
|
|
13
|
+
# result.to_json # => '"key": "value"\n'
|
|
14
|
+
class MergeResult < Ast::Merge::MergeResultBase
|
|
15
|
+
include Ast::Merge::StructuredReviewApplySupport
|
|
16
|
+
|
|
17
|
+
# Inherit decision constants from base class
|
|
18
|
+
DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
|
|
19
|
+
DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
|
|
20
|
+
DECISION_MERGED = Ast::Merge::MergeResultBase::DECISION_MERGED
|
|
21
|
+
DECISION_ADDED = Ast::Merge::MergeResultBase::DECISION_ADDED
|
|
22
|
+
DECISION_FREEZE_BLOCK = Ast::Merge::MergeResultBase::DECISION_FREEZE_BLOCK
|
|
23
|
+
|
|
24
|
+
# @return [Hash] Statistics about the merge
|
|
25
|
+
attr_reader :statistics
|
|
26
|
+
|
|
27
|
+
# Initialize a new merge result
|
|
28
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
29
|
+
def initialize(**options)
|
|
30
|
+
super(**options)
|
|
31
|
+
@statistics = {
|
|
32
|
+
template_lines: 0,
|
|
33
|
+
dest_lines: 0,
|
|
34
|
+
merged_lines: 0,
|
|
35
|
+
freeze_preserved_lines: 0,
|
|
36
|
+
total_decisions: 0
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Add a single line to the result
|
|
41
|
+
#
|
|
42
|
+
# @param line [String] Line content
|
|
43
|
+
# @param decision [Symbol] Decision that led to this line
|
|
44
|
+
# @param source [Symbol] Source of the line (:template, :destination, :merged)
|
|
45
|
+
# @param original_line [Integer, nil] Original line number
|
|
46
|
+
def add_line(line, decision:, source:, original_line: nil)
|
|
47
|
+
@lines << {
|
|
48
|
+
content: line,
|
|
49
|
+
decision: decision,
|
|
50
|
+
source: source,
|
|
51
|
+
original_line: original_line
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
track_statistics(decision, source)
|
|
55
|
+
track_decision(decision, source, line: original_line)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Add multiple lines to the result
|
|
59
|
+
#
|
|
60
|
+
# @param lines [Array<String>] Lines to add
|
|
61
|
+
# @param decision [Symbol] Decision for all lines
|
|
62
|
+
# @param source [Symbol] Source of the lines
|
|
63
|
+
# @param start_line [Integer, nil] Starting original line number
|
|
64
|
+
def add_lines(lines, decision:, source:, start_line: nil)
|
|
65
|
+
lines.each_with_index do |line, idx|
|
|
66
|
+
original_line = start_line ? start_line + idx : nil
|
|
67
|
+
add_line(line, decision: decision, source: source, original_line: original_line)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Add a blank line
|
|
72
|
+
#
|
|
73
|
+
# @param decision [Symbol] Decision for the blank line
|
|
74
|
+
# @param source [Symbol] Source
|
|
75
|
+
def add_blank_line(decision: DECISION_MERGED, source: :merged)
|
|
76
|
+
add_line('', decision: decision, source: source)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Add content from a freeze block
|
|
80
|
+
#
|
|
81
|
+
# @param freeze_node [FreezeNode] Freeze block to add
|
|
82
|
+
def add_freeze_block(freeze_node)
|
|
83
|
+
freeze_node.lines.each_with_index do |line, idx|
|
|
84
|
+
add_line(
|
|
85
|
+
line.chomp,
|
|
86
|
+
decision: DECISION_FREEZE_BLOCK,
|
|
87
|
+
source: :destination,
|
|
88
|
+
original_line: freeze_node.start_line + idx
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Add content from a node wrapper
|
|
94
|
+
#
|
|
95
|
+
# @param node [NodeWrapper] Node to add
|
|
96
|
+
# @param decision [Symbol] Decision that led to keeping this node
|
|
97
|
+
# @param source [Symbol] Source of the node
|
|
98
|
+
# @param analysis [FileAnalysis] Analysis for accessing source lines
|
|
99
|
+
def add_node(node, decision:, source:, analysis:)
|
|
100
|
+
return unless node.start_line && node.end_line
|
|
101
|
+
|
|
102
|
+
(node.start_line..node.end_line).each do |line_num|
|
|
103
|
+
line = analysis.line_at(line_num)
|
|
104
|
+
next unless line
|
|
105
|
+
|
|
106
|
+
add_line(line.chomp, decision: decision, source: source, original_line: line_num)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get the merged content as a JSON string
|
|
111
|
+
#
|
|
112
|
+
# @return [String]
|
|
113
|
+
def to_json(*_args)
|
|
114
|
+
content = @lines.map { |l| l[:content] }.join("\n")
|
|
115
|
+
# Ensure trailing newline
|
|
116
|
+
content += "\n" unless content.end_with?("\n") || content.empty?
|
|
117
|
+
content
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Alias for to_json
|
|
121
|
+
# @return [String]
|
|
122
|
+
def content
|
|
123
|
+
to_json
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Alias for to_json (used by SmartMerger#merge)
|
|
127
|
+
# @return [String]
|
|
128
|
+
def to_s
|
|
129
|
+
to_json
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get line count
|
|
133
|
+
# @return [Integer]
|
|
134
|
+
def line_count
|
|
135
|
+
@lines.size
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def track_statistics(decision, _source)
|
|
141
|
+
@statistics[:total_decisions] += 1
|
|
142
|
+
|
|
143
|
+
case decision
|
|
144
|
+
when DECISION_KEPT_TEMPLATE
|
|
145
|
+
@statistics[:template_lines] += 1
|
|
146
|
+
when DECISION_KEPT_DEST
|
|
147
|
+
@statistics[:dest_lines] += 1
|
|
148
|
+
when DECISION_FREEZE_BLOCK
|
|
149
|
+
@statistics[:freeze_preserved_lines] += 1
|
|
150
|
+
else
|
|
151
|
+
@statistics[:merged_lines] += 1
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# Wraps TreeHaver nodes with line information and signatures for JSON /
|
|
6
|
+
# JSONC merging.
|
|
7
|
+
class NodeWrapper < Ast::Merge::NodeWrapperBase
|
|
8
|
+
attr_reader :dialect
|
|
9
|
+
|
|
10
|
+
def initialize(node, lines:, source: nil, dialect: :jsonc, **options)
|
|
11
|
+
@dialect = dialect.to_sym
|
|
12
|
+
super(node, lines: lines, source: source, **options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def object?
|
|
16
|
+
@node.type.to_s == 'object'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def array?
|
|
20
|
+
@node.type.to_s == 'array'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def string?
|
|
24
|
+
@node.type.to_s == 'string'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def number?
|
|
28
|
+
@node.type.to_s == 'number'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def boolean?
|
|
32
|
+
%w[true false].include?(@node.type.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def null?
|
|
36
|
+
@node.type.to_s == 'null'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pair?
|
|
40
|
+
@node.type.to_s == 'pair'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def comment?
|
|
44
|
+
@node.type.to_s == 'comment'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def key_name
|
|
48
|
+
return unless pair?
|
|
49
|
+
|
|
50
|
+
key_node = find_child_by_field('key') || semantic_children.first
|
|
51
|
+
return unless key_node
|
|
52
|
+
|
|
53
|
+
Json::Merge.object_key_for_literal(node_text(key_node), dialect: dialect)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def value_node
|
|
57
|
+
return unless pair?
|
|
58
|
+
|
|
59
|
+
value = find_child_by_field('value') || semantic_children[1]
|
|
60
|
+
return unless value
|
|
61
|
+
|
|
62
|
+
NodeWrapper.new(value, lines: @lines, source: @source, dialect: dialect)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pairs
|
|
66
|
+
return [] unless object?
|
|
67
|
+
|
|
68
|
+
result = []
|
|
69
|
+
@node.each do |child|
|
|
70
|
+
next if child.type.to_s == 'comment'
|
|
71
|
+
next unless child.type.to_s == 'pair'
|
|
72
|
+
|
|
73
|
+
result << NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
|
|
74
|
+
end
|
|
75
|
+
result
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def elements
|
|
79
|
+
return [] unless array?
|
|
80
|
+
|
|
81
|
+
result = []
|
|
82
|
+
@node.each do |child|
|
|
83
|
+
child_type = child.type.to_s
|
|
84
|
+
next if child_type == 'comment'
|
|
85
|
+
next if child_type == ','
|
|
86
|
+
next if child_type == '['
|
|
87
|
+
next if child_type == ']'
|
|
88
|
+
|
|
89
|
+
result << NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
|
|
90
|
+
end
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mergeable_children
|
|
95
|
+
case type
|
|
96
|
+
when :object
|
|
97
|
+
pairs
|
|
98
|
+
when :array
|
|
99
|
+
elements
|
|
100
|
+
else
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def container?
|
|
106
|
+
object? || array?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def root_level_container?
|
|
110
|
+
return false unless container?
|
|
111
|
+
|
|
112
|
+
parent_node = @node.parent if @node.respond_to?(:parent)
|
|
113
|
+
return false unless parent_node
|
|
114
|
+
|
|
115
|
+
parent_node.type.to_s == 'document'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def opening_line
|
|
119
|
+
return unless container? && @start_line
|
|
120
|
+
|
|
121
|
+
return opening_bracket if @start_line == @end_line
|
|
122
|
+
|
|
123
|
+
@lines[@start_line - 1]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def closing_line
|
|
127
|
+
return unless container? && @end_line
|
|
128
|
+
|
|
129
|
+
return closing_bracket if @start_line == @end_line
|
|
130
|
+
|
|
131
|
+
@lines[@end_line - 1]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def opening_bracket
|
|
135
|
+
return '{' if object?
|
|
136
|
+
return '[' if array?
|
|
137
|
+
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def closing_bracket
|
|
142
|
+
return '}' if object?
|
|
143
|
+
return ']' if array?
|
|
144
|
+
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def find_child_by_field(field_name)
|
|
149
|
+
return unless @node.respond_to?(:child_by_field_name)
|
|
150
|
+
|
|
151
|
+
@node.child_by_field_name(field_name)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def find_child_by_type(type_name)
|
|
155
|
+
return unless @node.respond_to?(:each)
|
|
156
|
+
|
|
157
|
+
@node.each do |child|
|
|
158
|
+
return child if child.type.to_s == type_name
|
|
159
|
+
end
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def semantic_children
|
|
164
|
+
return [] unless @node.respond_to?(:each)
|
|
165
|
+
|
|
166
|
+
@node.each.filter_map do |child|
|
|
167
|
+
child_type = child.type.to_s
|
|
168
|
+
next if %w[comment , : { } [ ]].include?(child_type)
|
|
169
|
+
|
|
170
|
+
child
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
protected
|
|
175
|
+
|
|
176
|
+
def wrap_child(child)
|
|
177
|
+
NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def compute_signature(node)
|
|
181
|
+
node_type = node.type.to_s
|
|
182
|
+
|
|
183
|
+
case node_type
|
|
184
|
+
when 'document'
|
|
185
|
+
child = nil
|
|
186
|
+
node.each do |candidate|
|
|
187
|
+
child = candidate unless candidate.type.to_s == 'comment'
|
|
188
|
+
break if child
|
|
189
|
+
end
|
|
190
|
+
child_type = child&.type&.to_s
|
|
191
|
+
[:document, child_type]
|
|
192
|
+
when 'object'
|
|
193
|
+
if root_level_container?
|
|
194
|
+
[:root_object]
|
|
195
|
+
else
|
|
196
|
+
keys = extract_object_keys(node)
|
|
197
|
+
[:object, keys.sort]
|
|
198
|
+
end
|
|
199
|
+
when 'array'
|
|
200
|
+
if root_level_container?
|
|
201
|
+
[:root_array]
|
|
202
|
+
else
|
|
203
|
+
elements_count = 0
|
|
204
|
+
node.each { |candidate| elements_count += 1 unless %w[comment , \[ \]].include?(candidate.type.to_s) }
|
|
205
|
+
[:array, elements_count]
|
|
206
|
+
end
|
|
207
|
+
when 'pair'
|
|
208
|
+
[:pair, key_name]
|
|
209
|
+
when 'string'
|
|
210
|
+
[:string, node_text(node)]
|
|
211
|
+
when 'number'
|
|
212
|
+
[:number, node_text(node)]
|
|
213
|
+
when 'true', 'false'
|
|
214
|
+
[:boolean, node.type.to_s]
|
|
215
|
+
when 'null'
|
|
216
|
+
[:null]
|
|
217
|
+
when 'comment'
|
|
218
|
+
[:comment, node_text(node)&.strip]
|
|
219
|
+
else
|
|
220
|
+
content_preview = node_text(node)&.slice(0, 50)&.strip
|
|
221
|
+
[node_type.to_sym, content_preview]
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def extract_object_keys(object_node)
|
|
228
|
+
keys = []
|
|
229
|
+
object_node.each do |child|
|
|
230
|
+
next unless child.type.to_s == 'pair'
|
|
231
|
+
|
|
232
|
+
key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name('key') : nil
|
|
233
|
+
|
|
234
|
+
unless key_node
|
|
235
|
+
begin
|
|
236
|
+
child_each = child.method(:each)
|
|
237
|
+
child_each.call do |pair_child|
|
|
238
|
+
pair_child_type = pair_child.type.to_s
|
|
239
|
+
next if [':', 'comment'].include?(pair_child_type)
|
|
240
|
+
|
|
241
|
+
key_node = pair_child
|
|
242
|
+
break
|
|
243
|
+
end
|
|
244
|
+
rescue StandardError
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
next unless key_node
|
|
250
|
+
|
|
251
|
+
key_text = Json::Merge.object_key_for_literal(node_text(key_node), dialect: dialect)
|
|
252
|
+
keys << key_text if key_text
|
|
253
|
+
end
|
|
254
|
+
keys
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|