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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Tracks the result of a merge operation, including the merged content,
6
+ # decisions made, and statistics.
7
+ #
8
+ # Inherits decision constants and base functionality from Ast::Merge::MergeResultBase.
9
+ #
10
+ # @example Basic usage
11
+ # result = MergeResult.new
12
+ # result.add_line('"key": "value"', decision: :kept_template, source: :template)
13
+ # result.to_json # => '"key": "value"\n'
14
+ class MergeResult < Ast::Merge::MergeResultBase
15
+ include Ast::Merge::StructuredReviewApplySupport
16
+
17
+ # Inherit decision constants from base class
18
+ DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
19
+ DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
20
+ DECISION_MERGED = Ast::Merge::MergeResultBase::DECISION_MERGED
21
+ DECISION_ADDED = Ast::Merge::MergeResultBase::DECISION_ADDED
22
+ DECISION_FREEZE_BLOCK = Ast::Merge::MergeResultBase::DECISION_FREEZE_BLOCK
23
+
24
+ # @return [Hash] Statistics about the merge
25
+ attr_reader :statistics
26
+
27
+ # Initialize a new merge result
28
+ # @param options [Hash] Additional options for forward compatibility
29
+ def initialize(**options)
30
+ super(**options)
31
+ @statistics = {
32
+ template_lines: 0,
33
+ dest_lines: 0,
34
+ merged_lines: 0,
35
+ freeze_preserved_lines: 0,
36
+ total_decisions: 0
37
+ }
38
+ end
39
+
40
+ # Add a single line to the result
41
+ #
42
+ # @param line [String] Line content
43
+ # @param decision [Symbol] Decision that led to this line
44
+ # @param source [Symbol] Source of the line (:template, :destination, :merged)
45
+ # @param original_line [Integer, nil] Original line number
46
+ def add_line(line, decision:, source:, original_line: nil)
47
+ @lines << {
48
+ content: line,
49
+ decision: decision,
50
+ source: source,
51
+ original_line: original_line
52
+ }
53
+
54
+ track_statistics(decision, source)
55
+ track_decision(decision, source, line: original_line)
56
+ end
57
+
58
+ # Add multiple lines to the result
59
+ #
60
+ # @param lines [Array<String>] Lines to add
61
+ # @param decision [Symbol] Decision for all lines
62
+ # @param source [Symbol] Source of the lines
63
+ # @param start_line [Integer, nil] Starting original line number
64
+ def add_lines(lines, decision:, source:, start_line: nil)
65
+ lines.each_with_index do |line, idx|
66
+ original_line = start_line ? start_line + idx : nil
67
+ add_line(line, decision: decision, source: source, original_line: original_line)
68
+ end
69
+ end
70
+
71
+ # Add a blank line
72
+ #
73
+ # @param decision [Symbol] Decision for the blank line
74
+ # @param source [Symbol] Source
75
+ def add_blank_line(decision: DECISION_MERGED, source: :merged)
76
+ add_line('', decision: decision, source: source)
77
+ end
78
+
79
+ # Add content from a freeze block
80
+ #
81
+ # @param freeze_node [FreezeNode] Freeze block to add
82
+ def add_freeze_block(freeze_node)
83
+ freeze_node.lines.each_with_index do |line, idx|
84
+ add_line(
85
+ line.chomp,
86
+ decision: DECISION_FREEZE_BLOCK,
87
+ source: :destination,
88
+ original_line: freeze_node.start_line + idx
89
+ )
90
+ end
91
+ end
92
+
93
+ # Add content from a node wrapper
94
+ #
95
+ # @param node [NodeWrapper] Node to add
96
+ # @param decision [Symbol] Decision that led to keeping this node
97
+ # @param source [Symbol] Source of the node
98
+ # @param analysis [FileAnalysis] Analysis for accessing source lines
99
+ def add_node(node, decision:, source:, analysis:)
100
+ return unless node.start_line && node.end_line
101
+
102
+ (node.start_line..node.end_line).each do |line_num|
103
+ line = analysis.line_at(line_num)
104
+ next unless line
105
+
106
+ add_line(line.chomp, decision: decision, source: source, original_line: line_num)
107
+ end
108
+ end
109
+
110
+ # Get the merged content as a JSON string
111
+ #
112
+ # @return [String]
113
+ def to_json(*_args)
114
+ content = @lines.map { |l| l[:content] }.join("\n")
115
+ # Ensure trailing newline
116
+ content += "\n" unless content.end_with?("\n") || content.empty?
117
+ content
118
+ end
119
+
120
+ # Alias for to_json
121
+ # @return [String]
122
+ def content
123
+ to_json
124
+ end
125
+
126
+ # Alias for to_json (used by SmartMerger#merge)
127
+ # @return [String]
128
+ def to_s
129
+ to_json
130
+ end
131
+
132
+ # Get line count
133
+ # @return [Integer]
134
+ def line_count
135
+ @lines.size
136
+ end
137
+
138
+ private
139
+
140
+ def track_statistics(decision, _source)
141
+ @statistics[:total_decisions] += 1
142
+
143
+ case decision
144
+ when DECISION_KEPT_TEMPLATE
145
+ @statistics[:template_lines] += 1
146
+ when DECISION_KEPT_DEST
147
+ @statistics[:dest_lines] += 1
148
+ when DECISION_FREEZE_BLOCK
149
+ @statistics[:freeze_preserved_lines] += 1
150
+ else
151
+ @statistics[:merged_lines] += 1
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Wraps TreeHaver nodes with line information and signatures for JSON /
6
+ # JSONC merging.
7
+ class NodeWrapper < Ast::Merge::NodeWrapperBase
8
+ attr_reader :dialect
9
+
10
+ def initialize(node, lines:, source: nil, dialect: :jsonc, **options)
11
+ @dialect = dialect.to_sym
12
+ super(node, lines: lines, source: source, **options)
13
+ end
14
+
15
+ def object?
16
+ @node.type.to_s == 'object'
17
+ end
18
+
19
+ def array?
20
+ @node.type.to_s == 'array'
21
+ end
22
+
23
+ def string?
24
+ @node.type.to_s == 'string'
25
+ end
26
+
27
+ def number?
28
+ @node.type.to_s == 'number'
29
+ end
30
+
31
+ def boolean?
32
+ %w[true false].include?(@node.type.to_s)
33
+ end
34
+
35
+ def null?
36
+ @node.type.to_s == 'null'
37
+ end
38
+
39
+ def pair?
40
+ @node.type.to_s == 'pair'
41
+ end
42
+
43
+ def comment?
44
+ @node.type.to_s == 'comment'
45
+ end
46
+
47
+ def key_name
48
+ return unless pair?
49
+
50
+ key_node = find_child_by_field('key') || semantic_children.first
51
+ return unless key_node
52
+
53
+ Json::Merge.object_key_for_literal(node_text(key_node), dialect: dialect)
54
+ end
55
+
56
+ def value_node
57
+ return unless pair?
58
+
59
+ value = find_child_by_field('value') || semantic_children[1]
60
+ return unless value
61
+
62
+ NodeWrapper.new(value, lines: @lines, source: @source, dialect: dialect)
63
+ end
64
+
65
+ def pairs
66
+ return [] unless object?
67
+
68
+ result = []
69
+ @node.each do |child|
70
+ next if child.type.to_s == 'comment'
71
+ next unless child.type.to_s == 'pair'
72
+
73
+ result << NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
74
+ end
75
+ result
76
+ end
77
+
78
+ def elements
79
+ return [] unless array?
80
+
81
+ result = []
82
+ @node.each do |child|
83
+ child_type = child.type.to_s
84
+ next if child_type == 'comment'
85
+ next if child_type == ','
86
+ next if child_type == '['
87
+ next if child_type == ']'
88
+
89
+ result << NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
90
+ end
91
+ result
92
+ end
93
+
94
+ def mergeable_children
95
+ case type
96
+ when :object
97
+ pairs
98
+ when :array
99
+ elements
100
+ else
101
+ []
102
+ end
103
+ end
104
+
105
+ def container?
106
+ object? || array?
107
+ end
108
+
109
+ def root_level_container?
110
+ return false unless container?
111
+
112
+ parent_node = @node.parent if @node.respond_to?(:parent)
113
+ return false unless parent_node
114
+
115
+ parent_node.type.to_s == 'document'
116
+ end
117
+
118
+ def opening_line
119
+ return unless container? && @start_line
120
+
121
+ return opening_bracket if @start_line == @end_line
122
+
123
+ @lines[@start_line - 1]
124
+ end
125
+
126
+ def closing_line
127
+ return unless container? && @end_line
128
+
129
+ return closing_bracket if @start_line == @end_line
130
+
131
+ @lines[@end_line - 1]
132
+ end
133
+
134
+ def opening_bracket
135
+ return '{' if object?
136
+ return '[' if array?
137
+
138
+ nil
139
+ end
140
+
141
+ def closing_bracket
142
+ return '}' if object?
143
+ return ']' if array?
144
+
145
+ nil
146
+ end
147
+
148
+ def find_child_by_field(field_name)
149
+ return unless @node.respond_to?(:child_by_field_name)
150
+
151
+ @node.child_by_field_name(field_name)
152
+ end
153
+
154
+ def find_child_by_type(type_name)
155
+ return unless @node.respond_to?(:each)
156
+
157
+ @node.each do |child|
158
+ return child if child.type.to_s == type_name
159
+ end
160
+ nil
161
+ end
162
+
163
+ def semantic_children
164
+ return [] unless @node.respond_to?(:each)
165
+
166
+ @node.each.filter_map do |child|
167
+ child_type = child.type.to_s
168
+ next if %w[comment , : { } [ ]].include?(child_type)
169
+
170
+ child
171
+ end
172
+ end
173
+
174
+ protected
175
+
176
+ def wrap_child(child)
177
+ NodeWrapper.new(child, lines: @lines, source: @source, dialect: dialect)
178
+ end
179
+
180
+ def compute_signature(node)
181
+ node_type = node.type.to_s
182
+
183
+ case node_type
184
+ when 'document'
185
+ child = nil
186
+ node.each do |candidate|
187
+ child = candidate unless candidate.type.to_s == 'comment'
188
+ break if child
189
+ end
190
+ child_type = child&.type&.to_s
191
+ [:document, child_type]
192
+ when 'object'
193
+ if root_level_container?
194
+ [:root_object]
195
+ else
196
+ keys = extract_object_keys(node)
197
+ [:object, keys.sort]
198
+ end
199
+ when 'array'
200
+ if root_level_container?
201
+ [:root_array]
202
+ else
203
+ elements_count = 0
204
+ node.each { |candidate| elements_count += 1 unless %w[comment , \[ \]].include?(candidate.type.to_s) }
205
+ [:array, elements_count]
206
+ end
207
+ when 'pair'
208
+ [:pair, key_name]
209
+ when 'string'
210
+ [:string, node_text(node)]
211
+ when 'number'
212
+ [:number, node_text(node)]
213
+ when 'true', 'false'
214
+ [:boolean, node.type.to_s]
215
+ when 'null'
216
+ [:null]
217
+ when 'comment'
218
+ [:comment, node_text(node)&.strip]
219
+ else
220
+ content_preview = node_text(node)&.slice(0, 50)&.strip
221
+ [node_type.to_sym, content_preview]
222
+ end
223
+ end
224
+
225
+ private
226
+
227
+ def extract_object_keys(object_node)
228
+ keys = []
229
+ object_node.each do |child|
230
+ next unless child.type.to_s == 'pair'
231
+
232
+ key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name('key') : nil
233
+
234
+ unless key_node
235
+ begin
236
+ child_each = child.method(:each)
237
+ child_each.call do |pair_child|
238
+ pair_child_type = pair_child.type.to_s
239
+ next if [':', 'comment'].include?(pair_child_type)
240
+
241
+ key_node = pair_child
242
+ break
243
+ end
244
+ rescue StandardError
245
+ next
246
+ end
247
+ end
248
+
249
+ next unless key_node
250
+
251
+ key_text = Json::Merge.object_key_for_literal(node_text(key_node), dialect: dialect)
252
+ keys << key_text if key_text
253
+ end
254
+ keys
255
+ end
256
+ end
257
+ end
258
+ end