toml-merge 7.1.1 → 7.1.3

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: c72304ec6fb3ac2231f4d7997634eee95539439a658b652ea7ae7333491a8929
4
- data.tar.gz: 31f483b3bb9440c62dfc47fc3ea97c26e2c26123e322f8ae6c6052bdf2b5ff50
3
+ metadata.gz: 1897d1ad4cb680eef623f8a329d9c0c917345e30f9997e340e91ae35e9f57d01
4
+ data.tar.gz: e858428956c256aef47413f8efe5d9aa01b246ef160527b14d02857e4b3ac18f
5
5
  SHA512:
6
- metadata.gz: 67eb8d1b954629819cde2b3bb8cfd5067cd2b039feaf19ede96322af45a0dc50cd5f14874108015de67aa3bbeaf58292fe92955d0d6102cb4e337051dbb9fbb4
7
- data.tar.gz: b96748e39aa82cc42d8e60c76d741d0c25840ad9e7b4d23e1e090b8e4ac7761628f0740f276fb2b52d265c664e82989b71e5bfd134e936b0d3e586d842378954
6
+ metadata.gz: 19015402b3398c6d2ea9babedd05a76b2b7d681f946f83eae5fc32b5097f2f3fa3bd97c0e0736c9c4c0753b4ced6a8de670c96d77ce0f8d8bba8d224bbb0916d
7
+ data.tar.gz: 142922345fc8ba7158cc809f082453789b8b5fa3553628f06703b6d49b46834c0d48c61abb006cc33d08266edc4f671c36aa9870da4dbb70667abfaa7933f043
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -90,7 +90,7 @@ puts result.content if result.success?
90
90
 
91
91
  ### Compatibility
92
92
 
93
- Compatible with MRI Ruby 4.0.0+, and concordant releases of JRuby, and TruffleRuby.
93
+ Compatible with MRI Ruby 4.0.0+, JRuby, and TruffleRuby.
94
94
  CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
95
95
  This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
96
96
  may be higher than the gem's runtime compatibility floor when legacy Rubies are
@@ -178,20 +178,12 @@ See [SECURITY.md][🔐security].
178
178
  ## 🤝 Contributing
179
179
 
180
180
  If you need some ideas of where to help, you could work on adding more code coverage,
181
- or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls],
182
- or use the gem and think about how it could be better.
181
+ check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
183
182
 
184
183
  We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
185
184
 
186
185
  See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
187
186
 
188
- ### Code Coverage
189
-
190
- <details markdown="1">
191
- <summary>Coverage service badges</summary>
192
-
193
- </details>
194
-
195
187
  ## 📌 Versioning
196
188
 
197
189
  This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toml
