json-merge 1.1.2 → 7.1.1
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 +190 -618
- data/lib/json/merge/comment_tracker.rb +27 -0
- data/lib/json/merge/conflict_resolver.rb +1156 -113
- data/lib/json/merge/debug_logger.rb +8 -8
- data/lib/json/merge/emitter.rb +101 -37
- data/lib/json/merge/file_analysis.rb +261 -96
- data/lib/json/merge/freeze_node.rb +62 -0
- data/lib/json/merge/merge_result.rb +25 -5
- data/lib/json/merge/node_wrapper.rb +82 -131
- data/lib/json/merge/object_match_refiner.rb +13 -14
- data/lib/json/merge/smart_merger.rb +136 -10
- data/lib/json/merge/version.rb +5 -4
- data/lib/json/merge.rb +498 -101
- data/lib/json-merge.rb +7 -4
- data/sig/json/merge.rbs +3 -196
- data.tar.gz.sig +0 -0
- metadata +99 -81
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -149
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- data/SECURITY.md +0 -21
|
@@ -2,97 +2,137 @@
|
|
|
2
2
|
|
|
3
3
|
module Json
|
|
4
4
|
module Merge
|
|
5
|
-
# Analyzes JSON file structure, extracting
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# @example Basic usage
|
|
9
|
-
# analysis = FileAnalysis.new(json_source)
|
|
10
|
-
# analysis.valid? # => true
|
|
11
|
-
# analysis.statements # => [NodeWrapper, ...]
|
|
5
|
+
# Analyzes JSON / JSONC file structure, extracting nodes, comments, and
|
|
6
|
+
# freeze blocks for merging.
|
|
12
7
|
class FileAnalysis
|
|
13
8
|
include Ast::Merge::FileAnalyzable
|
|
14
9
|
|
|
15
|
-
|
|
16
|
-
attr_reader :ast
|
|
10
|
+
DEFAULT_FREEZE_TOKEN = 'json-merge'
|
|
17
11
|
|
|
18
|
-
|
|
19
|
-
attr_reader :errors
|
|
12
|
+
attr_reader :comment_tracker, :ast, :errors, :dialect
|
|
20
13
|
|
|
21
14
|
class << self
|
|
22
|
-
# Find the parser library path using TreeHaver::GrammarFinder
|
|
23
|
-
#
|
|
24
|
-
# @return [String, nil] Path to the parser library or nil if not found
|
|
25
15
|
def find_parser_path
|
|
26
16
|
TreeHaver::GrammarFinder.new(:json).find_library_path
|
|
27
17
|
end
|
|
28
18
|
end
|
|
29
19
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# @param source [String] JSON source code to analyze
|
|
33
|
-
# @param signature_generator [Proc, nil] Custom signature generator
|
|
34
|
-
# @param parser_path [String, nil] Path to tree-sitter-json parser library
|
|
35
|
-
# @param options [Hash] Additional options (forward compatibility - freeze_token, node_typing, etc.)
|
|
36
|
-
def initialize(source, signature_generator: nil, parser_path: nil, **options)
|
|
20
|
+
def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, parser_path: nil, dialect: :jsonc,
|
|
21
|
+
**_options)
|
|
37
22
|
@source = source
|
|
38
23
|
@lines = source.lines.map(&:chomp)
|
|
24
|
+
@freeze_token = freeze_token
|
|
39
25
|
@signature_generator = signature_generator
|
|
40
|
-
@parser_path = parser_path
|
|
26
|
+
@parser_path = parser_path
|
|
27
|
+
@dialect = dialect.to_sym
|
|
41
28
|
@errors = []
|
|
42
|
-
# **options captures any additional parameters (e.g., freeze_token, node_typing) for forward compatibility
|
|
43
29
|
|
|
44
|
-
|
|
45
|
-
DebugLogger.time("FileAnalysis#parse_json") { parse_json }
|
|
30
|
+
@comment_tracker = CommentTracker.new(source)
|
|
46
31
|
|
|
47
|
-
|
|
32
|
+
DebugLogger.time('FileAnalysis#parse_json') { parse_json }
|
|
33
|
+
validate_jsonc_dialect! if valid? && @dialect == :jsonc
|
|
48
34
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
@freeze_blocks = extract_freeze_blocks
|
|
36
|
+
@nodes = integrate_nodes_and_freeze_blocks
|
|
37
|
+
|
|
38
|
+
DebugLogger.debug('FileAnalysis initialized', {
|
|
39
|
+
signature_generator: signature_generator ? 'custom' : 'default',
|
|
40
|
+
nodes_count: @nodes.size,
|
|
41
|
+
freeze_blocks: @freeze_blocks.size,
|
|
42
|
+
valid: valid?
|
|
43
|
+
})
|
|
54
44
|
end
|
|
55
45
|
|
|
56
|
-
# Check if parse was successful
|
|
57
|
-
# @return [Boolean]
|
|
58
46
|
def valid?
|
|
59
47
|
@errors.empty? && !@ast.nil?
|
|
60
48
|
end
|
|
61
49
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
def comment_capability
|
|
51
|
+
@comment_capability ||= comment_tracker.augment(owners: []).capability
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def comment_support_style
|
|
55
|
+
@comment_support_style ||= shared_comment_support_style(
|
|
56
|
+
source: :json_source,
|
|
57
|
+
style: :c_style_line,
|
|
58
|
+
read_strategy: :source_augmented_portable_write
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def comment_nodes
|
|
63
|
+
comment_tracker.comment_nodes
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def comment_node_at(line_num)
|
|
67
|
+
comment_tracker.comment_node_at(line_num)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def comment_region_for_range(range, kind:, full_line_only: false)
|
|
71
|
+
comment_tracker.comment_region_for_range(
|
|
72
|
+
range,
|
|
73
|
+
kind: kind,
|
|
74
|
+
full_line_only: full_line_only
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def comment_augmenter(owners: nil, **options)
|
|
79
|
+
comment_tracker.augment(
|
|
80
|
+
owners: owners || comment_augmenter_default_owners,
|
|
81
|
+
**options
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def statements
|
|
86
|
+
@nodes || []
|
|
87
|
+
end
|
|
88
|
+
alias nodes statements
|
|
89
|
+
|
|
90
|
+
def in_freeze_block?(line_num)
|
|
91
|
+
@freeze_blocks.any? { |fb| fb.location.cover?(line_num) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def freeze_block_at(line_num)
|
|
95
|
+
@freeze_blocks.find { |fb| fb.location.cover?(line_num) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def generate_signature(node)
|
|
99
|
+
return super if @signature_generator
|
|
100
|
+
return super unless node.is_a?(NodeWrapper)
|
|
101
|
+
return super unless node.object?
|
|
102
|
+
|
|
103
|
+
return [:root_object] if statements.size == 1 && statements.first == node
|
|
104
|
+
|
|
105
|
+
super
|
|
106
|
+
end
|
|
107
|
+
|
|
65
108
|
def fallthrough_node?(value)
|
|
66
|
-
value.is_a?(NodeWrapper) || super
|
|
109
|
+
value.is_a?(NodeWrapper) || value.is_a?(FreezeNode) || super
|
|
67
110
|
end
|
|
68
111
|
|
|
69
|
-
# Get the root node of the parse tree
|
|
70
|
-
# @return [NodeWrapper, nil]
|
|
71
112
|
def root_node
|
|
72
|
-
return
|
|
113
|
+
return @root_node if defined?(@root_node)
|
|
114
|
+
return @root_node = nil unless valid?
|
|
73
115
|
|
|
74
|
-
NodeWrapper.new(@ast.root_node, lines: @lines, source: @source)
|
|
116
|
+
@root_node = NodeWrapper.new(@ast.root_node, lines: @lines, source: @source, dialect: @dialect)
|
|
75
117
|
end
|
|
76
118
|
|
|
77
|
-
# Get the root object if the JSON document is an object
|
|
78
|
-
# @return [NodeWrapper, nil]
|
|
79
119
|
def root_object
|
|
80
|
-
return
|
|
120
|
+
return @root_object if defined?(@root_object)
|
|
121
|
+
return @root_object = nil unless valid?
|
|
81
122
|
|
|
82
123
|
root = @ast.root_node
|
|
83
|
-
return unless root
|
|
124
|
+
return @root_object = nil unless root
|
|
84
125
|
|
|
85
|
-
# JSON root should be a document containing an object or array
|
|
86
126
|
root.each do |child|
|
|
87
|
-
if child.type.to_s ==
|
|
88
|
-
return NodeWrapper.new(child, lines: @lines, source: @source
|
|
127
|
+
if child.type.to_s == 'object'
|
|
128
|
+
return @root_object = NodeWrapper.new(child, lines: @lines, source: @source,
|
|
129
|
+
dialect: @dialect)
|
|
89
130
|
end
|
|
90
131
|
end
|
|
91
|
-
|
|
132
|
+
|
|
133
|
+
@root_object = nil
|
|
92
134
|
end
|
|
93
135
|
|
|
94
|
-
# Get the opening brace line of the root object (the line containing `{`)
|
|
95
|
-
# @return [String, nil]
|
|
96
136
|
def root_object_open_line
|
|
97
137
|
obj = root_object
|
|
98
138
|
return unless obj&.start_line
|
|
@@ -100,8 +140,6 @@ module Json
|
|
|
100
140
|
line_at(obj.start_line)&.chomp
|
|
101
141
|
end
|
|
102
142
|
|
|
103
|
-
# Get the closing brace line of the root object (the line containing `}`)
|
|
104
|
-
# @return [String, nil]
|
|
105
143
|
def root_object_close_line
|
|
106
144
|
obj = root_object
|
|
107
145
|
return unless obj&.end_line
|
|
@@ -109,81 +147,208 @@ module Json
|
|
|
109
147
|
line_at(obj.end_line)&.chomp
|
|
110
148
|
end
|
|
111
149
|
|
|
112
|
-
# Get key-value pairs from the root object
|
|
113
|
-
# @return [Array<NodeWrapper>]
|
|
114
150
|
def root_pairs
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
@root_pairs ||= begin
|
|
152
|
+
obj = root_object
|
|
153
|
+
obj ? obj.pairs : []
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def comment_attachment_for(owner, line_num: nil, **options)
|
|
158
|
+
shared_comment_attachment_for(
|
|
159
|
+
owner,
|
|
160
|
+
tracker_attachment: @comment_tracker.comment_attachment_for(owner, line_num: line_num, **options),
|
|
161
|
+
line_num: line_num,
|
|
162
|
+
**options
|
|
163
|
+
)
|
|
164
|
+
end
|
|
117
165
|
|
|
118
|
-
|
|
166
|
+
# @return [Symbol]
|
|
167
|
+
def comment_attachment_strategy
|
|
168
|
+
:augmenter_preferred_tracker_layout
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def ruleset_owner_selector
|
|
172
|
+
:line_bound_statements
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def ruleset_render_family
|
|
176
|
+
:json_object_pairs
|
|
119
177
|
end
|
|
120
178
|
|
|
121
179
|
private
|
|
122
180
|
|
|
181
|
+
def layout_augmenter_default_owners
|
|
182
|
+
pairs = root_pairs.select { |pair| pair.respond_to?(:start_line) && pair.respond_to?(:end_line) }
|
|
183
|
+
return pairs unless pairs.empty?
|
|
184
|
+
|
|
185
|
+
comment_augmenter_default_owners
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def root_merge_node
|
|
189
|
+
return unless valid?
|
|
190
|
+
|
|
191
|
+
root = @ast.root_node
|
|
192
|
+
return unless root
|
|
193
|
+
|
|
194
|
+
root_type = root.type.to_s
|
|
195
|
+
return NodeWrapper.new(root, lines: @lines, source: @source, dialect: @dialect) if %w[object
|
|
196
|
+
array].include?(root_type)
|
|
197
|
+
|
|
198
|
+
root.each do |child|
|
|
199
|
+
child_type = child.type.to_s
|
|
200
|
+
next if child_type == 'comment'
|
|
201
|
+
next unless %w[object array].include?(child_type)
|
|
202
|
+
|
|
203
|
+
return NodeWrapper.new(child, lines: @lines, source: @source, dialect: @dialect)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
123
209
|
def parse_json
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
# - Backend selection
|
|
127
|
-
parser = TreeHaver.parser_for(:json, library_path: @parser_path)
|
|
210
|
+
Json::Merge.register_backend!
|
|
211
|
+
parser = TreeHaver.parser_for(parser_language, backend_type: :tree_sitter)
|
|
128
212
|
|
|
129
213
|
@ast = parser.parse(@source)
|
|
130
214
|
|
|
131
|
-
|
|
132
|
-
if @ast&.root_node&.has_error?
|
|
133
|
-
collect_parse_errors(@ast.root_node)
|
|
134
|
-
end
|
|
215
|
+
collect_parse_errors(@ast.root_node) if @ast&.root_node
|
|
135
216
|
rescue TreeHaver::Error => e
|
|
136
|
-
|
|
137
|
-
# This also catches TreeHaver::NotAvailable (subclass of Error).
|
|
138
|
-
@errors << e.message
|
|
217
|
+
@errors << e
|
|
139
218
|
@ast = nil
|
|
140
219
|
rescue StandardError => e
|
|
141
220
|
@errors << e
|
|
142
221
|
@ast = nil
|
|
143
222
|
end
|
|
144
223
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
224
|
+
def parser_language
|
|
225
|
+
%i[jsonc json5].include?(@dialect) ? :json5 : :json
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def validate_jsonc_dialect!
|
|
229
|
+
validate_jsonc_node!(@ast.root_node)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def validate_jsonc_node!(node)
|
|
233
|
+
native_type = node.respond_to?(:native_type) ? node.native_type.to_s : node.type.to_s
|
|
234
|
+
case native_type
|
|
235
|
+
when 'identifier'
|
|
236
|
+
add_jsonc_dialect_error(node, 'unquoted object keys are JSON5-only')
|
|
237
|
+
when 'string'
|
|
238
|
+
validate_json_literal!(node, String, 'single-quoted strings and JSON5 escapes are not supported')
|
|
239
|
+
when 'number'
|
|
240
|
+
validate_json_literal!(node, Numeric, 'JSON5 numeric literals are not supported')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
node.each { |child| validate_jsonc_node!(child) } if node.respond_to?(:each)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Tree-sitter identifies complete string and number tokens. Delegating those
|
|
247
|
+
# token semantics to Ruby's strict JSON parser avoids a second hand-written
|
|
248
|
+
# lexer while retaining AST-derived source locations.
|
|
249
|
+
def validate_json_literal!(node, expected_class, reason)
|
|
250
|
+
value = ::JSON.parse(node.text)
|
|
251
|
+
return if value.is_a?(expected_class)
|
|
252
|
+
|
|
253
|
+
add_jsonc_dialect_error(node, reason)
|
|
254
|
+
rescue ::JSON::ParserError
|
|
255
|
+
add_jsonc_dialect_error(node, reason)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def add_jsonc_dialect_error(node, reason)
|
|
259
|
+
@errors << Json::Merge::JsoncDialectError.new(
|
|
260
|
+
"JSONC rejects #{reason} at line #{node.start_line}, column #{node.start_point[:column] + 1}."
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def collect_parse_errors(node, found_errors = [])
|
|
265
|
+
if node.type.to_s == 'ERROR' ||
|
|
266
|
+
(node.respond_to?(:has_error?) && node.has_error?) ||
|
|
267
|
+
(node.respond_to?(:missing?) && node.missing?)
|
|
268
|
+
found_errors << {
|
|
149
269
|
type: node.type.to_s,
|
|
150
|
-
start_point: node.start_point,
|
|
151
|
-
end_point: node.end_point,
|
|
152
|
-
text: node.to_s
|
|
270
|
+
start_point: node.respond_to?(:start_point) ? node.start_point : nil,
|
|
271
|
+
end_point: node.respond_to?(:end_point) ? node.end_point : nil,
|
|
272
|
+
text: node.to_s
|
|
153
273
|
}
|
|
154
274
|
end
|
|
155
275
|
|
|
156
|
-
node.each { |child| collect_parse_errors(child) }
|
|
276
|
+
node.each { |child| collect_parse_errors(child, found_errors) } if node.respond_to?(:each)
|
|
277
|
+
@errors.concat(found_errors) unless found_errors.empty?
|
|
278
|
+
found_errors
|
|
157
279
|
end
|
|
158
280
|
|
|
159
|
-
def
|
|
160
|
-
|
|
281
|
+
def extract_freeze_blocks
|
|
282
|
+
freeze_starts = []
|
|
283
|
+
freeze_ends = []
|
|
161
284
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return result unless root
|
|
285
|
+
single_line_pattern = %r{^\s*//\s*#{Regexp.escape(@freeze_token)}:(freeze|unfreeze)\b}i
|
|
286
|
+
block_pattern = %r{^\s*/\*\s*#{Regexp.escape(@freeze_token)}:(freeze|unfreeze)\b.*\*/}i
|
|
165
287
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
# The tree structure is preserved - children are accessed via NodeWrapper#children
|
|
169
|
-
root.each do |child|
|
|
170
|
-
# Skip whitespace-only or empty nodes
|
|
171
|
-
next if child.type.to_s == "comment" # Comments handled separately in JSONC
|
|
288
|
+
@lines.each_with_index do |line, idx|
|
|
289
|
+
line_num = idx + 1
|
|
172
290
|
|
|
173
|
-
|
|
174
|
-
|
|
291
|
+
marker_type = nil
|
|
292
|
+
if (match = line.match(single_line_pattern))
|
|
293
|
+
marker_type = match[1]&.downcase
|
|
294
|
+
elsif (match = line.match(block_pattern))
|
|
295
|
+
marker_type = match[1]&.downcase
|
|
296
|
+
end
|
|
175
297
|
|
|
176
|
-
|
|
298
|
+
next unless marker_type
|
|
299
|
+
|
|
300
|
+
if marker_type == 'freeze'
|
|
301
|
+
freeze_starts << { line: line_num, marker: line }
|
|
302
|
+
elsif marker_type == 'unfreeze'
|
|
303
|
+
freeze_ends << { line: line_num, marker: line }
|
|
304
|
+
end
|
|
177
305
|
end
|
|
178
306
|
|
|
179
|
-
|
|
180
|
-
|
|
307
|
+
blocks = []
|
|
308
|
+
freeze_starts.each do |start_info|
|
|
309
|
+
matching_end = freeze_ends.find { |ending| ending[:line] > start_info[:line] }
|
|
310
|
+
next unless matching_end
|
|
311
|
+
|
|
312
|
+
freeze_ends.delete(matching_end)
|
|
313
|
+
blocks << FreezeNode.new(
|
|
314
|
+
start_line: start_info[:line],
|
|
315
|
+
end_line: matching_end[:line],
|
|
316
|
+
lines: @lines,
|
|
317
|
+
start_marker: start_info[:marker],
|
|
318
|
+
end_marker: matching_end[:marker]
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
blocks
|
|
181
323
|
end
|
|
182
324
|
|
|
183
|
-
def
|
|
184
|
-
return unless
|
|
325
|
+
def integrate_nodes_and_freeze_blocks
|
|
326
|
+
return @freeze_blocks.dup unless valid?
|
|
185
327
|
|
|
186
|
-
|
|
328
|
+
result = []
|
|
329
|
+
processed_lines = ::Set.new
|
|
330
|
+
|
|
331
|
+
@freeze_blocks.each do |fb|
|
|
332
|
+
(fb.start_line..fb.end_line).each { |ln| processed_lines << ln }
|
|
333
|
+
result << fb
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
root = root_merge_node
|
|
337
|
+
if root&.start_line
|
|
338
|
+
root_lines = (root.start_line..root.end_line).to_a
|
|
339
|
+
result << root unless root_lines.any? { |ln| processed_lines.include?(ln) }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
result.sort_by { |node| node&.start_line || 0 }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def compute_node_signature(node)
|
|
346
|
+
case node
|
|
347
|
+
when FreezeNode
|
|
348
|
+
node.signature
|
|
349
|
+
when NodeWrapper
|
|
350
|
+
node.signature
|
|
351
|
+
end
|
|
187
352
|
end
|
|
188
353
|
end
|
|
189
354
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# Wrapper to represent comment-marked freeze blocks as first-class nodes in
|
|
6
|
+
# JSON / JSONC files.
|
|
7
|
+
class FreezeNode < Ast::Merge::FreezeNodeBase
|
|
8
|
+
InvalidStructureError = Ast::Merge::FreezeNodeBase::InvalidStructureError
|
|
9
|
+
Location = Ast::Merge::FreezeNodeBase::Location
|
|
10
|
+
|
|
11
|
+
def initialize(start_line:, end_line:, lines:, start_marker: nil, end_marker: nil, pattern_type: :c_style_line)
|
|
12
|
+
block_lines = (start_line..end_line).map { |ln| lines[ln - 1] }
|
|
13
|
+
|
|
14
|
+
super(
|
|
15
|
+
start_line: start_line,
|
|
16
|
+
end_line: end_line,
|
|
17
|
+
lines: block_lines,
|
|
18
|
+
start_marker: start_marker,
|
|
19
|
+
end_marker: end_marker,
|
|
20
|
+
pattern_type: pattern_type,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
validate_structure!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def signature
|
|
27
|
+
normalized = @lines.map { |line| line&.strip }.compact.reject(&:empty?).join("\n")
|
|
28
|
+
[:FreezeNode, normalized]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def object?
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def array?
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pair?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
"#<#{self.class.name} lines=#{start_line}..#{end_line} content_length=#{slice&.length || 0}>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_structure!
|
|
50
|
+
validate_line_order!
|
|
51
|
+
|
|
52
|
+
return unless @lines.empty? || @lines.all?(&:nil?)
|
|
53
|
+
|
|
54
|
+
raise InvalidStructureError.new(
|
|
55
|
+
'Freeze block is empty',
|
|
56
|
+
start_line: @start_line,
|
|
57
|
+
end_line: @end_line
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -12,11 +12,14 @@ module Json
|
|
|
12
12
|
# result.add_line('"key": "value"', decision: :kept_template, source: :template)
|
|
13
13
|
# result.to_json # => '"key": "value"\n'
|
|
14
14
|
class MergeResult < Ast::Merge::MergeResultBase
|
|
15
|
+
include Ast::Merge::StructuredReviewApplySupport
|
|
16
|
+
|
|
15
17
|
# Inherit decision constants from base class
|
|
16
18
|
DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
|
|
17
19
|
DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
|
|
18
20
|
DECISION_MERGED = Ast::Merge::MergeResultBase::DECISION_MERGED
|
|
19
21
|
DECISION_ADDED = Ast::Merge::MergeResultBase::DECISION_ADDED
|
|
22
|
+
DECISION_FREEZE_BLOCK = Ast::Merge::MergeResultBase::DECISION_FREEZE_BLOCK
|
|
20
23
|
|
|
21
24
|
# @return [Hash] Statistics about the merge
|
|
22
25
|
attr_reader :statistics
|
|
@@ -29,7 +32,8 @@ module Json
|
|
|
29
32
|
template_lines: 0,
|
|
30
33
|
dest_lines: 0,
|
|
31
34
|
merged_lines: 0,
|
|
32
|
-
|
|
35
|
+
freeze_preserved_lines: 0,
|
|
36
|
+
total_decisions: 0
|
|
33
37
|
}
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -44,7 +48,7 @@ module Json
|
|
|
44
48
|
content: line,
|
|
45
49
|
decision: decision,
|
|
46
50
|
source: source,
|
|
47
|
-
original_line: original_line
|
|
51
|
+
original_line: original_line
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
track_statistics(decision, source)
|
|
@@ -69,7 +73,21 @@ module Json
|
|
|
69
73
|
# @param decision [Symbol] Decision for the blank line
|
|
70
74
|
# @param source [Symbol] Source
|
|
71
75
|
def add_blank_line(decision: DECISION_MERGED, source: :merged)
|
|
72
|
-
add_line(
|
|
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
|
|
73
91
|
end
|
|
74
92
|
|
|
75
93
|
# Add content from a node wrapper
|
|
@@ -92,7 +110,7 @@ module Json
|
|
|
92
110
|
# Get the merged content as a JSON string
|
|
93
111
|
#
|
|
94
112
|
# @return [String]
|
|
95
|
-
def to_json
|
|
113
|
+
def to_json(*_args)
|
|
96
114
|
content = @lines.map { |l| l[:content] }.join("\n")
|
|
97
115
|
# Ensure trailing newline
|
|
98
116
|
content += "\n" unless content.end_with?("\n") || content.empty?
|
|
@@ -119,7 +137,7 @@ module Json
|
|
|
119
137
|
|
|
120
138
|
private
|
|
121
139
|
|
|
122
|
-
def track_statistics(decision,
|
|
140
|
+
def track_statistics(decision, _source)
|
|
123
141
|
@statistics[:total_decisions] += 1
|
|
124
142
|
|
|
125
143
|
case decision
|
|
@@ -127,6 +145,8 @@ module Json
|
|
|
127
145
|
@statistics[:template_lines] += 1
|
|
128
146
|
when DECISION_KEPT_DEST
|
|
129
147
|
@statistics[:dest_lines] += 1
|
|
148
|
+
when DECISION_FREEZE_BLOCK
|
|
149
|
+
@statistics[:freeze_preserved_lines] += 1
|
|
130
150
|
else
|
|
131
151
|
@statistics[:merged_lines] += 1
|
|
132
152
|
end
|