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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Debug logging utility for Json::Merge.
6
+ # Extends the base Ast::Merge::DebugLogger with Json-specific configuration.
7
+ #
8
+ # @example Enable debug logging
9
+ # ENV['JSON_MERGE_DEBUG'] = '1'
10
+ # DebugLogger.debug("Processing node", {type: "pair", line: 5})
11
+ #
12
+ # @example Disable debug logging (default)
13
+ # DebugLogger.debug("This won't be printed", {})
14
+ module DebugLogger
15
+ extend Ast::Merge::DebugLogger
16
+
17
+ # Json-specific configuration
18
+ self.env_var_name = 'JSON_MERGE_DEBUG'
19
+ self.log_prefix = '[Json::Merge]'
20
+
21
+ class << self
22
+ # Override log_node to handle Json-specific node types.
23
+ #
24
+ # @param node [Object] Node to log information about
25
+ # @param label [String] Label for the node
26
+ def log_node(node, label: 'Node')
27
+ return unless enabled?
28
+
29
+ info = case node
30
+ when Json::Merge::NodeWrapper
31
+ { type: node.type.to_s, lines: "#{node.start_line}..#{node.end_line}" }
32
+ else
33
+ extract_node_info(node)
34
+ end
35
+
36
+ debug(label, info)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Custom JSON emitter that preserves comments and formatting.
6
+ # This class provides utilities for emitting JSON while maintaining
7
+ # the original structure, comments, and style choices.
8
+ #
9
+ # Inherits common emitter functionality from Ast::Merge::EmitterBase.
10
+ #
11
+ # @example Basic usage
12
+ # emitter = Emitter.new
13
+ # emitter.emit_object_start
14
+ # emitter.emit_pair("key", '"value"')
15
+ # emitter.emit_object_end
16
+ class Emitter < Ast::Merge::EmitterBase
17
+ include Ast::Merge::EmitterLineMetadataSupport
18
+
19
+ # @return [Boolean] Whether next item needs a comma
20
+ attr_reader :needs_comma
21
+
22
+ # Initialize subclass-specific state (comma tracking for JSON)
23
+ def initialize_subclass_state(**_options)
24
+ @needs_comma = false
25
+ initialize_line_metadata_state
26
+ end
27
+
28
+ # Clear subclass-specific state
29
+ def clear_subclass_state
30
+ @needs_comma = false
31
+ clear_line_metadata_state
32
+ end
33
+
34
+ def emit_blank_line
35
+ append_line('')
36
+ end
37
+
38
+ # Emit a tracked comment from CommentTracker
39
+ # @param comment [Hash] Comment with :text, :indent, :block
40
+ def emit_tracked_comment(comment)
41
+ indent = ' ' * (comment[:indent] || 0)
42
+ append_line(if comment[:block]
43
+ "#{indent}/* #{comment[:text]} */"
44
+ else
45
+ "#{indent}// #{comment[:text]}"
46
+ end)
47
+ end
48
+
49
+ # Emit a single-line comment
50
+ #
51
+ # @param text [String] Comment text (without //)
52
+ # @param inline [Boolean] Whether this is an inline comment
53
+ def emit_comment(text, inline: false)
54
+ if inline
55
+ # Inline comments are appended to the last line
56
+ return if @lines.empty?
57
+
58
+ @lines[-1] = "#{@lines[-1]} // #{text}"
59
+ else
60
+ append_line("#{current_indent}// #{text}")
61
+ end
62
+ end
63
+
64
+ # Emit a block comment
65
+ #
66
+ # @param text [String] Comment text
67
+ def emit_block_comment(text)
68
+ append_line("#{current_indent}/* #{text} */")
69
+ end
70
+
71
+ # Emit object start
72
+ def emit_object_start(metadata: nil)
73
+ add_comma_if_needed
74
+ append_line("#{current_indent}{", metadata)
75
+ indent
76
+ @needs_comma = false
77
+ end
78
+
79
+ # Emit object end
80
+ def emit_object_end(metadata: nil)
81
+ dedent
82
+ append_line("#{current_indent}}", metadata)
83
+ @needs_comma = true
84
+ end
85
+
86
+ # Emit array start
87
+ #
88
+ # @param key [String, nil] Key name if this array is a value in an object
89
+ # @param inline_comment [String, nil] Optional inline comment for the opening line
90
+ def emit_array_start(key = nil, inline_comment: nil, metadata: nil)
91
+ add_comma_if_needed
92
+ line = if key
93
+ "#{current_indent}\"#{key}\": ["
94
+ else
95
+ "#{current_indent}["
96
+ end
97
+ line += " // #{inline_comment}" if inline_comment
98
+ append_line(line, metadata)
99
+ indent
100
+ @needs_comma = false
101
+ end
102
+
103
+ # Emit array end
104
+ def emit_array_end(metadata: nil)
105
+ dedent
106
+ append_line("#{current_indent}]", metadata)
107
+ @needs_comma = true
108
+ end
109
+
110
+ # Emit a key-value pair
111
+ #
112
+ # @param key [String] Key name (without quotes)
113
+ # @param value [String] Value (already formatted, e.g., '"string"', '123', 'true')
114
+ # @param inline_comment [String, nil] Optional inline comment
115
+ def emit_pair(key, value, inline_comment: nil, metadata: nil)
116
+ add_comma_if_needed
117
+ line = "#{current_indent}\"#{key}\": #{value}"
118
+ line += " // #{inline_comment}" if inline_comment
119
+ append_line(line, metadata)
120
+ @needs_comma = true
121
+ end
122
+
123
+ # Emit an array element
124
+ #
125
+ # @param value [String] Value (already formatted)
126
+ # @param inline_comment [String, nil] Optional inline comment
127
+ def emit_array_element(value, inline_comment: nil, metadata: nil)
128
+ add_comma_if_needed
129
+ line = "#{current_indent}#{value}"
130
+ line += " // #{inline_comment}" if inline_comment
131
+ append_line(line, metadata)
132
+ @needs_comma = true
133
+ end
134
+
135
+ # Emit a key with opening brace for nested object
136
+ # @param key [String] Key name
137
+ # @param inline_comment [String, nil] Optional inline comment for the opening line
138
+ def emit_nested_object_start(key, inline_comment: nil, metadata: nil)
139
+ add_comma_if_needed
140
+ line = "#{current_indent}\"#{key}\": {"
141
+ line += " // #{inline_comment}" if inline_comment
142
+ append_line(line, metadata)
143
+ indent
144
+ @needs_comma = false
145
+ end
146
+
147
+ # Emit closing brace for nested object
148
+ def emit_nested_object_end(metadata: nil)
149
+ dedent
150
+ append_line("#{current_indent}}", metadata)
151
+ @needs_comma = true
152
+ end
153
+
154
+ def emit_raw_lines(raw_lines, metadata: nil)
155
+ raw_lines.each_with_index do |line, idx|
156
+ append_line(
157
+ line.chomp,
158
+ expanded_line_metadata(metadata, idx)
159
+ )
160
+ end
161
+ end
162
+
163
+ def emit_raw_fragment(fragment, metadata: nil)
164
+ add_comma_if_needed
165
+ fragment.to_s.lines(chomp: true).each_with_index do |line, idx|
166
+ append_line(
167
+ "#{current_indent}#{line}",
168
+ expanded_line_metadata(metadata, idx)
169
+ )
170
+ end
171
+ @needs_comma = true
172
+ end
173
+
174
+ # Get the output as a JSON string
175
+ #
176
+ # @return [String]
177
+ def to_json(*_args)
178
+ to_s
179
+ end
180
+
181
+ private
182
+
183
+ def add_comma_if_needed
184
+ return unless @needs_comma && @lines.any?
185
+
186
+ line_index = @lines.length - 1
187
+ while line_index >= 0
188
+ line = @lines[line_index]
189
+ stripped = line.strip
190
+ if stripped.empty? || comment_line?(stripped)
191
+ line_index -= 1
192
+ next
193
+ end
194
+
195
+ break
196
+ end
197
+
198
+ return if line_index.negative?
199
+
200
+ # Add comma to the previous structural line if it doesn't already have one
201
+ last_line = @lines[line_index]
202
+ @lines[line_index] = add_comma_to_line(last_line)
203
+ end
204
+
205
+ def comment_line?(stripped_line)
206
+ stripped_line.start_with?('//', '/*', '*', '*/')
207
+ end
208
+
209
+ def add_comma_to_line(line)
210
+ return line if line.strip.empty?
211
+
212
+ inline_match = line.match(%r{\A(?<content>.*?)(?<spacing>\s+)(?<comment>//.*)\z})
213
+ if inline_match
214
+ content = inline_match[:content].rstrip
215
+ return line if content.end_with?(',', '{', '[')
216
+
217
+ return "#{content}, #{inline_match[:comment]}"
218
+ end
219
+
220
+ stripped = line.rstrip
221
+ return line if stripped.end_with?(',', '{', '[')
222
+
223
+ "#{line},"
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Analyzes JSON / JSONC file structure, extracting nodes, comments, and
6
+ # freeze blocks for merging.
7
+ class FileAnalysis
8
+ include Ast::Merge::FileAnalyzable
9
+
10
+ DEFAULT_FREEZE_TOKEN = 'json-merge'
11
+
12
+ attr_reader :comment_tracker, :ast, :errors, :dialect
13
+
14
+ class << self
15
+ def find_parser_path
16
+ TreeHaver::GrammarFinder.new(:json).find_library_path
17
+ end
18
+ end
19
+
20
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, parser_path: nil, dialect: :jsonc,
21
+ **_options)
22
+ @source = source
23
+ @lines = source.lines.map(&:chomp)
24
+ @freeze_token = freeze_token
25
+ @signature_generator = signature_generator
26
+ @parser_path = parser_path
27
+ @dialect = dialect.to_sym
28
+ @errors = []
29
+
30
+ @comment_tracker = CommentTracker.new(source)
31
+
32
+ DebugLogger.time('FileAnalysis#parse_json') { parse_json }
33
+ validate_jsonc_dialect! if valid? && @dialect == :jsonc
34
+
35
+ @freeze_blocks = extract_freeze_blocks
36
+ @nodes = integrate_nodes_and_freeze_blocks
37
+
38
+ DebugLogger.debug('FileAnalysis initialized', {
39
+ signature_generator: signature_generator ? 'custom' : 'default',
40
+ nodes_count: @nodes.size,
41
+ freeze_blocks: @freeze_blocks.size,
42
+ valid: valid?
43
+ })
44
+ end
45
+
46
+ def valid?
47
+ @errors.empty? && !@ast.nil?
48
+ end
49
+
50
+ def comment_capability
51
+ @comment_capability ||= comment_tracker.augment(owners: []).capability
52
+ end
53
+
54
+ def comment_support_style
55
+ @comment_support_style ||= shared_comment_support_style(
56
+ source: :json_source,
57
+ style: :c_style_line,
58
+ read_strategy: :source_augmented_portable_write
59
+ )
60
+ end
61
+
62
+ def comment_nodes
63
+ comment_tracker.comment_nodes
64
+ end
65
+
66
+ def comment_node_at(line_num)
67
+ comment_tracker.comment_node_at(line_num)
68
+ end
69
+
70
+ def comment_region_for_range(range, kind:, full_line_only: false)
71
+ comment_tracker.comment_region_for_range(
72
+ range,
73
+ kind: kind,
74
+ full_line_only: full_line_only
75
+ )
76
+ end
77
+
78
+ def comment_augmenter(owners: nil, **options)
79
+ comment_tracker.augment(
80
+ owners: owners || comment_augmenter_default_owners,
81
+ **options
82
+ )
83
+ end
84
+
85
+ def statements
86
+ @nodes || []
87
+ end
88
+ alias nodes statements
89
+
90
+ def in_freeze_block?(line_num)
91
+ @freeze_blocks.any? { |fb| fb.location.cover?(line_num) }
92
+ end
93
+
94
+ def freeze_block_at(line_num)
95
+ @freeze_blocks.find { |fb| fb.location.cover?(line_num) }
96
+ end
97
+
98
+ def generate_signature(node)
99
+ return super if @signature_generator
100
+ return super unless node.is_a?(NodeWrapper)
101
+ return super unless node.object?
102
+
103
+ return [:root_object] if statements.size == 1 && statements.first == node
104
+
105
+ super
106
+ end
107
+
108
+ def fallthrough_node?(value)
109
+ value.is_a?(NodeWrapper) || value.is_a?(FreezeNode) || super
110
+ end
111
+
112
+ def root_node
113
+ return @root_node if defined?(@root_node)
114
+ return @root_node = nil unless valid?
115
+
116
+ @root_node = NodeWrapper.new(@ast.root_node, lines: @lines, source: @source, dialect: @dialect)
117
+ end
118
+
119
+ def root_object
120
+ return @root_object if defined?(@root_object)
121
+ return @root_object = nil unless valid?
122
+
123
+ root = @ast.root_node
124
+ return @root_object = nil unless root
125
+
126
+ root.each do |child|
127
+ if child.type.to_s == 'object'
128
+ return @root_object = NodeWrapper.new(child, lines: @lines, source: @source,
129
+ dialect: @dialect)
130
+ end
131
+ end
132
+
133
+ @root_object = nil
134
+ end
135
+
136
+ def root_object_open_line
137
+ obj = root_object
138
+ return unless obj&.start_line
139
+
140
+ line_at(obj.start_line)&.chomp
141
+ end
142
+
143
+ def root_object_close_line
144
+ obj = root_object
145
+ return unless obj&.end_line
146
+
147
+ line_at(obj.end_line)&.chomp
148
+ end
149
+
150
+ def root_pairs
151
+ @root_pairs ||= begin
152
+ obj = root_object
153
+ obj ? obj.pairs : []
154
+ end
155
+ end
156
+
157
+ def comment_attachment_for(owner, line_num: nil, **options)
158
+ shared_comment_attachment_for(
159
+ owner,
160
+ tracker_attachment: @comment_tracker.comment_attachment_for(owner, line_num: line_num, **options),
161
+ line_num: line_num,
162
+ **options
163
+ )
164
+ end
165
+
166
+ # @return [Symbol]
167
+ def comment_attachment_strategy
168
+ :augmenter_preferred_tracker_layout
169
+ end
170
+
171
+ def ruleset_owner_selector
172
+ :line_bound_statements
173
+ end
174
+
175
+ def ruleset_render_family
176
+ :json_object_pairs
177
+ end
178
+
179
+ private
180
+
181
+ def layout_augmenter_default_owners
182
+ pairs = root_pairs.select { |pair| pair.respond_to?(:start_line) && pair.respond_to?(:end_line) }
183
+ return pairs unless pairs.empty?
184
+
185
+ comment_augmenter_default_owners
186
+ end
187
+
188
+ def root_merge_node
189
+ return unless valid?
190
+
191
+ root = @ast.root_node
192
+ return unless root
193
+
194
+ root_type = root.type.to_s
195
+ return NodeWrapper.new(root, lines: @lines, source: @source, dialect: @dialect) if %w[object
196
+ array].include?(root_type)
197
+
198
+ root.each do |child|
199
+ child_type = child.type.to_s
200
+ next if child_type == 'comment'
201
+ next unless %w[object array].include?(child_type)
202
+
203
+ return NodeWrapper.new(child, lines: @lines, source: @source, dialect: @dialect)
204
+ end
205
+
206
+ nil
207
+ end
208
+
209
+ def parse_json
210
+ Json::Merge.register_backend!
211
+ parser = TreeHaver.parser_for(parser_language, backend_type: :tree_sitter)
212
+
213
+ @ast = parser.parse(@source)
214
+
215
+ collect_parse_errors(@ast.root_node) if @ast&.root_node
216
+ rescue TreeHaver::Error => e
217
+ @errors << e
218
+ @ast = nil
219
+ rescue StandardError => e
220
+ @errors << e
221
+ @ast = nil
222
+ end
223
+
224
+ def parser_language
225
+ %i[jsonc json5].include?(@dialect) ? :json5 : :json
226
+ end
227
+
228
+ def validate_jsonc_dialect!
229
+ validate_jsonc_node!(@ast.root_node)
230
+ end
231
+
232
+ def validate_jsonc_node!(node)
233
+ native_type = node.respond_to?(:native_type) ? node.native_type.to_s : node.type.to_s
234
+ case native_type
235
+ when 'identifier'
236
+ add_jsonc_dialect_error(node, 'unquoted object keys are JSON5-only')
237
+ when 'string'
238
+ validate_json_literal!(node, String, 'single-quoted strings and JSON5 escapes are not supported')
239
+ when 'number'
240
+ validate_json_literal!(node, Numeric, 'JSON5 numeric literals are not supported')
241
+ end
242
+
243
+ node.each { |child| validate_jsonc_node!(child) } if node.respond_to?(:each)
244
+ end
245
+
246
+ # Tree-sitter identifies complete string and number tokens. Delegating those
247
+ # token semantics to Ruby's strict JSON parser avoids a second hand-written
248
+ # lexer while retaining AST-derived source locations.
249
+ def validate_json_literal!(node, expected_class, reason)
250
+ value = ::JSON.parse(node.text)
251
+ return if value.is_a?(expected_class)
252
+
253
+ add_jsonc_dialect_error(node, reason)
254
+ rescue ::JSON::ParserError
255
+ add_jsonc_dialect_error(node, reason)
256
+ end
257
+
258
+ def add_jsonc_dialect_error(node, reason)
259
+ @errors << Json::Merge::JsoncDialectError.new(
260
+ "JSONC rejects #{reason} at line #{node.start_line}, column #{node.start_point[:column] + 1}."
261
+ )
262
+ end
263
+
264
+ def collect_parse_errors(node, found_errors = [])
265
+ if node.type.to_s == 'ERROR' ||
266
+ (node.respond_to?(:has_error?) && node.has_error?) ||
267
+ (node.respond_to?(:missing?) && node.missing?)
268
+ found_errors << {
269
+ type: node.type.to_s,
270
+ start_point: node.respond_to?(:start_point) ? node.start_point : nil,
271
+ end_point: node.respond_to?(:end_point) ? node.end_point : nil,
272
+ text: node.to_s
273
+ }
274
+ end
275
+
276
+ node.each { |child| collect_parse_errors(child, found_errors) } if node.respond_to?(:each)
277
+ @errors.concat(found_errors) unless found_errors.empty?
278
+ found_errors
279
+ end
280
+
281
+ def extract_freeze_blocks
282
+ freeze_starts = []
283
+ freeze_ends = []
284
+
285
+ single_line_pattern = %r{^\s*//\s*#{Regexp.escape(@freeze_token)}:(freeze|unfreeze)\b}i
286
+ block_pattern = %r{^\s*/\*\s*#{Regexp.escape(@freeze_token)}:(freeze|unfreeze)\b.*\*/}i
287
+
288
+ @lines.each_with_index do |line, idx|
289
+ line_num = idx + 1
290
+
291
+ marker_type = nil
292
+ if (match = line.match(single_line_pattern))
293
+ marker_type = match[1]&.downcase
294
+ elsif (match = line.match(block_pattern))
295
+ marker_type = match[1]&.downcase
296
+ end
297
+
298
+ next unless marker_type
299
+
300
+ if marker_type == 'freeze'
301
+ freeze_starts << { line: line_num, marker: line }
302
+ elsif marker_type == 'unfreeze'
303
+ freeze_ends << { line: line_num, marker: line }
304
+ end
305
+ end
306
+
307
+ blocks = []
308
+ freeze_starts.each do |start_info|
309
+ matching_end = freeze_ends.find { |ending| ending[:line] > start_info[:line] }
310
+ next unless matching_end
311
+
312
+ freeze_ends.delete(matching_end)
313
+ blocks << FreezeNode.new(
314
+ start_line: start_info[:line],
315
+ end_line: matching_end[:line],
316
+ lines: @lines,
317
+ start_marker: start_info[:marker],
318
+ end_marker: matching_end[:marker]
319
+ )
320
+ end
321
+
322
+ blocks
323
+ end
324
+
325
+ def integrate_nodes_and_freeze_blocks
326
+ return @freeze_blocks.dup unless valid?
327
+
328
+ result = []
329
+ processed_lines = ::Set.new
330
+
331
+ @freeze_blocks.each do |fb|
332
+ (fb.start_line..fb.end_line).each { |ln| processed_lines << ln }
333
+ result << fb
334
+ end
335
+
336
+ root = root_merge_node
337
+ if root&.start_line
338
+ root_lines = (root.start_line..root.end_line).to_a
339
+ result << root unless root_lines.any? { |ln| processed_lines.include?(ln) }
340
+ end
341
+
342
+ result.sort_by { |node| node&.start_line || 0 }
343
+ end
344
+
345
+ def compute_node_signature(node)
346
+ case node
347
+ when FreezeNode
348
+ node.signature
349
+ when NodeWrapper
350
+ node.signature
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Wrapper to represent comment-marked freeze blocks as first-class nodes in
6
+ # JSON / JSONC files.
7
+ class FreezeNode < Ast::Merge::FreezeNodeBase
8
+ InvalidStructureError = Ast::Merge::FreezeNodeBase::InvalidStructureError
9
+ Location = Ast::Merge::FreezeNodeBase::Location
10
+
11
+ def initialize(start_line:, end_line:, lines:, start_marker: nil, end_marker: nil, pattern_type: :c_style_line)
12
+ block_lines = (start_line..end_line).map { |ln| lines[ln - 1] }
13
+
14
+ super(
15
+ start_line: start_line,
16
+ end_line: end_line,
17
+ lines: block_lines,
18
+ start_marker: start_marker,
19
+ end_marker: end_marker,
20
+ pattern_type: pattern_type,
21
+ )
22
+
23
+ validate_structure!
24
+ end
25
+
26
+ def signature
27
+ normalized = @lines.map { |line| line&.strip }.compact.reject(&:empty?).join("\n")
28
+ [:FreezeNode, normalized]
29
+ end
30
+
31
+ def object?
32
+ false
33
+ end
34
+
35
+ def array?
36
+ false
37
+ end
38
+
39
+ def pair?
40
+ false
41
+ end
42
+
43
+ def inspect
44
+ "#<#{self.class.name} lines=#{start_line}..#{end_line} content_length=#{slice&.length || 0}>"
45
+ end
46
+
47
+ private
48
+
49
+ def validate_structure!
50
+ validate_line_order!
51
+
52
+ return unless @lines.empty? || @lines.all?(&:nil?)
53
+
54
+ raise InvalidStructureError.new(
55
+ 'Freeze block is empty',
56
+ start_line: @start_line,
57
+ end_line: @end_line
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end