toml-merge 2.0.0 → 7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f9cb256fd16a085c6a1d5f568bbe2eef0bcbeaf4a9cbc3f7bce5637b5770bce
4
- data.tar.gz: 4000f1e9f82713bb1016ab52fa96112ca917f2f371baefc2dabf2a29f0807013
3
+ metadata.gz: 3acee846936049e12d2a86754efa4883e926acb0a27ff50088ab4b0f07b2136d
4
+ data.tar.gz: ad65f5a29dc6ffa0c683c5dd88d53f52eb1c9cd2109a1d69643ab9a8157a2c66
5
5
  SHA512:
6
- metadata.gz: b57d61b70484cc8099d2d7b8249c093b729718182ffb607cd4f1db59c8ca372b7ee26d4b64f92d7f359956e01b337f07498e396353373d998efefb921feba5a1
7
- data.tar.gz: 0dce84d713ce68e1f5158fa3c2d883311741e7d28c4a7d93a17c9baa9e2356e8ecbc6d7be0aa1d3247712e287ae153d6170284b7132caa2de19e225b1d305b1e
6
+ metadata.gz: efcc0a2c04eed99bbcb29e13fb232812d3ce0f783214425b3e85fbc6c0a7eb265ce104459181b2615d4ca6503e02991fb8bf9cceac709cfd03b0384c5bc5d3e9
7
+ data.tar.gz: 903808d9d7df793cce2e67cb76c27ed8d04ec2e1248aae96a70362b973c1ebe26e1bb326d5a9c6333673a47e52f0c8b2c57f8487ea69d1b105a71b413adac9f3
checksums.yaml.gz.sig CHANGED
Binary file
@@ -2,11 +2,10 @@
2
2
 
3
3
  module Toml
4
4
  module Merge
5
- # Version information for Toml::Merge
6
5
  module Version
7
- # Current version of the toml-merge gem
8
- VERSION = "2.0.0"
6
+ VERSION = "7.0.0"
9
7
  end
10
- VERSION = Version::VERSION # traditional location
8
+
9
+ VERSION = Version::VERSION
11
10
  end
12
11
  end
data/lib/toml/merge.rb CHANGED
@@ -1,108 +1,422 @@
1
1
  # frozen_string_literal: true
2
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, backend selection, and Citrus fallback automatically
9
- # via parser_for(:toml). No manual registration needed.
3
+ require "json"
10
4
  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
- # Toml::Merge provides a TOML file smart merge system using tree-sitter AST analysis.
20
- # It intelligently merges template and destination TOML files by identifying matching
21
- # keys and resolving differences using structural signatures.
22
- #
23
- # @example Basic usage
24
- # template = File.read("template.toml")
25
- # destination = File.read("destination.toml")
26
- # merger = Toml::Merge::SmartMerger.new(template, destination)
27
- # result = merger.merge
28
- #
29
- # @example With debug information
30
- # merger = Toml::Merge::SmartMerger.new(template, destination)
31
- # debug_result = merger.merge_with_debug
32
- # puts debug_result[:content]
33
- # puts debug_result[:statistics]
5
+
34
6
  module Toml
35
- # Smart merge system for TOML files using tree-sitter AST analysis.
36
- # Provides intelligent merging by understanding TOML structure
37
- # rather than treating files as plain text.
38
- #
39
- # @see SmartMerger Main entry point for merge operations
40
- # @see FileAnalysis Analyzes TOML structure
41
- # @see ConflictResolver Resolves content conflicts
42
7
  module Merge
