rust-merge 7.0.0 → 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.
@@ -0,0 +1,959 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'ast/merge/source_render'
6
+ require_relative 'node_wrapper'
7
+ require_relative 'file_analysis'
8
+
9
+ module Rust
10
+ # Registration and source-preserving workflow implementation for Rust.
11
+ module Merge
12
+ class << self
13
+ def merge_provider
14
+ @merge_provider ||= Provider.new
15
+ end
16
+
17
+ def register_provider!(replace: false)
18
+ return unless Ast::Merge.respond_to?(:register_provider)
19
+
20
+ Ast::Merge.register_provider(merge_provider, replace: replace)
21
+ end
22
+ end
23
+
24
+ # Base-aware, source-preserving provider for native Rust declarations.
25
+ # Compound use trees and macro forms are whole owners. Any ambiguous
26
+ # identity or changing compound membership is rejected before rendering.
27
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- provider decisions, source plans, and verification form one boundary
28
+ class Provider
29
+ DEFAULT_PROFILE = :source_preserving
30
+ Owner = Data.define(:id, :signature, :membership, :compound, :fingerprint, :start_line, :end_line, :role)
31
+ Document = Data.define(:source, :analysis, :owners, :by_id, :unmanaged_fingerprint)
32
+ ExactDocument = Data.define(:source, :analysis, :declarations, :issues, :role)
33
+ Decision = Data.define(:changes, :conflicts, :choices)
34
+
35
+ def provider_id = 'ruby.rust'
36
+ def family = 'rust'
37
+
38
+ def capabilities
39
+ {
40
+ operations: Ast::Merge::ProviderContract::OPERATIONS,
41
+ dialects: %i[rust],
42
+ backends: [TREE_SITTER_BACKEND.id.to_sym],
43
+ profiles: [DEFAULT_PROFILE],
44
+ role: :workflow,
45
+ compound_declaration_ownership: :whole_declaration_with_stable_member_identity,
46
+ source_preservation: %i[exact_source declaration_fragments line_provenance reparse semantic_verification]
47
+ }.freeze
48
+ end
49
+
50
+ def analyze(request)
51
+ document = parse_document(:analyze, request, :source)
52
+ return document if provider_failure?(document)
53
+
54
+ result(
55
+ :analyze,
56
+ request,
57
+ analysis: {
58
+ backend: TREE_SITTER_BACKEND.id,
59
+ valid: true,
60
+ declarations: document.owners.map { |owner| owner_description(owner) }
61
+ },
62
+ verification: { source_parsed: true }
63
+ )
64
+ end
65
+
66
+ def diff2(request)
67
+ before = parse_document(:diff2, request, :before)
68
+ return before if provider_failure?(before)
69
+
70
+ after = parse_document(:diff2, request, :after)
71
+ return after if provider_failure?(after)
72
+
73
+ changes = diff_documents(before, after)
74
+ result(
75
+ :diff2,
76
+ request,
77
+ diff: { changes: changes },
78
+ changes: changes,
79
+ verification: { before_parsed: true, after_parsed: true }
80
+ )
81
+ end
82
+
83
+ def merge2(request)
84
+ merge3_request = request.merge(
85
+ base_source: request.fetch(:current_source),
86
+ ours_source: request.fetch(:current_source),
87
+ theirs_source: request.fetch(:incoming_source)
88
+ )
89
+ merged = merge3(merge3_request)
90
+ merged.merge(
91
+ operation: :merge2,
92
+ verification: merged.fetch(:verification).except(:base_participated)
93
+ )
94
+ end
95
+
96
+ def merge3(request)
97
+ exact_role = exact_revision_role(request)
98
+ return merge3_exact(request, exact_role) if exact_role
99
+
100
+ documents = parse_merge3_documents(request)
101
+ return documents if provider_failure?(documents)
102
+
103
+ compound_failure = compound_ownership_failure(request, documents)
104
+ return compound_failure if compound_failure
105
+
106
+ decision = decide(documents, include_unmanaged_conflict: true)
107
+ return render_conflicts(request, documents, decision) unless decision.conflicts.empty?
108
+
109
+ render_composite(request, documents, decision)
110
+ end
111
+
112
+ private
113
+
114
+ def parse_merge3_documents(request)
115
+ %i[base ours theirs].each_with_object({}) do |role, documents|
116
+ source = request.fetch(:"#{role}_source")
117
+ document = parse_source(source, role, request[:dialect])
118
+ if document.is_a?(Hash) && document[:parse_error]
119
+ return parse_failure(:merge3, request, role, document)
120
+ elsif provider_failure?(document)
121
+ return unsafe_document_failure(request, role, document)
122
+ end
123
+
124
+ documents[role] = document
125
+ end
126
+ end
127
+
128
+ def parse_document(operation, request, role)
129
+ source = request.fetch(role == :source ? :source : :"#{role}_source")
130
+ parsed = parse_source(source, role, request[:dialect])
131
+ return parse_failure(operation, request, role, parsed) if provider_failure?(parsed)
132
+
133
+ parsed
134
+ end
135
+
136
+ def parse_source(source, role, _dialect = nil)
137
+ analysis = FileAnalysis.new(source)
138
+ unless analysis.errors.empty? && analysis.backend == TREE_SITTER_BACKEND.id.to_sym
139
+ return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role }
140
+ end
141
+
142
+ owners = analysis.declarations.map do |wrapper|
143
+ return { unsafe_range: wrapper.signature, source_role: role } unless safe_range?(source, wrapper)
144
+
145
+ signature = wrapper.signature
146
+ Owner.new(
147
+ id: owner_id(signature),
148
+ signature: signature,
149
+ membership: wrapper.membership_signature,
150
+ compound: wrapper.compound?,
151
+ fingerprint: owner_fingerprint(wrapper),
152
+ start_line: wrapper.start_line,
153
+ end_line: wrapper.end_line,
154
+ role: role
155
+ )
156
+ end
157
+ duplicate = owners.group_by(&:id).find { |_id, matches| matches.length > 1 }
158
+ return { ambiguous_owner: duplicate.first, source_role: role } if duplicate
159
+
160
+ return { unsafe_range: :overlapping_declarations, source_role: role } unless non_overlapping?(owners)
161
+
162
+ Document.new(
163
+ source: source,
164
+ analysis: analysis,
165
+ owners: owners.freeze,
166
+ by_id: owners.to_h { |owner| [owner.id, owner] }.freeze,
167
+ unmanaged_fingerprint: unmanaged_fingerprint(source, owners)
168
+ )
169
+ rescue StandardError => e
170
+ { parse_error: e.message, source_role: role }
171
+ end
172
+
173
+ def parse_exact_source(source, role, _dialect = nil)
174
+ analysis = FileAnalysis.new(source)
175
+ unless analysis.errors.empty? && analysis.backend == TREE_SITTER_BACKEND.id.to_sym
176
+ return { parse_error: analysis.errors.map(&:to_s).join('; '), source_role: role }
177
+ end
178
+
179
+ declarations = analysis.declarations.map do |wrapper|
180
+ [wrapper.signature, wrapper.semantic_tree]
181
+ end.freeze
182
+ duplicate_keys = declarations.group_by(&:first).filter_map do |signature, matches|
183
+ signature if matches.length > 1
184
+ end
185
+ issues = duplicate_keys.uniq.map do |identity|
186
+ {
187
+ category: :ambiguous_owner,
188
+ declaration: identity,
189
+ message: duplicate_message(identity)
190
+ }.freeze
191
+ end
192
+ ExactDocument.new(
193
+ source: source,
194
+ analysis: analysis,
195
+ declarations: declarations,
196
+ issues: issues.freeze,
197
+ role: role
198
+ )
199
+ rescue StandardError => e
200
+ { parse_error: e.message, source_role: role }
201
+ end
202
+
203
+ def safe_range?(source, wrapper)
204
+ return false if wrapper.start_byte.negative? || wrapper.end_byte > source.bytesize
205
+ return false if wrapper.end_byte < wrapper.start_byte
206
+
207
+ line_start = source.rindex("\n", [wrapper.start_byte - 1, 0].max)
208
+ line_start = line_start ? line_start + 1 : 0
209
+ line_end = source.index("\n", wrapper.end_byte) || source.bytesize
210
+ horizontal_whitespace?(source.byteslice(line_start...wrapper.start_byte).to_s) &&
211
+ horizontal_whitespace?(source.byteslice(wrapper.end_byte...line_end).to_s)
212
+ end
213
+
214
+ def horizontal_whitespace?(value)
215
+ value.each_byte.all? { |byte| [9, 13, 32].include?(byte) }
216
+ end
217
+
218
+ def non_overlapping?(owners)
219
+ owners.each_cons(2).all? { |left, right| left.end_line < right.start_line }
220
+ end
221
+
222
+ def unmanaged_fingerprint(source, owners)
223
+ owned_lines = owners.each_with_object({}) do |owner, lines|
224
+ (owner.start_line..owner.end_line).each { |line| lines[line] = true }
225
+ end
226
+ source.lines.each_with_index.filter_map do |line, index|
227
+ line unless owned_lines[index + 1]
228
+ end.join
229
+ end
230
+
231
+ def owner_fingerprint(wrapper)
232
+ Digest::SHA256.hexdigest(
233
+ JSON.generate(Ast::Merge.json_ready([wrapper.semantic_tree, wrapper.source_text]))
234
+ )
235
+ end
236
+
237
+ def owner_id(signature)
238
+ Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(signature)))
239
+ end
240
+
241
+ def owner_path(owner)
242
+ owner ? owner.signature.inspect : '<document>'
243
+ end
244
+
245
+ def owner_description(owner)
246
+ {
247
+ path: owner_path(owner),
248
+ signature: owner.signature,
249
+ source_role: owner.role,
250
+ line_range: [owner.start_line, owner.end_line]
251
+ }
252
+ end
253
+
254
+ def diff_documents(before, after)
255
+ ordered_ids(before, after).filter_map do |id|
256
+ left = before.by_id[id]
257
+ right = after.by_id[id]
258
+ next if equivalent?(left, right)
259
+
260
+ {
261
+ path: owner_path(left || right),
262
+ before: change_side(left),
263
+ after: change_side(right),
264
+ change: change_kind(left, right)
265
+ }.freeze
266
+ end.freeze
267
+ end
268
+
269
+ def change_side(owner)
270
+ return { present: false } unless owner
271
+
272
+ { present: true, source_role: owner.role, line_range: [owner.start_line, owner.end_line] }
273
+ end
274
+
275
+ def ordered_ids(*documents)
276
+ documents.flat_map { |document| document.owners.map(&:id) }.uniq
277
+ end
278
+
279
+ def change_kind(before, after)
280
+ return :added unless before
281
+ return :deleted unless after
282
+
283
+ :edited
284
+ end
285
+
286
+ def decide(documents, include_unmanaged_conflict:)
287
+ changes = []
288
+ conflicts = []
289
+ choices = {}
290
+ append_unmanaged_conflict(changes, conflicts, documents) if include_unmanaged_conflict
291
+ ordered_ids(*documents.values).each do |id|
292
+ base = documents.fetch(:base).by_id[id]
293
+ ours = documents.fetch(:ours).by_id[id]
294
+ theirs = documents.fetch(:theirs).by_id[id]
295
+ ours_change = side_change(base, ours)
296
+ theirs_change = side_change(base, theirs)
297
+ next choices[id] = :ours if ours_change == :unchanged && theirs_change == :unchanged
298
+
299
+ change = {
300
+ path: owner_path(base || ours || theirs),
301
+ ours: ours_change,
302
+ theirs: theirs_change
303
+ }.freeze
304
+ changes << change
305
+ choice = owner_choice(base, ours, theirs)
306
+ if choice == :conflict
307
+ conflicts << conflict_for(id, base, ours, theirs, change)
308
+ else
309
+ choices[id] = choice
310
+ end
311
+ end
312
+ Decision.new(changes: changes.freeze, conflicts: conflicts.freeze, choices: choices.freeze)
313
+ end
314
+
315
+ def append_unmanaged_conflict(changes, conflicts, documents)
316
+ base = documents.fetch(:base).unmanaged_fingerprint
317
+ ours = documents.fetch(:ours).unmanaged_fingerprint
318
+ theirs = documents.fetch(:theirs).unmanaged_fingerprint
319
+ return if ours == theirs
320
+
321
+ change = {
322
+ path: '<unmanaged-source>',
323
+ ours: base == ours ? :unchanged : :edited,
324
+ theirs: base == theirs ? :unchanged : :edited
325
+ }.freeze
326
+ changes << change
327
+ conflicts << {
328
+ conflict_id: "rust-unmanaged-#{Digest::SHA256.hexdigest([base, ours, theirs].join("\0"))[0, 16]}",
329
+ category: :unmanaged_source_change,
330
+ path: change.fetch(:path),
331
+ owner_id: nil,
332
+ change_classification: change
333
+ }.freeze
334
+ end
335
+
336
+ def compound_ownership_failure(request, documents)
337
+ identities = ordered_ids(*documents.values).filter_map do |id|
338
+ owners = documents.transform_values { |document| document.by_id[id] }
339
+ next unless owners.values.compact.any?(&:compound)
340
+ next unless owners.fetch(:base)
341
+
342
+ memberships = owners.transform_values { |owner| owner&.membership }
343
+ next if memberships.values.compact.uniq.length == 1
344
+ next if memberships[:ours] == memberships[:base] || memberships[:theirs] == memberships[:base]
345
+
346
+ [id, memberships]
347
+ end
348
+ return if identities.empty?
349
+
350
+ signature_digest = Digest::SHA256.hexdigest(JSON.generate(Ast::Merge.json_ready(identities)))
351
+ conflict = {
352
+ conflict_id: "rust-compound-#{signature_digest[0, 16]}",
353
+ category: :unproven_compound_ownership,
354
+ path: '<compound-declarations>',
355
+ owner_id: nil,
356
+ member_signatures: identities
357
+ }.freeze
358
+ decision = Decision.new(changes: [], conflicts: [conflict], choices: {})
359
+ rendered = render_plan(request, [whole_document_conflict(request, decision)])
360
+ failure(
361
+ :merge3,
362
+ request,
363
+ category: :unproven_compound_ownership,
364
+ message: 'Compound Rust declaration membership differs across revisions; ' \
365
+ 'whole-owner identity is unproven.',
366
+ conflicts: [conflict],
367
+ conflicted_output: rendered.content,
368
+ render_report: render_report(rendered, :full_file_conflict),
369
+ verification: { base_participated: true },
370
+ fallbacks: [{
371
+ from: :compound_declaration_ownership,
372
+ to: :full_file_conflict,
373
+ reason: :compound_member_identity_changed
374
+ }]
375
+ )
376
+ end
377
+
378
+ def side_change(base, side)
379
+ return :unchanged if equivalent?(base, side)
380
+ return :added if !base && side
381
+ return :deleted if base && !side
382
+ return :unchanged unless base || side
383
+
384
+ :edited
385
+ end
386
+
387
+ def owner_choice(base, ours, theirs)
388
+ return :ours if equivalent?(ours, theirs)
389
+ return :theirs if equivalent?(base, ours)
390
+ return :ours if equivalent?(base, theirs)
391
+ return if ours.nil? && theirs.nil?
392
+
393
+ :conflict
394
+ end
395
+
396
+ def equivalent?(left, right)
397
+ return true if left.nil? && right.nil?
398
+ return false unless left && right
399
+
400
+ left.fingerprint == right.fingerprint
401
+ end
402
+
403
+ def conflict_for(id, base, ours, theirs, change)
404
+ {
405
+ conflict_id: "rust-declaration-#{id[0, 16]}",
406
+ category: base && (!ours || !theirs) ? :delete_edit : :edit_edit,
407
+ path: change.fetch(:path),
408
+ owner_id: id,
409
+ base: owner_state(base),
410
+ ours: owner_state(ours),
411
+ theirs: owner_state(theirs),
412
+ change_classification: change
413
+ }.freeze
414
+ end
415
+
416
+ def owner_state(owner)
417
+ {
418
+ present: !owner.nil?,
419
+ fingerprint: owner&.fingerprint,
420
+ source_role: owner&.role,
421
+ line_range: owner && [owner.start_line, owner.end_line]
422
+ }
423
+ end
424
+
425
+ def exact_revision_role(request)
426
+ base = request.fetch(:base_source)
427
+ ours = request.fetch(:ours_source)
428
+ theirs = request.fetch(:theirs_source)
429
+ return :ours if ours == theirs || base == theirs
430
+
431
+ :theirs if base == ours
432
+ end
433
+
434
+ def merge3_exact(request, role)
435
+ documents = %i[base ours theirs].to_h do |source_role|
436
+ [source_role, parse_exact_source(request.fetch(:"#{source_role}_source"), source_role, request[:dialect])]
437
+ end
438
+ winner = documents.fetch(role)
439
+ return parse_failure(:merge3, request, role, winner) if provider_failure?(winner)
440
+
441
+ render_exact(request, documents, role)
442
+ end
443
+
444
+ def render_exact(request, documents, role)
445
+ winner = documents.fetch(role)
446
+ rendered = render_plan(request, [whole_source_fragment(role, request.fetch(:"#{role}_source"))])
447
+ verification = verify_exact_rendered(rendered.content, winner)
448
+ unless verification[:semantic_match] && verification[:byte_exact]
449
+ return failure(
450
+ :merge3,
451
+ request,
452
+ category: :render_failure,
453
+ message: 'Rust exact revision did not remain byte-exact and semantically identical ' \
454
+ 'after native reparse.',
455
+ source_role: role,
456
+ render_report: render_report(rendered, :exact_revision),
457
+ verification: verification.merge(base_participated: true)
458
+ )
459
+ end
460
+
461
+ result(
462
+ :merge3,
463
+ request,
464
+ output: rendered.content,
465
+ diagnostics: exact_diagnostics(documents),
466
+ render_report: render_report(rendered, :exact_revision),
467
+ verification: verification.merge(base_participated: true)
468
+ )
469
+ end
470
+
471
+ def verify_exact_rendered(output, expected)
472
+ parsed = parse_exact_source(output, :output)
473
+ if provider_failure?(parsed)
474
+ return {
475
+ output_reparsed: false,
476
+ byte_exact: output == expected.source,
477
+ semantic_match: false,
478
+ parse_error: failure_detail(parsed),
479
+ source_role: expected.role
480
+ }
481
+ end
482
+
483
+ actual_signatures = parsed.declarations.map(&:first)
484
+ expected_signatures = expected.declarations.map(&:first)
485
+ actual_attributes = parsed.declarations.map(&:last)
486
+ expected_attributes = expected.declarations.map(&:last)
487
+ {
488
+ output_reparsed: true,
489
+ byte_exact: output == expected.source,
490
+ semantic_match: parsed.declarations == expected.declarations,
491
+ ordered_declaration_signatures_verified: actual_signatures == expected_signatures,
492
+ ast_attributes_verified: actual_attributes == expected_attributes,
493
+ planned_declaration_count: expected.declarations.length,
494
+ output_declaration_count: parsed.declarations.length,
495
+ source_role: expected.role
496
+ }
497
+ end
498
+
499
+ def exact_diagnostics(documents)
500
+ documents.flat_map do |role, document|
501
+ if provider_failure?(document)
502
+ [{
503
+ category: :parse_error,
504
+ severity: :warning,
505
+ message: "#{role} parse error: #{failure_detail(document)}",
506
+ blocking: false,
507
+ source_role: role
508
+ }]
509
+ else
510
+ document.issues.map do |issue|
511
+ issue.merge(severity: :warning, blocking: false, source_role: role).freeze
512
+ end
513
+ end
514
+ end.freeze
515
+ end
516
+
517
+ def duplicate_message(signature)
518
+ "ambiguous duplicate declaration #{signature.inspect}"
519
+ end
520
+
521
+ def render_composite(request, documents, decision)
522
+ fragments = composite_fragments(documents, decision)
523
+ rendered = render_plan(request, fragments)
524
+ expected = expected_owners(documents, decision)
525
+ verification = verify_rendered(rendered.content, expected, request[:dialect])
526
+ unless verification[:semantic_match]
527
+ return failure(
528
+ :merge3,
529
+ request,
530
+ category: :render_failure,
531
+ message: 'Rust composite did not match the planned ordered declaration structure and AST attributes.',
532
+ changes: decision.changes,
533
+ render_report: render_report(rendered, :exact_declaration_composite),
534
+ verification: verification.merge(base_participated: true)
535
+ )
536
+ end
537
+
538
+ result(
539
+ :merge3,
540
+ request,
541
+ output: rendered.content,
542
+ changes: decision.changes,
543
+ render_report: render_report(rendered, :exact_declaration_composite),
544
+ verification: verification.merge(base_participated: true)
545
+ )
546
+ end
547
+
548
+ def composite_fragments(documents, decision)
549
+ ours = documents.fetch(:ours)
550
+ fragments = []
551
+ cursor = 1
552
+ edits = composite_edits(documents, decision).sort_by do |edit|
553
+ insertion = edit.fetch(:end_line) < edit.fetch(:start_line)
554
+ [edit.fetch(:start_line), insertion ? 0 : 1]
555
+ end
556
+ edits.each do |edit|
557
+ append_range(fragments, :ours, cursor, edit.fetch(:start_line) - 1)
558
+ edit.fetch(:owners).each_with_index do |owner, index|
559
+ append_owner(fragments, owner)
560
+ ensure_plan_boundary!(fragments, documents) if index < edit.fetch(:owners).length - 1
561
+ end
562
+ cursor = [cursor, edit.fetch(:end_line) + 1].max
563
+ end
564
+ append_range(fragments, :ours, cursor, ours.source.lines.length)
565
+ append_declaration_additions(fragments, documents, decision)
566
+ fragments
567
+ end
568
+
569
+ def composite_edits(documents, decision)
570
+ replacement_edits(documents, decision) + import_insertion_edits(documents, decision)
571
+ end
572
+
573
+ def replacement_edits(documents, decision)
574
+ ours = documents.fetch(:ours)
575
+ decision.choices.filter_map do |id, role|
576
+ ours_owner = ours.by_id[id]
577
+ next unless ours_owner
578
+ next if role == :ours
579
+
580
+ {
581
+ start_line: ours_owner.start_line,
582
+ end_line: ours_owner.end_line,
583
+ owners: [role && documents.fetch(role).by_id[id]].compact
584
+ }
585
+ end
586
+ end
587
+
588
+ def import_insertion_edits(documents, decision)
589
+ additions = added_theirs_owners(documents, decision).select { |owner| import_owner?(owner) }
590
+ return [] if additions.empty?
591
+
592
+ ours = documents.fetch(:ours)
593
+ anchor = ours.owners.reverse.find { |owner| package_or_import_owner?(owner) }
594
+ insertion_line = anchor ? anchor.end_line + 1 : ours.owners.first&.start_line || ours.source.lines.length + 1
595
+ [{
596
+ start_line: insertion_line,
597
+ end_line: insertion_line - 1,
598
+ owners: additions
599
+ }]
600
+ end
601
+
602
+ def append_declaration_additions(fragments, documents, decision)
603
+ additions = added_theirs_owners(documents, decision).reject { |owner| import_owner?(owner) }
604
+ return if additions.empty?
605
+
606
+ ensure_plan_boundary!(fragments, documents)
607
+ additions.each_with_index do |owner, index|
608
+ append_owner(fragments, owner)
609
+ ensure_plan_boundary!(fragments, documents) if index < additions.length - 1
610
+ end
611
+ end
612
+
613
+ def added_theirs_owners(documents, decision)
614
+ ours = documents.fetch(:ours)
615
+ documents.fetch(:theirs).owners.select do |owner|
616
+ !ours.by_id.key?(owner.id) && decision.choices[owner.id] == :theirs
617
+ end
618
+ end
619
+
620
+ def ensure_plan_boundary!(fragments, documents)
621
+ fragment = fragments.last
622
+ return if fragment.nil? || fragment_content(fragment, documents).end_with?("\n")
623
+
624
+ if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment) && fragment.start_line < fragment.end_line
625
+ fragments[-1] = source_range_fragment(fragment.revision, fragment.start_line, fragment.end_line - 1)
626
+ else
627
+ fragments.pop
628
+ end
629
+ content = if fragment.is_a?(Ast::Merge::SourceRender::SourceFragment)
630
+ documents.fetch(fragment.revision).source.lines.fetch(fragment.end_line - 1)
631
+ else
632
+ fragment.content
633
+ end
634
+ fragments << Ast::Merge::SourceRender::SynthesizedFragment.new(
635
+ content: "#{content}\n",
636
+ reason: :declaration_separator,
637
+ producer: provider_id,
638
+ metadata: { source_role: fragment.respond_to?(:revision) ? fragment.revision : nil, copied_source: true }
639
+ )
640
+ end
641
+
642
+ def fragment_content(fragment, documents)
643
+ return fragment.content if fragment.is_a?(Ast::Merge::SourceRender::SynthesizedFragment)
644
+
645
+ document = documents.fetch(fragment.revision)
646
+ document.source.lines.slice(fragment.start_line - 1, fragment.end_line - fragment.start_line + 1).join
647
+ end
648
+
649
+ def append_owner(fragments, owner)
650
+ fragments << source_range_fragment(owner.role, owner.start_line, owner.end_line)
651
+ end
652
+
653
+ def append_range(fragments, role, start_line, end_line)
654
+ return if start_line > end_line
655
+
656
+ fragments << source_range_fragment(role, start_line, end_line)
657
+ end
658
+
659
+ def expected_owners(documents, decision)
660
+ ours = documents.fetch(:ours)
661
+ expected = ours.owners.filter_map do |owner|
662
+ role = decision.choices[owner.id]
663
+ role && documents.fetch(role).by_id[owner.id]
664
+ end
665
+ import_offset = expected.rindex { |owner| package_or_import_owner?(owner) }
666
+ documents.fetch(:theirs).owners.each do |owner|
667
+ next if ours.by_id.key?(owner.id)
668
+ next unless decision.choices[owner.id] == :theirs
669
+
670
+ if import_owner?(owner)
671
+ import_offset = import_offset ? import_offset + 1 : 0
672
+ expected.insert(import_offset, owner)
673
+ else
674
+ expected << owner
675
+ end
676
+ end
677
+ expected
678
+ end
679
+
680
+ def import_owner?(owner)
681
+ %i[use use_tree extern_crate].include?(owner.signature.first)
682
+ end
683
+
684
+ alias package_or_import_owner? import_owner?
685
+
686
+ def verify_rendered(output, expected, dialect)
687
+ parsed = parse_source(output, :output, dialect)
688
+ if provider_failure?(parsed)
689
+ return {
690
+ output_reparsed: false,
691
+ semantic_match: false,
692
+ parse_error: failure_detail(parsed)
693
+ }
694
+ end
695
+
696
+ actual = parsed.owners.map { |owner| [owner.signature, owner.fingerprint] }
697
+ planned = expected.map { |owner| [owner.signature, owner.fingerprint] }
698
+ {
699
+ output_reparsed: true,
700
+ semantic_match: actual == planned,
701
+ ordered_declaration_signatures_verified: actual.map(&:first) == planned.map(&:first),
702
+ ast_attributes_verified: actual.map(&:last) == planned.map(&:last),
703
+ planned_declaration_count: planned.length,
704
+ output_declaration_count: actual.length
705
+ }
706
+ end
707
+
708
+ def render_conflicts(request, documents, decision)
709
+ localized = localized_conflict_fragments(request, documents, decision)
710
+ if localized
711
+ rendered = render_plan(request, localized)
712
+ return conflict_failure(request, decision, rendered, :declaration_localized_conflict)
713
+ end
714
+
715
+ rendered = render_plan(request, [whole_document_conflict(request, decision)])
716
+ conflict_failure(
717
+ request,
718
+ decision,
719
+ rendered,
720
+ :full_file_conflict,
721
+ fallbacks: [{ from: :declaration_localization, to: :full_file_conflict, reason: :source_ownership_unproven }]
722
+ )
723
+ end
724
+
725
+ def localized_conflict_fragments(request, documents, decision)
726
+ return if decision.conflicts.any? { |conflict| conflict[:owner_id].nil? }
727
+
728
+ ours = documents.fetch(:ours)
729
+ return unless decision.conflicts.all? { |conflict| ours.by_id.key?(conflict.fetch(:owner_id)) }
730
+
731
+ ranges = decision.conflicts.map { |conflict| [conflict, ours.by_id.fetch(conflict.fetch(:owner_id))] }
732
+ return unless ranges.sort_by { |_conflict, owner| owner.start_line }.each_cons(2).none? do |left, right|
733
+ left.last.end_line >= right.last.start_line
734
+ end
735
+
736
+ fragments = []
737
+ cursor = 1
738
+ ranges.sort_by { |_conflict, owner| owner.start_line }.each do |conflict, owner|
739
+ append_range(fragments, :ours, cursor, owner.start_line - 1)
740
+ fragments << declaration_conflict_fragment(request, documents, conflict)
741
+ cursor = owner.end_line + 1
742
+ end
743
+ append_range(fragments, :ours, cursor, ours.source.lines.length)
744
+ fragments
745
+ end
746
+
747
+ def declaration_conflict_fragment(request, documents, conflict)
748
+ Ast::Merge::SourceRender::ConflictFragment.new(
749
+ conflict_id: conflict.fetch(:conflict_id),
750
+ base: conflict_declaration_side(documents, conflict, :base),
751
+ ours: conflict_declaration_side(documents, conflict, :ours),
752
+ theirs: conflict_declaration_side(documents, conflict, :theirs),
753
+ labels: request.fetch(:labels, {}),
754
+ marker_size: request.fetch(:conflict_marker_size, 7),
755
+ metadata: { path: conflict.fetch(:path), category: conflict.fetch(:category) }
756
+ )
757
+ end
758
+
759
+ def conflict_declaration_side(documents, conflict, role)
760
+ document = documents.fetch(role)
761
+ owner = document.by_id[conflict.fetch(:owner_id)]
762
+ owner ? [conflict_source_fragment(owner, document.source)] : []
763
+ end
764
+
765
+ def unsafe_document_failure(request, role, parsed)
766
+ return parsed unless parsed[:unsafe_range] || parsed[:ambiguous_owner]
767
+
768
+ conflict_id = "rust-unsafe-#{Digest::SHA256.hexdigest([role, failure_detail(parsed)].join("\0"))[0, 16]}"
769
+ conflict = {
770
+ conflict_id: conflict_id,
771
+ category: parsed[:ambiguous_owner] ? :ambiguous_owner : :unsafe_source_range,
772
+ path: '<document>',
773
+ owner_id: nil,
774
+ source_role: role
775
+ }
776
+ rendered = render_plan(
777
+ request,
778
+ [whole_document_conflict(request, Decision.new(changes: [], conflicts: [conflict], choices: {}))]
779
+ )
780
+ failure(
781
+ :merge3,
782
+ request,
783
+ category: conflict.fetch(:category),
784
+ message: "#{role} cannot be merged safely: #{failure_detail(parsed)}",
785
+ conflicts: [conflict],
786
+ conflicted_output: rendered.content,
787
+ source_role: role,
788
+ render_report: render_report(rendered, :full_file_conflict),
789
+ verification: { base_participated: true }
790
+ )
791
+ end
792
+
793
+ def whole_document_conflict(request, decision)
794
+ Ast::Merge::SourceRender::ConflictFragment.new(
795
+ conflict_id: decision.conflicts.first.fetch(:conflict_id),
796
+ base: [conflict_whole_source_fragment(:base, request.fetch(:base_source))],
797
+ ours: [conflict_whole_source_fragment(:ours, request.fetch(:ours_source))],
798
+ theirs: [conflict_whole_source_fragment(:theirs, request.fetch(:theirs_source))],
799
+ labels: request.fetch(:labels, {}),
800
+ marker_size: request.fetch(:conflict_marker_size, 7),
801
+ metadata: { conflicts: decision.conflicts.map { |conflict| conflict[:conflict_id] } }
802
+ )
803
+ end
804
+
805
+ def conflict_source_fragment(owner, source)
806
+ fragment_source = source.lines.slice(owner.start_line - 1, owner.end_line - owner.start_line + 1).join
807
+ return source_range_fragment(owner.role, owner.start_line, owner.end_line) if fragment_source.end_with?("\n")
808
+
809
+ synthesized_copy("#{fragment_source}\n", :conflict_line_boundary, owner.role)
810
+ end
811
+
812
+ def conflict_whole_source_fragment(role, source)
813
+ return whole_source_fragment(role, source) if source.empty? || source.end_with?("\n")
814
+
815
+ synthesized_copy("#{source}\n", :conflict_line_boundary, role)
816
+ end
817
+
818
+ def synthesized_copy(content, reason, role)
819
+ Ast::Merge::SourceRender::SynthesizedFragment.new(
820
+ content: content,
821
+ reason: reason,
822
+ producer: provider_id,
823
+ metadata: { source_role: role, copied_source: true }
824
+ )
825
+ end
826
+
827
+ def whole_source_fragment(role, source)
828
+ return synthesized_copy('', :exact_empty_source, role) if source.empty?
829
+
830
+ source_range_fragment(role, 1, source.lines.length)
831
+ end
832
+
833
+ def source_range_fragment(role, start_line, end_line)
834
+ Ast::Merge::SourceRender::SourceFragment.new(
835
+ revision: role,
836
+ start_line: start_line,
837
+ end_line: end_line,
838
+ metadata: { source_role: role }
839
+ )
840
+ end
841
+
842
+ def render_plan(request, fragments)
843
+ Ast::Merge::SourceRender::Renderer.new.render(
844
+ Ast::Merge::SourceRender::Plan.new(
845
+ sources: {
846
+ base: request[:base_source].to_s,
847
+ ours: request[:ours_source].to_s,
848
+ theirs: request[:theirs_source].to_s
849
+ },
850
+ fragments: fragments,
851
+ metadata: { provider_id: provider_id }
852
+ )
853
+ )
854
+ end
855
+
856
+ def render_report(rendered, strategy)
857
+ {
858
+ strategy: strategy,
859
+ line_records: rendered.line_records,
860
+ synthesized_fragments: rendered.synthesized_fragments,
861
+ conflicts: rendered.conflicts,
862
+ verification_input: rendered.verification_input
863
+ }
864
+ end
865
+
866
+ def conflict_failure(request, decision, rendered, strategy, fallbacks: [])
867
+ failure(
868
+ :merge3,
869
+ request,
870
+ category: :merge_conflict,
871
+ message: 'Rust top-level declaration changed incompatibly on both sides.',
872
+ changes: decision.changes,
873
+ conflicts: decision.conflicts,
874
+ conflicted_output: rendered.content,
875
+ render_report: render_report(rendered, strategy),
876
+ verification: { base_participated: true },
877
+ fallbacks: fallbacks
878
+ )
879
+ end
880
+
881
+ def parse_failure(operation, request, role, parsed)
882
+ category = if parsed[:parse_error]
883
+ :parse_error
884
+ elsif parsed[:ambiguous_owner]
885
+ :ambiguous_owner
886
+ else
887
+ :unsafe_source_range
888
+ end
889
+ failure(
890
+ operation,
891
+ request,
892
+ category: category,
893
+ message: "#{role} parse error: #{failure_detail(parsed)}",
894
+ source_role: role
895
+ )
896
+ end
897
+
898
+ def failure_detail(parsed)
899
+ return parsed[:parse_error] if parsed[:parse_error]
900
+ return "ambiguous duplicate declaration #{parsed[:ambiguous_owner].inspect}" if parsed[:ambiguous_owner]
901
+
902
+ "unsafe declaration range #{parsed[:unsafe_range].inspect}"
903
+ end
904
+
905
+ def provider_failure?(value)
906
+ value.is_a?(Hash) &&
907
+ (value[:ok] == false || value.key?(:parse_error) || value.key?(:ambiguous_owner) || value.key?(:unsafe_range))
908
+ end
909
+
910
+ def result(operation, request, changes: [], diagnostics: [], render_report: {}, verification: {}, **payload)
911
+ Ast::Merge::ProviderResult.build(
912
+ operation: operation,
913
+ success: true,
914
+ envelope: envelope(
915
+ request,
916
+ changes: changes,
917
+ diagnostics: diagnostics,
918
+ render_report: render_report,
919
+ verification: verification
920
+ ),
921
+ **payload
922
+ )
923
+ end
924
+
925
+ def failure(operation, request, category:, message:, changes: [], conflicts: [], fallbacks: [],
926
+ render_report: {}, verification: {}, **payload)
927
+ Ast::Merge::ProviderResult.build(
928
+ operation: operation,
929
+ success: false,
930
+ envelope: envelope(
931
+ request,
932
+ changes: changes,
933
+ conflicts: conflicts,
934
+ fallbacks: fallbacks,
935
+ diagnostics: [{ category: category, severity: :error, message: message, blocking: true }],
936
+ render_report: render_report,
937
+ verification: verification
938
+ ),
939
+ **payload
940
+ )
941
+ end
942
+
943
+ def envelope(request, **fields)
944
+ {
945
+ provider: {
946
+ provider_id: provider_id,
947
+ family: family,
948
+ dialect: request[:dialect] || :rust,
949
+ backend: request[:backend] || TREE_SITTER_BACKEND.id.to_sym,
950
+ package: 'rust-merge',
951
+ package_version: Rust::Merge::Version::VERSION
952
+ },
953
+ profile: { profile_id: request[:profile_id] || DEFAULT_PROFILE }
954
+ }.merge(fields)
955
+ end
956
+ end
957
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
958
+ end
959
+ end