json-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 +48 -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 +966 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/json/merge/conflict_resolver.rb +249 -0
- data/lib/json/merge/debug_logger.rb +41 -0
- data/lib/json/merge/emitter.rb +183 -0
- data/lib/json/merge/file_analysis.rb +190 -0
- data/lib/json/merge/merge_result.rb +136 -0
- data/lib/json/merge/node_wrapper.rb +279 -0
- data/lib/json/merge/object_match_refiner.rb +339 -0
- data/lib/json/merge/smart_merger.rb +149 -0
- data/lib/json/merge/version.rb +12 -0
- data/lib/json/merge.rb +113 -0
- data/lib/json-merge.rb +6 -0
- data/sig/json/merge.rbs +201 -0
- data.tar.gz.sig +0 -0
- metadata +332 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# Match refiner for JSON objects and array elements that didn't match by exact signature.
|
|
6
|
+
#
|
|
7
|
+
# This refiner uses fuzzy matching to pair JSON nodes that have:
|
|
8
|
+
# - Similar key names in objects (e.g., `databaseUrl` vs `database_url`)
|
|
9
|
+
# - Array elements with similar structure or content
|
|
10
|
+
# - Objects with overlapping keys but different values
|
|
11
|
+
#
|
|
12
|
+
# The matching algorithm considers:
|
|
13
|
+
# - Key name similarity for object pairs (Levenshtein distance)
|
|
14
|
+
# - Value type and content similarity
|
|
15
|
+
# - Structural overlap for nested objects
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# refiner = ObjectMatchRefiner.new(threshold: 0.6)
|
|
19
|
+
# matches = refiner.call(template_nodes, dest_nodes)
|
|
20
|
+
#
|
|
21
|
+
# @example With custom weights
|
|
22
|
+
# refiner = ObjectMatchRefiner.new(
|
|
23
|
+
# threshold: 0.5,
|
|
24
|
+
# key_weight: 0.6,
|
|
25
|
+
# value_weight: 0.4
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# @see Ast::Merge::MatchRefinerBase
|
|
29
|
+
class ObjectMatchRefiner < Ast::Merge::MatchRefinerBase
|
|
30
|
+
# Default weight for key similarity
|
|
31
|
+
DEFAULT_KEY_WEIGHT = 0.7
|
|
32
|
+
|
|
33
|
+
# Default weight for value similarity
|
|
34
|
+
DEFAULT_VALUE_WEIGHT = 0.3
|
|
35
|
+
|
|
36
|
+
# @return [Float] Weight for key similarity (0.0-1.0)
|
|
37
|
+
attr_reader :key_weight
|
|
38
|
+
|
|
39
|
+
# @return [Float] Weight for value similarity (0.0-1.0)
|
|
40
|
+
attr_reader :value_weight
|
|
41
|
+
|
|
42
|
+
# Initialize an object match refiner.
|
|
43
|
+
#
|
|
44
|
+
# @param threshold [Float] Minimum score to accept a match (default: 0.5)
|
|
45
|
+
# @param key_weight [Float] Weight for key similarity (default: 0.7)
|
|
46
|
+
# @param value_weight [Float] Weight for value similarity (default: 0.3)
|
|
47
|
+
def initialize(threshold: DEFAULT_THRESHOLD, key_weight: DEFAULT_KEY_WEIGHT, value_weight: DEFAULT_VALUE_WEIGHT, **options)
|
|
48
|
+
super(threshold: threshold, **options)
|
|
49
|
+
@key_weight = key_weight
|
|
50
|
+
@value_weight = value_weight
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Find matches between unmatched JSON nodes.
|
|
54
|
+
#
|
|
55
|
+
# Handles both object key-value pairs and array elements.
|
|
56
|
+
#
|
|
57
|
+
# @param template_nodes [Array] Unmatched nodes from template
|
|
58
|
+
# @param dest_nodes [Array] Unmatched nodes from destination
|
|
59
|
+
# @param context [Hash] Additional context
|
|
60
|
+
# @return [Array<MatchResult>] Array of node matches
|
|
61
|
+
def call(template_nodes, dest_nodes, context = {})
|
|
62
|
+
# Match object pairs (key-value entries)
|
|
63
|
+
pair_matches = match_pairs(template_nodes, dest_nodes)
|
|
64
|
+
|
|
65
|
+
# Match array elements (objects within arrays)
|
|
66
|
+
array_matches = match_array_objects(template_nodes, dest_nodes)
|
|
67
|
+
|
|
68
|
+
pair_matches + array_matches
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Match key-value pairs from JSON objects.
|
|
74
|
+
#
|
|
75
|
+
# @param template_nodes [Array] Template nodes
|
|
76
|
+
# @param dest_nodes [Array] Destination nodes
|
|
77
|
+
# @return [Array<MatchResult>]
|
|
78
|
+
def match_pairs(template_nodes, dest_nodes)
|
|
79
|
+
template_pairs = template_nodes.select { |n| pair_node?(n) }
|
|
80
|
+
dest_pairs = dest_nodes.select { |n| pair_node?(n) }
|
|
81
|
+
|
|
82
|
+
return [] if template_pairs.empty? || dest_pairs.empty?
|
|
83
|
+
|
|
84
|
+
greedy_match(template_pairs, dest_pairs) do |t_node, d_node|
|
|
85
|
+
compute_pair_similarity(t_node, d_node)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Match object elements from JSON arrays.
|
|
90
|
+
#
|
|
91
|
+
# @param template_nodes [Array] Template nodes
|
|
92
|
+
# @param dest_nodes [Array] Destination nodes
|
|
93
|
+
# @return [Array<MatchResult>]
|
|
94
|
+
def match_array_objects(template_nodes, dest_nodes)
|
|
95
|
+
template_objects = template_nodes.select { |n| object_node?(n) && !pair_node?(n) }
|
|
96
|
+
dest_objects = dest_nodes.select { |n| object_node?(n) && !pair_node?(n) }
|
|
97
|
+
|
|
98
|
+
return [] if template_objects.empty? || dest_objects.empty?
|
|
99
|
+
|
|
100
|
+
greedy_match(template_objects, dest_objects) do |t_node, d_node|
|
|
101
|
+
compute_object_similarity(t_node, d_node)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check if a node is a key-value pair.
|
|
106
|
+
#
|
|
107
|
+
# @param node [Object] Node to check
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def pair_node?(node)
|
|
110
|
+
return false unless node.respond_to?(:pair?)
|
|
111
|
+
|
|
112
|
+
node.pair?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check if a node is a JSON object.
|
|
116
|
+
#
|
|
117
|
+
# @param node [Object] Node to check
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def object_node?(node)
|
|
120
|
+
return false unless node.respond_to?(:object?)
|
|
121
|
+
|
|
122
|
+
node.object?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Compute similarity score between two key-value pairs.
|
|
126
|
+
#
|
|
127
|
+
# @param t_pair [NodeWrapper] Template pair
|
|
128
|
+
# @param d_pair [NodeWrapper] Destination pair
|
|
129
|
+
# @return [Float] Similarity score (0.0-1.0)
|
|
130
|
+
def compute_pair_similarity(t_pair, d_pair)
|
|
131
|
+
t_key = t_pair.key_name
|
|
132
|
+
d_key = d_pair.key_name
|
|
133
|
+
|
|
134
|
+
return 0.0 unless t_key && d_key
|
|
135
|
+
|
|
136
|
+
key_score = key_similarity(t_key, d_key)
|
|
137
|
+
value_score = value_similarity(t_pair.value_node, d_pair.value_node)
|
|
138
|
+
|
|
139
|
+
(key_score * key_weight) + (value_score * value_weight)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Compute similarity score between two JSON objects.
|
|
143
|
+
#
|
|
144
|
+
# @param t_obj [NodeWrapper] Template object
|
|
145
|
+
# @param d_obj [NodeWrapper] Destination object
|
|
146
|
+
# @return [Float] Similarity score (0.0-1.0)
|
|
147
|
+
def compute_object_similarity(t_obj, d_obj)
|
|
148
|
+
t_keys = extract_keys(t_obj)
|
|
149
|
+
d_keys = extract_keys(d_obj)
|
|
150
|
+
|
|
151
|
+
return 1.0 if t_keys.empty? && d_keys.empty?
|
|
152
|
+
return 0.0 if t_keys.empty? || d_keys.empty?
|
|
153
|
+
|
|
154
|
+
# Compute key overlap
|
|
155
|
+
common_keys = (t_keys & d_keys).size
|
|
156
|
+
total_keys = (t_keys | d_keys).size
|
|
157
|
+
key_overlap = common_keys.to_f / total_keys
|
|
158
|
+
|
|
159
|
+
# Compute fuzzy key similarity for non-exact matches
|
|
160
|
+
fuzzy_score = compute_fuzzy_key_matches(t_keys - d_keys, d_keys - t_keys)
|
|
161
|
+
|
|
162
|
+
# Combine exact overlap and fuzzy matching
|
|
163
|
+
(key_overlap * 0.7) + (fuzzy_score * 0.3)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Compute fuzzy matches between two sets of keys.
|
|
167
|
+
#
|
|
168
|
+
# @param keys1 [Array<String>] First set of keys
|
|
169
|
+
# @param keys2 [Array<String>] Second set of keys
|
|
170
|
+
# @return [Float] Fuzzy match score (0.0-1.0)
|
|
171
|
+
def compute_fuzzy_key_matches(keys1, keys2)
|
|
172
|
+
return 1.0 if keys1.empty? && keys2.empty?
|
|
173
|
+
return 0.0 if keys1.empty? || keys2.empty?
|
|
174
|
+
|
|
175
|
+
total_similarity = 0.0
|
|
176
|
+
keys1.each do |k1|
|
|
177
|
+
best_match = keys2.map { |k2| key_similarity(k1, k2) }.max || 0.0
|
|
178
|
+
total_similarity += best_match
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
total_similarity / keys1.size
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Extract keys from a JSON object.
|
|
185
|
+
#
|
|
186
|
+
# @param obj [NodeWrapper] Object node
|
|
187
|
+
# @return [Array<String>] Keys
|
|
188
|
+
def extract_keys(obj)
|
|
189
|
+
return [] unless obj.respond_to?(:pairs)
|
|
190
|
+
|
|
191
|
+
obj.pairs.map(&:key_name).compact
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Compute similarity between two keys.
|
|
195
|
+
#
|
|
196
|
+
# @param key1 [String] First key
|
|
197
|
+
# @param key2 [String] Second key
|
|
198
|
+
# @return [Float] Key similarity (0.0-1.0)
|
|
199
|
+
def key_similarity(key1, key2)
|
|
200
|
+
return 1.0 if key1 == key2
|
|
201
|
+
|
|
202
|
+
str1 = normalize_key(key1.to_s)
|
|
203
|
+
str2 = normalize_key(key2.to_s)
|
|
204
|
+
|
|
205
|
+
string_similarity(str1, str2)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Normalize a key for comparison.
|
|
209
|
+
# Converts to lowercase and normalizes common naming conventions.
|
|
210
|
+
#
|
|
211
|
+
# @param key [String] Key to normalize
|
|
212
|
+
# @return [String] Normalized key
|
|
213
|
+
def normalize_key(key)
|
|
214
|
+
# Convert camelCase to snake_case first, then normalize
|
|
215
|
+
key.gsub(/([A-Z])/) { "_#{$1.downcase}" }
|
|
216
|
+
.downcase
|
|
217
|
+
.gsub(/[-_]/, "")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Compute similarity between two values.
|
|
221
|
+
#
|
|
222
|
+
# @param t_value [NodeWrapper, nil] Template value
|
|
223
|
+
# @param d_value [NodeWrapper, nil] Destination value
|
|
224
|
+
# @return [Float] Value similarity (0.0-1.0)
|
|
225
|
+
def value_similarity(t_value, d_value)
|
|
226
|
+
return 0.5 unless t_value && d_value
|
|
227
|
+
|
|
228
|
+
# Check if they're the same type
|
|
229
|
+
return 0.0 unless same_value_type?(t_value, d_value)
|
|
230
|
+
|
|
231
|
+
if t_value.string?
|
|
232
|
+
# Compare string values
|
|
233
|
+
t_text = extract_string_value(t_value)
|
|
234
|
+
d_text = extract_string_value(d_value)
|
|
235
|
+
string_similarity(t_text, d_text)
|
|
236
|
+
elsif t_value.object?
|
|
237
|
+
# Compare object structures
|
|
238
|
+
compute_object_similarity(t_value, d_value)
|
|
239
|
+
elsif t_value.array?
|
|
240
|
+
# Compare array lengths
|
|
241
|
+
array_similarity(t_value, d_value)
|
|
242
|
+
else
|
|
243
|
+
# Same type, consider similar
|
|
244
|
+
0.5
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Check if two values have the same JSON type.
|
|
249
|
+
#
|
|
250
|
+
# @param val1 [NodeWrapper] First value
|
|
251
|
+
# @param val2 [NodeWrapper] Second value
|
|
252
|
+
# @return [Boolean]
|
|
253
|
+
def same_value_type?(val1, val2)
|
|
254
|
+
val1.type == val2.type
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Extract string content from a string node.
|
|
258
|
+
#
|
|
259
|
+
# @param node [NodeWrapper] String node
|
|
260
|
+
# @return [String]
|
|
261
|
+
def extract_string_value(node)
|
|
262
|
+
# String nodes include quotes, remove them
|
|
263
|
+
text = node.respond_to?(:text) ? node.text : ""
|
|
264
|
+
text.gsub(/\A"|"\z/, "")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Compute similarity between two arrays.
|
|
268
|
+
#
|
|
269
|
+
# @param arr1 [NodeWrapper] First array
|
|
270
|
+
# @param arr2 [NodeWrapper] Second array
|
|
271
|
+
# @return [Float] Array similarity (0.0-1.0)
|
|
272
|
+
def array_similarity(arr1, arr2)
|
|
273
|
+
len1 = arr1.respond_to?(:elements) ? arr1.elements.size : 0
|
|
274
|
+
len2 = arr2.respond_to?(:elements) ? arr2.elements.size : 0
|
|
275
|
+
|
|
276
|
+
return 1.0 if len1 == 0 && len2 == 0
|
|
277
|
+
return 0.0 if len1 == 0 || len2 == 0
|
|
278
|
+
|
|
279
|
+
[len1, len2].min.to_f / [len1, len2].max
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Compute string similarity using Levenshtein distance.
|
|
283
|
+
#
|
|
284
|
+
# @param str1 [String] First string
|
|
285
|
+
# @param str2 [String] Second string
|
|
286
|
+
# @return [Float] Similarity score (0.0-1.0)
|
|
287
|
+
def string_similarity(str1, str2)
|
|
288
|
+
return 1.0 if str1 == str2
|
|
289
|
+
return 0.0 if str1.to_s.empty? || str2.to_s.empty?
|
|
290
|
+
|
|
291
|
+
distance = levenshtein_distance(str1.to_s, str2.to_s)
|
|
292
|
+
max_len = [str1.to_s.length, str2.to_s.length].max
|
|
293
|
+
|
|
294
|
+
1.0 - (distance.to_f / max_len)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Compute Levenshtein distance between two strings.
|
|
298
|
+
#
|
|
299
|
+
# Uses Wagner-Fischer algorithm with O(min(m,n)) space.
|
|
300
|
+
#
|
|
301
|
+
# @param str1 [String] First string
|
|
302
|
+
# @param str2 [String] Second string
|
|
303
|
+
# @return [Integer] Edit distance
|
|
304
|
+
def levenshtein_distance(str1, str2)
|
|
305
|
+
return str2.length if str1.empty?
|
|
306
|
+
return str1.length if str2.empty?
|
|
307
|
+
|
|
308
|
+
# Ensure str1 is the shorter string for space optimization
|
|
309
|
+
if str1.length > str2.length
|
|
310
|
+
str1, str2 = str2, str1
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
m = str1.length
|
|
314
|
+
n = str2.length
|
|
315
|
+
|
|
316
|
+
# Use two rows instead of full matrix
|
|
317
|
+
prev_row = (0..m).to_a
|
|
318
|
+
curr_row = Array.new(m + 1, 0)
|
|
319
|
+
|
|
320
|
+
(1..n).each do |j|
|
|
321
|
+
curr_row[0] = j
|
|
322
|
+
|
|
323
|
+
(1..m).each do |i|
|
|
324
|
+
cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
|
|
325
|
+
curr_row[i] = [
|
|
326
|
+
prev_row[i] + 1, # deletion
|
|
327
|
+
curr_row[i - 1] + 1, # insertion
|
|
328
|
+
prev_row[i - 1] + cost, # substitution
|
|
329
|
+
].min
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
prev_row, curr_row = curr_row, prev_row
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
prev_row[m]
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# High-level merger for JSON/JSONC content.
|
|
6
|
+
# Orchestrates parsing, analysis, and conflict resolution.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# merger = SmartMerger.new(template_content, dest_content)
|
|
10
|
+
# result = merger.merge
|
|
11
|
+
# File.write("merged.json", result.output)
|
|
12
|
+
#
|
|
13
|
+
# @example With options
|
|
14
|
+
# merger = SmartMerger.new(template, dest,
|
|
15
|
+
# preference: :template,
|
|
16
|
+
# add_template_only_nodes: true)
|
|
17
|
+
# result = merger.merge
|
|
18
|
+
#
|
|
19
|
+
# @example Enable fuzzy matching
|
|
20
|
+
# merger = SmartMerger.new(template, dest, match_refiner: ObjectMatchRefiner.new)
|
|
21
|
+
#
|
|
22
|
+
# @example With regions (embedded content)
|
|
23
|
+
# merger = SmartMerger.new(template, dest,
|
|
24
|
+
# regions: [{ detector: SomeDetector.new, merger_class: SomeMerger }])
|
|
25
|
+
class SmartMerger < ::Ast::Merge::SmartMergerBase
|
|
26
|
+
# Creates a new SmartMerger
|
|
27
|
+
#
|
|
28
|
+
# @param template_content [String] Template JSON content
|
|
29
|
+
# @param dest_content [String] Destination JSON content
|
|
30
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
31
|
+
# @param preference [Symbol, Hash] :destination, :template, or per-type Hash
|
|
32
|
+
# @param add_template_only_nodes [Boolean] Whether to add nodes only found in template
|
|
33
|
+
# @param freeze_token [String, nil] Token for freeze block markers
|
|
34
|
+
# @param match_refiner [#call, nil] Match refiner for fuzzy matching
|
|
35
|
+
# @param regions [Array<Hash>, nil] Region configurations for nested merging
|
|
36
|
+
# @param region_placeholder [String, nil] Custom placeholder for regions
|
|
37
|
+
# @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
38
|
+
# for per-node-type merge preferences
|
|
39
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
40
|
+
def initialize(
|
|
41
|
+
template_content,
|
|
42
|
+
dest_content,
|
|
43
|
+
signature_generator: nil,
|
|
44
|
+
preference: :destination,
|
|
45
|
+
add_template_only_nodes: false,
|
|
46
|
+
freeze_token: nil,
|
|
47
|
+
match_refiner: nil,
|
|
48
|
+
regions: nil,
|
|
49
|
+
region_placeholder: nil,
|
|
50
|
+
node_typing: nil,
|
|
51
|
+
**options
|
|
52
|
+
)
|
|
53
|
+
super(
|
|
54
|
+
template_content,
|
|
55
|
+
dest_content,
|
|
56
|
+
signature_generator: signature_generator,
|
|
57
|
+
preference: preference,
|
|
58
|
+
add_template_only_nodes: add_template_only_nodes,
|
|
59
|
+
freeze_token: freeze_token,
|
|
60
|
+
match_refiner: match_refiner,
|
|
61
|
+
regions: regions,
|
|
62
|
+
region_placeholder: region_placeholder,
|
|
63
|
+
node_typing: node_typing,
|
|
64
|
+
**options
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Backward-compatible options hash
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash] The merge options
|
|
71
|
+
def options
|
|
72
|
+
{
|
|
73
|
+
preference: @preference,
|
|
74
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
75
|
+
match_refiner: @match_refiner,
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
protected
|
|
80
|
+
|
|
81
|
+
# @return [Class] The analysis class for JSON files
|
|
82
|
+
def analysis_class
|
|
83
|
+
FileAnalysis
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [String] The default freeze token (not used for JSON)
|
|
87
|
+
def default_freeze_token
|
|
88
|
+
"json-merge"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Class] The resolver class for JSON files
|
|
92
|
+
def resolver_class
|
|
93
|
+
ConflictResolver
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Class] The result class for JSON files
|
|
97
|
+
def result_class
|
|
98
|
+
MergeResult
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Perform the JSON-specific merge
|
|
102
|
+
#
|
|
103
|
+
# @return [MergeResult] The merge result
|
|
104
|
+
def perform_merge
|
|
105
|
+
@resolver.resolve(@result)
|
|
106
|
+
|
|
107
|
+
DebugLogger.debug("Merge complete", {
|
|
108
|
+
lines: @result.line_count,
|
|
109
|
+
decisions: @result.statistics,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
@result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Build the resolver with JSON-specific signature
|
|
116
|
+
def build_resolver
|
|
117
|
+
ConflictResolver.new(
|
|
118
|
+
@template_analysis,
|
|
119
|
+
@dest_analysis,
|
|
120
|
+
preference: @preference,
|
|
121
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
122
|
+
match_refiner: @match_refiner,
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build the result (no-arg constructor for JSON)
|
|
127
|
+
def build_result
|
|
128
|
+
MergeResult.new
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Class] The template parse error class for JSON
|
|
132
|
+
def template_parse_error_class
|
|
133
|
+
TemplateParseError
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Class] The destination parse error class for JSON
|
|
137
|
+
def destination_parse_error_class
|
|
138
|
+
DestinationParseError
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# JSON FileAnalysis only accepts signature_generator, not freeze_token
|
|
144
|
+
def build_full_analysis_options
|
|
145
|
+
{signature_generator: @signature_generator}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/json/merge.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# std libs
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
# External gems
|
|
7
|
+
# TreeHaver provides a unified cross-Ruby interface to tree-sitter.
|
|
8
|
+
# It handles grammar discovery and backend selection automatically
|
|
9
|
+
# via parser_for(:json). No manual registration needed.
|
|
10
|
+
require "tree_haver"
|
|
11
|
+
require "version_gem"
|
|
12
|
+
|
|
13
|
+
# Shared merge infrastructure
|
|
14
|
+
require "ast/merge"
|
|
15
|
+
|
|
16
|
+
# This gem
|
|
17
|
+
require_relative "merge/version"
|
|
18
|
+
|
|
19
|
+
# Json::Merge provides a JSON file smart merge system using tree-sitter AST analysis.
|
|
20
|
+
# It intelligently merges template and destination JSON files by identifying matching
|
|
21
|
+
# keys and resolving differences using structural signatures.
|
|
22
|
+
#
|
|
23
|
+
# For JSONC (JSON with Comments) support, see the jsonc-merge gem which handles
|
|
24
|
+
# configuration files that include comments
|
|
25
|
+
# (like devcontainer.json, tsconfig.json, VS Code settings, etc.).
|
|
26
|
+
#
|
|
27
|
+
# @example Basic usage
|
|
28
|
+
# template = File.read("template.json")
|
|
29
|
+
# destination = File.read("destination.json")
|
|
30
|
+
# merger = Json::Merge::SmartMerger.new(template, destination)
|
|
31
|
+
# result = merger.merge
|
|
32
|
+
#
|
|
33
|
+
# @example With debug information
|
|
34
|
+
# merger = Json::Merge::SmartMerger.new(template, destination)
|
|
35
|
+
# debug_result = merger.merge_with_debug
|
|
36
|
+
# puts debug_result[:content]
|
|
37
|
+
# puts debug_result[:statistics]
|
|
38
|
+
module Json
|
|
39
|
+
# Smart merge system for JSON files using tree-sitter AST analysis.
|
|
40
|
+
# Provides intelligent merging by understanding JSON structure
|
|
41
|
+
# rather than treating files as plain text.
|
|
42
|
+
#
|
|
43
|
+
# For JSONC (JSON with Comments) support, use the jsonc-merge gem instead.
|
|
44
|
+
#
|
|
45
|
+
# @see SmartMerger Main entry point for merge operations
|
|
46
|
+
# @see FileAnalysis Analyzes JSON structure
|
|
47
|
+
# @see ConflictResolver Resolves content conflicts
|
|
48
|
+
module Merge
|
|
49
|
+
# Base error class for Json::Merge
|
|
50
|
+
# Inherits from Ast::Merge::Error for consistency across merge gems.
|
|
51
|
+
class Error < Ast::Merge::Error; end
|
|
52
|
+
|
|
53
|
+
# Raised when a JSON file has parsing errors.
|
|
54
|
+
# Inherits from Ast::Merge::ParseError for consistency across merge gems.
|
|
55
|
+
#
|
|
56
|
+
# @example Handling parse errors
|
|
57
|
+
# begin
|
|
58
|
+
# analysis = FileAnalysis.new(json_content)
|
|
59
|
+
# rescue ParseError => e
|
|
60
|
+
# puts "JSON syntax error: #{e.message}"
|
|
61
|
+
# e.errors.each { |error| puts " #{error}" }
|
|
62
|
+
# end
|
|
63
|
+
class ParseError < Ast::Merge::ParseError
|
|
64
|
+
# @param message [String, nil] Error message (auto-generated if nil)
|
|
65
|
+
# @param content [String, nil] The JSON source that failed to parse
|
|
66
|
+
# @param errors [Array] Parse errors from tree-sitter
|
|
67
|
+
def initialize(message = nil, content: nil, errors: [])
|
|
68
|
+
super(message, errors: errors, content: content)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Raised when the template file has syntax errors.
|
|
73
|
+
#
|
|
74
|
+
# @example Handling template parse errors
|
|
75
|
+
# begin
|
|
76
|
+
# merger = SmartMerger.new(template, destination)
|
|
77
|
+
# result = merger.merge
|
|
78
|
+
# rescue TemplateParseError => e
|
|
79
|
+
# puts "Template syntax error: #{e.message}"
|
|
80
|
+
# e.errors.each do |error|
|
|
81
|
+
# puts " #{error.message}"
|
|
82
|
+
# end
|
|
83
|
+
# end
|
|
84
|
+
class TemplateParseError < ParseError; end
|
|
85
|
+
|
|
86
|
+
# Raised when the destination file has syntax errors.
|
|
87
|
+
#
|
|
88
|
+
# @example Handling destination parse errors
|
|
89
|
+
# begin
|
|
90
|
+
# merger = SmartMerger.new(template, destination)
|
|
91
|
+
# result = merger.merge
|
|
92
|
+
# rescue DestinationParseError => e
|
|
93
|
+
# puts "Destination syntax error: #{e.message}"
|
|
94
|
+
# e.errors.each do |error|
|
|
95
|
+
# puts " #{error.message}"
|
|
96
|
+
# end
|
|
97
|
+
# end
|
|
98
|
+
class DestinationParseError < ParseError; end
|
|
99
|
+
|
|
100
|
+
autoload :DebugLogger, "json/merge/debug_logger"
|
|
101
|
+
autoload :Emitter, "json/merge/emitter"
|
|
102
|
+
autoload :FileAnalysis, "json/merge/file_analysis"
|
|
103
|
+
autoload :MergeResult, "json/merge/merge_result"
|
|
104
|
+
autoload :NodeWrapper, "json/merge/node_wrapper"
|
|
105
|
+
autoload :ConflictResolver, "json/merge/conflict_resolver"
|
|
106
|
+
autoload :SmartMerger, "json/merge/smart_merger"
|
|
107
|
+
autoload :ObjectMatchRefiner, "json/merge/object_match_refiner"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Json::Merge::Version.class_eval do
|
|
112
|
+
extend VersionGem::Basic
|
|
113
|
+
end
|
data/lib/json-merge.rb
ADDED