43
- # Base error class for Toml::Merge
44
- # Inherits from Ast::Merge::Error for consistency across merge gems.
45
- class Error < Ast::Merge::Error; end
46
-
47
- # Raised when a TOML file has parsing errors.
48
- # Inherits from Ast::Merge::ParseError for consistency across merge gems.
49
- #
50
- # @example Handling parse errors
51
- # begin
52
- # analysis = FileAnalysis.new(toml_content)
53
- # rescue ParseError => e
54
- # puts "TOML syntax error: #{e.message}"
55
- # e.errors.each { |error| puts " #{error}" }
56
- # end
57
- class ParseError < Ast::Merge::ParseError
58
- # @param message [String, nil] Error message (auto-generated if nil)
59
- # @param content [String, nil] The TOML source that failed to parse
60
- # @param errors [Array] Parse errors from tree-sitter
61
- def initialize(message = nil, content: nil, errors: [])
62
- super(message, errors: errors, content: content)
8
+ PACKAGE_NAME = "toml-merge"
9
+ DESTINATION_WINS_ARRAY_POLICY = {
10
+ surface: "array",
11
+ name: "destination_wins_array"
12
+ }.freeze
13
+
14
+ class ParseError < StandardError; end
15
+
16
+ module_function
17
+
18
+ def toml_feature_profile
19
+ {
20
+ family: "toml",
21
+ supported_dialects: ["toml"],
22
+ supported_policies: [DESTINATION_WINS_ARRAY_POLICY]
23
+ }
24
+ end
25
+
26
+ def available_toml_backends
27
+ [TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND]
28
+ end
29
+
30
+ def toml_backend_feature_profile(backend: nil)
31
+ resolved_backend = resolve_backend(backend)
32
+ return unsupported_feature_result("Unsupported TOML backend #{resolved_backend}.") unless resolved_backend == TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND.id
33
+
34
+ toml_feature_profile.merge(
35
+ backend: TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND.id,
36
+ backend_ref: TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND.to_h
37
+ )
38
+ end
39
+
40
+ def toml_plan_context(backend: nil)
41
+ profile = toml_backend_feature_profile(backend: backend)
42
+ return profile if profile[:ok] == false
43
+
44
+ {
45
+ family_profile: toml_feature_profile,
46
+ feature_profile: {
47
+ backend: profile[:backend],
48
+ supports_dialects: false,
49
+ supported_policies: profile[:supported_policies]
50
+ }
51
+ }
52
+ end
53
+
54
+ def analyze_toml_source(source, dialect)
55
+ return unsupported_feature_result("Unsupported TOML dialect #{dialect}.") unless dialect == "toml"
56
+
57
+ parsed = parse_toml_document(source)
58
+ {
59
+ ok: true,
60
+ diagnostics: [],
61
+ analysis: {
62
+ kind: "toml",
63
+ dialect: "toml",
64
+ normalized_source: canonical_toml(parsed),
65
+ root_kind: "table",
66
+ owners: collect_toml_owners(parsed)
67
+ },
68
+ policies: []
69
+ }
70
+ rescue StandardError => e
71
+ parse_error_result(e.message)
72
+ end
73
+
74
+ def parse_toml(source, dialect, backend: nil)
75
+ return unsupported_feature_result("Unsupported TOML dialect #{dialect}.") unless dialect == "toml"
76
+
77
+ resolved_backend = resolve_backend(backend)
78
+ return unsupported_feature_result("Unsupported TOML backend #{resolved_backend}.") unless resolved_backend == TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND.id
79
+
80
+ syntax_result = TreeHaver.parse_with_language_pack(
81
+ TreeHaver::ParserRequest.new(source: source, language: "toml", dialect: dialect)
82
+ )
83
+ return { ok: false, diagnostics: syntax_result[:diagnostics] } unless syntax_result[:ok]
84
+
85
+ analyze_toml_source(source, dialect)
86
+ end
87
+
88
+ def match_toml_owners(template, destination)
89
+ destination_paths = destination[:owners].to_h { |owner| [owner[:path], true] }
90
+ template_paths = template[:owners].to_h { |owner| [owner[:path], true] }
91
+
92
+ {
93
+ matched: template[:owners]
94
+ .filter { |owner| destination_paths[owner[:path]] }
95
+ .map { |owner| { template_path: owner[:path], destination_path: owner[:path] } },
96
+ unmatched_template: template[:owners].map { |owner| owner[:path] }.reject { |path| destination_paths[path] },
97
+ unmatched_destination: destination[:owners].map { |owner| owner[:path] }.reject { |path| template_paths[path] }
98
+ }
99
+ end
100
+
101
+ def merge_toml_with_parser(template_source, destination_source, dialect, &parser)
102
+ template = parser.call(template_source, dialect)
103
+ return { ok: false, diagnostics: template[:diagnostics], policies: [] } unless template[:ok]
104
+
105
+ destination = parser.call(destination_source, dialect)
106
+ unless destination[:ok]
107
+ return {
108
+ ok: false,
109
+ diagnostics: destination[:diagnostics].map do |diagnostic|
110
+ diagnostic[:category] == "parse_error" ? diagnostic.merge(category: "destination_parse_error") : diagnostic
111
+ end,
112
+ policies: []
113
+ }
63
114
  end
