json-merge 1.0.0 → 1.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/CHANGELOG.md +75 -1
- data/README.md +240 -144
- data/lib/json/merge/conflict_resolver.rb +173 -86
- data/lib/json/merge/emitter.rb +42 -62
- data/lib/json/merge/node_wrapper.rb +35 -7
- data/lib/json/merge/smart_merger.rb +1 -0
- data/lib/json/merge/version.rb +1 -1
- data/lib/json/merge.rb +12 -0
- data.tar.gz.sig +0 -0
- metadata +12 -12
- metadata.gz.sig +0 -0
|
@@ -13,14 +13,16 @@ module Json
|
|
|
13
13
|
#
|
|
14
14
|
# @param template_analysis [FileAnalysis] Analyzed template file
|
|
15
15
|
# @param dest_analysis [FileAnalysis] Analyzed destination file
|
|
16
|
-
# @param preference [Symbol] Which version to prefer when
|
|
16
|
+
# @param preference [Symbol, Hash] Which version to prefer when
|
|
17
17
|
# nodes have matching signatures:
|
|
18
18
|
# - :destination (default) - Keep destination version (customizations)
|
|
19
19
|
# - :template - Use template version (updates)
|
|
20
20
|
# @param add_template_only_nodes [Boolean] Whether to add nodes only in template
|
|
21
21
|
# @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
|
|
22
22
|
# @param options [Hash] Additional options for forward compatibility
|
|
23
|
-
|
|
23
|
+
# @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
24
|
+
# for per-node-type preferences
|
|
25
|
+
def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false, match_refiner: nil, node_typing: nil, **options)
|
|
24
26
|
super(
|
|
25
27
|
strategy: :batch,
|
|
26
28
|
preference: preference,
|
|
@@ -30,6 +32,8 @@ module Json
|
|
|
30
32
|
match_refiner: match_refiner,
|
|
31
33
|
**options
|
|
32
34
|
)
|
|
35
|
+
@node_typing = node_typing
|
|
36
|
+
@emitter = Emitter.new
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
protected
|
|
@@ -42,15 +46,25 @@ module Json
|
|
|
42
46
|
template_statements = @template_analysis.statements
|
|
43
47
|
dest_statements = @dest_analysis.statements
|
|
44
48
|
|
|
45
|
-
#
|
|
46
|
-
|
|
49
|
+
# Clear emitter for fresh merge
|
|
50
|
+
@emitter.clear
|
|
51
|
+
|
|
52
|
+
# Merge root-level statements via emitter
|
|
53
|
+
merge_node_lists_to_emitter(
|
|
47
54
|
template_statements,
|
|
48
55
|
dest_statements,
|
|
49
56
|
@template_analysis,
|
|
50
57
|
@dest_analysis,
|
|
51
|
-
result,
|
|
52
58
|
)
|
|
53
59
|
|
|
60
|
+
# Transfer emitter output to result
|
|
61
|
+
emitted_content = @emitter.to_s
|
|
62
|
+
unless emitted_content.empty?
|
|
63
|
+
emitted_content.lines.each do |line|
|
|
64
|
+
result.add_line(line.chomp, decision: MergeResult::DECISION_MERGED, source: :merged)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
DebugLogger.debug("Conflict resolution complete", {
|
|
55
69
|
template_statements: template_statements.size,
|
|
56
70
|
dest_statements: dest_statements.size,
|
|
@@ -61,13 +75,12 @@ module Json
|
|
|
61
75
|
|
|
62
76
|
private
|
|
63
77
|
|
|
64
|
-
# Recursively merge two lists of nodes
|
|
78
|
+
# Recursively merge two lists of nodes, emitting to emitter
|
|
65
79
|
# @param template_nodes [Array<NodeWrapper>] Template nodes
|
|
66
80
|
# @param dest_nodes [Array<NodeWrapper>] Destination nodes
|
|
67
81
|
# @param template_analysis [FileAnalysis] Template analysis for line access
|
|
68
82
|
# @param dest_analysis [FileAnalysis] Destination analysis for line access
|
|
69
|
-
|
|
70
|
-
def merge_node_lists(template_nodes, dest_nodes, template_analysis, dest_analysis, result)
|
|
83
|
+
def merge_node_lists_to_emitter(template_nodes, dest_nodes, template_analysis, dest_analysis)
|
|
71
84
|
# Build signature maps for matching
|
|
72
85
|
template_by_sig = build_signature_map(template_nodes, template_analysis)
|
|
73
86
|
dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
|
|
@@ -84,20 +97,13 @@ module Json
|
|
|
84
97
|
dest_nodes.each do |dest_node|
|
|
85
98
|
dest_sig = dest_analysis.generate_signature(dest_node)
|
|
86
99
|
|
|
87
|
-
# Freeze blocks from destination are always preserved
|
|
88
|
-
if freeze_node?(dest_node)
|
|
89
|
-
add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_FREEZE_BLOCK, dest_analysis)
|
|
90
|
-
processed_dest_sigs << dest_sig if dest_sig
|
|
91
|
-
next
|
|
92
|
-
end
|
|
93
|
-
|
|
94
100
|
# Check for signature match
|
|
95
101
|
if dest_sig && template_by_sig[dest_sig]
|
|
96
102
|
template_info = template_by_sig[dest_sig].first
|
|
97
103
|
template_node = template_info[:node]
|
|
98
104
|
|
|
99
105
|
# Both have this node - merge them (recursively if containers)
|
|
100
|
-
|
|
106
|
+
merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
101
107
|
|
|
102
108
|
processed_dest_sigs << dest_sig
|
|
103
109
|
processed_template_sigs << dest_sig
|
|
@@ -107,13 +113,13 @@ module Json
|
|
|
107
113
|
template_sig = template_analysis.generate_signature(template_node)
|
|
108
114
|
|
|
109
115
|
# Merge matched nodes
|
|
110
|
-
|
|
116
|
+
merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
111
117
|
|
|
112
118
|
processed_dest_sigs << dest_sig if dest_sig
|
|
113
119
|
processed_template_sigs << template_sig if template_sig
|
|
114
120
|
else
|
|
115
121
|
# Destination-only node - always keep
|
|
116
|
-
|
|
122
|
+
emit_node(dest_node, dest_analysis)
|
|
117
123
|
processed_dest_sigs << dest_sig if dest_sig
|
|
118
124
|
end
|
|
119
125
|
end
|
|
@@ -127,35 +133,172 @@ module Json
|
|
|
127
133
|
# Skip if already processed
|
|
128
134
|
next if template_sig && processed_template_sigs.include?(template_sig)
|
|
129
135
|
|
|
130
|
-
# Skip freeze blocks from template
|
|
131
|
-
next if freeze_node?(template_node)
|
|
132
|
-
|
|
133
136
|
# Add template-only node
|
|
134
|
-
|
|
137
|
+
emit_node(template_node, template_analysis)
|
|
135
138
|
processed_template_sigs << template_sig if template_sig
|
|
136
139
|
end
|
|
137
140
|
end
|
|
138
141
|
|
|
139
142
|
# Merge two matched nodes - for containers, recursively merge children
|
|
143
|
+
# Emits to emitter instead of result
|
|
140
144
|
# @param template_node [NodeWrapper] Template node
|
|
141
145
|
# @param dest_node [NodeWrapper] Destination node
|
|
142
146
|
# @param template_analysis [FileAnalysis] Template analysis
|
|
143
147
|
# @param dest_analysis [FileAnalysis] Destination analysis
|
|
144
|
-
|
|
145
|
-
def merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
|
|
148
|
+
def merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
146
149
|
if dest_node.container? && template_node.container?
|
|
147
150
|
# Both are containers - recursively merge their children
|
|
148
|
-
|
|
149
|
-
elsif
|
|
151
|
+
merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
152
|
+
elsif dest_node.pair? && template_node.pair?
|
|
153
|
+
# Both are pairs - check if their values are OBJECTS (not arrays) that need recursive merge
|
|
154
|
+
template_value = template_node.value_node
|
|
155
|
+
dest_value = dest_node.value_node
|
|
156
|
+
|
|
157
|
+
# Only recursively merge if BOTH values are objects (not arrays)
|
|
158
|
+
# Arrays are replaced atomically based on preference
|
|
159
|
+
if template_value&.type == :object && dest_value&.type == :object &&
|
|
160
|
+
template_value.container? && dest_value.container?
|
|
161
|
+
# Both values are objects - recursively merge
|
|
162
|
+
@emitter.emit_nested_object_start(dest_node.key_name)
|
|
163
|
+
|
|
164
|
+
# Recursively merge the value objects
|
|
165
|
+
merge_node_lists_to_emitter(
|
|
166
|
+
template_value.mergeable_children,
|
|
167
|
+
dest_value.mergeable_children,
|
|
168
|
+
template_analysis,
|
|
169
|
+
dest_analysis,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Emit closing brace
|
|
173
|
+
@emitter.emit_nested_object_end
|
|
174
|
+
elsif preference_for_pair(template_node, dest_node) == :destination
|
|
175
|
+
# Values are not both objects, or one/both are arrays - use preference and emit
|
|
176
|
+
# Arrays are always replaced, not merged
|
|
177
|
+
emit_node(dest_node, dest_analysis)
|
|
178
|
+
else
|
|
179
|
+
emit_node(template_node, template_analysis)
|
|
180
|
+
end
|
|
181
|
+
elsif preference_for_pair(template_node, dest_node) == :destination
|
|
150
182
|
# Leaf nodes or mismatched types - use preference
|
|
151
|
-
|
|
183
|
+
emit_node(dest_node, dest_analysis)
|
|
152
184
|
else
|
|
153
|
-
|
|
185
|
+
emit_node(template_node, template_analysis)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Merge container nodes by emitting via emitter
|
|
190
|
+
# @param template_node [NodeWrapper] Template container node
|
|
191
|
+
# @param dest_node [NodeWrapper] Destination container node
|
|
192
|
+
# @param template_analysis [FileAnalysis] Template analysis
|
|
193
|
+
# @param dest_analysis [FileAnalysis] Destination analysis
|
|
194
|
+
def merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
195
|
+
# Emit opening bracket
|
|
196
|
+
if dest_node.object?
|
|
197
|
+
@emitter.emit_object_start
|
|
198
|
+
elsif dest_node.array?
|
|
199
|
+
@emitter.emit_array_start
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Recursively merge the children
|
|
203
|
+
template_children = template_node.mergeable_children
|
|
204
|
+
dest_children = dest_node.mergeable_children
|
|
205
|
+
|
|
206
|
+
merge_node_lists_to_emitter(
|
|
207
|
+
template_children,
|
|
208
|
+
dest_children,
|
|
209
|
+
template_analysis,
|
|
210
|
+
dest_analysis,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Emit closing bracket
|
|
214
|
+
if dest_node.object?
|
|
215
|
+
@emitter.emit_object_end
|
|
216
|
+
elsif dest_node.array?
|
|
217
|
+
@emitter.emit_array_end
|
|
154
218
|
end
|
|
155
219
|
end
|
|
156
220
|
|
|
157
|
-
|
|
158
|
-
|
|
221
|
+
def preference_for_pair(template_node, dest_node)
|
|
222
|
+
return @preference unless @preference.is_a?(Hash)
|
|
223
|
+
|
|
224
|
+
typed_template = apply_node_typing(template_node)
|
|
225
|
+
typed_dest = apply_node_typing(dest_node)
|
|
226
|
+
|
|
227
|
+
if Ast::Merge::NodeTyping.typed_node?(typed_template)
|
|
228
|
+
merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_template)
|
|
229
|
+
return @preference.fetch(merge_type) { default_preference } if merge_type
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if Ast::Merge::NodeTyping.typed_node?(typed_dest)
|
|
233
|
+
merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_dest)
|
|
234
|
+
return @preference.fetch(merge_type) { default_preference } if merge_type
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
default_preference
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def apply_node_typing(node)
|
|
241
|
+
return node unless @node_typing
|
|
242
|
+
return node unless node
|
|
243
|
+
|
|
244
|
+
Ast::Merge::NodeTyping.process(node, @node_typing)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Emit a single node to the emitter
|
|
248
|
+
# @param node [NodeWrapper] Node to emit
|
|
249
|
+
# @param analysis [FileAnalysis] Analysis for accessing source
|
|
250
|
+
def emit_node(node, analysis)
|
|
251
|
+
# Emit the node content
|
|
252
|
+
if node.pair?
|
|
253
|
+
# Emit as pair
|
|
254
|
+
key = node.key_name
|
|
255
|
+
value_node = node.value_node
|
|
256
|
+
|
|
257
|
+
if value_node
|
|
258
|
+
# Check if value is an object (not array) and needs recursive emission
|
|
259
|
+
if value_node.type == :object && value_node.container?
|
|
260
|
+
# Object value - emit structure recursively
|
|
261
|
+
@emitter.emit_nested_object_start(key)
|
|
262
|
+
# Recursively emit object children
|
|
263
|
+
value_node.mergeable_children.each do |child|
|
|
264
|
+
emit_node(child, analysis)
|
|
265
|
+
end
|
|
266
|
+
@emitter.emit_nested_object_end
|
|
267
|
+
else
|
|
268
|
+
# Leaf value or array - get its text and emit as simple pair
|
|
269
|
+
# Arrays are emitted as raw text (not recursively)
|
|
270
|
+
value_text = if value_node.start_line == value_node.end_line
|
|
271
|
+
value_node.text
|
|
272
|
+
else
|
|
273
|
+
# Multi-line value - get all lines
|
|
274
|
+
lines = []
|
|
275
|
+
(value_node.start_line..value_node.end_line).each do |ln|
|
|
276
|
+
lines << analysis.line_at(ln)
|
|
277
|
+
end
|
|
278
|
+
lines.join("\n")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
@emitter.emit_pair(key, value_text) if key && value_text
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
elsif node.start_line && node.end_line
|
|
285
|
+
# Emit raw content for non-pair nodes
|
|
286
|
+
if node.start_line == node.end_line
|
|
287
|
+
# Single line - add directly
|
|
288
|
+
@emitter.lines << node.text
|
|
289
|
+
else
|
|
290
|
+
# Multi-line - collect and emit
|
|
291
|
+
lines = []
|
|
292
|
+
(node.start_line..node.end_line).each do |ln|
|
|
293
|
+
line = analysis.line_at(ln)
|
|
294
|
+
lines << line if line
|
|
295
|
+
end
|
|
296
|
+
@emitter.emit_raw_lines(lines)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Build a map of refined matches from template node to destination node
|
|
159
302
|
# @param template_nodes [Array<NodeWrapper>] Template nodes
|
|
160
303
|
# @param dest_nodes [Array<NodeWrapper>] Destination nodes
|
|
161
304
|
# @param template_by_sig [Hash] Template signature map
|
|
@@ -188,62 +331,6 @@ module Json
|
|
|
188
331
|
h[match.template_node] = match.dest_node
|
|
189
332
|
end
|
|
190
333
|
end
|
|
191
|
-
|
|
192
|
-
# Merge two container nodes by emitting opening, recursively merging children, then closing
|
|
193
|
-
# @param template_node [NodeWrapper] Template container node
|
|
194
|
-
# @param dest_node [NodeWrapper] Destination container node
|
|
195
|
-
# @param template_analysis [FileAnalysis] Template analysis
|
|
196
|
-
# @param dest_analysis [FileAnalysis] Destination analysis
|
|
197
|
-
# @param result [MergeResult] Result to populate
|
|
198
|
-
def merge_container_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
|
|
199
|
-
# Use destination's opening line (or template if dest doesn't have one)
|
|
200
|
-
opening = dest_node.opening_line || template_node.opening_line
|
|
201
|
-
result.add_line(opening, decision: MergeResult::DECISION_MERGED, source: :merged) if opening
|
|
202
|
-
|
|
203
|
-
# Recursively merge the children
|
|
204
|
-
template_children = template_node.mergeable_children
|
|
205
|
-
dest_children = dest_node.mergeable_children
|
|
206
|
-
|
|
207
|
-
merge_node_lists(
|
|
208
|
-
template_children,
|
|
209
|
-
dest_children,
|
|
210
|
-
template_analysis,
|
|
211
|
-
dest_analysis,
|
|
212
|
-
result,
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
# Use destination's closing line (or template if dest doesn't have one)
|
|
216
|
-
closing = dest_node.closing_line || template_node.closing_line
|
|
217
|
-
result.add_line(closing, decision: MergeResult::DECISION_MERGED, source: :merged) if closing
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# Add a node to the result (non-container or leaf node)
|
|
221
|
-
# @param node [NodeWrapper] Node to add
|
|
222
|
-
# @param result [MergeResult] Result to populate
|
|
223
|
-
# @param source [Symbol] :template or :destination
|
|
224
|
-
# @param decision [String] Decision constant
|
|
225
|
-
# @param analysis [FileAnalysis] Analysis for line access
|
|
226
|
-
def add_node_to_result(node, result, source, decision, analysis)
|
|
227
|
-
if freeze_node?(node)
|
|
228
|
-
result.add_freeze_block(node)
|
|
229
|
-
elsif node.is_a?(NodeWrapper)
|
|
230
|
-
add_wrapper_to_result(node, result, source, decision, analysis)
|
|
231
|
-
else
|
|
232
|
-
DebugLogger.debug("Unknown node type", {node_type: node.class.name})
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def add_wrapper_to_result(wrapper, result, source, decision, analysis)
|
|
237
|
-
return unless wrapper.start_line && wrapper.end_line
|
|
238
|
-
|
|
239
|
-
# Add the node content line by line
|
|
240
|
-
(wrapper.start_line..wrapper.end_line).each do |line_num|
|
|
241
|
-
line = analysis.line_at(line_num)
|
|
242
|
-
next unless line
|
|
243
|
-
|
|
244
|
-
result.add_line(line.chomp, decision: decision, source: source, original_line: line_num)
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
334
|
end
|
|
248
335
|
end
|
|
249
336
|
end
|
data/lib/json/merge/emitter.rb
CHANGED
|
@@ -6,31 +6,38 @@ module Json
|
|
|
6
6
|
# This class provides utilities for emitting JSON while maintaining
|
|
7
7
|
# the original structure, comments, and style choices.
|
|
8
8
|
#
|
|
9
|
+
# Inherits common emitter functionality from Ast::Merge::EmitterBase.
|
|
10
|
+
#
|
|
9
11
|
# @example Basic usage
|
|
10
12
|
# emitter = Emitter.new
|
|
11
13
|
# emitter.emit_object_start
|
|
12
14
|
# emitter.emit_pair("key", '"value"')
|
|
13
15
|
# emitter.emit_object_end
|
|
14
|
-
class Emitter
|
|
15
|
-
# @return [
|
|
16
|
-
attr_reader :
|
|
17
|
-
|
|
18
|
-
# @return [Integer] Current indentation level
|
|
19
|
-
attr_reader :indent_level
|
|
16
|
+
class Emitter < Ast::Merge::EmitterBase
|
|
17
|
+
# @return [Boolean] Whether next item needs a comma
|
|
18
|
+
attr_reader :needs_comma
|
|
20
19
|
|
|
21
|
-
#
|
|
22
|
-
|
|
20
|
+
# Initialize subclass-specific state (comma tracking for JSON)
|
|
21
|
+
def initialize_subclass_state(**options)
|
|
22
|
+
@needs_comma = false
|
|
23
|
+
end
|
|
23
24
|
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
# @param indent_size [Integer] Number of spaces per indent level
|
|
27
|
-
def initialize(indent_size: 2)
|
|
28
|
-
@lines = []
|
|
29
|
-
@indent_level = 0
|
|
30
|
-
@indent_size = indent_size
|
|
25
|
+
# Clear subclass-specific state
|
|
26
|
+
def clear_subclass_state
|
|
31
27
|
@needs_comma = false
|
|
32
28
|
end
|
|
33
29
|
|
|
30
|
+
# Emit a tracked comment from CommentTracker
|
|
31
|
+
# @param comment [Hash] Comment with :text, :indent, :block
|
|
32
|
+
def emit_tracked_comment(comment)
|
|
33
|
+
indent = " " * (comment[:indent] || 0)
|
|
34
|
+
@lines << if comment[:block]
|
|
35
|
+
"#{indent}/* #{comment[:text]} */"
|
|
36
|
+
else
|
|
37
|
+
"#{indent}// #{comment[:text]}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
34
41
|
# Emit a single-line comment
|
|
35
42
|
#
|
|
36
43
|
# @param text [String] Comment text (without //)
|
|
@@ -53,36 +60,17 @@ module Json
|
|
|
53
60
|
@lines << "#{current_indent}/* #{text} */"
|
|
54
61
|
end
|
|
55
62
|
|
|
56
|
-
# Emit leading comments
|
|
57
|
-
#
|
|
58
|
-
# @param comments [Array<Hash>] Comment hashes from CommentTracker
|
|
59
|
-
def emit_leading_comments(comments)
|
|
60
|
-
comments.each do |comment|
|
|
61
|
-
indent = " " * (comment[:indent] || 0)
|
|
62
|
-
@lines << if comment[:block]
|
|
63
|
-
"#{indent}/* #{comment[:text]} */"
|
|
64
|
-
else
|
|
65
|
-
"#{indent}// #{comment[:text]}"
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Emit a blank line
|
|
71
|
-
def emit_blank_line
|
|
72
|
-
@lines << ""
|
|
73
|
-
end
|
|
74
|
-
|
|
75
63
|
# Emit object start
|
|
76
64
|
def emit_object_start
|
|
77
65
|
add_comma_if_needed
|
|
78
66
|
@lines << "#{current_indent}{"
|
|
79
|
-
|
|
67
|
+
indent
|
|
80
68
|
@needs_comma = false
|
|
81
69
|
end
|
|
82
70
|
|
|
83
71
|
# Emit object end
|
|
84
72
|
def emit_object_end
|
|
85
|
-
|
|
73
|
+
dedent
|
|
86
74
|
@lines << "#{current_indent}}"
|
|
87
75
|
@needs_comma = true
|
|
88
76
|
end
|
|
@@ -97,13 +85,13 @@ module Json
|
|
|
97
85
|
else
|
|
98
86
|
"#{current_indent}["
|
|
99
87
|
end
|
|
100
|
-
|
|
88
|
+
indent
|
|
101
89
|
@needs_comma = false
|
|
102
90
|
end
|
|
103
91
|
|
|
104
92
|
# Emit array end
|
|
105
93
|
def emit_array_end
|
|
106
|
-
|
|
94
|
+
dedent
|
|
107
95
|
@lines << "#{current_indent}]"
|
|
108
96
|
@needs_comma = true
|
|
109
97
|
end
|
|
@@ -133,39 +121,31 @@ module Json
|
|
|
133
121
|
@needs_comma = true
|
|
134
122
|
end
|
|
135
123
|
|
|
136
|
-
# Emit
|
|
137
|
-
#
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
124
|
+
# Emit a key with opening brace for nested object
|
|
125
|
+
# @param key [String] Key name
|
|
126
|
+
def emit_nested_object_start(key)
|
|
127
|
+
add_comma_if_needed
|
|
128
|
+
@lines << "#{current_indent}\"#{key}\": {"
|
|
129
|
+
indent
|
|
130
|
+
@needs_comma = false
|
|
141
131
|
end
|
|
142
132
|
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
content += "\n" unless content.empty? || content.end_with?("\n")
|
|
149
|
-
content
|
|
133
|
+
# Emit closing brace for nested object
|
|
134
|
+
def emit_nested_object_end
|
|
135
|
+
dedent
|
|
136
|
+
@lines << "#{current_indent}}"
|
|
137
|
+
@needs_comma = true
|
|
150
138
|
end
|
|
151
139
|
|
|
152
|
-
#
|
|
140
|
+
# Get the output as a JSON string
|
|
141
|
+
#
|
|
153
142
|
# @return [String]
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# Clear the output
|
|
157
|
-
def clear
|
|
158
|
-
@lines = []
|
|
159
|
-
@indent_level = 0
|
|
160
|
-
@needs_comma = false
|
|
143
|
+
def to_json
|
|
144
|
+
to_s
|
|
161
145
|
end
|
|
162
146
|
|
|
163
147
|
private
|
|
164
148
|
|
|
165
|
-
def current_indent
|
|
166
|
-
" " * (@indent_level * @indent_size)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
149
|
def add_comma_if_needed
|
|
170
150
|
return unless @needs_comma && @lines.any?
|
|
171
151
|
|
|
@@ -74,6 +74,7 @@ module Json
|
|
|
74
74
|
|
|
75
75
|
# In JSON tree-sitter, pair has key and value children
|
|
76
76
|
key_node = find_child_by_field("key")
|
|
77
|
+
|
|
77
78
|
return unless key_node
|
|
78
79
|
|
|
79
80
|
# Key is typically a string, extract its content without quotes using byte positions
|
|
@@ -88,6 +89,7 @@ module Json
|
|
|
88
89
|
return unless pair?
|
|
89
90
|
|
|
90
91
|
value = find_child_by_field("value")
|
|
92
|
+
|
|
91
93
|
return unless value
|
|
92
94
|
|
|
93
95
|
NodeWrapper.new(value, lines: @lines, source: @source)
|
|
@@ -148,6 +150,19 @@ module Json
|
|
|
148
150
|
object? || array?
|
|
149
151
|
end
|
|
150
152
|
|
|
153
|
+
# Check if this is a root-level container (direct child of document)
|
|
154
|
+
# Root-level containers get a generic signature so they always match.
|
|
155
|
+
# @return [Boolean]
|
|
156
|
+
def root_level_container?
|
|
157
|
+
return false unless container?
|
|
158
|
+
|
|
159
|
+
# Check if parent is a document node
|
|
160
|
+
parent_node = @node.parent if @node.respond_to?(:parent)
|
|
161
|
+
return false unless parent_node
|
|
162
|
+
|
|
163
|
+
parent_node.type.to_s == "document"
|
|
164
|
+
end
|
|
165
|
+
|
|
151
166
|
# Get the opening line for a container node (the line with { or [)
|
|
152
167
|
# Returns the full line content including any leading whitespace
|
|
153
168
|
# @return [String, nil]
|
|
@@ -226,14 +241,26 @@ module Json
|
|
|
226
241
|
child_type = child&.type&.to_s
|
|
227
242
|
[:document, child_type]
|
|
228
243
|
when "object"
|
|
229
|
-
#
|
|
230
|
-
|
|
231
|
-
|
|
244
|
+
# For root-level objects (direct child of document), use a generic signature
|
|
245
|
+
# that always matches so merging happens at the pair level.
|
|
246
|
+
# This is critical for JSON merging - there's typically only one root object/array.
|
|
247
|
+
if root_level_container?
|
|
248
|
+
[:root_object]
|
|
249
|
+
else
|
|
250
|
+
# Nested objects identified by their keys
|
|
251
|
+
keys = extract_object_keys(node)
|
|
252
|
+
[:object, keys.sort]
|
|
253
|
+
end
|
|
232
254
|
when "array"
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
255
|
+
# For root-level arrays (direct child of document), use a generic signature
|
|
256
|
+
if root_level_container?
|
|
257
|
+
[:root_array]
|
|
258
|
+
else
|
|
259
|
+
# Nested arrays identified by their length and first few elements
|
|
260
|
+
elements_count = 0
|
|
261
|
+
node.each { |c| elements_count += 1 unless %w[comment , \[ \]].include?(c.type.to_s) }
|
|
262
|
+
[:array, elements_count]
|
|
263
|
+
end
|
|
237
264
|
when "pair"
|
|
238
265
|
# Pairs identified by their key name
|
|
239
266
|
key = key_name
|
|
@@ -267,6 +294,7 @@ module Json
|
|
|
267
294
|
next unless child.type.to_s == "pair"
|
|
268
295
|
|
|
269
296
|
key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name("key") : nil
|
|
297
|
+
|
|
270
298
|
next unless key_node
|
|
271
299
|
|
|
272
300
|
key_text = node_text(key_node)&.gsub(/\A"|"\z/, "")
|
data/lib/json/merge/version.rb
CHANGED
data/lib/json/merge.rb
CHANGED
|
@@ -108,6 +108,18 @@ module Json
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# Register with ast-merge's MergeGemRegistry for RSpec dependency tags
|
|
112
|
+
# Only register if MergeGemRegistry is loaded (i.e., in test environment)
|
|
113
|
+
if defined?(Ast::Merge::RSpec::MergeGemRegistry)
|
|
114
|
+
Ast::Merge::RSpec::MergeGemRegistry.register(
|
|
115
|
+
:json_merge,
|
|
116
|
+
require_path: "json/merge",
|
|
117
|
+
merger_class: "Json::Merge::SmartMerger",
|
|
118
|
+
test_source: '{"key": "value"}',
|
|
119
|
+
category: :data,
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
111
123
|
Json::Merge::Version.class_eval do
|
|
112
124
|
extend VersionGem::Basic
|
|
113
125
|
end
|
data.tar.gz.sig
CHANGED
|
Binary file
|