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.
@@ -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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Version information for Json::Merge
6
+ module Version
7
+ # Current version of the json-merge gem
8
+ VERSION = "1.0.0"
9
+ end
10
+ VERSION = Version::VERSION # traditional location
11
+ end
12
+ 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
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"