115
+
116
+ merged = merge_toml_tables(
117
+ parse_toml_document(template.dig(:analysis, :normalized_source)),
118
+ parse_toml_document(destination.dig(:analysis, :normalized_source))
119
+ )
120
+
121
+ {
122
+ ok: true,
123
+ diagnostics: [],
124
+ output: canonical_toml(merged),
125
+ policies: [DESTINATION_WINS_ARRAY_POLICY]
126
+ }
127
+ rescue StandardError => e
128
+ {
129
+ ok: false,
130
+ diagnostics: [{ severity: "error", category: "destination_parse_error", message: e.message }],
131
+ policies: []
132
+ }
64
133
  end
65
134
 
66
- # Raised when the template file has syntax errors.
67
- #
68
- # @example Handling template parse errors
69
- # begin
70
- # merger = SmartMerger.new(template, destination)
71
- # result = merger.merge
72
- # rescue TemplateParseError => e
73
- # puts "Template syntax error: #{e.message}"
74
- # e.errors.each do |error|
75
- # puts " #{error.message}"
76
- # end
77
- # end
78
- class TemplateParseError < ParseError; end
79
-
80
- # Raised when the destination file has syntax errors.
81
- #
82
- # @example Handling destination parse errors
83
- # begin
84
- # merger = SmartMerger.new(template, destination)
85
- # result = merger.merge
86
- # rescue DestinationParseError => e
87
- # puts "Destination syntax error: #{e.message}"
88
- # e.errors.each do |error|
89
- # puts " #{error.message}"
90
- # end
91
- # end
92
- class DestinationParseError < ParseError; end
93
-
94
- autoload :DebugLogger, "toml/merge/debug_logger"
95
- autoload :Emitter, "toml/merge/emitter"
96
- autoload :FileAnalysis, "toml/merge/file_analysis"
97
- autoload :MergeResult, "toml/merge/merge_result"
98
- autoload :NodeTypeNormalizer, "toml/merge/node_type_normalizer"
99
- autoload :NodeWrapper, "toml/merge/node_wrapper"
100
- autoload :ConflictResolver, "toml/merge/conflict_resolver"
101
- autoload :SmartMerger, "toml/merge/smart_merger"
102
- autoload :TableMatchRefiner, "toml/merge/table_match_refiner"
103
- end
104
- end
135
+ def merge_toml(template_source, destination_source, dialect, backend: nil)
136
+ resolved_backend = resolve_backend(backend)
137
+ return unsupported_feature_result("Unsupported TOML backend #{resolved_backend}.") unless resolved_backend == TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND.id
138
+
139
+ merge_toml_with_parser(template_source, destination_source, dialect) do |source, parse_dialect|
140
+ parse_toml(source, parse_dialect, backend: resolved_backend)
141
+ end
142
+ end
143
+
144
+ def resolve_backend(backend)
145
+ backend.to_s.empty? ? TreeHaver::KREUZBERG_LANGUAGE_PACK_BACKEND.id : backend.to_s
146
+ end
147
+ private_class_method :resolve_backend
148
+
149
+ def normalize_toml_source(source)
150
+ source.gsub(/\r\n?/, "\n")
151
+ end
152
+ private_class_method :normalize_toml_source
153
+
154
+ def strip_toml_comment(line)
155
+ result = +""
156
+ in_string = false
157
+ escaped = false
158
+
159
+ line.each_char do |char|
160
+ if in_string
161
+ result << char
162
+ if escaped
163
+ escaped = false
164
+ elsif char == "\\"
165
+ escaped = true
166
+ elsif char == '"'
167
+ in_string = false
168
+ end
169
+ next
170
+ end
105
171
 
