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,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
|