jsonc-merge 1.0.0
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +48 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +992 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/jsonc/merge/comment_tracker.rb +195 -0
- data/lib/jsonc/merge/conflict_resolver.rb +373 -0
- data/lib/jsonc/merge/debug_logger.rb +43 -0
- data/lib/jsonc/merge/emitter.rb +163 -0
- data/lib/jsonc/merge/file_analysis.rb +325 -0
- data/lib/jsonc/merge/freeze_node.rb +102 -0
- data/lib/jsonc/merge/merge_result.rb +154 -0
- data/lib/jsonc/merge/node_wrapper.rb +328 -0
- data/lib/jsonc/merge/smart_merger.rb +154 -0
- data/lib/jsonc/merge/version.rb +12 -0
- data/lib/jsonc/merge.rb +123 -0
- data/lib/jsonc-merge.rb +6 -0
- data/sig/json/merge.rbs +259 -0
- data.tar.gz.sig +1 -0
- metadata +333 -0
- metadata.gz.sig +3 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jsonc
|
|
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::MergeResult.
|
|
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
|
+
# Inherit decision constants from base class
|
|
16
|
+
DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
|
|
17
|
+
DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
|
|
18
|
+
DECISION_MERGED = Ast::Merge::MergeResultBase::DECISION_MERGED
|
|
19
|
+
DECISION_ADDED = Ast::Merge::MergeResultBase::DECISION_ADDED
|
|
20
|
+
DECISION_FREEZE_BLOCK = Ast::Merge::MergeResultBase::DECISION_FREEZE_BLOCK
|
|
21
|
+
|
|
22
|
+
# @return [Hash] Statistics about the merge
|
|
23
|
+
attr_reader :statistics
|
|
24
|
+
|
|
25
|
+
# Initialize a new merge result
|
|
26
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
27
|
+
def initialize(**options)
|
|
28
|
+
super(**options)
|
|
29
|
+
@statistics = {
|
|
30
|
+
template_lines: 0,
|
|
31
|
+
dest_lines: 0,
|
|
32
|
+
merged_lines: 0,
|
|
33
|
+
freeze_preserved_lines: 0,
|
|
34
|
+
total_decisions: 0,
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Add a single line to the result
|
|
39
|
+
#
|
|
40
|
+
# @param line [String] Line content
|
|
41
|
+
# @param decision [Symbol] Decision that led to this line
|
|
42
|
+
# @param source [Symbol] Source of the line (:template, :destination, :merged)
|
|
43
|
+
# @param original_line [Integer, nil] Original line number
|
|
44
|
+
def add_line(line, decision:, source:, original_line: nil)
|
|
45
|
+
@lines << {
|
|
46
|
+
content: line,
|
|
47
|
+
decision: decision,
|
|
48
|
+
source: source,
|
|
49
|
+
original_line: original_line,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
track_statistics(decision, source)
|
|
53
|
+
track_decision(decision, source, line: original_line)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Add multiple lines to the result
|
|
57
|
+
#
|
|
58
|
+
# @param lines [Array<String>] Lines to add
|
|
59
|
+
# @param decision [Symbol] Decision for all lines
|
|
60
|
+
# @param source [Symbol] Source of the lines
|
|
61
|
+
# @param start_line [Integer, nil] Starting original line number
|
|
62
|
+
def add_lines(lines, decision:, source:, start_line: nil)
|
|
63
|
+
lines.each_with_index do |line, idx|
|
|
64
|
+
original_line = start_line ? start_line + idx : nil
|
|
65
|
+
add_line(line, decision: decision, source: source, original_line: original_line)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Add a blank line
|
|
70
|
+
#
|
|
71
|
+
# @param decision [Symbol] Decision for the blank line
|
|
72
|
+
# @param source [Symbol] Source
|
|
73
|
+
def add_blank_line(decision: DECISION_MERGED, source: :merged)
|
|
74
|
+
add_line("", decision: decision, source: source)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Add content from a freeze block
|
|
78
|
+
#
|
|
79
|
+
# @param freeze_node [FreezeNode] Freeze block to add
|
|
80
|
+
def add_freeze_block(freeze_node)
|
|
81
|
+
freeze_node.lines.each_with_index do |line, idx|
|
|
82
|
+
add_line(
|
|
83
|
+
line.chomp,
|
|
84
|
+
decision: DECISION_FREEZE_BLOCK,
|
|
85
|
+
source: :destination,
|
|
86
|
+
original_line: freeze_node.start_line + idx,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Add content from a node wrapper
|
|
92
|
+
#
|
|
93
|
+
# @param node [NodeWrapper] Node to add
|
|
94
|
+
# @param decision [Symbol] Decision that led to keeping this node
|
|
95
|
+
# @param source [Symbol] Source of the node
|
|
96
|
+
# @param analysis [FileAnalysis] Analysis for accessing source lines
|
|
97
|
+
def add_node(node, decision:, source:, analysis:)
|
|
98
|
+
return unless node.start_line && node.end_line
|
|
99
|
+
|
|
100
|
+
(node.start_line..node.end_line).each do |line_num|
|
|
101
|
+
line = analysis.line_at(line_num)
|
|
102
|
+
next unless line
|
|
103
|
+
|
|
104
|
+
add_line(line.chomp, decision: decision, source: source, original_line: line_num)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the merged content as a JSON string
|
|
109
|
+
#
|
|
110
|
+
# @return [String]
|
|
111
|
+
def to_json
|
|
112
|
+
content = @lines.map { |l| l[:content] }.join("\n")
|
|
113
|
+
# Ensure trailing newline
|
|
114
|
+
content += "\n" unless content.end_with?("\n") || content.empty?
|
|
115
|
+
content
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Alias for to_json
|
|
119
|
+
# @return [String]
|
|
120
|
+
def content
|
|
121
|
+
to_json
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Alias for to_json (used by SmartMerger#merge)
|
|
125
|
+
# @return [String]
|
|
126
|
+
def to_s
|
|
127
|
+
to_json
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get line count
|
|
131
|
+
# @return [Integer]
|
|
132
|
+
def line_count
|
|
133
|
+
@lines.size
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def track_statistics(decision, source)
|
|
139
|
+
@statistics[:total_decisions] += 1
|
|
140
|
+
|
|
141
|
+
case decision
|
|
142
|
+
when DECISION_KEPT_TEMPLATE
|
|
143
|
+
@statistics[:template_lines] += 1
|
|
144
|
+
when DECISION_KEPT_DEST
|
|
145
|
+
@statistics[:dest_lines] += 1
|
|
146
|
+
when DECISION_FREEZE_BLOCK
|
|
147
|
+
@statistics[:freeze_preserved_lines] += 1
|
|
148
|
+
else
|
|
149
|
+
@statistics[:merged_lines] += 1
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jsonc
|
|
4
|
+
module Merge
|
|
5
|
+
# Wraps TreeHaver nodes with comment associations, line information, and signatures.
|
|
6
|
+
# This provides a unified interface for working with JSONC (JSON with Comments) AST nodes during merging.
|
|
7
|
+
#
|
|
8
|
+
# Inherits common functionality from Ast::Merge::NodeWrapperBase:
|
|
9
|
+
# - Source context (lines, source, comments)
|
|
10
|
+
# - Line info extraction
|
|
11
|
+
# - Basic methods: #type, #type?, #text, #content, #signature
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# parser = TreeHaver::Parser.new
|
|
15
|
+
# parser.language = TreeHaver::Language.jsonc
|
|
16
|
+
# tree = parser.parse(source)
|
|
17
|
+
# wrapper = NodeWrapper.new(tree.root_node, lines: source.lines, source: source)
|
|
18
|
+
# wrapper.signature # => [:object, ...]
|
|
19
|
+
#
|
|
20
|
+
# @see Ast::Merge::NodeWrapperBase
|
|
21
|
+
class NodeWrapper < Ast::Merge::NodeWrapperBase
|
|
22
|
+
# Check if this is a JSON object
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def object?
|
|
25
|
+
@node.type.to_s == "object"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if this is a JSON array
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def array?
|
|
31
|
+
@node.type.to_s == "array"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if this is a JSON string
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def string?
|
|
37
|
+
@node.type.to_s == "string"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if this is a JSON number
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def number?
|
|
43
|
+
@node.type.to_s == "number"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if this is a JSON boolean (true/false)
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def boolean?
|
|
49
|
+
%w[true false].include?(@node.type.to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check if this is a JSON null
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def null?
|
|
55
|
+
@node.type.to_s == "null"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if this is a key-value pair
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def pair?
|
|
61
|
+
@node.type.to_s == "pair"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if this is a comment
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def comment?
|
|
67
|
+
@node.type.to_s == "comment"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get the key name if this is a pair node
|
|
71
|
+
# @return [String, nil]
|
|
72
|
+
def key_name
|
|
73
|
+
return unless pair?
|
|
74
|
+
|
|
75
|
+
# In JSONC tree-sitter, pair has key and value children
|
|
76
|
+
key_node = find_child_by_field("key")
|
|
77
|
+
|
|
78
|
+
return unless key_node
|
|
79
|
+
|
|
80
|
+
# Key is typically a string, extract its content without quotes using byte positions
|
|
81
|
+
key_text = node_text(key_node)
|
|
82
|
+
# Remove surrounding quotes if present
|
|
83
|
+
key_text&.gsub(/\A"|"\z/, "")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get the value node if this is a pair
|
|
87
|
+
# @return [NodeWrapper, nil]
|
|
88
|
+
def value_node
|
|
89
|
+
return unless pair?
|
|
90
|
+
|
|
91
|
+
value = find_child_by_field("value")
|
|
92
|
+
|
|
93
|
+
return unless value
|
|
94
|
+
|
|
95
|
+
NodeWrapper.new(value, lines: @lines, source: @source)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get key-value pairs if this is an object
|
|
99
|
+
# @return [Array<NodeWrapper>]
|
|
100
|
+
def pairs
|
|
101
|
+
return [] unless object?
|
|
102
|
+
|
|
103
|
+
result = []
|
|
104
|
+
@node.each do |child|
|
|
105
|
+
next if child.type.to_s == "comment"
|
|
106
|
+
next unless child.type.to_s == "pair"
|
|
107
|
+
|
|
108
|
+
result << NodeWrapper.new(child, lines: @lines, source: @source)
|
|
109
|
+
end
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get array elements if this is an array
|
|
114
|
+
# @return [Array<NodeWrapper>]
|
|
115
|
+
def elements
|
|
116
|
+
return [] unless array?
|
|
117
|
+
|
|
118
|
+
result = []
|
|
119
|
+
@node.each do |child|
|
|
120
|
+
child_type = child.type.to_s
|
|
121
|
+
# Skip punctuation and comments
|
|
122
|
+
next if child_type == "comment"
|
|
123
|
+
next if child_type == ","
|
|
124
|
+
next if child_type == "["
|
|
125
|
+
next if child_type == "]"
|
|
126
|
+
|
|
127
|
+
result << NodeWrapper.new(child, lines: @lines, source: @source)
|
|
128
|
+
end
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get mergeable children - the semantically meaningful children for tree merging
|
|
133
|
+
# For objects, returns pairs. For arrays, returns elements.
|
|
134
|
+
# For other node types, returns empty array (leaf nodes).
|
|
135
|
+
# @return [Array<NodeWrapper>]
|
|
136
|
+
def mergeable_children
|
|
137
|
+
case type
|
|
138
|
+
when :object
|
|
139
|
+
pairs
|
|
140
|
+
when :array
|
|
141
|
+
elements
|
|
142
|
+
else
|
|
143
|
+
[]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Check if this node is a container (has mergeable children)
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def container?
|
|
150
|
+
object? || array?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check if this is a root-level container (direct child of document)
|
|
154
|
+
# Root-level containers get a generic signature so they always match.
|
|
155
|
+
# @return [Boolean]
|
|
156
|
+
def root_level_container?
|
|
157
|
+
return false unless container?
|
|
158
|
+
|
|
159
|
+
# Check if parent is a document node
|
|
160
|
+
parent_node = @node.parent if @node.respond_to?(:parent)
|
|
161
|
+
return false unless parent_node
|
|
162
|
+
|
|
163
|
+
parent_node.type.to_s == "document"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Get the opening line for a container node (the line with { or [)
|
|
167
|
+
# For multi-line containers, returns the full line.
|
|
168
|
+
# For single-line containers, returns just the opening bracket to avoid duplicating content.
|
|
169
|
+
# @return [String, nil]
|
|
170
|
+
def opening_line
|
|
171
|
+
return unless container? && @start_line
|
|
172
|
+
|
|
173
|
+
# If this is a single-line container, just return the opening bracket
|
|
174
|
+
if @start_line == @end_line
|
|
175
|
+
return opening_bracket
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@lines[@start_line - 1]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Get the closing line for a container node (the line with } or ])
|
|
182
|
+
# For multi-line containers, returns the full line.
|
|
183
|
+
# For single-line containers, returns just the closing bracket to avoid duplicating content.
|
|
184
|
+
# @return [String, nil]
|
|
185
|
+
def closing_line
|
|
186
|
+
return unless container? && @end_line
|
|
187
|
+
|
|
188
|
+
# If this is a single-line container, just return the closing bracket
|
|
189
|
+
if @start_line == @end_line
|
|
190
|
+
return closing_bracket
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@lines[@end_line - 1]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Get the opening bracket character for this container
|
|
197
|
+
# @return [String, nil]
|
|
198
|
+
def opening_bracket
|
|
199
|
+
return "{" if object?
|
|
200
|
+
return "[" if array?
|
|
201
|
+
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Get the closing bracket character for this container
|
|
206
|
+
# @return [String, nil]
|
|
207
|
+
def closing_bracket
|
|
208
|
+
return "}" if object?
|
|
209
|
+
return "]" if array?
|
|
210
|
+
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Find a child by field name
|
|
215
|
+
# @param field_name [String] Field name to look for
|
|
216
|
+
# @return [TreeSitter::Node, nil]
|
|
217
|
+
def find_child_by_field(field_name)
|
|
218
|
+
return unless @node.respond_to?(:child_by_field_name)
|
|
219
|
+
|
|
220
|
+
@node.child_by_field_name(field_name)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Find a child by type
|
|
224
|
+
# @param type_name [String] Type name to look for
|
|
225
|
+
# @return [TreeSitter::Node, nil]
|
|
226
|
+
def find_child_by_type(type_name)
|
|
227
|
+
return unless @node.respond_to?(:each)
|
|
228
|
+
|
|
229
|
+
@node.each do |child|
|
|
230
|
+
return child if child.type.to_s == type_name
|
|
231
|
+
end
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
protected
|
|
236
|
+
|
|
237
|
+
# Override wrap_child to use Jsonc::Merge::NodeWrapper
|
|
238
|
+
def wrap_child(child)
|
|
239
|
+
NodeWrapper.new(child, lines: @lines, source: @source)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def compute_signature(node)
|
|
243
|
+
node_type = node.type.to_s
|
|
244
|
+
|
|
245
|
+
case node_type
|
|
246
|
+
when "document"
|
|
247
|
+
# Root document - signature based on root content type
|
|
248
|
+
child = nil
|
|
249
|
+
node.each { |c|
|
|
250
|
+
child = c unless c.type.to_s == "comment"
|
|
251
|
+
break if child
|
|
252
|
+
}
|
|
253
|
+
child_type = child&.type&.to_s
|
|
254
|
+
[:document, child_type]
|
|
255
|
+
when "object"
|
|
256
|
+
# For root-level objects (direct child of document), use a generic signature
|
|
257
|
+
# that always matches so merging happens at the pair level.
|
|
258
|
+
if root_level_container?
|
|
259
|
+
[:root_object]
|
|
260
|
+
else
|
|
261
|
+
# Nested objects identified by their keys
|
|
262
|
+
keys = extract_object_keys(node)
|
|
263
|
+
[:object, keys.sort]
|
|
264
|
+
end
|
|
265
|
+
when "array"
|
|
266
|
+
# For root-level arrays (direct child of document), use a generic signature
|
|
267
|
+
if root_level_container?
|
|
268
|
+
[:root_array]
|
|
269
|
+
else
|
|
270
|
+
# Nested arrays identified by their length and first few elements
|
|
271
|
+
elements_count = 0
|
|
272
|
+
node.each { |c| elements_count += 1 unless %w[comment , \[ \]].include?(c.type.to_s) }
|
|
273
|
+
[:array, elements_count]
|
|
274
|
+
end
|
|
275
|
+
when "pair"
|
|
276
|
+
# Pairs identified by their key name
|
|
277
|
+
key = key_name
|
|
278
|
+
[:pair, key]
|
|
279
|
+
when "string"
|
|
280
|
+
# Strings identified by their content
|
|
281
|
+
[:string, node_text(node)]
|
|
282
|
+
when "number"
|
|
283
|
+
# Numbers identified by their value
|
|
284
|
+
[:number, node_text(node)]
|
|
285
|
+
when "true", "false"
|
|
286
|
+
# Booleans
|
|
287
|
+
[:boolean, node.type.to_s]
|
|
288
|
+
when "null"
|
|
289
|
+
[:null]
|
|
290
|
+
when "comment"
|
|
291
|
+
# Comments identified by their content
|
|
292
|
+
[:comment, node_text(node)&.strip]
|
|
293
|
+
else
|
|
294
|
+
# Generic fallback
|
|
295
|
+
content_preview = node_text(node)&.slice(0, 50)&.strip
|
|
296
|
+
[node_type.to_sym, content_preview]
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
def extract_object_keys(object_node)
|
|
303
|
+
keys = []
|
|
304
|
+
object_node.each do |child|
|
|
305
|
+
next unless child.type.to_s == "pair"
|
|
306
|
+
|
|
307
|
+
key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name("key") : nil
|
|
308
|
+
|
|
309
|
+
# Fallback for backends without field access (FFI)
|
|
310
|
+
unless key_node
|
|
311
|
+
child.each do |pair_child|
|
|
312
|
+
pair_child_type = pair_child.type.to_s
|
|
313
|
+
next if pair_child_type == ":" || pair_child_type == "comment"
|
|
314
|
+
key_node = pair_child
|
|
315
|
+
break
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
next unless key_node
|
|
320
|
+
|
|
321
|
+
key_text = node_text(key_node)&.gsub(/\A"|"\z/, "")
|
|
322
|
+
keys << key_text if key_text
|
|
323
|
+
end
|
|
324
|
+
keys
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jsonc
|
|
4
|
+
module Merge
|
|
5
|
+
# High-level merger for JSONC (JSON with Comments) content.
|
|
6
|
+
# Orchestrates parsing, analysis, and conflict resolution.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# merger = SmartMerger.new(template_content, dest_content)
|
|
10
|
+
# merged_string = merger.merge
|
|
11
|
+
# File.write("merged.jsonc", merged_string)
|
|
12
|
+
#
|
|
13
|
+
# @example With full result object
|
|
14
|
+
# merger = SmartMerger.new(template, dest)
|
|
15
|
+
# result = merger.merge_result
|
|
16
|
+
# puts result.statistics
|
|
17
|
+
# File.write("merged.jsonc", result.content)
|
|
18
|
+
#
|
|
19
|
+
# @example With options
|
|
20
|
+
# merger = SmartMerger.new(template, dest,
|
|
21
|
+
# preference: :template,
|
|
22
|
+
# add_template_only_nodes: true)
|
|
23
|
+
# merged_string = merger.merge
|
|
24
|
+
#
|
|
25
|
+
# @example With node_typing for per-node-type preferences
|
|
26
|
+
# merger = SmartMerger.new(template, dest,
|
|
27
|
+
# node_typing: { "object" => ->(n) { NodeTyping.with_merge_type(n, :config) } },
|
|
28
|
+
# preference: { default: :destination, config: :template })
|
|
29
|
+
class SmartMerger < ::Ast::Merge::SmartMergerBase
|
|
30
|
+
# Creates a new SmartMerger
|
|
31
|
+
#
|
|
32
|
+
# @param template_content [String] Template JSONC content
|
|
33
|
+
# @param dest_content [String] Destination JSONC content
|
|
34
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
35
|
+
# @param preference [Symbol, Hash] :destination, :template, or per-type Hash
|
|
36
|
+
# @param add_template_only_nodes [Boolean] Whether to add nodes only found in 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
|
+
freeze_token: nil,
|
|
51
|
+
match_refiner: nil,
|
|
52
|
+
regions: nil,
|
|
53
|
+
region_placeholder: nil,
|
|
54
|
+
node_typing: nil,
|
|
55
|
+
**options
|
|
56
|
+
)
|
|
57
|
+
super(
|
|
58
|
+
template_content,
|
|
59
|
+
dest_content,
|
|
60
|
+
signature_generator: signature_generator,
|
|
61
|
+
preference: preference,
|
|
62
|
+
add_template_only_nodes: add_template_only_nodes,
|
|
63
|
+
freeze_token: freeze_token,
|
|
64
|
+
match_refiner: match_refiner,
|
|
65
|
+
regions: regions,
|
|
66
|
+
region_placeholder: region_placeholder,
|
|
67
|
+
node_typing: node_typing,
|
|
68
|
+
**options
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Backward-compatible options hash
|
|
73
|
+
#
|
|
74
|
+
# @return [Hash] The merge options
|
|
75
|
+
def options
|
|
76
|
+
{
|
|
77
|
+
preference: @preference,
|
|
78
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
79
|
+
match_refiner: @match_refiner,
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
# @return [Class] The analysis class for JSONC files
|
|
86
|
+
def analysis_class
|
|
87
|
+
FileAnalysis
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [String] The default freeze token
|
|
91
|
+
def default_freeze_token
|
|
92
|
+
"jsonc-merge"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Class] The resolver class for JSONC files
|
|
96
|
+
def resolver_class
|
|
97
|
+
ConflictResolver
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Class] The result class for JSONC files
|
|
101
|
+
def result_class
|
|
102
|
+
MergeResult
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Perform the JSONC-specific merge
|
|
106
|
+
#
|
|
107
|
+
# @return [MergeResult] The merge result
|
|
108
|
+
def perform_merge
|
|
109
|
+
@resolver.resolve(@result)
|
|
110
|
+
|
|
111
|
+
DebugLogger.debug("Merge complete", {
|
|
112
|
+
lines: @result.line_count,
|
|
113
|
+
decisions: @result.statistics,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
@result
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Build the resolver with JSONC-specific configuration
|
|
120
|
+
def build_resolver
|
|
121
|
+
ConflictResolver.new(
|
|
122
|
+
@template_analysis,
|
|
123
|
+
@dest_analysis,
|
|
124
|
+
preference: @preference,
|
|
125
|
+
add_template_only_nodes: @add_template_only_nodes,
|
|
126
|
+
match_refiner: @match_refiner,
|
|
127
|
+
node_typing: @node_typing,
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Build the result (no-arg constructor for JSONC)
|
|
132
|
+
def build_result
|
|
133
|
+
MergeResult.new
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Class] The template parse error class for JSONC
|
|
137
|
+
def template_parse_error_class
|
|
138
|
+
TemplateParseError
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [Class] The destination parse error class for JSONC
|
|
142
|
+
def destination_parse_error_class
|
|
143
|
+
DestinationParseError
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# JSONC FileAnalysis only accepts signature_generator, not freeze_token
|
|
149
|
+
def build_full_analysis_options
|
|
150
|
+
{signature_generator: @signature_generator}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|