106
- Toml::Merge::Version.class_eval do
107
- extend VersionGem::Basic
172
+ if char == '"'
173
+ in_string = true
174
+ result << char
175
+ next
176
+ end
177
+
178
+ break if char == "#"
179
+
180
+ result << char
181
+ end
182
+
183
+ raise ParseError, "Unterminated TOML string." if in_string
184
+
185
+ result.strip
186
+ end
187
+ private_class_method :strip_toml_comment
188
+
189
+ def split_outside_quotes(value, separator)
190
+ parts = []
191
+ current = +""
192
+ in_string = false
193
+ escaped = false
194
+ depth = 0
195
+
196
+ value.each_char do |char|
197
+ if in_string
198
+ current << char
199
+ if escaped
200
+ escaped = false
201
+ elsif char == "\\"
202
+ escaped = true
203
+ elsif char == '"'
204
+ in_string = false
205
+ end
206
+ next
207
+ end
208
+
209
+ case char
210
+ when '"'
211
+ in_string = true
212
+ current << char
213
+ when "["
214
+ depth += 1
215
+ current << char
216
+ when "]"
217
+ depth -= 1
218
+ current << char
219
+ else
220
+ if char == separator && depth.zero?
221
+ parts << current.strip
222
+ current = +""
223
+ else
224
+ current << char
225
+ end
226
+ end
227
+ end
228
+
229
+ raise ParseError, "Unterminated TOML string or array." if in_string || !depth.zero?
230
+
231
+ parts << current.strip
232
+ parts
233
+ end
234
+ private_class_method :split_outside_quotes
235
+
236
+ def parse_toml_key_path(value)
237
+ trimmed = value.strip
238
+ raise ParseError, "Missing TOML key path." if trimmed.empty?
239
+
240
+ parts = trimmed.split(".").map(&:strip)
241
+ raise ParseError, "Unsupported TOML key path #{trimmed}." unless parts.all? { |part| part.match?(/\A[A-Za-z0-9_-]+\z/) }
242
+
243
+ parts
244
+ end
245
+ private_class_method :parse_toml_key_path
246
+
247
+ def parse_toml_scalar_value(value)
248
+ case value
249
+ when /\A".*"\z/m
250
+ JSON.parse(value)
251
+ when "true"
252
+ true
253
+ when "false"
254
+ false
255
+ when /\A-?\d+\z/
256
+ value.to_i
257
+ when /\A-?\d+\.\d+\z/
258
+ value.to_f
259
+ else
260
+ raise ParseError, "Unsupported TOML value #{value}."
261
+ end
262
+ rescue JSON::ParserError
263
+ raise ParseError, "Invalid TOML string #{value}."
264
+ end
265
+ private_class_method :parse_toml_scalar_value
266
+
267
+ def parse_toml_value(value)
268
+ stripped = value.strip
269
+ if stripped.start_with?("[")
270
+ raise ParseError, "Invalid TOML array #{value}." unless stripped.end_with?("]")
271
+
272
+ inner = stripped[1..-2].strip
273
+ return [] if inner.empty?
274
+
275
+ split_outside_quotes(inner, ",").map { |entry| parse_toml_scalar_value(entry) }
276
+ else
277
+ parse_toml_scalar_value(stripped)
278
+ end
279
+ end
280
+ private_class_method :parse_toml_value
281
+
282
+ def ensure_toml_table(root, path)
283
+ current = root
284
+ path.each do |segment|
285
+ existing = current[segment]
286
+ if existing.nil?
287
+ current[segment] = {}
288
+ current = current[segment]
289
+ elsif existing.is_a?(Hash)
290
+ current = existing
291
+ else
292
+ raise ParseError, "TOML table path /#{path.join('/')} conflicts with a value."
293
+ end
294
+ end
295
+ current
296
+ end
297
+ private_class_method :ensure_toml_table
298
+
299
+ def assign_toml_value(root, path, value)
300
+ raise ParseError, "Missing TOML assignment path." if path.empty?
301
+
302
+ table = ensure_toml_table(root, path[0..-2])
303
+ key = path[-1]
304
+ existing = table[key]
305
+ raise ParseError, "TOML key /#{path.join('/')} conflicts with a table." if existing.is_a?(Hash)
306
+
307
+ table[key] = value
308
+ end
309
+ private_class_method :assign_toml_value
310
+
311
+ def parse_toml_document(source)
312
+ lines = normalize_toml_source(source).split("\n")
313
+ root = {}
314
+ current_table_path = []
315
+
316
+ lines.each do |raw_line|
317
+ line = strip_toml_comment(raw_line)
318
+ next if line.empty?
319
+
320
+ if line.start_with?("[")
321
+ raise ParseError, "Invalid TOML table header #{line}." unless line.end_with?("]")
322
+
323
+ current_table_path = parse_toml_key_path(line[1..-2])
324
+ ensure_toml_table(root, current_table_path)
325
+ next
326
+ end
327
+
328
+ parts = split_outside_quotes(line, "=")
329
+ raise ParseError, "Invalid TOML assignment #{line}." unless parts.length == 2
330
+
331
+ key_path = parse_toml_key_path(parts[0])
332
+ value = parse_toml_value(parts[1])
333
+ assign_toml_value(root, current_table_path + key_path, value)
334
+ end
335
+
336
+ root
337
+ end
338
+ private_class_method :parse_toml_document
339
+
340
+ def render_toml_scalar(value)
341
+ if value.is_a?(String)
342
+ JSON.generate(value)
343
+ elsif value == true || value == false
344
+ value ? "true" : "false"
345
+ else
346
+ value.to_s
347
+ end
348
+ end
349
+ private_class_method :render_toml_scalar
350
+
351
+ def render_toml_value(value)
352
+ return "[#{value.map { |item| render_toml_scalar(item) }.join(', ')}]" if value.is_a?(Array)
353
+
354
+ render_toml_scalar(value)
355
+ end
356
+ private_class_method :render_toml_value
357
+
358
+ def render_toml_table(table, path = [])
359
+ lines = []
360
+ keys = table.keys.sort
361
+ value_keys = keys.reject { |key| table[key].is_a?(Hash) }
362
+ table_keys = keys.select { |key| table[key].is_a?(Hash) }
363
+
364
+ lines << "[#{path.join('.')}]" unless path.empty?
365
+ value_keys.each do |key|
366
+ lines << "#{key} = #{render_toml_value(table[key])}"
367
+ end
368
+ table_keys.each do |key|
369
+ lines << "" unless lines.empty?
370
+ lines.concat(render_toml_table(table[key], path + [key]))
371
+ end
372
+ lines
373
+ end
374
+ private_class_method :render_toml_table
375
+
376
+ def canonical_toml(table)
377
+ "#{render_toml_table(table).join("\n")}\n"
378
+ end
379
+ private_class_method :canonical_toml
380
+
381
+ def collect_toml_owners(table, prefix = "")
382
+ table.keys.sort.flat_map do |key|
383
+ path = "#{prefix}/#{key}"
384
+ value = table[key]
385
+ if value.is_a?(Array)
386
+ [{ path: path, owner_kind: "key_value", match_key: key }] +
387
+ value.each_index.map { |index| { path: "#{path}/#{index}", owner_kind: "array_item" } }
388
+ elsif value.is_a?(Hash)
389
+ [{ path: path, owner_kind: "table", match_key: key }] + collect_toml_owners(value, path)
390
+ else
391
+ [{ path: path, owner_kind: "key_value", match_key: key }]
392
+ end
393
+ end
394
+ end
395
+ private_class_method :collect_toml_owners
396
+
397
+ def merge_toml_tables(template, destination)
398
+ (template.keys | destination.keys).sort.each_with_object({}) do |key, merged|
399
+ if !template.key?(key)
400
+ merged[key] = destination[key]
401
+ elsif !destination.key?(key)
402
+ merged[key] = template[key]
403
+ elsif template[key].is_a?(Hash) && destination[key].is_a?(Hash)
404
+ merged[key] = merge_toml_tables(template[key], destination[key])
405
+ else
406
+ merged[key] = destination[key]
407
+ end
408
+ end
409
+ end
410
+ private_class_method :merge_toml_tables
411
+
412
+ def parse_error_result(message)
413
+ { ok: false, diagnostics: [{ severity: "error", category: "parse_error", message: message }] }
414
+ end
415
+ private_class_method :parse_error_result
416
+
417
+ def unsupported_feature_result(message)
418
+ { ok: false, diagnostics: [{ severity: "error", category: "unsupported_feature", message: message }], policies: [] }
419
+ end
420
+ private_class_method :unsupported_feature_result
421
+ end
108
422
  end
data/lib/toml-merge.rb CHANGED
@@ -1,4 +1,3 @@
1
- # For technical reasons, if we move to Zeitwerk, this cannot be require_relative.
2
- # See: https://github.com/fxn/zeitwerk#for_gem_extension
3
- # Hook for other libraries to load this library (e.g. via bundler)
4
- require "toml/merge"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "toml/merge"
data.tar.gz.sig CHANGED
Binary file