json-merge 7.0.0 → 7.1.3

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,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # High-level merger for JSON / JSONC content.
6
+ #
7
+ # @example Basic usage
8
+ # merger = SmartMerger.new(template_content, dest_content)
9
+ # result = merger.merge
10
+ # File.write("merged.json", result.output)
11
+ #
12
+ # @example With options
13
+ # merger = SmartMerger.new(template, dest,
14
+ # preference: :template,
15
+ # add_template_only_nodes: true)
16
+ # result = merger.merge
17
+ #
18
+ # @example Enable fuzzy matching
19
+ # merger = SmartMerger.new(template, dest, match_refiner: ObjectMatchRefiner.new)
20
+ #
21
+ # @example With regions (embedded content)
22
+ # merger = SmartMerger.new(template, dest,
23
+ # regions: [{ detector: SomeDetector.new, merger_class: SomeMerger }])
24
+ class SmartMerger < ::Ast::Merge::SmartMergerBase
25
+ include ::Ast::Merge::Runtime::RootSessionSupport
26
+
27
+ attr_reader :runtime_session, :corruption_handling, :dialect
28
+
29
+ # Creates a new SmartMerger
30
+ #
31
+ # @param template_content [String] Template JSON content
32
+ # @param dest_content [String] Destination JSON content
33
+ # @param signature_generator [Proc, nil] Custom signature generator
34
+ # @param preference [Symbol, Hash] :destination, :template, or per-type Hash
35
+ # @param add_template_only_nodes [Boolean] Whether to add nodes only found in template
36
+ # @param remove_template_missing_nodes [Boolean] Whether to remove nodes missing from template
37
+ # @param freeze_token [String, nil] Token for freeze block markers
38
+ # @param match_refiner [#call, nil] Match refiner for fuzzy matching
39
+ # @param regions [Array<Hash>, nil] Region configurations for nested merging
40
+ # @param region_placeholder [String, nil] Custom placeholder for regions
41
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
42
+ # for per-node-type merge preferences
43
+ # @param options [Hash] Additional options for forward compatibility
44
+ def initialize(
45
+ template_content,
46
+ dest_content,
47
+ signature_generator: nil,
48
+ preference: :destination,
49
+ add_template_only_nodes: false,
50
+ remove_template_missing_nodes: false,
51
+ corruption_handling: :heal,
52
+ freeze_token: nil,
53
+ match_refiner: nil,
54
+ regions: nil,
55
+ region_placeholder: nil,
56
+ node_typing: nil,
57
+ merge_arrays: true,
58
+ preserve_atomic_formatting: false,
59
+ dialect: :jsonc,
60
+ **options
61
+ )
62
+ @remove_template_missing_nodes = remove_template_missing_nodes
63
+ @corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
64
+ @merge_arrays = merge_arrays
65
+ @preserve_atomic_formatting = preserve_atomic_formatting
66
+ @dialect = dialect.to_sym
67
+ unless %i[json jsonc json5].include?(@dialect)
68
+ raise ArgumentError, "Unsupported JSON dialect #{dialect.inspect}. Expected json, jsonc, or json5."
69
+ end
70
+
71
+ super(
72
+ template_content,
73
+ dest_content,
74
+ signature_generator: signature_generator,
75
+ preference: preference,
76
+ add_template_only_nodes: add_template_only_nodes,
77
+ remove_template_missing_nodes: remove_template_missing_nodes,
78
+ freeze_token: freeze_token,
79
+ match_refiner: match_refiner,
80
+ regions: regions,
81
+ region_placeholder: region_placeholder,
82
+ node_typing: node_typing,
83
+ merge_arrays: merge_arrays,
84
+ preserve_atomic_formatting: preserve_atomic_formatting,
85
+ dialect: @dialect,
86
+ **options
87
+ )
88
+ end
89
+
90
+ # Backward-compatible options hash
91
+ #
92
+ # @return [Hash] The merge options
93
+ def options
94
+ {
95
+ preference: @preference,
96
+ add_template_only_nodes: @add_template_only_nodes,
97
+ remove_template_missing_nodes: @remove_template_missing_nodes,
98
+ resolution_mode: @resolution_mode,
99
+ unresolved_policy: @unresolved_policy.to_h,
100
+ corruption_handling: @corruption_handling,
101
+ match_refiner: @match_refiner,
102
+ dialect: @dialect
103
+ }
104
+ end
105
+
106
+ # Perform the merge operation and return the full MergeResult object.
107
+ #
108
+ # @return [MergeResult] The merge result containing merged JSON content and metadata
109
+ def merge_result
110
+ return @merge_result if @merge_result
111
+
112
+ root_operation = start_runtime_session!
113
+ @merge_result = super
114
+ complete_runtime_session!(root_operation, @merge_result)
115
+ @merge_result
116
+ rescue StandardError => e
117
+ fail_runtime_session!(root_operation, e)
118
+ raise
119
+ end
120
+
121
+ # Perform the merge and return detailed runtime-aware debug information.
122
+ #
123
+ # @return [Hash] Hash containing :content, :debug, :runtime, :statistics, and :decisions
124
+ def merge_with_debug
125
+ result_obj = merge_result
126
+ template_analysis_debug = {
127
+ valid: @template_analysis.valid?,
128
+ nodes: @template_analysis.nodes.size,
129
+ freeze_blocks: @template_analysis.freeze_blocks.size
130
+ }
131
+ dest_analysis_debug = {
132
+ valid: @dest_analysis.valid?,
133
+ nodes: @dest_analysis.nodes.size,
134
+ freeze_blocks: @dest_analysis.freeze_blocks.size
135
+ }
136
+
137
+ {
138
+ content: result_obj.to_json,
139
+ debug: {
140
+ template_nodes: template_analysis_debug[:nodes],
141
+ dest_nodes: dest_analysis_debug[:nodes],
142
+ preference: @preference,
143
+ add_template_only_nodes: @add_template_only_nodes,
144
+ remove_template_missing_nodes: @remove_template_missing_nodes,
145
+ corruption_handling: @corruption_handling,
146
+ freeze_token: @freeze_token,
147
+ runtime_operation_count: runtime_session&.operations&.size || 0,
148
+ runtime_diagnostic_count: runtime_session&.diagnostics&.size || 0
149
+ },
150
+ runtime: runtime_session&.to_h,
151
+ statistics: result_obj.statistics,
152
+ decisions: result_obj.decision_summary,
153
+ template_analysis: template_analysis_debug,
154
+ dest_analysis: dest_analysis_debug
155
+ }
156
+ end
157
+
158
+ protected
159
+
160
+ # @return [Class] The analysis class for JSON files
161
+ def analysis_class
162
+ FileAnalysis
163
+ end
164
+
165
+ # @return [String] The default freeze token (not used for JSON)
166
+ def default_freeze_token
167
+ 'json-merge'
168
+ end
169
+
170
+ # @return [Class] The resolver class for JSON files
171
+ def resolver_class
172
+ ConflictResolver
173
+ end
174
+
175
+ # @return [Class] The result class for JSON files
176
+ def result_class
177
+ MergeResult
178
+ end
179
+
180
+ # Perform the JSON-specific merge
181
+ #
182
+ # @return [MergeResult] The merge result
183
+ def perform_merge
184
+ @resolver.resolve(@result)
185
+
186
+ DebugLogger.debug('Merge complete', {
187
+ lines: @result.line_count,
188
+ decisions: @result.statistics
189
+ })
190
+
191
+ @result
192
+ end
193
+
194
+ # Build the resolver with JSON-specific signature
195
+ def build_resolver
196
+ ConflictResolver.new(
197
+ @template_analysis,
198
+ @dest_analysis,
199
+ preference: @preference,
200
+ add_template_only_nodes: @add_template_only_nodes,
201
+ remove_template_missing_nodes: @remove_template_missing_nodes,
202
+ resolution_mode: @resolution_mode,
203
+ corruption_handling: @corruption_handling,
204
+ match_refiner: @match_refiner,
205
+ node_typing: @node_typing,
206
+ merge_arrays: @merge_arrays,
207
+ preserve_atomic_formatting: @preserve_atomic_formatting
208
+ )
209
+ end
210
+
211
+ # Build the result (no-arg constructor for JSON)
212
+ def build_result
213
+ MergeResult.new
214
+ end
215
+
216
+ # @return [Class] The template parse error class for JSON
217
+ def template_parse_error_class
218
+ TemplateParseError
219
+ end
220
+
221
+ # @return [Class] The destination parse error class for JSON
222
+ def destination_parse_error_class
223
+ DestinationParseError
224
+ end
225
+
226
+ private
227
+
228
+ def start_runtime_session!
229
+ start_runtime_root_session!(
230
+ surface_kind: :json_document,
231
+ declared_language: :json,
232
+ effective_language: :json,
233
+ operation_id: 'json-document-root',
234
+ delegate_name: 'json-runtime',
235
+ policy_context: {
236
+ preference: @preference,
237
+ add_template_only_nodes: @add_template_only_nodes,
238
+ remove_template_missing_nodes: @remove_template_missing_nodes,
239
+ resolution_mode: @resolution_mode,
240
+ unresolved_policy: @unresolved_policy.to_h
241
+ },
242
+ metadata: { merger: self.class.name },
243
+ options: {
244
+ preference: @preference,
245
+ add_template_only_nodes: @add_template_only_nodes,
246
+ remove_template_missing_nodes: @remove_template_missing_nodes,
247
+ resolution_mode: @resolution_mode,
248
+ unresolved_policy: @unresolved_policy.to_h
249
+ },
250
+ language_chain: [:json],
251
+ delegate_metadata: { merger: self.class.name }
252
+ )
253
+ end
254
+
255
+ def complete_runtime_session!(root_operation, merge_result)
256
+ complete_runtime_root_session!(
257
+ root_operation: root_operation,
258
+ replacement_text: merge_result.to_json,
259
+ unresolved_cases: merge_result.unresolved_cases,
260
+ metadata: {
261
+ stats: merge_result.statistics,
262
+ decisions: merge_result.decision_summary
263
+ }
264
+ )
265
+ end
266
+
267
+ def fail_runtime_session!(root_operation, error)
268
+ fail_runtime_root_session!(
269
+ root_operation: root_operation,
270
+ error: error,
271
+ kind: :merge_failed
272
+ )
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Locates JSON object-pair owners that can be copied as whole source lines.
6
+ class SourceLocator
7
+ Range = Data.define(:start_line, :end_line)
8
+
9
+ def initialize(source, dialect:, backend: nil)
10
+ @source = source
11
+ @lines = source.lines
12
+ Json::Merge.register_backend!
13
+ backend_id = backend.to_s.empty? ? Json::Merge::TREE_SITTER_BACKEND.id : backend.to_s
14
+ @analysis = TreeHaver.with_backend(backend_id) do
15
+ FileAnalysis.new(source, dialect: dialect)
16
+ end
17
+ end
18
+
19
+ def pair_range(path)
20
+ pair = pair_for(path)
21
+ return unless pair
22
+ return unless whole_line_owner?(pair)
23
+
24
+ Range.new(start_line: pair.start_line, end_line: pair.end_line)
25
+ end
26
+
27
+ private
28
+
29
+ def pair_for(path)
30
+ current = @analysis.root_object
31
+ pair = nil
32
+ segments = pointer_segments(path)
33
+ until segments.empty?
34
+ segment = segments.shift
35
+ pair = pair_with_key(current, segment)
36
+ return unless pair
37
+
38
+ current = pair.value_node
39
+ end
40
+ pair
41
+ end
42
+
43
+ def pair_with_key(object, key)
44
+ return unless object&.object?
45
+
46
+ object.pairs.find { |candidate| candidate.key_name == key }
47
+ end
48
+
49
+ def pointer_segments(path)
50
+ return [] if path.to_s.empty?
51
+
52
+ path.to_s.split('/').drop(1).map do |segment|
53
+ segment.gsub('~1', '/').gsub('~0', '~')
54
+ end
55
+ end
56
+
57
+ def whole_line_owner?(pair)
58
+ start_line = pair.start_line
59
+ end_line = pair.end_line
60
+ return false unless start_line && end_line
61
+ return false unless @lines.fetch(end_line - 1, '').end_with?("\n")
62
+
63
+ start_column = pair.node.start_point.fetch(:column)
64
+ @lines.fetch(start_line - 1, '').slice(0, start_column).to_s.strip.empty?
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Computes base-aware JSON-family semantic decisions without rendering.
6
+ # rubocop:disable Metrics/ClassLength -- recursive decisions and classifications form one algorithm
7
+ class ThreeWayDecision
8
+ State = Data.define(:present, :value) do
9
+ def self.present(value)
10
+ new(present: true, value: value)
11
+ end
12
+
13
+ def self.missing
14
+ @missing ||= new(present: false, value: nil)
15
+ end
16
+
17
+ def to_h
18
+ present ? { present: true, value: value } : { present: false }
19
+ end
20
+ end
21
+
22
+ Result = Data.define(:state, :conflicts, :changes) do
23
+ def present?
24
+ state.present
25
+ end
26
+
27
+ def value
28
+ state.value
29
+ end
30
+
31
+ def conflicted?
32
+ !conflicts.empty?
33
+ end
34
+ end
35
+
36
+ def call(base:, ours:, theirs:)
37
+ merge_states(
38
+ State.present(base),
39
+ State.present(ours),
40
+ State.present(theirs),
41
+ path: ''
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def merge_states(base, ours, theirs, path:)
48
+ return success(ours) if ours == theirs
49
+ return success(theirs, change: classify(path, base, ours, theirs)) if base == ours
50
+ return success(ours, change: classify(path, base, ours, theirs)) if base == theirs
51
+ return merge_added_objects(ours, theirs, path: path) if added_object_states?(base, ours, theirs)
52
+ return merge_objects(base, ours, theirs, path: path) if object_states?(base, ours, theirs)
53
+
54
+ conflict(base, ours, theirs, path: path)
55
+ end
56
+
57
+ def merge_objects(base, ours, theirs, path:)
58
+ aggregate = { value: {}, conflicts: [], changes: [] }
59
+ states = [base, ours, theirs]
60
+ ordered_keys(base.value, ours.value, theirs.value).each do |key|
61
+ merge_object_key(aggregate, key, states, path: path)
62
+ end
63
+
64
+ Result.new(
65
+ state: State.present(aggregate[:value]),
66
+ conflicts: aggregate[:conflicts].freeze,
67
+ changes: aggregate[:changes].freeze
68
+ )
69
+ end
70
+
71
+ def merge_added_objects(ours, theirs, path:)
72
+ merge_objects(State.present({}), ours, theirs, path: path)
73
+ end
74
+
75
+ def merge_object_key(aggregate, key, states, path:)
76
+ child_states = states.map { |state| state_for(state.value, key) }
77
+ child = merge_states(*child_states, path: child_path(path, key))
78
+ aggregate[:value][key] = child.value if child.present?
79
+ aggregate[:conflicts].concat(child.conflicts)
80
+ aggregate[:changes].concat(child.changes)
81
+ end
82
+
83
+ def success(state, change: nil)
84
+ Result.new(
85
+ state: state,
86
+ conflicts: [].freeze,
87
+ changes: change ? [change].freeze : [].freeze
88
+ )
89
+ end
90
+
91
+ def conflict(base, ours, theirs, path:)
92
+ classification = classify(path, base, ours, theirs)
93
+ selected = ours.present ? ours : theirs
94
+ Result.new(
95
+ state: selected,
96
+ conflicts: [conflict_payload(base, ours, theirs, path, classification)].freeze,
97
+ changes: [classification].freeze
98
+ )
99
+ end
100
+
101
+ def conflict_payload(base, ours, theirs, path, classification)
102
+ {
103
+ conflict_id: conflict_id(path),
104
+ category: conflict_category(ours, theirs),
105
+ path: path,
106
+ base: base.to_h,
107
+ ours: ours.to_h,
108
+ theirs: theirs.to_h,
109
+ change_classification: classification
110
+ }.freeze
111
+ end
112
+
113
+ def classify(path, base, ours, theirs)
114
+ {
115
+ path: path,
116
+ ours: change_state(base, ours),
117
+ theirs: change_state(base, theirs)
118
+ }.freeze
119
+ end
120
+
121
+ def change_state(base, side)
122
+ return :unchanged if base == side
123
+ return :added unless base.present
124
+ return :deleted unless side.present
125
+
126
+ :edited
127
+ end
128
+
129
+ def conflict_category(ours, theirs)
130
+ ours.present && theirs.present ? :edit_edit : :delete_edit
131
+ end
132
+
133
+ def object_states?(*states)
134
+ states.all? { |state| state.present && state.value.is_a?(Hash) }
135
+ end
136
+
137
+ def added_object_states?(base, ours, theirs)
138
+ !base.present &&
139
+ ours.present && ours.value.is_a?(Hash) &&
140
+ theirs.present && theirs.value.is_a?(Hash)
141
+ end
142
+
143
+ def state_for(object, key)
144
+ object.key?(key) ? State.present(object.fetch(key)) : State.missing
145
+ end
146
+
147
+ def ordered_keys(*objects)
148
+ objects.each_with_object([]) do |object, keys|
149
+ object.each_key { |key| keys << key unless keys.include?(key) }
150
+ end
151
+ end
152
+
153
+ def child_path(path, key)
154
+ "#{path}/#{escape_path_segment(key)}"
155
+ end
156
+
157
+ def escape_path_segment(key)
158
+ key.to_s.gsub('~', '~0').gsub('/', '~1')
159
+ end
160
+
161
+ def conflict_id(path)
162
+ suffix = path.delete_prefix('/').gsub(/[^a-zA-Z0-9_-]+/, '-')
163
+ "json-conflict-#{suffix.empty? ? 'root' : suffix}"
164
+ end
165
+ end
166
+ # rubocop:enable Metrics/ClassLength
167
+ end
168
+ end
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Json
4
4
  module Merge
5
+ # Version namespace for this gem.
5
6
  module Version
6
- VERSION = "7.0.0"
7
+ # Current gem version.
8
+ VERSION = '7.1.3'
7
9
  end
8
-
9
- VERSION = Version::VERSION
10
+ # Current gem version exposed at the traditional constant location.
11
+ VERSION = Version::VERSION # Traditional Constant Location
10
12
  end
11
13
  end