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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonc
4
+ module Merge
5
+ # Debug logging utility for Jsonc::Merge.
6
+ # Extends the base Ast::Merge::DebugLogger with Jsonc-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
+ # Jsonc-specific configuration
18
+ self.env_var_name = "JSONC_MERGE_DEBUG"
19
+ self.log_prefix = "[Jsonc::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 Jsonc::Merge::FreezeNode
31
+ {type: "FreezeNode", lines: "#{node.start_line}..#{node.end_line}"}
32
+ when Jsonc::Merge::NodeWrapper
33
+ {type: node.type.to_s, lines: "#{node.start_line}..#{node.end_line}"}
34
+ else
35
+ extract_node_info(node)
36
+ end
37
+
38
+ debug(label, info)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonc
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
+ # @return [Boolean] Whether next item needs a comma
18
+ attr_reader :needs_comma
19
+
20
+ # Initialize subclass-specific state (comma tracking for JSON)
21
+ def initialize_subclass_state(**options)
22
+ @needs_comma = false
23
+ end
24
+
25
+ # Clear subclass-specific state
26
+ def clear_subclass_state
27
+ @needs_comma = false
28
+ end
29
+
30
+ # Emit a tracked comment from CommentTracker
31
+ # @param comment [Hash] Comment with :text, :indent, :block
32
+ def emit_tracked_comment(comment)
33
+ indent = " " * (comment[:indent] || 0)
34
+ @lines << if comment[:block]
35
+ "#{indent}/* #{comment[:text]} */"
36
+ else
37
+ "#{indent}// #{comment[:text]}"
38
+ end
39
+ end
40
+
41
+ # Emit a single-line comment
42
+ #
43
+ # @param text [String] Comment text (without //)
44
+ # @param inline [Boolean] Whether this is an inline comment
45
+ def emit_comment(text, inline: false)
46
+ if inline
47
+ # Inline comments are appended to the last line
48
+ return if @lines.empty?
49
+
50
+ @lines[-1] = "#{@lines[-1]} // #{text}"
51
+ else
52
+ @lines << "#{current_indent}// #{text}"
53
+ end
54
+ end
55
+
56
+ # Emit a block comment
57
+ #
58
+ # @param text [String] Comment text
59
+ def emit_block_comment(text)
60
+ @lines << "#{current_indent}/* #{text} */"
61
+ end
62
+
63
+ # Emit object start
64
+ def emit_object_start
65
+ add_comma_if_needed
66
+ @lines << "#{current_indent}{"
67
+ indent
68
+ @needs_comma = false
69
+ end
70
+
71
+ # Emit object end
72
+ def emit_object_end
73
+ dedent
74
+ @lines << "#{current_indent}}"
75
+ @needs_comma = true
76
+ end
77
+
78
+ # Emit array start
79
+ #
80
+ # @param key [String, nil] Key name if this array is a value in an object
81
+ def emit_array_start(key = nil)
82
+ add_comma_if_needed
83
+ @lines << if key
84
+ "#{current_indent}\"#{key}\": ["
85
+ else
86
+ "#{current_indent}["
87
+ end
88
+ indent
89
+ @needs_comma = false
90
+ end
91
+
92
+ # Emit array end
93
+ def emit_array_end
94
+ dedent
95
+ @lines << "#{current_indent}]"
96
+ @needs_comma = true
97
+ end
98
+
99
+ # Emit a key-value pair
100
+ #
101
+ # @param key [String] Key name (without quotes)
102
+ # @param value [String] Value (already formatted, e.g., '"string"', '123', 'true')
103
+ # @param inline_comment [String, nil] Optional inline comment
104
+ def emit_pair(key, value, inline_comment: nil)
105
+ add_comma_if_needed
106
+ line = "#{current_indent}\"#{key}\": #{value}"
107
+ line += " // #{inline_comment}" if inline_comment
108
+ @lines << line
109
+ @needs_comma = true
110
+ end
111
+
112
+ # Emit an array element
113
+ #
114
+ # @param value [String] Value (already formatted)
115
+ # @param inline_comment [String, nil] Optional inline comment
116
+ def emit_array_element(value, inline_comment: nil)
117
+ add_comma_if_needed
118
+ line = "#{current_indent}#{value}"
119
+ line += " // #{inline_comment}" if inline_comment
120
+ @lines << line
121
+ @needs_comma = true
122
+ end
123
+
124
+ # Emit a key with opening brace for nested object
125
+ # @param key [String] Key name
126
+ def emit_nested_object_start(key)
127
+ add_comma_if_needed
128
+ @lines << "#{current_indent}\"#{key}\": {"
129
+ indent
130
+ @needs_comma = false
131
+ end
132
+
133
+ # Emit closing brace for nested object
134
+ def emit_nested_object_end
135
+ dedent
136
+ @lines << "#{current_indent}}"
137
+ @needs_comma = true
138
+ end
139
+
140
+ # Get the output as a JSON string
141
+ #
142
+ # @return [String]
143
+ def to_json
144
+ to_s
145
+ end
146
+
147
+ private
148
+
149
+ def add_comma_if_needed
150
+ return unless @needs_comma && @lines.any?
151
+
152
+ # Add comma to the previous line if it doesn't already have one
153
+ last_line = @lines.last
154
+ return if last_line.strip.empty?
155
+ return if last_line.rstrip.end_with?(",")
156
+ return if last_line.rstrip.end_with?("{")
157
+ return if last_line.rstrip.end_with?("[")
158
+
159
+ @lines[-1] = "#{last_line},"
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonc
4
+ module Merge
5
+ # Analyzes JSON/JSONC file structure, extracting nodes, comments, and freeze blocks.
6
+ # This is the main analysis class that prepares JSON content for merging.
7
+ #
8
+ # Supports JSONC (JSON with Comments) which allows single-line (//) and
9
+ # multi-line (/* */) comments in JSON files. This is commonly used in
10
+ # configuration files like tsconfig.json, VS Code settings, etc.
11
+ #
12
+ # @example Basic usage
13
+ # analysis = FileAnalysis.new(json_source)
14
+ # analysis.valid? # => true
15
+ # analysis.nodes # => [NodeWrapper, FreezeNodeBase, ...]
16
+ # analysis.freeze_blocks # => [FreezeNodeBase, ...]
17
+ class FileAnalysis
18
+ include Ast::Merge::FileAnalyzable
19
+
20
+ # Default freeze token for identifying freeze blocks
21
+ DEFAULT_FREEZE_TOKEN = "json-merge"
22
+
23
+ # @return [CommentTracker] Comment tracker for this file
24
+ attr_reader :comment_tracker
25
+
26
+ # @return [TreeHaver::Tree, nil] Parsed AST
27
+ attr_reader :ast
28
+
29
+ # @return [Array] Parse errors if any
30
+ attr_reader :errors
31
+
32
+ class << self
33
+ # Find the parser library path using TreeHaver::GrammarFinder
34
+ #
35
+ # Note: JSONC uses the tree-sitter-jsonc grammar (supports JSON with Comments)
36
+ #
37
+ # @return [String, nil] Path to the parser library or nil if not found
38
+ def find_parser_path
39
+ TreeHaver::GrammarFinder.new(:jsonc).find_library_path
40
+ end
41
+ end
42
+
43
+ # Initialize file analysis
44
+ #
45
+ # @param source [String] JSON/JSONC source code to analyze
46
+ # @param freeze_token [String] Token for freeze block markers
47
+ # @param signature_generator [Proc, nil] Custom signature generator
48
+ # @param parser_path [String, nil] Path to tree-sitter-json parser library
49
+ # @param options [Hash] Additional options (forward compatibility - ignored by FileAnalysis)
50
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, parser_path: nil, **options)
51
+ @source = source
52
+ @lines = source.lines.map(&:chomp)
53
+ @freeze_token = freeze_token
54
+ @signature_generator = signature_generator
55
+ @parser_path = parser_path || self.class.find_parser_path
56
+ @errors = []
57
+ # **options captures any additional parameters (e.g., node_typing) for forward compatibility
58
+
59
+ # Initialize comment tracking
60
+ @comment_tracker = CommentTracker.new(source)
61
+
62
+ # Parse the JSON
63
+ DebugLogger.time("FileAnalysis#parse_json") { parse_json }
64
+
65
+ # Extract freeze blocks and integrate with nodes
66
+ @freeze_blocks = extract_freeze_blocks
67
+ @nodes = integrate_nodes_and_freeze_blocks
68
+
69
+ DebugLogger.debug("FileAnalysis initialized", {
70
+ signature_generator: signature_generator ? "custom" : "default",
71
+ nodes_count: @nodes.size,
72
+ freeze_blocks: @freeze_blocks.size,
73
+ valid: valid?,
74
+ })
75
+ end
76
+
77
+ # Check if parse was successful
78
+ # @return [Boolean]
79
+ def valid?
80
+ @errors.empty? && !@ast.nil?
81
+ end
82
+
83
+ # The base module uses 'statements' - provide both names for compatibility
84
+ # @return [Array<NodeWrapper, FreezeNode>]
85
+ def statements
86
+ @nodes ||= []
87
+ end
88
+
89
+ # Alias for convenience - json-merge prefers "nodes" terminology
90
+ alias_method :nodes, :statements
91
+
92
+ # Check if a line is within a freeze block.
93
+ #
94
+ # @param line_num [Integer] 1-based line number
95
+ # @return [Boolean]
96
+ def in_freeze_block?(line_num)
97
+ @freeze_blocks.any? { |fb| fb.location.cover?(line_num) }
98
+ end
99
+
100
+ # Get the freeze block containing the given line.
101
+ #
102
+ # @param line_num [Integer] 1-based line number
103
+ # @return [FreezeNode, nil]
104
+ def freeze_block_at(line_num)
105
+ @freeze_blocks.find { |fb| fb.location.cover?(line_num) }
106
+ end
107
+
108
+ # Override to handle special signature generation for root-level objects
109
+ # When there's only one root object, we want it to match regardless of keys
110
+ # so that add_template_only_nodes can work properly
111
+ def generate_signature(node)
112
+ # If custom signature generator provided, let it handle everything
113
+ return super if @signature_generator
114
+
115
+ return super unless node.is_a?(NodeWrapper)
116
+ return super unless node.object?
117
+
118
+ # Check if this is the sole root object
119
+ if statements.size == 1 && statements.first == node
120
+ # Root object gets a consistent signature so they always match
121
+ return [:root_object]
122
+ end
123
+
124
+ super
125
+ end
126
+
127
+ # Override to detect tree-sitter nodes for signature generator fallthrough
128
+ # @param value [Object] The value to check
129
+ # @return [Boolean] true if this is a fallthrough node
130
+ def fallthrough_node?(value)
131
+ value.is_a?(NodeWrapper) || value.is_a?(FreezeNode) || super
132
+ end
133
+
134
+ # Get the root node of the parse tree
135
+ # @return [NodeWrapper, nil]
136
+ def root_node
137
+ return unless valid?
138
+
139
+ NodeWrapper.new(@ast.root_node, lines: @lines, source: @source)
140
+ end
141
+
142
+ # Get the root object if the JSON document is an object
143
+ # @return [NodeWrapper, nil]
144
+ def root_object
145
+ return unless valid?
146
+
147
+ root = @ast.root_node
148
+ return unless root
149
+
150
+ # JSON root should be a document containing an object or array
151
+ root.each do |child|
152
+ if child.type.to_s == "object"
153
+ return NodeWrapper.new(child, lines: @lines, source: @source)
154
+ end
155
+ end
156
+ nil
157
+ end
158
+
159
+ # Get the opening brace line of the root object (the line containing `{`)
160
+ # @return [String, nil]
161
+ def root_object_open_line
162
+ obj = root_object
163
+ return unless obj&.start_line
164
+
165
+ line_at(obj.start_line)&.chomp
166
+ end
167
+
168
+ # Get the closing brace line of the root object (the line containing `}`)
169
+ # @return [String, nil]
170
+ def root_object_close_line
171
+ obj = root_object
172
+ return unless obj&.end_line
173
+
174
+ line_at(obj.end_line)&.chomp
175
+ end
176
+
177
+ # Get key-value pairs from the root object
178
+ # @return [Array<NodeWrapper>]
179
+ def root_pairs
180
+ obj = root_object
181
+ return [] unless obj
182
+
183
+ obj.pairs
184
+ end
185
+
186
+ private
187
+
188
+ def parse_json
189
+ # Use TreeHaver's high-level API - it handles:
190
+ # - Grammar auto-discovery
191
+ # - Backend selection
192
+ # - Explicit path validation (raises NotAvailable if path doesn't exist)
193
+ # Note: JSONC uses the tree-sitter-jsonc grammar (supports JSON with Comments)
194
+ parser = TreeHaver.parser_for(:jsonc, library_path: @parser_path)
195
+
196
+ @ast = parser.parse(@source)
197
+
198
+ # Check for parse errors in the tree
199
+ # Note: Some backends (Java/jtreesitter) may not set has_error? correctly,
200
+ # so we always collect errors if present, regardless of has_error? return value
201
+ if @ast&.root_node
202
+ # Collect parse errors from the AST
203
+ # This works whether has_error? is supported or not
204
+ collect_parse_errors(@ast.root_node)
205
+ end
206
+ rescue TreeHaver::Error => e
207
+ # TreeHaver::Error inherits from Exception, not StandardError.
208
+ # This also catches TreeHaver::NotAvailable (subclass of Error).
209
+ @errors << e.message
210
+ @ast = nil
211
+ rescue StandardError => e
212
+ @errors << e
213
+ @ast = nil
214
+ end
215
+
216
+ def collect_parse_errors(node, found_errors = [])
217
+ # Collect ERROR and MISSING nodes from the tree
218
+ if node.type.to_s == "ERROR" || (node.respond_to?(:missing?) && node.missing?)
219
+ found_errors << {
220
+ type: node.type.to_s,
221
+ start_point: node.respond_to?(:start_point) ? node.start_point : nil,
222
+ end_point: node.respond_to?(:end_point) ? node.end_point : nil,
223
+ text: node.to_s,
224
+ }
225
+ end
226
+
227
+ node.each { |child| collect_parse_errors(child, found_errors) } if node.respond_to?(:each)
228
+
229
+ # Add found errors to @errors
230
+ @errors.concat(found_errors) unless found_errors.empty?
231
+
232
+ found_errors
233
+ end
234
+
235
+ def extract_freeze_blocks
236
+ # JSONC supports both // and /* */ comments
237
+ # We look for freeze markers in both styles
238
+ freeze_starts = []
239
+ freeze_ends = []
240
+
241
+ # Pattern for single-line comments: // json-merge:freeze
242
+ single_line_pattern = %r{^\s*//\s*#{Regexp.escape(@freeze_token)}:(freeze|unfreeze)\b}i
243
+
244
+ # Pattern for block comments: /* json-merge:freeze */
245
+ block_pattern = %r{^\s*/\*\s*#{Regexp.escape(@freeze_token)}:(freeze|unfreeze)\b.*\*/}i
246
+
247
+ @lines.each_with_index do |line, idx|
248
+ line_num = idx + 1
249
+
250
+ marker_type = nil
251
+ if (match = line.match(single_line_pattern))
252
+ marker_type = match[1]&.downcase
253
+ elsif (match = line.match(block_pattern))
254
+ marker_type = match[1]&.downcase
255
+ end
256
+
257
+ next unless marker_type
258
+
259
+ if marker_type == "freeze"
260
+ freeze_starts << {line: line_num, marker: line}
261
+ elsif marker_type == "unfreeze"
262
+ freeze_ends << {line: line_num, marker: line}
263
+ end
264
+ end
265
+
266
+ # Match freeze starts with ends
267
+ blocks = []
268
+ freeze_starts.each do |start_info|
269
+ # Find the next unfreeze after this freeze
270
+ matching_end = freeze_ends.find { |e| e[:line] > start_info[:line] }
271
+ next unless matching_end
272
+
273
+ # Remove used end marker
274
+ freeze_ends.delete(matching_end)
275
+
276
+ blocks << FreezeNode.new(
277
+ start_line: start_info[:line],
278
+ end_line: matching_end[:line],
279
+ lines: @lines,
280
+ start_marker: start_info[:marker],
281
+ end_marker: matching_end[:marker],
282
+ )
283
+ end
284
+
285
+ blocks
286
+ end
287
+
288
+ def integrate_nodes_and_freeze_blocks
289
+ return @freeze_blocks.dup unless valid?
290
+
291
+ result = []
292
+ processed_lines = ::Set.new
293
+
294
+ # Mark freeze block lines as processed
295
+ @freeze_blocks.each do |fb|
296
+ (fb.start_line..fb.end_line).each { |ln| processed_lines << ln }
297
+ result << fb
298
+ end
299
+
300
+ # Add the root object/array if it exists and isn't in a freeze block
301
+ # This is the top-level structural node that should be merged
302
+ root = root_object
303
+ if root&.start_line
304
+ # Check if root is in a freeze block
305
+ root_lines = (root.start_line..root.end_line).to_a
306
+ unless root_lines.any? { |ln| processed_lines.include?(ln) }
307
+ result << root
308
+ end
309
+ end
310
+
311
+ # Sort by start line
312
+ result.sort_by { |node| node&.start_line || 0 }
313
+ end
314
+
315
+ def compute_node_signature(node)
316
+ case node
317
+ when FreezeNode
318
+ node.signature
319
+ when NodeWrapper
320
+ node.signature
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonc
4
+ module Merge
5
+ # Wrapper to represent freeze blocks as first-class nodes in JSON/JSONC files.
6
+ # A freeze block is a section marked with freeze/unfreeze comment markers that
7
+ # should be preserved from the destination during merges.
8
+ #
9
+ # Inherits from Ast::Merge::FreezeNodeBase for shared functionality including
10
+ # the Location struct, InvalidStructureError, and configurable marker patterns.
11
+ #
12
+ # Uses the `:c_style_line` or `:c_style_block` pattern types for JSONC files
13
+ # (// comments and /* */ comments).
14
+ #
15
+ # @example Freeze block with single-line comments
16
+ # // json-merge:freeze
17
+ # "secret_key": "my-secret-value",
18
+ # "api_endpoint": "https://custom.example.com"
19
+ # // json-merge:unfreeze
20
+ #
21
+ # @example Freeze block with block comments
22
+ # /* json-merge:freeze */
23
+ # "secret_key": "my-secret-value"
24
+ # /* json-merge:unfreeze */
25
+ class FreezeNode < Ast::Merge::FreezeNodeBase
26
+ # Inherit InvalidStructureError from base class
27
+ InvalidStructureError = Ast::Merge::FreezeNodeBase::InvalidStructureError
28
+
29
+ # Inherit Location from base class
30
+ Location = Ast::Merge::FreezeNodeBase::Location
31
+
32
+ # @param start_line [Integer] Line number of freeze marker
33
+ # @param end_line [Integer] Line number of unfreeze marker
34
+ # @param lines [Array<String>] All source lines
35
+ # @param start_marker [String, nil] The freeze start marker text
36
+ # @param end_marker [String, nil] The freeze end marker text
37
+ # @param pattern_type [Symbol] Pattern type for marker matching (defaults to :c_style_line)
38
+ def initialize(start_line:, end_line:, lines:, start_marker: nil, end_marker: nil, pattern_type: :c_style_line)
39
+ # Extract lines for the entire block (lines param is all source lines)
40
+ block_lines = (start_line..end_line).map { |ln| lines[ln - 1] }
41
+
42
+ super(
43
+ start_line: start_line,
44
+ end_line: end_line,
45
+ lines: block_lines,
46
+ start_marker: start_marker,
47
+ end_marker: end_marker,
48
+ pattern_type: pattern_type
49
+ )
50
+
51
+ validate_structure!
52
+ end
53
+
54
+ # Returns a stable signature for this freeze block.
55
+ # Signature includes the normalized content to detect changes.
56
+ # @return [Array] Signature array
57
+ def signature
58
+ # Normalize by stripping each line and joining
59
+ normalized = @lines.map { |l| l&.strip }.compact.reject(&:empty?).join("\n")
60
+ [:FreezeNode, normalized]
61
+ end
62
+
63
+ # Check if this is an object node (always false for FreezeNode)
64
+ # @return [Boolean]
65
+ def object?
66
+ false
67
+ end
68
+
69
+ # Check if this is an array node (always false for FreezeNode)
70
+ # @return [Boolean]
71
+ def array?
72
+ false
73
+ end
74
+
75
+ # Check if this is a pair node (always false for FreezeNode)
76
+ # @return [Boolean]
77
+ def pair?
78
+ false
79
+ end
80
+
81
+ # String representation for debugging
82
+ # @return [String]
83
+ def inspect
84
+ "#<#{self.class.name} lines=#{start_line}..#{end_line} content_length=#{slice&.length || 0}>"
85
+ end
86
+
87
+ private
88
+
89
+ def validate_structure!
90
+ validate_line_order!
91
+
92
+ if @lines.empty? || @lines.all?(&:nil?)
93
+ raise InvalidStructureError.new(
94
+ "Freeze block is empty",
95
+ start_line: @start_line,
96
+ end_line: @end_line,
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end