4
+ # Portable TOML workflow integration.
5
+ module Merge
6
+ # Source-preserving TOML workflow configured for kreuzberg-language-pack.
7
+ class Provider < SourcePreservingProvider
8
+ PROVIDER_ID = 'ruby.toml'
9
+ BACKEND = TREE_SITTER_BACKEND_REFERENCE
10
+ PACKAGE_NAME = Merge::PACKAGE_NAME
11
+ PACKAGE_VERSION = Version::VERSION
12
+ ROLE = :workflow
13
+ end
14
+
15
+ class << self
16
+ def merge_provider
17
+ @merge_provider ||= Provider.new
18
+ end
19
+
20
+ def register_provider!(replace: false)
21
+ Ast::Merge.register_provider(merge_provider, replace: replace)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,871 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Toml
6
+ module Merge
7
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider validation, planning, rendering, and verification form one safety boundary
8
+ # Backend-parameterized conservative source-preserving provider for top-level TOML mappings.
9
+ class SourcePreservingProvider
10
+ DEFAULT_PROFILE = :source_preserving
11
+ DIALECTS = %i[toml].freeze
12
+
13
+ Entry = Data.define(
14
+ :key,
15
+ :start_line,
16
+ :end_line,
17
+ :fragment,
18
+ :semantic,
19
+ :attributes,
20
+ :role,
21
+ :ownership_provable
22
+ )
23
+ Document = Data.define(
24
+ :role,
25
+ :source,
26
+ :analysis,
27
+ :entries,
28
+ :by_key,
29
+ :issues,
30
+ :document_attributes
31
+ )
32
+ Decision = Data.define(:key, :base, :ours, :theirs, :selected_role, :classification, :conflict)
33
+
34
+ def provider_id = self.class::PROVIDER_ID
35
+ def family = 'toml'
36
+ def backend_reference = self.class::BACKEND
37
+ def backend_id = backend_reference.id
38
+ def package_name = self.class::PACKAGE_NAME
39
+ def package_version = self.class::PACKAGE_VERSION
40
+ def role = self.class.const_defined?(:ROLE, false) ? self.class::ROLE : :backend
41
+
42
+ def capabilities
43
+ {
44
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
45
+ dialects: DIALECTS,
46
+ backends: [backend_id.to_sym],
47
+ profiles: [DEFAULT_PROFILE],
48
+ role: role,
49
+ source_preservation: %i[
50
+ exact_source
51
+ mapping_entry_fragments
52
+ comment_ownership
53
+ line_provenance
54
+ reparse
55
+ semantic_verification
56
+ ast_attribute_verification
57
+ ]
58
+ }.freeze
59
+ end
60
+
61
+ def analyze(request)
62
+ document = analyze_role(:source, request.fetch(:source))
63
+ return invalid_document_failure(:analyze, request, [document]) unless document.issues.empty?
64
+
65
+ result(
66
+ :analyze,
67
+ request,
68
+ analysis: analysis_payload(document),
69
+ verification: { source_parsed: true, structurally_unambiguous: true }
70
+ )
71
+ end
72
+
73
+ def diff2(request)
74
+ before = analyze_role(:before, request.fetch(:before_source))
75
+ after = analyze_role(:after, request.fetch(:after_source))
76
+ unsafe = [before, after].reject { |document| document.issues.empty? }
77
+ return invalid_document_failure(:diff2, request, unsafe) unless unsafe.empty?
78
+
79
+ changes = mapping_changes(before, after)
80
+ result(
81
+ :diff2,
82
+ request,
83
+ diff: { before: analysis_payload(before), after: analysis_payload(after), changes: changes },
84
+ changes: changes,
85
+ verification: { before_parsed: true, after_parsed: true }
86
+ )
87
+ end
88
+
89
+ def merge2(request)
90
+ documents = {
91
+ incoming: analyze_role(:incoming, request.fetch(:incoming_source)),
92
+ current: analyze_role(:current, request.fetch(:current_source))
93
+ }
94
+ unsafe = documents.values.reject { |document| document.issues.empty? }
95
+ return invalid_document_failure(:merge2, request, unsafe) unless unsafe.empty?
96
+
97
+ additions = documents[:incoming].entries.reject { |entry| documents[:current].by_key.key?(entry.key) }
98
+ render_documents = { ours: documents[:current], theirs: documents[:incoming] }
99
+ fragments = [whole_source_fragment(:ours, documents[:current].source)]
100
+ fragments.concat(additions.map { |entry| entry_fragment(:theirs, entry) })
101
+ rendered = render_plan(request, separated_fragments(fragments, render_documents))
102
+ expected = [
103
+ *documents[:current].entries.map { |entry| selected_entry(entry, :current) },
104
+ *additions.map { |entry| selected_entry(entry, :incoming) }
105
+ ]
106
+ verification = verify_composite(rendered.content, expected)
107
+ return verification_failure(:merge2, request, rendered, [], verification) unless verification[:semantic_match]
108
+
109
+ result(
110
+ :merge2,
111
+ request,
112
+ output: rendered.content,
113
+ changes: mapping_changes(documents[:current], analyze_role(:output, rendered.content)),
114
+ render_report: render_report(rendered, :exact_mapping_entry_composite),
115
+ verification: verification
116
+ )
117
+ end
118
+
119
+ def merge3(request)
120
+ documents = %i[base ours theirs].to_h do |role|
121
+ [role, analyze_role(role, request.fetch(:"#{role}_source"))]
122
+ end
123
+ exact_role = exact_revision_role(documents)
124
+ return render_exact(request, documents, exact_role) if exact_role
125
+
126
+ unsafe = documents.values.reject { |document| document.issues.empty? }
127
+ return structural_conflict(request, documents, unsafe) unless unsafe.empty?
128
+ return unmanaged_conflict(request, documents) unless unmanaged_unchanged?(documents)
129
+
130
+ decisions = three_way_decisions(documents)
131
+ conflicts = decisions.select(&:conflict)
132
+ return render_conflicts(request, documents, decisions, conflicts) unless conflicts.empty?
133
+
134
+ render_composite(request, documents, decisions)
135
+ end
136
+
137
+ private
138
+
139
+ def analyze_role(role, source)
140
+ analysis = TreeHaver.with_backend(backend_id) { ::Toml::Merge::FileAnalysis.new(source) }
141
+ unless analysis.valid?
142
+ message = analysis.errors.map(&:to_s).join('; ')
143
+ return invalid_document(role, source, :parse_error, message.empty? ? 'TOML parse failed' : message)
144
+ end
145
+
146
+ issues = []
147
+ if (table = analysis.tables.find(&:table?))
148
+ issues << issue(
149
+ :table,
150
+ 'TOML tables require an exact one-sided merge',
151
+ source_lines: [table.start_line, table.effective_end_line]
152
+ )
153
+ end
154
+ if (table_array = analysis.tables.find(&:array_of_tables?))
155
+ issues << issue(
156
+ :array_of_tables,
157
+ 'TOML arrays of tables require an exact one-sided merge',
158
+ source_lines: [table_array.start_line, table_array.effective_end_line]
159
+ )
160
+ end
161
+ entries = mapping_entries(analysis, source, role)
162
+ duplicate = entries.group_by(&:key).find { |_key, matches| matches.length > 1 }
163
+ if duplicate
164
+ issues << issue(
165
+ :duplicate_key,
166
+ "Duplicate TOML key #{duplicate.first}",
167
+ key: duplicate.first,
168
+ source_lines: duplicate.last.map { |entry| [entry.start_line, entry.end_line] }
169
+ )
170
+ end
171
+ entries.each do |entry|
172
+ if entry.key.include?('.')
173
+ issues << issue(
174
+ :dotted_key,
175
+ "TOML key #{entry.key} has ambiguous ownership",
176
+ key: entry.key,
177
+ source_lines: [entry.start_line, entry.end_line]
178
+ )
179
+ end
180
+ next if entry.ownership_provable
181
+
182
+ issues << issue(
183
+ :unprovable_range,
184
+ "TOML key #{entry.key} has no provable source range",
185
+ key: entry.key,
186
+ source_lines: [entry.start_line, entry.end_line]
187
+ )
188
+ end
189
+
190
+ Document.new(
191
+ role: role,
192
+ source: source,
193
+ analysis: analysis,
194
+ entries: entries.freeze,
195
+ by_key: entries.to_h { |entry| [entry.key, entry] }.freeze,
196
+ issues: issues.freeze,
197
+ document_attributes: node_attributes(analysis.root_node)
198
+ )
199
+ rescue TreeHaver::Error => e
200
+ invalid_document(role, source, :parse_error, e.message)
201
+ rescue StandardError => e
202
+ invalid_document(role, source, :structural_error, e.message)
203
+ end
204
+
205
+ def invalid_document(role, source, category, message)
206
+ Document.new(
207
+ role: role,
208
+ source: source,
209
+ analysis: nil,
210
+ entries: [].freeze,
211
+ by_key: {}.freeze,
212
+ issues: [issue(category, message)].freeze,
213
+ document_attributes: {}
214
+ )
215
+ end
216
+
217
+ def mapping_entries(analysis, source, role)
218
+ analysis.root_pairs.filter_map.with_index do |pair, index|
219
+ build_entry(analysis, pair, source, role, index)
220
+ end
221
+ end
222
+
223
+ def build_entry(analysis, pair, source, role, index)
224
+ key = pair.key_name
225
+ value = pair.value_node
226
+ key_line = pair.start_line
227
+ value_end = pair.end_line
228
+ return unless key && value && key_line && value_end && value_end >= key_line
229
+
230
+ leading_start = owned_leading_start(analysis, pair, index, key_line)
231
+ fragment = source_lines(source, leading_start, value_end)
232
+ Entry.new(
233
+ key: key,
234
+ start_line: leading_start,
235
+ end_line: value_end,
236
+ fragment: fragment,
237
+ semantic: node_semantic(value),
238
+ attributes: node_attributes(value),
239
+ role: role,
240
+ ownership_provable: ownership_provable?(analysis, pair, index, key_line, leading_start)
241
+ )
242
+ end
243
+
244
+ def owned_leading_start(analysis, pair, index, key_line)
245
+ return key_line if index.zero?
246
+
247
+ region = analysis.comment_attachment_for(pair)&.leading_region
248
+ return key_line unless region && region.end_line == key_line - 1
249
+ return key_line if region.metadata[:floating]
250
+
251
+ region.start_line
252
+ end
253
+
254
+ def ownership_provable?(analysis, pair, index, key_line, leading_start)
255
+ return true if leading_start == key_line
256
+ return false if index.zero?
257
+
258
+ region = analysis.comment_attachment_for(pair)&.leading_region
259
+ region && !region.metadata[:floating] && region.start_line == leading_start
260
+ end
261
+
262
+ def node_semantic(node)
263
+ {
264
+ type: node.canonical_type.to_s,
265
+ source_value: node.text
266
+ }.freeze
267
+ end
268
+
269
+ def node_attributes(node)
270
+ return {} unless node
271
+
272
+ {
273
+ type: node.type.to_s,
274
+ canonical_type: node.canonical_type.to_s,
275
+ signature: public_value(node.signature)
276
+ }.freeze
277
+ end
278
+
279
+ def public_value(value)
280
+ case value
281
+ when Array then value.map { |item| public_value(item) }
282
+ when Hash then value.to_h { |key, item| [key.to_s, public_value(item)] }
283
+ when String, Integer, Float, TrueClass, FalseClass, NilClass then value
284
+ else value.to_s
285
+ end
286
+ end
287
+
288
+ def analysis_payload(document)
289
+ {
290
+ dialect: 'toml',
291
+ backend: backend_id,
292
+ document_attributes: document.document_attributes,
293
+ entries: document.entries.map do |entry|
294
+ {
295
+ key: entry.key,
296
+ value: entry.semantic,
297
+ attributes: entry.attributes,
298
+ source_role: document.role,
299
+ source_lines: [entry.start_line, entry.end_line]
300
+ }
301
+ end,
302
+ structurally_unambiguous: document.issues.empty?
303
+ }
304
+ end
305
+
306
+ def entry_state(entry)
307
+ entry && [entry.semantic, entry.attributes, entry.fragment]
308
+ end
309
+
310
+ def exact_revision_role(documents)
311
+ return :ours if documents[:ours].source == documents[:theirs].source
312
+ return :ours if documents[:base].source == documents[:theirs].source
313
+
314
+ :theirs if documents[:base].source == documents[:ours].source
315
+ end
316
+
317
+ def render_exact(request, documents, role)
318
+ winner = documents.fetch(role)
319
+ return structural_conflict(request, documents, [winner]) unless fatal_issues(winner).empty?
320
+
321
+ rendered = render_plan(request, [whole_source_fragment(role, winner.source)])
322
+ verification = verify_exact(rendered.content, winner, role)
323
+ return verification_failure(:merge3, request, rendered, [], verification) unless verification[:semantic_match]
324
+
325
+ result(
326
+ :merge3,
327
+ request,
328
+ output: rendered.content,
329
+ changes: mapping_changes(documents[:base], winner),
330
+ diagnostics: diagnostics_for(*documents.values),
331
+ render_report: render_report(rendered, :exact_revision),
332
+ verification: verification.merge(base_participated: true)
333
+ )
334
+ end
335
+
336
+ def verify_exact(output, expected, role)
337
+ parsed = analyze_role(:output, output)
338
+ {
339
+ output_reparsed: true,
340
+ byte_exact: output == expected.source,
341
+ semantic_match: fatal_issues(parsed).empty? && document_signature(parsed) == document_signature(expected),
342
+ ast_attributes_match: parsed.document_attributes == expected.document_attributes &&
343
+ parsed.entries.map(&:attributes) == expected.entries.map(&:attributes),
344
+ ordered_entries: ordered_semantics(parsed),
345
+ source_role: role
346
+ }
347
+ end
348
+
349
+ def mapping_changes(before, after)
350
+ ordered_keys(before, after).filter_map do |key|
351
+ left = before.by_key[key]
352
+ right = after.by_key[key]
353
+ next if entry_state(left) == entry_state(right)
354
+
355
+ { path: "/#{key}", ours: :unchanged, theirs: change_kind(left, right) }.freeze
356
+ end.freeze
357
+ end
358
+
359
+ def ordered_keys(*documents)
360
+ documents.flat_map { |document| document.entries.map(&:key) }.uniq
361
+ end
362
+
363
+ def change_kind(before, after)
364
+ return :added unless before
365
+ return :deleted unless after
366
+
367
+ :edited
368
+ end
369
+
370
+ def three_way_decisions(documents)
371
+ ordered_keys(documents[:ours], documents[:theirs], documents[:base]).map do |key|
372
+ base = documents[:base].by_key[key]
373
+ ours = documents[:ours].by_key[key]
374
+ theirs = documents[:theirs].by_key[key]
375
+ selected_role =
376
+ if entry_state(ours) == entry_state(theirs)
377
+ :ours
378
+ elsif entry_state(ours) == entry_state(base)
379
+ :theirs
380
+ elsif entry_state(theirs) == entry_state(base)
381
+ :ours
382
+ end
383
+ classification = {
384
+ path: "/#{key}",
385
+ ours: side_change(base, ours),
386
+ theirs: side_change(base, theirs)
387
+ }.freeze
388
+ Decision.new(
389
+ key: key,
390
+ base: base,
391
+ ours: ours,
392
+ theirs: theirs,
393
+ selected_role: selected_role,
394
+ classification: classification,
395
+ conflict: selected_role.nil?
396
+ )
397
+ end.freeze
398
+ end
399
+
400
+ def side_change(base, side)
401
+ entry_state(base) == entry_state(side) ? :unchanged : change_kind(base, side)
402
+ end
403
+
404
+ def unmanaged_unchanged?(documents)
405
+ anchor_keys = documents.values.map { |document| document.by_key.keys }.reduce(&:intersection)
406
+ signatures = documents.transform_values { |document| unmanaged_signature(document, anchor_keys) }
407
+ signatures[:base] == signatures[:ours] && signatures[:base] == signatures[:theirs]
408
+ end
409
+
410
+ def unmanaged_signature(document, anchor_keys)
411
+ entries = document.entries.sort_by(&:start_line)
412
+ signature = []
413
+ cursor = 1
414
+ entries.each do |entry|
415
+ if cursor < entry.start_line
416
+ signature << [:source, source_lines(document.source, cursor, entry.start_line - 1)]
417
+ end
418
+ signature << [:entry, entry.key] if anchor_keys.include?(entry.key)
419
+ cursor = entry.end_line + 1
420
+ end
421
+ signature << [:source, source_lines(document.source, cursor, document.source.lines.length)]
422
+ signature
423
+ end
424
+
425
+ def render_composite(request, documents, decisions)
426
+ by_key = decisions.to_h { |decision| [decision.key, decision] }
427
+ fragments = []
428
+ selected = []
429
+ cursor = 1
430
+ documents[:ours].entries.sort_by(&:start_line).each do |ours_entry|
431
+ fragments << range_fragment(:ours, cursor, ours_entry.start_line - 1) if cursor < ours_entry.start_line
432
+ decision = by_key.fetch(ours_entry.key)
433
+ selected_entry_value = decision.selected_role && decision.public_send(decision.selected_role)
434
+ if selected_entry_value
435
+ fragments << entry_fragment(decision.selected_role, selected_entry_value)
436
+ selected << selected_entry(selected_entry_value, decision.selected_role)
437
+ end
438
+ cursor = ours_entry.end_line + 1
439
+ end
440
+ ours_line_count = documents[:ours].source.lines.length
441
+ fragments << range_fragment(:ours, cursor, ours_line_count) if cursor <= ours_line_count
442
+ decisions.each do |decision|
443
+ next if decision.ours || decision.selected_role != :theirs || !decision.theirs
444
+
445
+ fragments << entry_fragment(:theirs, decision.theirs)
446
+ selected << selected_entry(decision.theirs, :theirs)
447
+ end
448
+
449
+ rendered = render_plan(request, separated_fragments(fragments.compact, documents))
450
+ verification = verify_composite(rendered.content, selected)
451
+ unless verification[:semantic_match]
452
+ return verification_failure(:merge3, request, rendered, decisions, verification)
453
+ end
454
+
455
+ result(
456
+ :merge3,
457
+ request,
458
+ output: rendered.content,
459
+ changes: decisions.map(&:classification),
460
+ render_report: render_report(rendered, :exact_mapping_entry_composite),
461
+ verification: verification.merge(base_participated: true)
462
+ )
463
+ end
464
+
465
+ def selected_entry(entry, role)
466
+ {
467
+ key: entry.key,
468
+ semantic: entry.semantic,
469
+ attributes: entry.attributes,
470
+ source_role: role,
471
+ source_lines: [entry.start_line, entry.end_line],
472
+ fragment: entry.fragment
473
+ }.freeze
474
+ end
475
+
476
+ def verify_composite(output, selected)
477
+ parsed = analyze_role(:output, output)
478
+ expected_semantics = selected.map { |entry| [entry[:key], entry[:semantic]] }
479
+ expected_attributes = selected.map { |entry| [entry[:key], entry[:attributes]] }
480
+ actual_semantics = parsed.entries.map { |entry| [entry.key, entry.semantic] }
481
+ actual_attributes = parsed.entries.map { |entry| [entry.key, entry.attributes] }
482
+ source_match = parsed.entries.zip(selected).all? do |actual, expected|
483
+ actual.fragment == expected[:fragment] ||
484
+ (!expected[:fragment].end_with?("\n") && actual.fragment == "#{expected[:fragment]}\n")
485
+ end
486
+ {
487
+ output_reparsed: true,
488
+ semantic_match: parsed.issues.empty? && actual_semantics == expected_semantics &&
489
+ actual_attributes == expected_attributes && source_match,
490
+ ast_attributes_match: actual_attributes == expected_attributes,
491
+ source_match: source_match,
492
+ ordered_entries: actual_semantics,
493
+ entry_sources: selected.map { |entry| entry.slice(:key, :source_role, :source_lines) }
494
+ }
495
+ end
496
+
497
+ def ordered_semantics(document)
498
+ document.entries.map { |entry| [entry.key, entry.semantic] }
499
+ end
500
+
501
+ def document_signature(document)
502
+ [ordered_semantics(document), document.entries.map(&:attributes)]
503
+ end
504
+
505
+ def fatal_issues(document)
506
+ document.issues.select { |item| %i[parse_error structural_error].include?(item[:category]) }
507
+ end
508
+
509
+ def render_conflicts(request, documents, decisions, conflicts)
510
+ unless conflicts.all? { |conflict| localized_conflict_position(conflict, documents) }
511
+ return full_file_conflict(request, documents, decisions, conflicts, :ownership_unproven)
512
+ end
513
+ unless unmanaged_unchanged?(documents)
514
+ return full_file_conflict(request, documents, decisions, conflicts, :unmanaged_source_change)
515
+ end
516
+
517
+ by_key = conflicts.to_h { |conflict| [conflict.key, conflict] }
518
+ inserted_conflicts = conflicts.select { |conflict| conflict.ours.nil? }
519
+ insert_before = inserted_conflicts.group_by do |conflict|
520
+ localized_conflict_position(conflict, documents)
521
+ end
522
+ fragments = []
523
+ cursor = 1
524
+ documents[:ours].entries.sort_by(&:start_line).each do |entry|
525
+ fragments << range_fragment(:ours, cursor, entry.start_line - 1) if cursor < entry.start_line
526
+ Array(insert_before[entry.key]).each { |conflict| fragments << conflict_fragment(request, conflict) }
527
+ conflict = by_key[entry.key]
528
+ fragments << (conflict ? conflict_fragment(request, conflict) : entry_fragment(:ours, entry))
529
+ cursor = entry.end_line + 1
530
+ end
531
+ Array(insert_before[:append]).each { |conflict| fragments << conflict_fragment(request, conflict) }
532
+ ours_line_count = documents[:ours].source.lines.length
533
+ fragments << range_fragment(:ours, cursor, ours_line_count) if cursor <= ours_line_count
534
+ rendered = render_plan(request, fragments.compact)
535
+ conflict_failure(request, decisions, conflicts, rendered, :mapping_entry_localized_conflict)
536
+ end
537
+
538
+ def localized_conflict_position(conflict, documents)
539
+ return conflict.key if conflict.ours&.ownership_provable
540
+ return unless conflict.base&.ownership_provable && conflict.theirs&.ownership_provable
541
+
542
+ base_keys = documents[:base].entries.map(&:key)
543
+ conflict_index = base_keys.index(conflict.key)
544
+ return unless conflict_index
545
+
546
+ following_anchor = base_keys.drop(conflict_index + 1).find { |key| documents[:ours].by_key.key?(key) }
547
+ following_anchor || :append
548
+ end
549
+
550
+ def conflict_fragment(request, decision)
551
+ Ast::Merge::SourceRender::ConflictFragment.new(
552
+ conflict_id: conflict_id(decision.key),
553
+ base: conflict_side(:base, decision.base),
554
+ ours: conflict_side(:ours, decision.ours),
555
+ theirs: conflict_side(:theirs, decision.theirs),
556
+ labels: request.fetch(:labels, {}),
557
+ marker_size: request.fetch(:conflict_marker_size, 7),
558
+ metadata: { path: decision.classification[:path], category: conflict_category(decision) }
559
+ )
560
+ end
561
+
562
+ def conflict_side(role, entry)
563
+ return [] unless entry
564
+
565
+ [entry_fragment(role, entry, line_bounded: true)]
566
+ end
567
+
568
+ def conflict_category(decision)
569
+ decision.ours.nil? || decision.theirs.nil? ? :delete_edit : :edit_edit
570
+ end
571
+
572
+ def conflict_id(key)
573
+ "toml-mapping-#{Digest::SHA256.hexdigest(key)[0, 16]}"
574
+ end
575
+
576
+ def conflict_failure(request, decisions, conflicts, rendered, strategy)
577
+ failure(
578
+ :merge3,
579
+ request,
580
+ category: :merge_conflict,
581
+ message: 'TOML mapping entries changed incompatibly.',
582
+ changes: decisions.map(&:classification),
583
+ conflicts: conflicts.map { |decision| conflict_payload(decision) },
584
+ conflicted_output: rendered.content,
585
+ conflicted_source: rendered.content,
586
+ render_report: render_report(rendered, strategy),
587
+ verification: { base_participated: true }
588
+ )
589
+ end
590
+
591
+ def structural_conflict(request, documents, unsafe)
592
+ source_role = unsafe.first.role
593
+ full_file_conflict(
594
+ request,
595
+ documents,
596
+ safe_decisions(documents),
597
+ [],
598
+ :structural_ambiguity,
599
+ diagnostics: [
600
+ {
601
+ category: unsafe.first.issues.first[:category],
602
+ severity: :error,
603
+ message: "#{source_role} TOML input is unsafe for structural merge.",
604
+ blocking: true,
605
+ source_role: source_role
606
+ },
607
+ *diagnostics_for(*unsafe)
608
+ ]
609
+ )
610
+ end
611
+
612
+ def safe_decisions(documents)
613
+ return [] unless documents.values.all? { |document| document.issues.empty? }
614
+
615
+ three_way_decisions(documents)
616
+ end
617
+
618
+ def unmanaged_conflict(request, documents)
619
+ full_file_conflict(
620
+ request,
621
+ documents,
622
+ three_way_decisions(documents),
623
+ [],
624
+ :unmanaged_source_change,
625
+ diagnostics: [{
626
+ category: :unmanaged_source_change,
627
+ severity: :error,
628
+ message: 'Unmanaged TOML layout or comments changed independently.',
629
+ blocking: true
630
+ }]
631
+ )
632
+ end
633
+
634
+ def full_file_conflict(request, documents, decisions, conflicts, reason, diagnostics: nil)
635
+ conflict = { conflict_id: 'toml-conflict-root', category: reason, path: '<document>' }.freeze
636
+ rendered = render_plan(
637
+ request,
638
+ [
639
+ Ast::Merge::SourceRender::ConflictFragment.new(
640
+ conflict_id: conflict[:conflict_id],
641
+ base: conflict_source(:base, documents[:base].source),
642
+ ours: conflict_source(:ours, documents[:ours].source),
643
+ theirs: conflict_source(:theirs, documents[:theirs].source),
644
+ labels: request.fetch(:labels, {}),
645
+ marker_size: request.fetch(:conflict_marker_size, 7),
646
+ metadata: { category: reason }
647
+ )
648
+ ]
649
+ )
650
+ failure(
651
+ :merge3,
652
+ request,
653
+ category: reason,
654
+ message: 'TOML source cannot be safely combined structurally.',
655
+ diagnostics: diagnostics,
656
+ changes: decisions.map(&:classification),
657
+ conflicts: conflicts.empty? ? [conflict] : conflicts.map { |item| conflict_payload(item) },
658
+ fallbacks: [{ from: :mapping_entry_composite, to: :full_file_conflict, reason: reason }],
659
+ conflicted_output: rendered.content,
660
+ conflicted_source: rendered.content,
661
+ render_report: render_report(rendered, :full_file_conflict),
662
+ verification: { base_participated: true },
663
+ source_role: diagnostics&.first&.fetch(:source_role, nil)
664
+ )
665
+ end
666
+
667
+ def conflict_payload(decision)
668
+ {
669
+ conflict_id: conflict_id(decision.key),
670
+ category: conflict_category(decision),
671
+ path: decision.classification[:path],
672
+ change_classification: decision.classification
673
+ }.freeze
674
+ end
675
+
676
+ def conflict_source(role, source)
677
+ return [] if source.empty?
678
+
679
+ [whole_source_fragment(role, source, line_bounded: true)]
680
+ end
681
+
682
+ def separated_fragments(fragments, documents)
683
+ fragments.each_with_index.flat_map do |fragment, index|
684
+ next [fragment] if index == fragments.length - 1
685
+ next [fragment] if fragment.is_a?(Ast::Merge::SourceRender::ConflictFragment)
686
+
687
+ content = fragment_content(fragment, documents)
688
+ next [fragment] if content.empty? || content.end_with?("\n")
689
+
690
+ [
691
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
692
+ content: "#{content}\n",
693
+ reason: :mapping_entry_separator,
694
+ producer: provider_id,
695
+ metadata: fragment.metadata.merge(copied_source: true)
696
+ )
697
+ ]
698
+ end
699
+ end
700
+
701
+ def fragment_content(fragment, documents)
702
+ return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
703
+
704
+ document = documents.fetch(fragment.revision)
705
+ source_lines(document.source, fragment.start_line, fragment.end_line)
706
+ end
707
+
708
+ def whole_source_fragment(role, source, line_bounded: false)
709
+ if source.empty?
710
+ return Ast::Merge::SourceRender::SynthesizedFragment.new(
711
+ content: '',
712
+ reason: :exact_empty_source,
713
+ producer: provider_id,
714
+ metadata: { source_role: role }
715
+ )
716
+ end
717
+ fragment = range_fragment(role, 1, source.lines.length)
718
+ return fragment unless line_bounded && !source.end_with?("\n")
719
+
720
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
721
+ content: "#{source}\n",
722
+ reason: :conflict_line_boundary,
723
+ producer: provider_id,
724
+ metadata: { source_role: role, copied_source: true }
725
+ )
726
+ end
727
+
728
+ def entry_fragment(role, entry, line_bounded: false)
729
+ unless line_bounded && !entry.fragment.end_with?("\n")
730
+ return range_fragment(role, entry.start_line, entry.end_line, toml_key: entry.key)
731
+ end
732
+
733
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
734
+ content: "#{entry.fragment}\n",
735
+ reason: :conflict_line_boundary,
736
+ producer: provider_id,
737
+ metadata: { source_role: role, toml_key: entry.key, copied_source: true }
738
+ )
739
+ end
740
+
741
+ def range_fragment(role, start_line, end_line, **metadata)
742
+ return if end_line < start_line
743
+
744
+ Ast::Merge::SourceRender::SourceFragment.new(
745
+ revision: role,
746
+ start_line: start_line,
747
+ end_line: end_line,
748
+ metadata: { source_role: role }.merge(metadata)
749
+ )
750
+ end
751
+
752
+ def source_lines(source, start_line, end_line)
753
+ return '' if end_line < start_line
754
+
755
+ source.lines.slice(start_line - 1, end_line - start_line + 1).to_a.join
756
+ end
757
+
758
+ def render_plan(request, fragments)
759
+ Ast::Merge::SourceRender::Renderer.new.render(
760
+ Ast::Merge::SourceRender::Plan.new(
761
+ sources: {
762
+ base: request[:base_source].to_s,
763
+ ours: request[:ours_source] || request[:current_source].to_s,
764
+ theirs: request[:theirs_source] || request[:incoming_source].to_s
765
+ },
766
+ fragments: fragments,
767
+ metadata: { provider_id: provider_id }
768
+ )
769
+ )
770
+ end
771
+
772
+ def render_report(rendered, strategy)
773
+ {
774
+ strategy: strategy,
775
+ line_records: rendered.line_records,
776
+ synthesized_fragments: rendered.synthesized_fragments,
777
+ conflicts: rendered.conflicts,
778
+ verification_input: rendered.verification_input
779
+ }
780
+ end
781
+
782
+ def issue(category, message, **metadata)
783
+ { category: category, message: message, **metadata }.freeze
784
+ end
785
+
786
+ def diagnostics_for(*documents)
787
+ documents.flat_map do |document|
788
+ document.issues.map do |item|
789
+ item.merge(severity: :warning, blocking: false, source_role: document.role).freeze
790
+ end
791
+ end.freeze
792
+ end
793
+
794
+ def invalid_document_failure(operation, request, documents)
795
+ first = documents.first
796
+ failure(
797
+ operation,
798
+ request,
799
+ category: first.issues.first[:category],
800
+ message: "#{first.role} TOML input is unsafe",
801
+ diagnostics: diagnostics_for(*documents).map { |diagnostic| diagnostic.merge(blocking: true) },
802
+ source_role: first.role
803
+ )
804
+ end
805
+
806
+ def verification_failure(operation, request, rendered, decisions, verification)
807
+ failure(
808
+ operation,
809
+ request,
810
+ category: :verification_failure,
811
+ message: 'Rendered TOML did not preserve planned ordered semantics, AST attributes, and source fragments.',
812
+ changes: decisions.map(&:classification),
813
+ render_report: render_report(rendered, :exact_mapping_entry_composite),
814
+ verification: operation == :merge3 ? verification.merge(base_participated: true) : verification
815
+ )
816
+ end
817
+
818
+ def result(operation, request, diagnostics: [], changes: [], conflicts: [], fallbacks: [],
819
+ render_report: {}, verification: {}, **payload)
820
+ Ast::Merge::ProviderResult.build(
821
+ operation: operation,
822
+ success: true,
823
+ envelope: envelope(
824
+ request,
825
+ diagnostics: diagnostics,
826
+ changes: changes,
827
+ conflicts: conflicts,
828
+ fallbacks: fallbacks,
829
+ render_report: render_report,
830
+ verification: verification
831
+ ),
832
+ **payload
833
+ )
834
+ end
835
+
836
+ def failure(operation, request, category:, message:, diagnostics: nil, changes: [], conflicts: [],
837
+ fallbacks: [], render_report: {}, verification: {}, **payload)
838
+ diagnostics ||= [{ category: category, severity: :error, message: message, blocking: true }]
839
+ Ast::Merge::ProviderResult.build(
840
+ operation: operation,
841
+ success: false,
842
+ envelope: envelope(
843
+ request,
844
+ diagnostics: diagnostics,
845
+ changes: changes,
846
+ conflicts: conflicts,
847
+ fallbacks: fallbacks,
848
+ render_report: render_report,
849
+ verification: verification
850
+ ),
851
+ **payload
852
+ )
853
+ end
854
+
855
+ def envelope(request, **fields)
856
+ {
857
+ provider: {
858
+ provider_id: provider_id,
859
+ family: family,
860
+ dialect: request[:dialect] || :toml,
861
+ backend: request[:backend] || backend_id.to_sym,
862
+ package: package_name,
863
+ package_version: package_version
864
+ },
865
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
866
+ }.merge(fields)
867
+ end
868
+ end
869
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
870
+ end
871
+ end
@@ -5,7 +5,7 @@ module Toml
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = '7.1.1'
8
+ VERSION = '7.1.3'
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/toml/merge.rb CHANGED
@@ -108,6 +108,7 @@ module Toml
108
108
  autoload :NodeWrapper, 'toml/merge/node_wrapper'
