json-merge 1.1.2 → 7.1.1

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.
@@ -2,97 +2,137 @@
2
2
 
3
3
  module Json
4
4
  module Merge
5
- # Analyzes JSON file structure, extracting statements for merging.
6
- # This is the main analysis class that prepares JSON content for merging.
7
- #
8
- # @example Basic usage
9
- # analysis = FileAnalysis.new(json_source)
10
- # analysis.valid? # => true
11
- # analysis.statements # => [NodeWrapper, ...]
5
+ # Analyzes JSON / JSONC file structure, extracting nodes, comments, and
6
+ # freeze blocks for merging.
12
7
  class FileAnalysis
13
8
  include Ast::Merge::FileAnalyzable
14
9
 
15
- # @return [TreeHaver::Tree, nil] Parsed AST
16
- attr_reader :ast
10
+ DEFAULT_FREEZE_TOKEN = 'json-merge'
17
11
 
18
- # @return [Array] Parse errors if any
19
- attr_reader :errors
12
+ attr_reader :comment_tracker, :ast, :errors, :dialect
20
13
 
21
14
  class << self
22
- # Find the parser library path using TreeHaver::GrammarFinder
23
- #
24
- # @return [String, nil] Path to the parser library or nil if not found
25
15
  def find_parser_path
26
16
  TreeHaver::GrammarFinder.new(:json).find_library_path
27
17
  end
28
18
  end
29
19
 
30
- # Initialize file analysis
31
- #
32
- # @param source [String] JSON source code to analyze
33
- # @param signature_generator [Proc, nil] Custom signature generator
34
- # @param parser_path [String, nil] Path to tree-sitter-json parser library
35
- # @param options [Hash] Additional options (forward compatibility - freeze_token, node_typing, etc.)
36
- def initialize(source, signature_generator: nil, parser_path: nil, **options)
20
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, parser_path: nil, dialect: :jsonc,
21
+ **_options)
37
22
  @source = source
38
23
  @lines = source.lines.map(&:chomp)
24
+ @freeze_token = freeze_token
39
25
  @signature_generator = signature_generator
40
- @parser_path = parser_path || self.class.find_parser_path
26
+ @parser_path = parser_path
27
+ @dialect = dialect.to_sym
41
28
  @errors = []
42
- # **options captures any additional parameters (e.g., freeze_token, node_typing) for forward compatibility
43
29
 
44
- # Parse the JSON
45
- DebugLogger.time("FileAnalysis#parse_json") { parse_json }
30
+ @comment_tracker = CommentTracker.new(source)
46
31
 
47
- @statements = integrate_nodes
32
+ DebugLogger.time('FileAnalysis#parse_json') { parse_json }
33
+ validate_jsonc_dialect! if valid? && @dialect == :jsonc
48
34
 
49
- DebugLogger.debug("FileAnalysis initialized", {
50
- signature_generator: signature_generator ? "custom" : "default",
51
- statements_count: @statements.size,
52
- valid: valid?,
53
- })
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
+ })
54
44
  end
55
45
 
56
- # Check if parse was successful
57
- # @return [Boolean]
58
46
  def valid?
59
47
  @errors.empty? && !@ast.nil?
60
48
  end
61
49
 
62
- # Override to detect tree-sitter nodes for signature generator fallthrough
63
- # @param value [Object] The value to check
64
- # @return [Boolean] true if this is a fallthrough node
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
+
65
108
  def fallthrough_node?(value)
66
- value.is_a?(NodeWrapper) || super
109
+ value.is_a?(NodeWrapper) || value.is_a?(FreezeNode) || super
67
110
  end
68
111
 
69
- # Get the root node of the parse tree
70
- # @return [NodeWrapper, nil]
71
112
  def root_node
72
- return unless valid?
113
+ return @root_node if defined?(@root_node)
114
+ return @root_node = nil unless valid?
73
115
 
