json-merge 1.1.2 → 7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38f954bb103511024a69c2d85caa277ff1de782b88cd1c9b77ff0ae4809b6568
4
- data.tar.gz: 5beaadc8745638f82acac95e7d62be37dbfce579a250cde95cc9efafa329463c
3
+ metadata.gz: '07183e1164d6af24dd6dad4a4ded1b57b591bb5e9197bdb2fab3e49af2b176a3'
4
+ data.tar.gz: 3dc67caacf7ed48b31cf0f7860d9f6381fef38c68b7c5671b3a7a327c6e43240
5
5
  SHA512:
6
- metadata.gz: 3b6b6dc2287465a4789a78bdc32cbbb317924ead1bf24305642946b21a5b1dece510e829cde881f2c23d1f3a31d31a789812bfe23fc7dc38e6f7729ce9953837
7
- data.tar.gz: 210cfe2fa6b47a09f0949feddf641e57a499c939df837de420df8f0e7f0c4401b778922f6f1aa7a41ef9e645c2fe1a5be2330617deeaa7abb4ee15e1922e94db
6
+ metadata.gz: 79d8e551876b7b8cca367a73ab5d5b0041d35f649737983bb1029b85a5c7388aaa5f6f80605004183f2d107a3dff34292ca5b15ecbe38380b0efb2651d3fd2d5
7
+ data.tar.gz: e16ebd70f7f0b5776d1d21d290f3980031c87aa3adbd9b69c4870010f249ff6b04c5d391554390eb7dc08186b39b3f8f2efe1549a1b1e23c00586ad5d86dffd8
checksums.yaml.gz.sig CHANGED
Binary file
@@ -2,11 +2,10 @@
2
2
 
3
3
  module Json
4
4
  module Merge
5
- # Version information for Json::Merge
6
5
  module Version
7
- # Current version of the json-merge gem
8
- VERSION = "1.1.2"
6
+ VERSION = "7.0.0"
9
7
  end
10
- VERSION = Version::VERSION # traditional location
8
+
9
+ VERSION = Version::VERSION
11
10
  end
12
11
  end
data/lib/json/merge.rb CHANGED
@@ -1,125 +1,407 @@
1
1
  # frozen_string_literal: true
2
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.
3
+ require "json"
10
4
  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]
5
+
38
6
  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
7
  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)
