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.
@@ -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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonc
4
+ module Merge
5
+ # Version information for Jsonc::Merge
6
+ module Version
7
+ # Current version of the json-merge gem
8
+ VERSION = "1.0.0"
9
+ end
10
+ VERSION = Version::VERSION # traditional location
11
+ end
12
+ end