109
109
  autoload :ConflictResolver, 'toml/merge/conflict_resolver'
110
110
  autoload :SmartMerger, 'toml/merge/smart_merger'
111
+ autoload :SourcePreservingProvider, 'toml/merge/source_preserving_provider'
111
112
  autoload :TableMatchRefiner, 'toml/merge/table_match_refiner'
112
113
 
113
114
  class << self
@@ -365,6 +366,8 @@ module Toml
365
366
  end
366
367
 
367
368
  Toml::Merge.register_backend!
369
+ require_relative 'merge/provider'
370
+ Toml::Merge.register_provider!
368
371
 
369
372
  # Register with ast-merge's MergeGemRegistry for RSpec dependency tags
370
373
  # Only register if MergeGemRegistry is loaded (i.e., in test environment)
data/sig/toml/merge.rbs CHANGED
@@ -1,5 +1,26 @@
1
1
  module Toml
2
2
  module Merge
3
+ class SourcePreservingProvider
4
+ def provider_id: () -> String
5
+ def family: () -> String
6
+ def backend_reference: () -> untyped
7
+ def backend_id: () -> String
8
+ def package_name: () -> String
9
+ def package_version: () -> String
10
+ def role: () -> Symbol
11
+ def capabilities: () -> Hash[Symbol, untyped]
12
+ def analyze: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
13
+ def diff2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
14
+ def merge2: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
15
+ def merge3: (Hash[Symbol, untyped] request) -> Hash[Symbol, untyped]
16
+ end
17
+
18
+ class Provider < SourcePreservingProvider
19
+ end
20
+
21
+ def self.merge_provider: () -> Provider
22
+ def self.register_provider!: (?replace: bool) -> untyped
23
+
3
24
  module Version
