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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +600 -0
- data/lib/json/merge/comment_tracker.rb +27 -0
- data/lib/json/merge/conflict_resolver.rb +1379 -0
- data/lib/json/merge/debug_logger.rb +41 -0
- data/lib/json/merge/emitter.rb +227 -0
- data/lib/json/merge/file_analysis.rb +355 -0
- data/lib/json/merge/freeze_node.rb +62 -0
- data/lib/json/merge/merge_result.rb +156 -0
- data/lib/json/merge/node_wrapper.rb +258 -0
- data/lib/json/merge/object_match_refiner.rb +338 -0
- data/lib/json/merge/provider.rb +696 -0
- data/lib/json/merge/smart_merger.rb +276 -0
- data/lib/json/merge/source_locator.rb +68 -0
- data/lib/json/merge/three_way_decision.rb +168 -0
- data/lib/json/merge/version.rb +5 -3
- data/lib/json/merge.rb +393 -266
- data/lib/json-merge.rb +7 -1
- data/sig/json/merge.rbs +54 -0
- data.tar.gz.sig +0 -0
- metadata +264 -15
- metadata.gz.sig +0 -0
|
@@ -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
|