74
- NodeWrapper.new(@ast.root_node, lines: @lines, source: @source)
116
+ @root_node = NodeWrapper.new(@ast.root_node, lines: @lines, source: @source, dialect: @dialect)
75
117
  end
76
118
 
77
- # Get the root object if the JSON document is an object
78
- # @return [NodeWrapper, nil]
79
119
  def root_object
80
- return unless valid?
120
+ return @root_object if defined?(@root_object)
121
+ return @root_object = nil unless valid?
81
122
 
82
123
  root = @ast.root_node
83
- return unless root
124
+ return @root_object = nil unless root
84
125
 
85
- # JSON root should be a document containing an object or array
86
126
  root.each do |child|
87
- if child.type.to_s == "object"
88
- return NodeWrapper.new(child, lines: @lines, source: @source)
127
+ if child.type.to_s == 'object'
128
+ return @root_object = NodeWrapper.new(child, lines: @lines, source: @source,
129
+ dialect: @dialect)
89
130
  end
90
131
  end
91
- nil
132
+
133
+ @root_object = nil
92
134
  end
93
135
 
94
- # Get the opening brace line of the root object (the line containing `{`)
95
- # @return [String, nil]
96
136
  def root_object_open_line
97
137
  obj = root_object
98
138
  return unless obj&.start_line
@@ -100,8 +140,6 @@ module Json
100
140
  line_at(obj.start_line)&.chomp
101
141
  end
102
142
 
103
- # Get the closing brace line of the root object (the line containing `}`)
104
- # @return [String, nil]
105
143
  def root_object_close_line
106
144
  obj = root_object
107
145
  return unless obj&.end_line
@@ -109,81 +147,208 @@ module Json
109
147
  line_at(obj.end_line)&.chomp
110
148
  end
111
149
 
112
- # Get key-value pairs from the root object
113
- # @return [Array<NodeWrapper>]
114
150
  def root_pairs
115
- obj = root_object
116
- return [] unless obj
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
117
165
 
118
- obj.pairs
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
119
177
  end
120
178
 
121
179
  private
122
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
+
123
209
  def parse_json
124
- # Use TreeHaver's high-level API - it handles:
125
- # - Grammar auto-discovery
126
- # - Backend selection
127
- parser = TreeHaver.parser_for(:json, library_path: @parser_path)
210
+ Json::Merge.register_backend!
211
+ parser = TreeHaver.parser_for(parser_language, backend_type: :tree_sitter)
128
212
 
129
213
  @ast = parser.parse(@source)
130
214
 
131
- # Check for parse errors in the tree
132
- if @ast&.root_node&.has_error?
133
- collect_parse_errors(@ast.root_node)
134
- end
215
+ collect_parse_errors(@ast.root_node) if @ast&.root_node
135
216
  rescue TreeHaver::Error => e
136
- # TreeHaver::Error inherits from Exception, not StandardError.
137
- # This also catches TreeHaver::NotAvailable (subclass of Error).
138
- @errors << e.message
217
+ @errors << e
139
218
  @ast = nil
140
219
  rescue StandardError => e
141
220
  @errors << e
142
221
  @ast = nil
143
222
  end
144
223
 