4
25
  VERSION: String
5
26
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toml-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.1
4
+ version: 7.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -43,28 +43,28 @@ dependencies:
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: 7.1.1
46
+ version: 7.1.3
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: 7.1.1
53
+ version: 7.1.3
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: tree_haver
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - '='
59
59
  - !ruby/object:Gem::Version
60
- version: 7.1.1
60
+ version: 7.1.3
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - '='
66
66
  - !ruby/object:Gem::Version
67
- version: 7.1.1
67
+ version: 7.1.3
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: version_gem
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -91,20 +91,20 @@ dependencies:
91
91
  requirements:
92
92
  - - "~>"
93
93
  - !ruby/object:Gem::Version
94
- version: '2.5'
94
+ version: '3.0'
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
- version: 2.5.17
97
+ version: 3.0.0
98
98
  type: :development
99
99
  prerelease: false
100
100
  version_requirements: !ruby/object:Gem::Requirement
101
101
  requirements:
102
102
  - - "~>"
103
103
  - !ruby/object:Gem::Version
104
- version: '2.5'
104
+ version: '3.0'
105
105
  - - ">="
106
106
  - !ruby/object:Gem::Version
107
- version: 2.5.17
107
+ version: 3.0.0
108
108
  - !ruby/object:Gem::Dependency