8
+ PACKAGE_NAME = "json-merge"
9
+ DESTINATION_WINS_ARRAY_POLICY = {
10
+ surface: "array",
11
+ name: "destination_wins_array"
12
+ }.freeze
13
+ TRAILING_COMMA_FALLBACK_POLICY = {
14
+ surface: "fallback",
15
+ name: "trailing_comma_destination_fallback"
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ def json_feature_profile
21
+ {
22
+ family: "json",
23
+ supported_dialects: %w[json jsonc],
24
+ supported_policies: [DESTINATION_WINS_ARRAY_POLICY, TRAILING_COMMA_FALLBACK_POLICY]
25
+ }
26
+ end
27
+
28
+ def json_parse_request(source, dialect)
29
+ TreeHaver::ParserRequest.new(source: source, language: "json", dialect: dialect)
30
+ end
31
+
32
+ def parse_json_with_language_pack(source, dialect)
33
+ return unsupported_jsonc_language_pack_result if dialect != "json"
34
+
35
+ backend_result = TreeHaver.parse_with_language_pack(json_parse_request(source, dialect))
36
+ return { ok: false, diagnostics: backend_result[:diagnostics] } unless backend_result[:ok]
37
+
38
+ parse_json(source, dialect)
39
+ end
40
+
41
+ def parse_json(source, dialect)
42
+ normalized_source = dialect == "jsonc" ? strip_json_comments(source) : source
43
+ allows_comments = dialect == "jsonc"
44
+ return parse_failure("Trailing commas are not supported for #{dialect}.") if detect_trailing_comma(normalized_source)
45
+
46
+ parsed = JSON.parse(normalized_source)
47
+ canonical = JSON.generate(parsed)
48
+ analysis = {
49
+ kind: "json",
50
+ dialect: dialect,
51
+ allows_comments: allows_comments,
52
+ normalized_source: canonical,
53
+ root_kind: json_root_kind(parsed),
54
+ owners: collect_json_owners(parsed)
55
+ }
56
+ {
57
+ ok: true,
58
+ diagnostics: [],
59
+ analysis: analysis
60
+ }
61
+ rescue JSON::ParserError => e
62
+ parse_failure(e.message)
63
+ end
64
+
65
+ def match_json_owners(template, destination)
66
+ destination_paths = destination[:owners].to_h { |owner| [owner[:path], true] }
67
+ template_paths = template[:owners].to_h { |owner| [owner[:path], true] }
68
+
69
+ {
70
+ matched: template[:owners].filter_map do |owner|
71
+ next unless destination_paths[owner[:path]]
72
+
73
+ { template_path: owner[:path], destination_path: owner[:path] }
74
+ end,
75
+ unmatched_template: template[:owners].map { |owner| owner[:path] }.reject { |path| destination_paths[path] },
76
+ unmatched_destination: destination[:owners].map { |owner| owner[:path] }.reject { |path| template_paths[path] }
77
+ }
78
+ end
79
+
80
+ def merge_json(template_source, destination_source, dialect)
81
+ template_result = parse_json(template_source, dialect)
82
+ return { ok: false, diagnostics: template_result[:diagnostics] } unless template_result[:ok]
83
+
84
+ destination_result = parse_json(destination_source, dialect)
85
+ if destination_result[:ok]
86
+ output = JSON.generate(
87
+ merge_json_values(
88
+ JSON.parse(template_result.dig(:analysis, :normalized_source)),
89
+ JSON.parse(destination_result.dig(:analysis, :normalized_source))
90
+ )
91
+ )
92
+ return {
93
+ ok: true,
94
+ diagnostics: [],
95
+ output: output,
96
+ policies: [DESTINATION_WINS_ARRAY_POLICY]
97
+ }
98
+ end
99
+
100
+ fallback_source = try_destination_trailing_comma_fallback(destination_source)
101
+ if fallback_source
102
+ retried = parse_json(fallback_source, dialect)
103
+ if retried[:ok]
104
+ output = JSON.generate(
105
+ merge_json_values(
106
+ JSON.parse(template_result.dig(:analysis, :normalized_source)),
107
+ JSON.parse(retried.dig(:analysis, :normalized_source))
108
+ )
109
+ )
110
+ return {
111
+ ok: true,
112
+ diagnostics: [
113
+ fallback_applied("stripped trailing commas from destination before retrying json merge.")
114
+ ],
115
+ output: output,
116
+ policies: [DESTINATION_WINS_ARRAY_POLICY, TRAILING_COMMA_FALLBACK_POLICY]
117
+ }
118
+ end
69
119
  end
120
+
121
+ {
122
+ ok: false,
123
+ diagnostics: destination_result[:diagnostics].map do |diagnostic|
124
+ diagnostic[:category] == "parse_error" ? diagnostic.merge(category: "destination_parse_error") : diagnostic
125
+ end
126
+ }
70
127
  end
71
128
 
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
129
+ def parse_failure(message)
130
+ {
131
+ ok: false,
132
+ diagnostics: [parse_error(message)]
133
+ }
134
+ end
135
+ private_class_method :parse_failure
110
136
 
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
137
+ def unsupported_jsonc_language_pack_result
138
+ {
139
+ ok: false,
140
+ diagnostics: [
141
+ unsupported_feature("tree-sitter-language-pack json parsing currently supports only the json dialect.")
142
+ ]
143
+ }
144
+ end
145
+ private_class_method :unsupported_jsonc_language_pack_result
146
+
147
+ def parse_error(message)
148
+ { severity: "error", category: "parse_error", message: message }
149
+ end
150
+ private_class_method :parse_error
151
+
152
+ def unsupported_feature(message)
153
+ { severity: "error", category: "unsupported_feature", message: message }
154
+ end
155
+ private_class_method :unsupported_feature
156
+
157
+ def fallback_applied(message)
158
+ { severity: "warning", category: "fallback_applied", message: message }
159
+ end
160
+ private_class_method :fallback_applied
161
+
162
+ def json_root_kind(value)
163
+ return "object" if value.is_a?(Hash)
164
+ return "array" if value.is_a?(Array)
165
+
166
+ "scalar"
167
+ end
168
+ private_class_method :json_root_kind
169
+
170
+ def collect_json_owners(value, path = "")
171
+ if value.is_a?(Hash)
172
+ value.keys.sort.flat_map do |key|
173
+ next_path = "#{path}/#{key}"
174
+ [{ path: next_path, owner_kind: "member", match_key: key }] + collect_json_owners(value[key], next_path)
175
+ end
176
+ elsif value.is_a?(Array)
177
+ value.each_with_index.flat_map do |item, index|
178
+ next_path = "#{path}/#{index}"
179
+ [{ path: next_path, owner_kind: "element" }] + collect_json_owners(item, next_path)
180
+ end
181
+ else
182
+ []
183
+ end
184
+ end
185
+ private_class_method :collect_json_owners
186
+
187
+ def merge_json_values(template, destination)
188
+ if template.is_a?(Hash) && destination.is_a?(Hash)
189
+ ordered_merge_keys(template, destination).each_with_object({}) do |key, merged|
190
+ if !template.key?(key)
191
+ merged[key] = destination[key]
192
+ elsif !destination.key?(key)
193
+ merged[key] = template[key]
194
+ else
195
+ merged[key] = merge_json_values(template[key], destination[key])
196
+ end
197
+ end
198
+ else
199
+ destination
200
+ end
201
+ end
202
+ private_class_method :merge_json_values
122
203
 
123
- Json::Merge::Version.class_eval do
124
- extend VersionGem::Basic
204
+ def ordered_merge_keys(template, destination)
205
+ template.keys + destination.keys.reject { |key| template.key?(key) }
206
+ end
207
+ private_class_method :ordered_merge_keys
208
+
209
+ def detect_trailing_comma(source)
210
+ state = scanner_state
211
+ source.each_char.with_index do |char, index|
212
+ next_char = source[index + 1]
213
+ advance_scanner_state(state, char, next_char)
214
+ next if state[:in_line_comment] || state[:in_block_comment] || state[:in_string]
215
+
216
+ if char == ","
217
+ lookahead = source[(index + 1)..]
218
+ next unless lookahead
219
+
220
+ trimmed = lookahead.lstrip
221
+ return true if trimmed.start_with?("]", "}")
222
+ end
223
+ end
224
+ false
225
+ end
226
+ private_class_method :detect_trailing_comma
227
+
228
+ def strip_json_comments(source)
229
+ result = +""
230
+ state = scanner_state
231
+ index = 0
232
+ while index < source.length
233
+ char = source[index]
234
+ next_char = source[index + 1]
235
+
236
+ if state[:in_line_comment]
237
+ if char == "\n"
238
+ state[:in_line_comment] = false
239
+ result << "\n"
240
+ end
241
+ index += 1
242
+ next
243
+ end
244
+
245
+ if state[:in_block_comment]
246
+ if char == "*" && next_char == "/"
247
+ state[:in_block_comment] = false
248
+ index += 2
249
+ next
250
+ end
251
+ index += 1
252
+ next
253
+ end
254
+
255
+ if state[:in_string]
256
+ result << char
257
+ if state[:escaped]
258
+ state[:escaped] = false
259
+ elsif char == "\\"
260
+ state[:escaped] = true
261
+ elsif char == "\""
262
+ state[:in_string] = false
263
+ end
264
+ index += 1
265
+ next
266
+ end
267
+
268
+ if char == "\""
269
+ state[:in_string] = true
270
+ result << char
271
+ index += 1
272
+ next
273
+ end
274
+
275
+ if char == "/" && next_char == "/"
276
+ state[:in_line_comment] = true
277
+ index += 2
278
+ next
279
+ end
280
+
281
+ if char == "/" && next_char == "*"
282
+ state[:in_block_comment] = true
283
+ index += 2
284
+ next
285
+ end
286
+
287
+ result << char
288
+ index += 1
289
+ end
290
+ result
291
+ end
292
+ private_class_method :strip_json_comments
293
+
294
+ def try_destination_trailing_comma_fallback(source)
295
+ stripped = strip_trailing_commas(source)
296
+ return nil if stripped == source
297
+
298
+ stripped
299
+ end
300
+ private_class_method :try_destination_trailing_comma_fallback
301
+
302
+ def strip_trailing_commas(source)
303
+ result = +""
304
+ state = scanner_state
305
+ source.each_char.with_index do |char, index|
306
+ next_char = source[index + 1]
307
+
308
+ if state[:in_line_comment]
309
+ result << char
310
+ state[:in_line_comment] = false if char == "\n"
311
+ next
312
+ end
313
+
314
+ if state[:in_block_comment]
315
+ result << char
316
+ if char == "*" && next_char == "/"
317
+ result << next_char
318
+ state[:in_block_comment] = false
319
+ end
320
+ next
321
+ end
322
+
323
+ if state[:in_string]
324
+ result << char
325
+ if state[:escaped]
326
+ state[:escaped] = false
327
+ elsif char == "\\"
328
+ state[:escaped] = true
329
+ elsif char == "\""
330
+ state[:in_string] = false
331
+ end
332
+ next
333
+ end
334
+
335
+ if char == "\""
336
+ state[:in_string] = true
337
+ result << char
338
+ next
339
+ end
340
+
341
+ if char == "/" && next_char == "/"
342
+ state[:in_line_comment] = true
343
+ result << char
344
+ next
345
+ end
346
+
347
+ if char == "/" && next_char == "*"
348
+ state[:in_block_comment] = true
349
+ result << char
350
+ next
351
+ end
352
+
353
+ if char == ","
354
+ lookahead = source[(index + 1)..]
355
+ trimmed = lookahead&.lstrip
356
+ next if trimmed&.start_with?("]", "}")
357
+ end
358
+
359
+ result << char
360
+ end
361
+ result
362
+ end
363
+ private_class_method :strip_trailing_commas
364
+
365
+ def scanner_state
366
+ {
367
+ in_string: false,
368
+ in_line_comment: false,
369
+ in_block_comment: false,
370
+ escaped: false
371
+ }
372
+ end
373
+ private_class_method :scanner_state
374
+
375
+ def advance_scanner_state(state, char, next_char)
376
+ if state[:in_line_comment]
377
+ state[:in_line_comment] = false if char == "\n"
378
+ return
379
+ end
380
+
381
+ if state[:in_block_comment]
382
+ state[:in_block_comment] = false if char == "*" && next_char == "/"
383
+ return
384
+ end
385
+
386
+ if state[:in_string]
387
+ if state[:escaped]
388
+ state[:escaped] = false
389
+ elsif char == "\\"
390
+ state[:escaped] = true
391
+ elsif char == "\""
392
+ state[:in_string] = false
393
+ end
394
+ return
395
+ end
396
+
397
+ if char == "\""
398
+ state[:in_string] = true
399
+ elsif char == "/" && next_char == "/"
400
+ state[:in_line_comment] = true
401
+ elsif char == "/" && next_char == "*"
402
+ state[:in_block_comment] = true
403
+ end
404
+ end
405
+ private_class_method :advance_scanner_state
406
+ end
125
407
  end
data/lib/json-merge.rb CHANGED
@@ -1,6 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # For technical reasons, if we move to Zeitwerk, this cannot be require_relative.
4
- # See: https://github.com/fxn/zeitwerk#for_gem_extension
5
- # Hook for other libraries to load this library (e.g. via bundler)
6
- require "json/merge"
3
+ require_relative "json/merge"
data.tar.gz.sig CHANGED
Binary file