145
- def collect_parse_errors(node)
146
- # Collect ERROR and MISSING nodes from the tree
147
- if node.type.to_s == "ERROR" || node.missing?
148
- @errors << {
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 << {
149
269
  type: node.type.to_s,
150
- start_point: node.start_point,
151
- end_point: node.end_point,
152
- text: node.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
153
273
  }
154
274
  end
155
275
 
156
- node.each { |child| collect_parse_errors(child) }
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
157
279
  end
158
280
 
159
- def integrate_nodes
160
- return [] unless valid?
281
+ def extract_freeze_blocks
282
+ freeze_starts = []
283
+ freeze_ends = []
161
284
 
162
- result = []
163
- root = @ast.root_node
164
- return result unless root
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
165
287
 
166
- # Return all root-level nodes (document children)
167
- # For JSON, this is typically just the root object or array
168
- # The tree structure is preserved - children are accessed via NodeWrapper#children
169
- root.each do |child|
170
- # Skip whitespace-only or empty nodes
171
- next if child.type.to_s == "comment" # Comments handled separately in JSONC
288
+ @lines.each_with_index do |line, idx|
289
+ line_num = idx + 1
172
290
 
173
- wrapper = NodeWrapper.new(child, lines: @lines, source: @source)
174
- next unless wrapper.start_line && wrapper.end_line
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
175
297
 
176
- result << wrapper
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
177
305
  end
178
306
 
179
- # Sort by start line
180
- result.sort_by { |node| node.start_line || 0 }
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
181
323
  end
182
324
 
183
- def compute_node_signature(node)
184
- return unless node.is_a?(NodeWrapper)
325
+ def integrate_nodes_and_freeze_blocks
326
+ return @freeze_blocks.dup unless valid?
185
327
 
186
- node.signature
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
187
352
  end
188
353
  end
189
354
  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
@@ -12,11 +12,14 @@ module Json
12
12
  # result.add_line('"key": "value"', decision: :kept_template, source: :template)
13
13
  # result.to_json # => '"key": "value"\n'
14
14
  class MergeResult < Ast::Merge::MergeResultBase
15
+ include Ast::Merge::StructuredReviewApplySupport
16
+
15
17
  # Inherit decision constants from base class
16
18
  DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
17
19
  DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
18
20
  DECISION_MERGED = Ast::Merge::MergeResultBase::DECISION_MERGED
19
21
  DECISION_ADDED = Ast::Merge::MergeResultBase::DECISION_ADDED
22
+ DECISION_FREEZE_BLOCK = Ast::Merge::MergeResultBase::DECISION_FREEZE_BLOCK
20
23
 
21
24
  # @return [Hash] Statistics about the merge
22
25
  attr_reader :statistics
@@ -29,7 +32,8 @@ module Json
29
32
  template_lines: 0,
30
33
  dest_lines: 0,
31
34
  merged_lines: 0,
32
- total_decisions: 0,
35
+ freeze_preserved_lines: 0,
36
+ total_decisions: 0
33
37
  }
34
38
  end
35
39
 
@@ -44,7 +48,7 @@ module Json
44
48
  content: line,
45
49
  decision: decision,
46
50
  source: source,
47
- original_line: original_line,
51
+ original_line: original_line
48
52
  }
49
53
 
50
54
  track_statistics(decision, source)
@@ -69,7 +73,21 @@ module Json
69
73
  # @param decision [Symbol] Decision for the blank line
70
74
  # @param source [Symbol] Source
71
75
  def add_blank_line(decision: DECISION_MERGED, source: :merged)
72
- add_line("", decision: decision, source: source)
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
73
91
  end
74
92
 
75
93
  # Add content from a node wrapper
@@ -92,7 +110,7 @@ module Json
92
110
  # Get the merged content as a JSON string
93
111
  #
94
112
  # @return [String]
95
- def to_json
113
+ def to_json(*_args)
96
114
  content = @lines.map { |l| l[:content] }.join("\n")
97
115
  # Ensure trailing newline
98
116
  content += "\n" unless content.end_with?("\n") || content.empty?
@@ -119,7 +137,7 @@ module Json
119
137
 
120
138
  private
121
139
 
122
- def track_statistics(decision, source)
140
+ def track_statistics(decision, _source)
123
141
  @statistics[:total_decisions] += 1
124
142
 
125
143
  case decision
@@ -127,6 +145,8 @@ module Json
127
145
  @statistics[:template_lines] += 1
128
146
  when DECISION_KEPT_DEST
129
147
  @statistics[:dest_lines] += 1
148
+ when DECISION_FREEZE_BLOCK
149
+ @statistics[:freeze_preserved_lines] += 1
130
150
  else
131
151
  @statistics[:merged_lines] += 1
132
152
  end