109
109
  name: bundler-audit
110
110
  requirement: !ruby/object:Gem::Requirement
@@ -182,7 +182,7 @@ dependencies:
182
182
  version: '3.2'
183
183
  - - ">="
184
184
  - !ruby/object:Gem::Version
185
- version: 3.2.0
185
+ version: 3.2.2
186
186
  type: :development
187
187
  prerelease: false
188
188
  version_requirements: !ruby/object:Gem::Requirement
@@ -192,7 +192,7 @@ dependencies:
192
192
  version: '3.2'
193
193
  - - ">="
194
194
  - !ruby/object:Gem::Version
195
- version: 3.2.0
195
+ version: 3.2.2
196
196
  - !ruby/object:Gem::Dependency
197
197
  name: kettle-test
198
198
  requirement: !ruby/object:Gem::Requirement
@@ -222,7 +222,7 @@ dependencies:
222
222
  version: '3.2'
223
223
  - - ">="
224
224
  - !ruby/object:Gem::Version
225
- version: 3.2.4
225
+ version: 3.2.5
226
226
  type: :development
227
227
  prerelease: false
228
228
  version_requirements: !ruby/object:Gem::Requirement
@@ -232,7 +232,7 @@ dependencies:
232
232
  version: '3.2'
233
233
  - - ">="
