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
data/lib/jsonc/merge.rb
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# std libs
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
# External gems
|
|
7
|
+
# TreeHaver provides a unified cross-Ruby interface to tree-sitter.
|
|
8
|
+
# It handles grammar discovery and backend selection automatically
|
|
9
|
+
# via parser_for(:jsonc). No manual registration needed.
|
|
10
|
+
require "tree_haver"
|
|
11
|
+
require "version_gem"
|
|
12
|
+
|
|
13
|
+
# Shared merge infrastructure
|
|
14
|
+
require "ast/merge"
|
|
15
|
+
|
|
16
|
+
# This gem
|
|
17
|
+
require_relative "merge/version"
|
|
18
|
+
|
|
19
|
+
# Jsonc::Merge provides a generic JSONC file smart merge system using tree-sitter AST analysis.
|
|
20
|
+
# It intelligently merges template and destination JSON/JSONC files by identifying matching
|
|
21
|
+
# keys and resolving differences using structural signatures.
|
|
22
|
+
#
|
|
23
|
+
# JSONC (JSON with Comments) support allows merging configuration files that include
|
|
24
|
+
# comments (like devcontainer.json, tsconfig.json, VS Code settings, etc.).
|
|
25
|
+
#
|
|
26
|
+
# @example Basic usage
|
|
27
|
+
# template = File.read("template.json")
|
|
28
|
+
# destination = File.read("destination.json")
|
|
29
|
+
# merger = Jsonc::Merge::SmartMerger.new(template, destination)
|
|
30
|
+
# result = merger.merge
|
|
31
|
+
#
|
|
32
|
+
# @example With debug information
|
|
33
|
+
# merger = Jsonc::Merge::SmartMerger.new(template, destination)
|
|
34
|
+
# debug_result = merger.merge_with_debug
|
|
35
|
+
# puts debug_result[:content]
|
|
36
|
+
# puts debug_result[:statistics]
|
|
37
|
+
module Jsonc
|
|
38
|
+
# Smart merge system for JSONC files using tree-sitter AST analysis.
|
|
39
|
+
# Provides intelligent merging by understanding JSON structure
|
|
40
|
+
# rather than treating files as plain text.
|
|
41
|
+
#
|
|
42
|
+
# @see SmartMerger Main entry point for merge operations
|
|
43
|
+
# @see FileAnalysis Analyzes JSON structure
|
|
44
|
+
# @see ConflictResolver Resolves content conflicts
|
|
45
|
+
module Merge
|
|
46
|
+
# Base error class for Jsonc::Merge
|
|
47
|
+
# Inherits from Ast::Merge::Error for consistency across merge gems.
|
|
48
|
+
class Error < Ast::Merge::Error; end
|
|
49
|
+
|
|
50
|
+
# Raised when a JSON/JSONC file has parsing errors.
|
|
51
|
+
# Inherits from Ast::Merge::ParseError for consistency across merge gems.
|
|
52
|
+
#
|
|
53
|
+
# @example Handling parse errors
|
|
54
|
+
# begin
|
|
55
|
+
# analysis = FileAnalysis.new(json_content)
|
|
56
|
+
# rescue ParseError => e
|
|
57
|
+
# puts "JSON syntax error: #{e.message}"
|
|
58
|
+
# e.errors.each { |error| puts " #{error}" }
|
|
59
|
+
# end
|
|
60
|
+
class ParseError < Ast::Merge::ParseError
|
|
61
|
+
# @param message [String, nil] Error message (auto-generated if nil)
|
|
62
|
+
# @param content [String, nil] The JSON source that failed to parse
|
|
63
|
+
# @param errors [Array] Parse errors from tree-sitter
|
|
64
|
+
def initialize(message = nil, content: nil, errors: [])
|
|
65
|
+
super(message, errors: errors, content: content)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Raised when the template file has syntax errors.
|
|
70
|
+
#
|
|
71
|
+
# @example Handling template parse errors
|
|
72
|
+
# begin
|
|
73
|
+
# merger = SmartMerger.new(template, destination)
|
|
74
|
+
# result = merger.merge
|
|
75
|
+
# rescue TemplateParseError => e
|
|
76
|
+
# puts "Template syntax error: #{e.message}"
|
|
77
|
+
# e.errors.each do |error|
|
|
78
|
+
# puts " #{error.message}"
|
|
79
|
+
# end
|
|
80
|
+
# end
|
|
81
|
+
class TemplateParseError < ParseError; end
|
|
82
|
+
|
|
83
|
+
# Raised when the destination file has syntax errors.
|
|
84
|
+
#
|
|
85
|
+
# @example Handling destination parse errors
|
|
86
|
+
# begin
|
|
87
|
+
# merger = SmartMerger.new(template, destination)
|
|
88
|
+
# result = merger.merge
|
|
89
|
+
# rescue DestinationParseError => e
|
|
90
|
+
# puts "Destination syntax error: #{e.message}"
|
|
91
|
+
# e.errors.each do |error|
|
|
92
|
+
# puts " #{error.message}"
|
|
93
|
+
# end
|
|
94
|
+
# end
|
|
95
|
+
class DestinationParseError < ParseError; end
|
|
96
|
+
|
|
97
|
+
autoload :CommentTracker, "jsonc/merge/comment_tracker"
|
|
98
|
+
autoload :DebugLogger, "jsonc/merge/debug_logger"
|
|
99
|
+
autoload :Emitter, "jsonc/merge/emitter"
|
|
100
|
+
autoload :FreezeNode, "jsonc/merge/freeze_node"
|
|
101
|
+
autoload :FileAnalysis, "jsonc/merge/file_analysis"
|
|
102
|
+
autoload :MergeResult, "jsonc/merge/merge_result"
|
|
103
|
+
autoload :NodeWrapper, "jsonc/merge/node_wrapper"
|
|
104
|
+
autoload :ConflictResolver, "jsonc/merge/conflict_resolver"
|
|
105
|
+
autoload :SmartMerger, "jsonc/merge/smart_merger"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Register with ast-merge's MergeGemRegistry for RSpec dependency tags
|
|
110
|
+
# Only register if MergeGemRegistry is loaded (i.e., in test environment)
|
|
111
|
+
if defined?(Ast::Merge::RSpec::MergeGemRegistry)
|
|
112
|
+
Ast::Merge::RSpec::MergeGemRegistry.register(
|
|
113
|
+
:jsonc_merge,
|
|
114
|
+
require_path: "jsonc/merge",
|
|
115
|
+
merger_class: "Jsonc::Merge::SmartMerger",
|
|
116
|
+
test_source: "// comment\n{\"key\": \"value\"}",
|
|
117
|
+
category: :data,
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Jsonc::Merge::Version.class_eval do
|
|
122
|
+
extend VersionGem::Basic
|
|
123
|
+
end
|
data/lib/jsonc-merge.rb
ADDED
data/sig/json/merge.rbs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Type signatures for json-merge gem
|
|
2
|
+
# Smart merge for JSON/JSONC files using tree-sitter AST analysis
|
|
3
|
+
|
|
4
|
+
module Json
|
|
5
|
+
module Merge
|
|
6
|
+
VERSION: String
|
|
7
|
+
|
|
8
|
+
# Base error class for json-merge errors
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Error raised when template JSON file has syntax errors
|
|
13
|
+
class TemplateParseError < Error
|
|
14
|
+
attr_reader errors: Array[untyped]
|
|
15
|
+
attr_reader content: String?
|
|
16
|
+
|
|
17
|
+
def initialize: (?String? message, ?errors: Array[untyped], ?content: String?) -> void
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Error raised when destination JSON file has syntax errors
|
|
21
|
+
class DestinationParseError < Error
|
|
22
|
+
attr_reader errors: Array[untyped]
|
|
23
|
+
attr_reader content: String?
|
|
24
|
+
|
|
25
|
+
def initialize: (?String? message, ?errors: Array[untyped], ?content: String?) -> void
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Debug logging utility for Json::Merge
|
|
29
|
+
module DebugLogger
|
|
30
|
+
extend Ast::Merge::DebugLogger
|
|
31
|
+
|
|
32
|
+
def self.env_var_name: () -> String
|
|
33
|
+
def self.env_var_name=: (String name) -> String
|
|
34
|
+
def self.log_prefix: () -> String
|
|
35
|
+
def self.log_prefix=: (String prefix) -> String
|
|
36
|
+
def self.enabled?: () -> bool
|
|
37
|
+
def self.debug: (String message, ?Hash[Symbol, untyped] context) -> void
|
|
38
|
+
def self.info: (String message) -> void
|
|
39
|
+
def self.warning: (String message) -> void
|
|
40
|
+
def self.time: [T] (String operation) { () -> T } -> T
|
|
41
|
+
def self.extract_node_info: (untyped node) -> Hash[Symbol, untyped]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Freeze block node for JSON/JSONC files
|
|
45
|
+
class FreezeNode < Ast::Merge::FreezeNode
|
|
46
|
+
InvalidStructureError: singleton(Ast::Merge::FreezeNode::InvalidStructureError)
|
|
47
|
+
Location: singleton(Ast::Merge::FreezeNode::Location)
|
|
48
|
+
|
|
49
|
+
attr_reader lines: Array[String]
|
|
50
|
+
|
|
51
|
+
def initialize: (
|
|
52
|
+
start_line: Integer,
|
|
53
|
+
end_line: Integer,
|
|
54
|
+
lines: Array[String],
|
|
55
|
+
?start_marker: String?,
|
|
56
|
+
?end_marker: String?,
|
|
57
|
+
?pattern_type: Symbol
|
|
58
|
+
) -> void
|
|
59
|
+
|
|
60
|
+
def signature: () -> Array[Symbol | String]
|
|
61
|
+
def object?: () -> bool
|
|
62
|
+
def array?: () -> bool
|
|
63
|
+
def pair?: () -> bool
|
|
64
|
+
def slice: () -> String?
|
|
65
|
+
def inspect: () -> String
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def validate_structure!: () -> void
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Wrapper for tree-sitter JSON nodes
|
|
73
|
+
class NodeWrapper
|
|
74
|
+
attr_reader node: untyped
|
|
75
|
+
attr_reader start_line: Integer
|
|
76
|
+
attr_reader end_line: Integer
|
|
77
|
+
attr_reader analysis: FileAnalysis?
|
|
78
|
+
|
|
79
|
+
def initialize: (
|
|
80
|
+
untyped node,
|
|
81
|
+
String source,
|
|
82
|
+
?analysis: FileAnalysis?
|
|
83
|
+
) -> void
|
|
84
|
+
|
|
85
|
+
def location: () -> Ast::Merge::FreezeNode::Location
|
|
86
|
+
def signature: () -> Array[untyped]
|
|
87
|
+
def freeze_node?: () -> bool
|
|
88
|
+
def object?: () -> bool
|
|
89
|
+
def array?: () -> bool
|
|
90
|
+
def pair?: () -> bool
|
|
91
|
+
def key: () -> String?
|
|
92
|
+
def value: () -> untyped
|
|
93
|
+
def slice: () -> String?
|
|
94
|
+
def text: () -> String
|
|
95
|
+
def inspect: () -> String
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Comment tracker for JSONC files
|
|
99
|
+
class CommentTracker
|
|
100
|
+
attr_reader comments: Array[Hash[Symbol, untyped]]
|
|
101
|
+
attr_reader source: String
|
|
102
|
+
attr_reader lines: Array[String]
|
|
103
|
+
|
|
104
|
+
def initialize: (String source) -> void
|
|
105
|
+
|
|
106
|
+
def extract_comments: () -> Array[Hash[Symbol, untyped]]
|
|
107
|
+
def comments_for_line: (Integer line) -> Array[Hash[Symbol, untyped]]
|
|
108
|
+
def leading_comments_for: (Integer line) -> Array[Hash[Symbol, untyped]]
|
|
109
|
+
def trailing_comment_for: (Integer line) -> Hash[Symbol, untyped]?
|
|
110
|
+
def line_comment?: (String line) -> bool
|
|
111
|
+
def block_comment?: (String line) -> bool
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# File analysis for JSON/JSONC files
|
|
115
|
+
class FileAnalysis
|
|
116
|
+
include Ast::Merge::FileAnalysisBase
|
|
117
|
+
|
|
118
|
+
DEFAULT_FREEZE_TOKEN: String
|
|
119
|
+
PARSER_SEARCH_PATHS: Array[String]
|
|
120
|
+
|
|
121
|
+
attr_reader source: String
|
|
122
|
+
attr_reader lines: Array[String]
|
|
123
|
+
attr_reader ast: untyped
|
|
124
|
+
attr_reader statements: Array[NodeWrapper | FreezeNode]
|
|
125
|
+
attr_reader freeze_blocks: Array[FreezeNode]
|
|
126
|
+
attr_reader freeze_token: String
|
|
127
|
+
attr_reader signature_generator: (^(untyped) -> Array[untyped]?)?
|
|
128
|
+
attr_reader comment_tracker: CommentTracker
|
|
129
|
+
attr_reader errors: Array[untyped]
|
|
130
|
+
|
|
131
|
+
def self.find_parser_path: () -> String?
|
|
132
|
+
|
|
133
|
+
def initialize: (
|
|
134
|
+
String source,
|
|
135
|
+
?freeze_token: String,
|
|
136
|
+
?signature_generator: (^(untyped) -> Array[untyped]?)?,
|
|
137
|
+
?parser_path: String?
|
|
138
|
+
) -> void
|
|
139
|
+
|
|
140
|
+
def valid?: () -> bool
|
|
141
|
+
def nodes: () -> Array[NodeWrapper | FreezeNode]
|
|
142
|
+
def line_at: (Integer line_num) -> String?
|
|
143
|
+
def normalized_line: (Integer line_num) -> String?
|
|
144
|
+
def in_freeze_block?: (Integer line_num) -> bool
|
|
145
|
+
def freeze_block_at: (Integer line_num) -> FreezeNode?
|
|
146
|
+
def signature_at: (Integer index) -> Array[untyped]?
|
|
147
|
+
def generate_signature: (untyped node) -> Array[untyped]?
|
|
148
|
+
def compute_node_signature: (untyped node) -> Array[untyped]?
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def parse_json: () -> void
|
|
153
|
+
def extract_nodes: (untyped tree_node) -> Array[NodeWrapper]
|
|
154
|
+
def extract_freeze_blocks: () -> Array[FreezeNode]
|
|
155
|
+
def integrate_nodes_and_freeze_blocks: () -> Array[NodeWrapper | FreezeNode]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Result of a JSON merge operation
|
|
159
|
+
class MergeResult < Ast::Merge::MergeResult
|
|
160
|
+
DECISION_KEPT_TEMPLATE: Symbol
|
|
161
|
+
DECISION_KEPT_DEST: Symbol
|
|
162
|
+
DECISION_MERGED: Symbol
|
|
163
|
+
DECISION_ADDED: Symbol
|
|
164
|
+
DECISION_FREEZE_BLOCK: Symbol
|
|
165
|
+
|
|
166
|
+
attr_reader lines: Array[Hash[Symbol, untyped]]
|
|
167
|
+
attr_reader decisions: Array[Hash[Symbol, untyped]]
|
|
168
|
+
attr_reader statistics: Hash[Symbol, Integer]
|
|
169
|
+
|
|
170
|
+
def initialize: (
|
|
171
|
+
?template_analysis: FileAnalysis?,
|
|
172
|
+
?dest_analysis: FileAnalysis?,
|
|
173
|
+
?conflicts: Array[Hash[Symbol, untyped]],
|
|
174
|
+
?frozen_blocks: Array[FreezeNode],
|
|
175
|
+
?stats: Hash[Symbol, untyped]
|
|
176
|
+
) -> void
|
|
177
|
+
|
|
178
|
+
def add_line: (
|
|
179
|
+
String line,
|
|
180
|
+
decision: Symbol,
|
|
181
|
+
source: Symbol,
|
|
182
|
+
?original_line: Integer?
|
|
183
|
+
) -> void
|
|
184
|
+
|
|
185
|
+
def add_lines: (
|
|
186
|
+
Array[String] lines,
|
|
187
|
+
decision: Symbol,
|
|
188
|
+
source: Symbol,
|
|
189
|
+
?start_line: Integer?
|
|
190
|
+
) -> void
|
|
191
|
+
|
|
192
|
+
def add_blank_line: (?decision: Symbol, ?source: Symbol) -> void
|
|
193
|
+
def add_freeze_block: (FreezeNode freeze_node) -> void
|
|
194
|
+
def to_json: () -> String
|
|
195
|
+
def content: () -> Array[Hash[Symbol, untyped]]
|
|
196
|
+
def content_string: () -> String
|
|
197
|
+
def empty?: () -> bool
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def track_statistics: (Symbol decision, Symbol source) -> void
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Smart merger for JSON/JSONC files
|
|
205
|
+
class SmartMerger
|
|
206
|
+
include Ast::Merge::MergerConfig
|
|
207
|
+
|
|
208
|
+
attr_reader template_analysis: FileAnalysis
|
|
209
|
+
attr_reader dest_analysis: FileAnalysis
|
|
210
|
+
attr_reader signature_match_preference: (Symbol | Hash[Symbol, Symbol])
|
|
211
|
+
attr_reader add_template_only_nodes: bool
|
|
212
|
+
|
|
213
|
+
def initialize: (
|
|
214
|
+
String template_content,
|
|
215
|
+
String dest_content,
|
|
216
|
+
?signature_match_preference: (Symbol | Hash[Symbol, Symbol]),
|
|
217
|
+
?add_template_only_nodes: bool,
|
|
218
|
+
?freeze_token: String,
|
|
219
|
+
?signature_generator: (^(untyped) -> Array[untyped]?)?,
|
|
220
|
+
?node_splitter: Hash[Symbol, untyped]?,
|
|
221
|
+
?parser_path: String?
|
|
222
|
+
) -> void
|
|
223
|
+
|
|
224
|
+
def merge: () -> MergeResult
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def perform_merge: () -> MergeResult
|
|
229
|
+
def merge_nodes: (MergeResult result) -> void
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Conflict resolver for JSON merges
|
|
233
|
+
class ConflictResolver
|
|
234
|
+
attr_reader template_analysis: FileAnalysis
|
|
235
|
+
attr_reader dest_analysis: FileAnalysis
|
|
236
|
+
attr_reader signature_match_preference: (Symbol | Hash[Symbol, Symbol])
|
|
237
|
+
attr_reader add_template_only_nodes: bool
|
|
238
|
+
|
|
239
|
+
def initialize: (
|
|
240
|
+
FileAnalysis template_analysis,
|
|
241
|
+
FileAnalysis dest_analysis,
|
|
242
|
+
?signature_match_preference: (Symbol | Hash[Symbol, Symbol]),
|
|
243
|
+
?add_template_only_nodes: bool
|
|
244
|
+
) -> void
|
|
245
|
+
|
|
246
|
+
def resolve: (untyped boundary, MergeResult result) -> void
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Emitter for reconstructing JSON output
|
|
250
|
+
class Emitter
|
|
251
|
+
attr_reader result: MergeResult
|
|
252
|
+
attr_reader indent: Integer
|
|
253
|
+
|
|
254
|
+
def initialize: (MergeResult result, ?indent: Integer) -> void
|
|
255
|
+
|
|
256
|
+
def emit: () -> String
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
data.tar.gz.sig
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
��~���bV(4F���]'�4LKa�U bI1��Z��P���m�d���9�� B�n%�U!Ը�oL&=ivH5E92��jZ��g������c�>���/�c��~S2�T
|