234
234
  - !ruby/object:Gem::Version
235
- version: 3.2.4
235
+ version: 3.2.5
236
236
  - !ruby/object:Gem::Dependency
237
237
  name: ruby-progressbar
238
238
  requirement: !ruby/object:Gem::Requirement
@@ -308,7 +308,9 @@ files:
308
308
  - lib/toml/merge/merge_result.rb
309
309
  - lib/toml/merge/node_type_normalizer.rb
310
310
  - lib/toml/merge/node_wrapper.rb
311
+ - lib/toml/merge/provider.rb
311
312
  - lib/toml/merge/smart_merger.rb
313
+ - lib/toml/merge/source_preserving_provider.rb
312
314
  - lib/toml/merge/table_match_refiner.rb
313
315
  - lib/toml/merge/version.rb
314
316
  - sig/toml/merge.rbs
@@ -318,10 +320,10 @@ licenses:
318
320
  - PolyForm-Small-Business-1.0.0
319
321
  metadata:
320
322
  homepage_uri: https://structuredmerge.org
321
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.1
322
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.1/CHANGELOG.md
323
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.3
324
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.3/CHANGELOG.md
323
325
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
324
- documentation_uri: https://www.rubydoc.info/gems/toml-merge/7.1.1
326
+ documentation_uri: https://www.rubydoc.info/gems/toml-merge/7.1.3
325
327
  funding_uri: https://github.com/sponsors/pboling
326
328
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
327
329
  news_uri: https://www.railsbling.com/tags/toml-merge
metadata.gz.sig CHANGED
Binary file