markdown-merge 1.0.3 → 7.1.1

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +99 -482
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +5 -3
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +489 -47
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +77 -6
  16. data/lib/markdown/merge/debug_logger.rb +2 -2
  17. data/lib/markdown/merge/document_problems.rb +3 -3
  18. data/lib/markdown/merge/file_aligner.rb +433 -133
  19. data/lib/markdown/merge/file_analysis.rb +387 -51
  20. data/lib/markdown/merge/file_analysis_base.rb +188 -51
  21. data/lib/markdown/merge/gap_line_node.rb +14 -8
  22. data/lib/markdown/merge/link_definition_node.rb +5 -5
  23. data/lib/markdown/merge/link_parser.rb +60 -60
  24. data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
  25. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  26. data/lib/markdown/merge/list_merger.rb +322 -0
  27. data/lib/markdown/merge/markdown_structure.rb +3 -3
  28. data/lib/markdown/merge/merge_result.rb +321 -4
  29. data/lib/markdown/merge/node_type_normalizer.rb +6 -6
  30. data/lib/markdown/merge/output_builder.rb +101 -19
  31. data/lib/markdown/merge/partial_template_merger.rb +248 -27
  32. data/lib/markdown/merge/preservation_support.rb +291 -0
  33. data/lib/markdown/merge/smart_merger.rb +62 -14
  34. data/lib/markdown/merge/smart_merger_base.rb +929 -60
  35. data/lib/markdown/merge/table_match_algorithm.rb +22 -27
  36. data/lib/markdown/merge/table_match_refiner.rb +6 -10
  37. data/lib/markdown/merge/version.rb +5 -4
  38. data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
  39. data/lib/markdown/merge/wrapper_support.rb +194 -0
  40. data/lib/markdown/merge.rb +669 -122
  41. data/lib/markdown-merge.rb +9 -4
  42. data/sig/markdown/merge.rbs +3 -336
  43. data.tar.gz.sig +0 -0
  44. metadata +104 -93
  45. metadata.gz.sig +0 -0
  46. data/CHANGELOG.md +0 -308
  47. data/CITATION.cff +0 -20
  48. data/CODE_OF_CONDUCT.md +0 -134
  49. data/CONTRIBUTING.md +0 -227
  50. data/FUNDING.md +0 -74
  51. data/LICENSE.txt +0 -21
  52. data/REEK +0 -0
  53. data/RUBOCOP.md +0 -71
  54. data/SECURITY.md +0 -21
@@ -41,6 +41,8 @@ module Markdown
41
41
  # @see ConflictResolver
42
42
  # @see MergeResult
43
43
  class SmartMergerBase
44
+ include PreservationSupport
45
+
44
46
  # @return [FileAnalysisBase] Analysis of the template file
45
47
  attr_reader :template_analysis
46
48
 
@@ -59,6 +61,10 @@ module Markdown
59
61
  # @return [Hash{Symbol,String => #call}, nil] Node typing configuration
60
62
  attr_reader :node_typing
61
63
 
64
+ # @return [Ast::Merge::Runtime::Session, nil] Runtime session for this merge
65
+ attr_reader :runtime_session
66
+ attr_reader :corruption_handling, :resolution_mode, :unresolved_policy
67
+
62
68
  # Creates a new SmartMerger for intelligent Markdown file merging.
63
69
  #
64
70
  # @param template_content [String] Template Markdown source code
@@ -94,6 +100,13 @@ module Markdown
94
100
  # - `false` (default) - Disable inner-merge (use standard conflict resolution)
95
101
  # - `CodeBlockMerger` instance - Use custom CodeBlockMerger
96
102
  #
103
+ # @param remove_template_missing_nodes [Boolean] Controls whether to remove nodes that only
104
+ # exist in destination when not present in template:
105
+ # - `false` (default) - Preserve destination-only nodes
106
+ # - `true` - Remove destination-only structural nodes while still preserving
107
+ # freeze blocks, standalone HTML comment-only fragments, and parser-consumed
108
+ # non-structural content such as link reference definitions
109
+ #
97
110
  # @param freeze_token [String] Token to use for freeze block markers.
98
111
  # Default: "markdown-merge"
99
112
  #
@@ -139,34 +152,60 @@ module Markdown
139
152
  preference: :destination,
140
153
  add_template_only_nodes: false,
141
154
  inner_merge_code_blocks: false,
155
+ inner_merge_lists: false,
156
+ remove_template_missing_nodes: false,
157
+ corruption_handling: :heal,
142
158
  freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN,
143
159
  match_refiner: nil,
144
160
  node_typing: nil,
161
+ resolution_mode: :eager,
162
+ unresolved_policy: nil,
145
163
  normalize_whitespace: false,
146
164
  rehydrate_link_references: false,
147
165
  **parser_options
148
166
  )
149
167
  @preference = preference
150
168
  @add_template_only_nodes = add_template_only_nodes
151
- @match_refiner = match_refiner
169
+ @remove_template_missing_nodes = remove_template_missing_nodes
170
+ @corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
171
+ @match_refiner = match_refiner || default_match_refiner(
172
+ inner_merge_lists: inner_merge_lists,
173
+ inner_merge_code_blocks: inner_merge_code_blocks
174
+ )
152
175
  @node_typing = node_typing
176
+ @resolution_mode = resolution_mode
177
+ @unresolved_policy = Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
153
178
  @normalize_whitespace = normalize_whitespace
154
179
  @rehydrate_link_references = rehydrate_link_references
155
180
 
156
181
  # Validate node_typing if provided
157
182
  Ast::Merge::NodeTyping.validate!(node_typing) if node_typing
183
+ validate_resolution_mode!(resolution_mode)
158
184
 
159
185
  # Set up code block merger
160
186
  @code_block_merger = case inner_merge_code_blocks
161
- when true
162
- CodeBlockMerger.new
163
- when false
164
- nil
165
- when CodeBlockMerger
166
- inner_merge_code_blocks
167
- else
168
- raise ArgumentError, "inner_merge_code_blocks must be true, false, or a CodeBlockMerger instance"
169
- end
187
+ when true
188
+ CodeBlockMerger.new
189
+ when false
190
+ nil
191
+ when CodeBlockMerger
192
+ inner_merge_code_blocks
193
+ else
194
+ raise ArgumentError,
195
+ 'inner_merge_code_blocks must be true, false, or a CodeBlockMerger instance'
196
+ end
197
+
198
+ # Set up list merger
199
+ @list_merger = case inner_merge_lists
200
+ when true
201
+ ListMerger.new
202
+ when false
203
+ nil
204
+ when ListMerger
205
+ inner_merge_lists
206
+ else
207
+ raise ArgumentError, 'inner_merge_lists must be true, false, or a ListMerger instance'
208
+ end
170
209
 
171
210
  # Parse template
172
211
  begin
@@ -174,7 +213,7 @@ module Markdown
174
213
  template_content,
175
214
  freeze_token: freeze_token,
176
215
  signature_generator: signature_generator,
177
- **parser_options,
216
+ **parser_options
178
217
  )
179
218
  rescue StandardError => e
180
219
  raise template_parse_error_class.new(errors: [e])
@@ -186,7 +225,7 @@ module Markdown
186
225
  dest_content,
187
226
  freeze_token: freeze_token,
188
227
  signature_generator: signature_generator,
189
- **parser_options,
228
+ **parser_options
190
229
  )
191
230
  rescue StandardError => e
192
231
  raise destination_parse_error_class.new(errors: [e])
@@ -197,7 +236,21 @@ module Markdown
197
236
  preference: @preference,
198
237
  template_analysis: @template_analysis,
199
238
  dest_analysis: @dest_analysis,
239
+ resolution_mode: @resolution_mode,
240
+ unresolved_policy: @unresolved_policy
200
241
  )
242
+ @runtime_session = nil
243
+ @runtime_root_operation = nil
244
+ end
245
+
246
+ def default_match_refiner(inner_merge_lists:, inner_merge_code_blocks:)
247
+ refiners = []
248
+ refiners << ListMatchRefiner.new if inner_merge_lists
249
+ refiners << CodeBlockMatchRefiner.new if inner_merge_code_blocks
250
+ return if refiners.empty?
251
+ return refiners.first if refiners.length == 1
252
+
253
+ Ast::Merge::CompositeMatchRefiner.new(*refiners)
201
254
  end
202
255
 
203
256
  # Create a FileAnalysis instance for the given content.
@@ -241,43 +294,83 @@ module Markdown
241
294
  def merge_result
242
295
  return @merge_result if @merge_result
243
296
 
244
- @merge_result = DebugLogger.time("SmartMergerBase#merge") do
245
- alignment = DebugLogger.time("SmartMergerBase#align") do
297
+ @merge_result = DebugLogger.time('SmartMergerBase#merge') do
298
+ prepare_runtime_session!
299
+ alignment = DebugLogger.time('SmartMergerBase#align') do
246
300
  @aligner.align
247
301
  end
248
302
 
249
- DebugLogger.debug("Alignment complete", {
250
- total_entries: alignment.size,
251
- matches: alignment.count { |e| e[:type] == :match },
252
- template_only: alignment.count { |e| e[:type] == :template_only },
253
- dest_only: alignment.count { |e| e[:type] == :dest_only },
254
- })
303
+ DebugLogger.debug('Alignment complete', {
304
+ total_entries: alignment.size,
305
+ matches: alignment.count { |e| e[:type] == :match },
306
+ template_only: alignment.count { |e| e[:type] == :template_only },
307
+ dest_only: alignment.count { |e| e[:type] == :dest_only }
308
+ })
255
309
 
256
310
  # Process alignment using OutputBuilder
257
- builder, stats, frozen_blocks, conflicts = DebugLogger.time("SmartMergerBase#process") do
311
+ builder, stats, frozen_blocks, conflicts, unresolved_cases = DebugLogger.time('SmartMergerBase#process') do
258
312
  process_alignment(alignment)
259
313
  end
260
314
 
261
315
  # Get content from OutputBuilder
262
- content = builder.to_s
316
+ raw_content = builder.to_s
317
+ content = raw_content
263
318
 
264
319
  # Collect problems from post-processing
265
320
  problems = DocumentProblems.new
266
321
 
267
322
  # Apply post-processing transformations
268
323
  content, problems = apply_post_processing(content, problems)
324
+ complete_runtime_session!(content: content, stats: stats, problems: problems,
325
+ unresolved_cases: unresolved_cases)
269
326
 
270
327
  # Get final content from OutputBuilder
271
328
  MergeResult.new(
272
329
  content: content,
330
+ raw_content: raw_content,
273
331
  conflicts: conflicts,
274
332
  frozen_blocks: frozen_blocks,
275
333
  stats: stats,
276
334
  problems: problems,
335
+ unresolved_cases: unresolved_cases
277
336
  )
278
337
  end
279
338
  end
280
339
 
340
+ # Perform the merge and return a hash with content, debug info, and runtime data.
341
+ #
342
+ # @return [Hash] Hash with :content, :debug, :runtime, and :statistics keys
343
+ def merge_with_debug
344
+ result = merge_result
345
+ template_analysis_debug = {
346
+ valid: @template_analysis&.valid? || false,
347
+ statements: @template_analysis&.statements&.size || 0
348
+ }
349
+ dest_analysis_debug = {
350
+ valid: @dest_analysis&.valid? || false,
351
+ statements: @dest_analysis&.statements&.size || 0
352
+ }
353
+
354
+ {
355
+ content: result.content,
356
+ debug: {
357
+ template_statements: template_analysis_debug[:statements],
358
+ dest_statements: dest_analysis_debug[:statements],
359
+ preference: @preference,
360
+ add_template_only_nodes: @add_template_only_nodes,
361
+ remove_template_missing_nodes: @remove_template_missing_nodes,
362
+ corruption_handling: @corruption_handling,
363
+ runtime_operation_count: runtime_session&.operations&.size || 0,
364
+ runtime_diagnostic_count: runtime_session&.diagnostics&.size || 0
365
+ },
366
+ runtime: runtime_session&.to_h,
367
+ statistics: result.stats,
368
+ decisions: result.stats,
369
+ template_analysis: template_analysis_debug,
370
+ dest_analysis: dest_analysis_debug
371
+ }
372
+ end
373
+
281
374
  # Get merge statistics (convenience method).
282
375
  #
283
376
  # @return [Hash] Statistics from the merge result
@@ -287,16 +380,159 @@ module Markdown
287
380
 
288
381
  private
289
382
 
383
+ def prepare_runtime_session!
384
+ root_surface = Ast::Merge::Runtime::Surface.new(
385
+ surface_kind: :markdown_document,
386
+ declared_language: :markdown,
387
+ effective_language: :markdown,
388
+ address: 'document[0]',
389
+ reconstruction_strategy: :portable_write,
390
+ metadata: {
391
+ backend: @template_analysis.respond_to?(:backend) ? @template_analysis.backend : nil
392
+ }.compact
393
+ )
394
+ registry = Ast::Merge::Runtime::DelegationRegistry.new(
395
+ delegates: runtime_delegates,
396
+ metadata: {
397
+ source: :markdown_merge
398
+ }
399
+ )
400
+
401
+ @runtime_session = Ast::Merge::Runtime::Session.new(
402
+ policy_context: runtime_policy_context,
403
+ metadata: runtime_metadata,
404
+ delegation_registry: registry
405
+ )
406
+ @runtime_root_operation = Ast::Merge::Runtime::Operation.new(
407
+ operation_id: 'markdown-document-root',
408
+ surface: root_surface,
409
+ template_fragment: @template_analysis.source.to_s,
410
+ destination_fragment: @dest_analysis.source.to_s,
411
+ requested_strategy: :merge_document,
412
+ options: {
413
+ inner_merge_code_blocks: !@code_block_merger.nil?,
414
+ inner_merge_lists: !@list_merger.nil?,
415
+ corruption_handling: @corruption_handling,
416
+ resolution_mode: @resolution_mode,
417
+ unresolved_policy: @unresolved_policy.to_h
418
+ }
419
+ )
420
+ @runtime_session.register(
421
+ @runtime_root_operation,
422
+ frame: Ast::Merge::Runtime::Frame.new(
423
+ operation_id: @runtime_root_operation.operation_id,
424
+ depth: 0,
425
+ surface_path: root_surface.address,
426
+ language_chain: [:markdown]
427
+ ),
428
+ delegate: @runtime_session.resolve_delegate_for(root_surface)
429
+ )
430
+ end
431
+
432
+ def complete_runtime_session!(content:, stats:, problems:, unresolved_cases: [])
433
+ return unless @runtime_root_operation
434
+
435
+ delegated_children = @runtime_root_operation.children.select do |operation|
436
+ operation.surface.surface_kind == :markdown_fenced_code_block
437
+ end
438
+
439
+ child_result = Ast::Merge::Runtime::ChildResult.new(
440
+ replacement_text: content,
441
+ diagnostics: @runtime_root_operation.diagnostics,
442
+ capabilities_used: runtime_capabilities_used_for(delegated_children),
443
+ capabilities_missing: runtime_capabilities_missing_for(delegated_children),
444
+ unresolved_cases: unresolved_cases,
445
+ metadata: {
446
+ child_operation_ids: @runtime_root_operation.children.map(&:operation_id),
447
+ stats: stats,
448
+ problems: problems.all
449
+ }
450
+ )
451
+
452
+ if child_result.unresolved?
453
+ @runtime_root_operation.unresolved!(result: child_result)
454
+ else
455
+ @runtime_root_operation.complete!(result: child_result)
456
+ end
457
+ end
458
+
459
+ def runtime_policy_context
460
+ {
461
+ preference: @preference,
462
+ add_template_only_nodes: @add_template_only_nodes,
463
+ remove_template_missing_nodes: @remove_template_missing_nodes,
464
+ corruption_handling: @corruption_handling,
465
+ resolution_mode: @resolution_mode,
466
+ unresolved_policy: @unresolved_policy.to_h
467
+ }
468
+ end
469
+
470
+ def validate_resolution_mode!(resolution_mode)
471
+ return if Ast::Merge::MergerConfig::VALID_RESOLUTION_MODES.include?(resolution_mode)
472
+
473
+ raise ArgumentError,
474
+ "Invalid resolution_mode: #{resolution_mode.inspect}. " \
475
+ "Must be one of: #{Ast::Merge::MergerConfig::VALID_RESOLUTION_MODES.map(&:inspect).join(', ')}"
476
+ end
477
+
478
+ def runtime_metadata
479
+ {
480
+ merger_class: self.class.name,
481
+ inner_merge_code_blocks: !@code_block_merger.nil?,
482
+ inner_merge_lists: !@list_merger.nil?
483
+ }
484
+ end
485
+
486
+ def runtime_delegates
487
+ [runtime_markdown_delegate, *@code_block_merger&.runtime_delegates.to_a]
488
+ end
489
+
490
+ def runtime_markdown_delegate
491
+ Ast::Merge::Runtime::Delegate.new(
492
+ name: 'markdown-document',
493
+ priority: 10,
494
+ surface_kinds: [:markdown_document],
495
+ languages: [:markdown],
496
+ feature_profile: safe_runtime_feature_profile_for(@dest_analysis),
497
+ capabilities: { merge: [:markdown_document] },
498
+ metadata: {
499
+ source: :markdown_merge
500
+ }
501
+ )
502
+ end
503
+
504
+ def safe_runtime_feature_profile_for(analysis)
505
+ return unless analysis&.respond_to?(:feature_profile)
506
+
507
+ analysis.feature_profile
508
+ rescue StandardError
509
+ nil
510
+ end
511
+
512
+ def runtime_capabilities_used_for(delegated_children)
513
+ capabilities = [:top_level_merge]
514
+ capabilities << :delegated_child_merge unless delegated_children.empty?
515
+ capabilities
516
+ end
517
+
518
+ def runtime_capabilities_missing_for(delegated_children)
519
+ return [] unless delegated_children.any?(&:failed?)
520
+
521
+ [:delegated_child_merge]
522
+ end
523
+
290
524
  # Apply post-processing transformations to merged content.
291
525
  #
292
526
  # @param content [String] The merged content
293
527
  # @param problems [DocumentProblems] Problems collector to add to
294
528
  # @return [Array<String, DocumentProblems>] [transformed_content, problems]
295
529
  def apply_post_processing(content, problems)
530
+ content = collapse_cross_source_preamble_prefixes(content)
531
+
296
532
  # Apply whitespace normalization if enabled
297
533
  if @normalize_whitespace
298
534
  # Support both boolean and symbol modes
299
- mode = (@normalize_whitespace == true) ? :basic : @normalize_whitespace
535
+ mode = @normalize_whitespace == true ? :basic : @normalize_whitespace
300
536
  normalizer = WhitespaceNormalizer.new(content, mode: mode)
301
537
  content = normalizer.normalize
302
538
  problems.merge!(normalizer.problems)
@@ -312,30 +548,103 @@ module Markdown
312
548
  [content, problems]
313
549
  end
314
550
 
551
+ STANDALONE_HTML_COMMENT_LINE_RE = /\A\s*<!--.*?-->\s*\z/
552
+
553
+ def collapse_cross_source_preamble_prefixes(content)
554
+ template_comments, = leading_standalone_comment_run(@template_analysis.source.to_s)
555
+ return content if template_comments.empty?
556
+
557
+ merged_comments, remainder = leading_standalone_comment_run(content)
558
+ return content if merged_comments.empty?
559
+
560
+ destination_specific_comments = merged_comments.reject { |line| template_comments.include?(line) }
561
+ return content if destination_specific_comments.empty?
562
+
563
+ should_heal = ::Ast::Merge::Healer.handle(
564
+ mode: @corruption_handling,
565
+ kind: :duplicate_template_preamble_prefix,
566
+ message: 'merged Markdown preamble begins with duplicated template-owned standalone comment lines',
567
+ prefix: '[markdown-merge]',
568
+ error_class: Markdown::Merge::CorruptionDetectedError,
569
+ warner: lambda { |formatted|
570
+ DebugLogger.debug_warning(formatted, {
571
+ template_comment_lines: template_comments.length,
572
+ merged_comment_lines: merged_comments.length,
573
+ destination_specific_comment_lines: destination_specific_comments.length
574
+ })
575
+ }
576
+ )
577
+ return content unless should_heal
578
+
579
+ remainder = remainder.sub(/\A(?:\s*\n)+/, '')
580
+ rebuilt = destination_specific_comments.join("\n")
581
+ return rebuilt if remainder.empty?
582
+
583
+ "#{rebuilt}\n\n#{remainder}"
584
+ end
585
+
586
+ def leading_standalone_comment_run(text)
587
+ lines = text.to_s.split("\n", -1)
588
+ comment_lines = []
589
+ index = 0
590
+
591
+ while index < lines.length
592
+ line = lines[index]
593
+ if line.strip.empty?
594
+ comment_lines << line if comment_lines.any?
595
+ index += 1
596
+ next
597
+ end
598
+
599
+ break unless STANDALONE_HTML_COMMENT_LINE_RE.match?(line)
600
+
601
+ comment_lines << line
602
+ index += 1
603
+ end
604
+
605
+ normalized_comment_lines = comment_lines.reject(&:empty?)
606
+ remainder = lines[index..]&.join("\n").to_s
607
+ [normalized_comment_lines, remainder]
608
+ end
609
+
315
610
  # Process alignment entries and build result using OutputBuilder
316
611
  #
317
612
  # @param alignment [Array<Hash>] Alignment entries
318
- # @return [Array] [output_builder, stats, frozen_blocks, conflicts]
613
+ # @return [Array] [output_builder, stats, frozen_blocks, conflicts, unresolved_cases]
319
614
  def process_alignment(alignment)
320
615
  builder = OutputBuilder.new
321
616
  frozen_blocks = []
322
617
  conflicts = []
323
- stats = {nodes_added: 0, nodes_removed: 0, nodes_modified: 0}
618
+ unresolved_cases = []
619
+ stats = { nodes_added: 0, nodes_removed: 0, nodes_modified: 0 }
620
+ preserve_removed_separator_gap = false
621
+ link_ownership_context = removal_mode_link_ownership_context(alignment) if @remove_template_missing_nodes
622
+ removal_comment_ownership = removal_mode_comment_ownership_context(alignment) if @remove_template_missing_nodes
324
623
 
325
- alignment.each do |entry|
624
+ alignment.each_with_index do |entry, index|
326
625
  case entry[:type]
327
626
  when :match
328
- frozen = process_match_to_builder(entry, builder, stats)
627
+ preserve_removed_separator_gap = false
628
+ frozen = process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
329
629
  frozen_blocks << frozen if frozen
330
630
  when :template_only
631
+ preserve_removed_separator_gap = false
331
632
  process_template_only_to_builder(entry, builder, stats)
332
633
  when :dest_only
333
- frozen = process_dest_only_to_builder(entry, builder, stats)
634
+ frozen, preserve_removed_separator_gap = process_dest_only_to_builder(
635
+ entry,
636
+ builder,
637
+ stats,
638
+ preserve_separator_gap: preserve_removed_separator_gap,
639
+ remaining_entries: alignment[(index + 1)..] || [],
640
+ link_ownership_context: link_ownership_context,
641
+ removal_comment_ownership: removal_comment_ownership&.[](entry[:dest_index])
642
+ )
334
643
  frozen_blocks << frozen if frozen
335
644
  end
336
645
  end
337
646
 
338
- [builder, stats, frozen_blocks, conflicts]
647
+ [builder, stats, frozen_blocks, conflicts, unresolved_cases]
339
648
  end
340
649
 
341
650
  # Process a matched node pair, adding to OutputBuilder
@@ -344,13 +653,21 @@ module Markdown
344
653
  # @param builder [OutputBuilder] Output builder to add to
345
654
  # @param stats [Hash] Statistics hash to update
346
655
  # @return [Hash, nil] Frozen block info if applicable
347
- def process_match_to_builder(entry, builder, stats)
656
+ def process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
348
657
  template_node = apply_node_typing(entry[:template_node])
349
658
  dest_node = apply_node_typing(entry[:dest_node])
350
659
 
351
660
  # Try inner-merge for code blocks first
352
661
  if @code_block_merger && code_block_node?(template_node) && code_block_node?(dest_node)
353
- inner_result = try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats)
662
+ inner_result = try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats, conflicts,
663
+ unresolved_cases)
664
+ return if inner_result
665
+ end
666
+
667
+ # Try inner-merge for lists
668
+ if @list_merger && list_node?(template_node) && list_node?(dest_node)
669
+ inner_result = try_inner_merge_list_to_builder(template_node, dest_node, builder, stats, conflicts,
670
+ unresolved_cases)
354
671
  return if inner_result
355
672
  end
356
673
 
@@ -358,8 +675,9 @@ module Markdown
358
675
  template_node,
359
676
  dest_node,
360
677
  template_index: entry[:template_index],
361
- dest_index: entry[:dest_index],
678
+ dest_index: entry[:dest_index]
362
679
  )
680
+ conflicts << resolution[:conflict] if resolution[:conflict]
363
681
 
364
682
  frozen_info = nil
365
683
 
@@ -369,22 +687,101 @@ module Markdown
369
687
 
370
688
  case resolution[:source]
371
689
  when :template
690
+ preserved_link_definitions = preserved_destination_link_definitions_for_match(raw_template_node,
691
+ raw_dest_node)
692
+ preserved_link_definitions.each do |link_definition|
693
+ builder.add_node_source(link_definition, @dest_analysis)
694
+ end
695
+ unless preserved_link_definitions.empty?
696
+ stats[:preserved_destination_link_definitions] ||= 0
697
+ stats[:preserved_destination_link_definitions] += preserved_link_definitions.length
698
+ end
372
699
  stats[:nodes_modified] += 1 if resolution[:decision] != :identical
373
- builder.add_node_source(raw_template_node, @template_analysis)
700
+ emitted_range = builder.add_node_source(raw_template_node, @template_analysis)
374
701
  when :destination
375
702
  if raw_dest_node.respond_to?(:freeze_node?) && raw_dest_node.freeze_node?
376
703
  frozen_info = {
377
704
  start_line: raw_dest_node.start_line,
378
705
  end_line: raw_dest_node.end_line,
379
- reason: raw_dest_node.reason,
706
+ reason: raw_dest_node.reason
380
707
  }
381
708
  end
382
- builder.add_node_source(raw_dest_node, @dest_analysis)
709
+ emitted_range = builder.add_node_source(raw_dest_node, @dest_analysis)
710
+ end
711
+
712
+ if resolution[:unresolved_case]
713
+ unresolved_cases << unresolved_case_with_output_range(resolution[:unresolved_case],
714
+ emitted_range)
383
715
  end
384
716
 
385
717
  frozen_info
386
718
  end
387
719
 
720
+ def unresolved_case_with_output_range(unresolved_case, output_range)
721
+ return unresolved_case unless unresolved_case && output_range
722
+
723
+ metadata = unresolved_case.metadata.dup
724
+ relative_output_range = metadata.delete(:relative_output_range)
725
+ metadata[:output_range] =
726
+ if relative_output_range
727
+ output_range_from_relative(output_range, relative_output_range)
728
+ else
729
+ output_range
730
+ end
731
+
732
+ Ast::Merge::Runtime::ResolutionCase.new(
733
+ case_id: unresolved_case.case_id,
734
+ reason: unresolved_case.reason,
735
+ candidates: unresolved_case.candidates,
736
+ provisional_winner: unresolved_case.provisional_winner,
737
+ surface_path: unresolved_case.surface_path,
738
+ operation_id: unresolved_case.operation_id,
739
+ metadata: metadata
740
+ )
741
+ end
742
+
743
+ def output_range_from_relative(parent_range, relative_range)
744
+ start_offset, = Array(parent_range)
745
+ relative_start, relative_end = Array(relative_range)
746
+ return parent_range unless [start_offset, relative_start, relative_end].all?
747
+
748
+ [start_offset + relative_start.to_i, start_offset + relative_end.to_i]
749
+ end
750
+
751
+ def preserved_destination_link_definitions_for_match(template_node, dest_node)
752
+ destination_link_definitions = consumed_link_definitions_within(dest_node, @dest_analysis)
753
+ return [] if destination_link_definitions.empty?
754
+
755
+ template_signatures = consumed_link_definitions_within(template_node, @template_analysis)
756
+ .map(&:signature)
757
+ .to_set
758
+
759
+ destination_link_definitions.reject do |link_definition|
760
+ template_signatures.include?(link_definition.signature)
761
+ end
762
+ end
763
+
764
+ def consumed_link_definitions_within(node, analysis)
765
+ return [] if skip_link_ownership_scanning_for_node?(node, analysis)
766
+
767
+ pos = node.source_position
768
+ start_line = pos&.dig(:start_line)
769
+ end_line = pos&.dig(:end_line)
770
+ return [] unless start_line && end_line
771
+
772
+ (start_line..end_line).filter_map do |line_number|
773
+ LinkDefinitionNode.parse(analysis.source_range(line_number, line_number), line_number: line_number)
774
+ end.then { |definitions| unique_link_definitions_by_signature(definitions) }
775
+ rescue StandardError => e
776
+ return [] if missing_source_position_protocol_error?(e)
777
+
778
+ raise
779
+ end
780
+
781
+ def missing_source_position_protocol_error?(error)
782
+ error.is_a?(NoMethodError) || error.class.name == 'RSpec::Mocks::MockExpectationError'
783
+ end
784
+
388
785
  # Apply node typing to a node if node_typing is configured.
389
786
  #
390
787
  # For markdown nodes, this supports matching by:
@@ -406,8 +803,8 @@ module Markdown
406
803
  if node.respond_to?(:type)
407
804
  canonical_type = node.type
408
805
  callable = @node_typing[canonical_type] ||
409
- @node_typing[canonical_type.to_s] ||
410
- @node_typing[canonical_type.to_sym]
806
+ @node_typing[canonical_type.to_s] ||
807
+ @node_typing[canonical_type.to_sym]
411
808
  if callable
412
809
  # Call the custom lambda - it may return a refined typed node
413
810
  # or the original node unchanged
@@ -429,7 +826,53 @@ module Markdown
429
826
  def code_block_node?(node)
430
827
  return false if node.respond_to?(:freeze_node?) && node.freeze_node?
431
828
 
432
- node.respond_to?(:type) && node.type == :code_block
829
+ node.respond_to?(:type) && node.type.to_s == 'code_block'
830
+ end
831
+
832
+ # Check if a node is an ordered or unordered list.
833
+ #
834
+ # @param node [Object] Node to check
835
+ # @return [Boolean] true if the node is a list
836
+ def list_node?(node)
837
+ return false if node.respond_to?(:freeze_node?) && node.freeze_node?
838
+
839
+ node.respond_to?(:type) && node.type.to_s == 'list'
840
+ end
841
+
842
+ # Try to inner-merge two list nodes at the item level, adding to OutputBuilder.
843
+ #
844
+ # @param template_node [Object] Template list node
845
+ # @param dest_node [Object] Destination list node
846
+ # @param builder [OutputBuilder] Output builder to add to
847
+ # @param stats [Hash] Statistics hash to update
848
+ # @return [Boolean] true if merged, false to fall back to standard resolution
849
+ def try_inner_merge_list_to_builder(template_node, dest_node, builder, stats, conflicts, unresolved_cases)
850
+ result = @list_merger.merge_lists(
851
+ template_node,
852
+ dest_node,
853
+ preference: @preference.is_a?(Hash) ? @preference.fetch(:default, :destination) : @preference,
854
+ add_template_only_nodes: @add_template_only_nodes,
855
+ template_analysis: @template_analysis,
856
+ dest_analysis: @dest_analysis,
857
+ resolution_mode: @resolution_mode,
858
+ unresolved_policy: @unresolved_policy
859
+ )
860
+
861
+ if result[:merged]
862
+ stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
863
+ stats[:inner_merges] ||= 0
864
+ stats[:inner_merges] += 1
865
+ emitted_range = builder.add_raw(result[:content])
866
+ remapped_cases = Array(result[:unresolved_cases]).map do |resolution_case|
867
+ unresolved_case_with_output_range(resolution_case, emitted_range)
868
+ end
869
+ unresolved_cases.concat(remapped_cases)
870
+ conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
871
+ true
872
+ else
873
+ DebugLogger.debug('List inner-merge skipped', { reason: result[:reason] })
874
+ false
875
+ end
433
876
  end
434
877
 
435
878
  # Try to inner-merge two code block nodes, adding to OutputBuilder
@@ -439,26 +882,97 @@ module Markdown
439
882
  # @param builder [OutputBuilder] Output builder to add to
440
883
  # @param stats [Hash] Statistics hash to update
441
884
  # @return [Boolean] true if merged, false to fall back to standard resolution
442
- def try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats)
885
+ def try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats, conflicts, unresolved_cases)
443
886
  result = @code_block_merger.merge_code_blocks(
444
887
  template_node,
445
888
  dest_node,
446
889
  preference: @preference,
890
+ runtime_session: @runtime_session,
891
+ parent_operation: @runtime_root_operation,
447
892
  add_template_only_nodes: @add_template_only_nodes,
893
+ resolution_mode: @resolution_mode,
894
+ unresolved_policy: @unresolved_policy
448
895
  )
449
896
 
450
897
  if result[:merged]
451
898
  stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
452
899
  stats[:inner_merges] ||= 0
453
900
  stats[:inner_merges] += 1
454
- builder.add_raw(result[:content])
901
+ emitted_range = builder.add_raw(result[:content])
902
+ remapped_cases = remap_delegated_unresolved_cases(
903
+ result[:unresolved_cases],
904
+ result[:runtime_operation_id],
905
+ result[:runtime_surface_path],
906
+ emitted_range,
907
+ result[:metadata]
908
+ )
909
+ unresolved_cases.concat(remapped_cases)
910
+ conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
455
911
  true
456
912
  else
457
- DebugLogger.debug("Inner-merge skipped", {reason: result[:reason]})
913
+ DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
458
914
  false # Fall back to standard resolution
459
915
  end
460
916
  end
461
917
 
918
+ def remap_delegated_unresolved_cases(unresolved_cases, runtime_operation_id, runtime_surface_path,
919
+ output_range = nil, delegated_metadata = nil)
920
+ root_apply_candidates = delegated_metadata.to_h[:root_apply_candidates_by_case_id].to_h
921
+ delegated_apply_renderer = delegated_metadata.to_h[:delegated_apply_renderer]
922
+ Array(unresolved_cases).map do |resolution_case|
923
+ suffix = delegated_surface_suffix_for(resolution_case.surface_path)
924
+ metadata = resolution_case.metadata.merge(
925
+ delegated_case_id: resolution_case.case_id
926
+ )
927
+ apply_candidates = root_apply_candidates[resolution_case.case_id]
928
+ if output_range && apply_candidates
929
+ metadata = metadata.merge(
930
+ output_range: output_range,
931
+ output_candidate_by_selection: apply_candidates
932
+ )
933
+ end
934
+ if output_range && delegated_apply_renderer
935
+ metadata = metadata.merge(
936
+ output_range: output_range,
937
+ delegated_apply_group: runtime_operation_id,
938
+ delegated_apply_renderer: delegated_apply_renderer,
939
+ delegated_applied_selections: {},
940
+ delegated_root_applied_selections: {},
941
+ delegated_runtime_operation_id: runtime_operation_id,
942
+ delegated_runtime_surface_path: runtime_surface_path
943
+ )
944
+ end
945
+
946
+ Ast::Merge::Runtime::ResolutionCase.new(
947
+ case_id: "#{runtime_operation_id}-#{resolution_case.case_id}",
948
+ reason: resolution_case.reason,
949
+ candidates: resolution_case.candidates,
950
+ provisional_winner: resolution_case.provisional_winner,
951
+ surface_path: [runtime_surface_path, suffix].compact.join(' > '),
952
+ operation_id: runtime_operation_id,
953
+ metadata: metadata
954
+ )
955
+ end
956
+ end
957
+
958
+ def delegated_surface_suffix_for(surface_path)
959
+ path = surface_path.to_s
960
+ return if path.empty? || path == 'document[0]'
961
+
962
+ path.sub(/\Adocument\[0\]\s*>\s*/, '')
963
+ end
964
+
965
+ def conflict_for_resolution_case(resolution_case)
966
+ {
967
+ case_id: resolution_case.case_id,
968
+ reason: resolution_case.reason,
969
+ template: resolution_case.candidates[:template],
970
+ destination: resolution_case.candidates[:destination],
971
+ provisional_winner: resolution_case.provisional_winner,
972
+ location: resolution_case.surface_path
973
+ }.compact
974
+ end
975
+
462
976
  # Try to inner-merge two code block nodes.
463
977
  #
464
978
  # @deprecated Use try_inner_merge_code_block_to_builder instead
@@ -471,7 +985,7 @@ module Markdown
471
985
  template_node,
472
986
  dest_node,
473
987
  preference: @preference,
474
- add_template_only_nodes: @add_template_only_nodes,
988
+ add_template_only_nodes: @add_template_only_nodes
475
989
  )
476
990
 
477
991
  if result[:merged]
@@ -480,7 +994,7 @@ module Markdown
480
994
  stats[:inner_merges] += 1
481
995
  [result[:content], nil]
482
996
  else
483
- DebugLogger.debug("Inner-merge skipped", {reason: result[:reason]})
997
+ DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
484
998
  nil # Fall back to standard resolution
485
999
  end
486
1000
  end
@@ -536,7 +1050,8 @@ module Markdown
536
1050
  # @param builder [OutputBuilder] Output builder to add to
537
1051
  # @param stats [Hash] Statistics hash to update
538
1052
  # @return [Hash, nil] Frozen block info if applicable
539
- def process_dest_only_to_builder(entry, builder, stats)
1053
+ def process_dest_only_to_builder(entry, builder, stats, preserve_separator_gap: false, remaining_entries: [],
1054
+ link_ownership_context: nil, removal_comment_ownership: nil)
540
1055
  node = entry[:dest_node]
541
1056
 
542
1057
  frozen_info = nil
@@ -545,12 +1060,366 @@ module Markdown
545
1060
  frozen_info = {
546
1061
  start_line: node.start_line,
547
1062
  end_line: node.end_line,
548
- reason: node.reason,
1063
+ reason: node.reason
549
1064
  }
550
1065
  end
551
1066
 
552
- builder.add_node_source(node, @dest_analysis)
553
- frozen_info
1067
+ unless @remove_template_missing_nodes
1068
+ builder.add_node_source(node, @dest_analysis)
1069
+ return [frozen_info, false]
1070
+ end
1071
+
1072
+ if preserve_removed_dest_only_node?(node, removal_comment_ownership)
1073
+ if link_definition_node?(node)
1074
+ return [frozen_info, false] unless preserve_removed_link_definition_node?(node, link_ownership_context)
1075
+
1076
+ link_ownership_context[:preserved] << node.signature if link_ownership_context
1077
+ end
1078
+
1079
+ if standalone_comment_node?(node,
1080
+ @dest_analysis) && preserved_removal_comment_node?(node,
1081
+ removal_comment_ownership)
1082
+ stats[:preserved_destination_comment_fragments] ||= 0
1083
+ stats[:preserved_destination_comment_fragments] += 1
1084
+ end
1085
+
1086
+ builder.add_node_source(node, @dest_analysis)
1087
+ return [frozen_info, preserve_separator_gap_after_removed_node?(node, remaining_entries)]
1088
+ end
1089
+
1090
+ if preserve_removed_separator_gap_line?(node)
1091
+ return [frozen_info, false] if builder.empty? || builder.blank_line_terminated?
1092
+
1093
+ should_preserve_gap = preserve_separator_gap && separator_gap_needed_after_removed_node?(remaining_entries)
1094
+ should_preserve_gap ||= leading_separator_gap_before_preserved_comment_needed?(remaining_entries)
1095
+ return [frozen_info, false] unless should_preserve_gap
1096
+
1097
+ builder.add_node_source(node, @dest_analysis)
1098
+ return [frozen_info, false]
1099
+ end
1100
+
1101
+ if removable_destination_only_node?(node)
1102
+ preserved_link_definitions = preserved_destination_link_definitions_for_removed_node(node,
1103
+ link_ownership_context)
1104
+
1105
+ if preserved_link_definitions.any?
1106
+ builder.add_gap_line(count: 1) unless builder.empty? || builder.blank_line_terminated?
1107
+
1108
+ preserved_link_definitions.each do |link_definition|
1109
+ builder.add_node_source(link_definition, @dest_analysis)
1110
+ link_ownership_context[:preserved] << link_definition.signature if link_ownership_context
1111
+ end
1112
+
1113
+ stats[:preserved_destination_link_definitions] ||= 0
1114
+ stats[:preserved_destination_link_definitions] += preserved_link_definitions.length
1115
+ end
1116
+
1117
+ stats[:nodes_removed] += 1
1118
+ return [nil, preserve_separator_gap || separator_gap_needed_after_removed_node?(remaining_entries)]
1119
+ end
1120
+
1121
+ [nil, preserve_separator_gap]
1122
+ end
1123
+
1124
+ # Removal-mode node classification — Markdown-family local.
1125
+ #
1126
+ # These predicates live here rather than in PreservationSupport because their
1127
+ # semantics are specific to full-document removal mode and would be meaningless
1128
+ # or misleading in a shared preservation context:
1129
+ #
1130
+ # * preserve_removed_separator_gap_line? — named entry-point for removal mode;
1131
+ # delegates directly to PreservationSupport#blank_gap_line_node?.
1132
+ #
1133
+ # * removable_destination_only_node? — intentionally does NOT exclude standalone
1134
+ # comment nodes. Standalone comments ARE removable in removal mode unless they
1135
+ # are owned by a remove plan. This makes it distinct from
1136
+ # PreservationSupport#structural_preservation_statement?, which excludes all
1137
+ # three non-structural categories (gap lines, standalone comments, link defs).
1138
+ #
1139
+ # * boundary_owner_statement? — identifies any non-blank-gap-line statement as a
1140
+ # valid boundary anchor for remove-plan construction. This is a removal-mode
1141
+ # concept only; PartialTemplateMerger has no equivalent boundary-anchoring need.
1142
+ def preserve_removed_dest_only_node?(node, removal_comment_ownership = nil)
1143
+ return true if node.respond_to?(:freeze_node?) && node.freeze_node?
1144
+ return true if link_definition_node?(node)
1145
+ return true if non_blank_gap_line_node?(node)
1146
+
1147
+ preserved_removal_comment_node?(node, removal_comment_ownership)
1148
+ end
1149
+
1150
+ def preserve_separator_gap_after_removed_node?(node, remaining_entries = [])
1151
+ standalone_comment_node?(node, @dest_analysis) && separator_gap_needed_after_removed_node?(remaining_entries)
1152
+ end
1153
+
1154
+ def leading_separator_gap_before_preserved_comment_needed?(remaining_entries)
1155
+ next_entry = remaining_entries.find do |entry|
1156
+ entry[:type] != :dest_only || !preserve_removed_separator_gap_line?(entry[:dest_node])
1157
+ end
1158
+
1159
+ next_entry && next_entry[:type] == :dest_only && standalone_comment_node?(next_entry[:dest_node],
1160
+ @dest_analysis)
1161
+ end
1162
+
1163
+ def separator_gap_needed_after_removed_node?(remaining_entries)
1164
+ remaining_entries.any? { |entry| entry_kept_after_removed_node?(entry) }
1165
+ end
1166
+
1167
+ def entry_kept_after_removed_node?(entry)
1168
+ case entry[:type]
1169
+ when :match
1170
+ true
1171
+ when :template_only
1172
+ should_add_template_only_node?(entry)
1173
+ when :dest_only
1174
+ node = entry[:dest_node]
1175
+ (node.respond_to?(:freeze_node?) && node.freeze_node?) || preserve_removed_dest_only_node?(node)
1176
+ else
1177
+ false
1178
+ end
1179
+ end
1180
+
1181
+ def preserve_removed_separator_gap_line?(node)
1182
+ blank_gap_line_node?(node)
1183
+ end
1184
+
1185
+ def removable_destination_only_node?(node)
1186
+ !gap_line_node?(node) && !link_definition_node?(node)
1187
+ end
1188
+
1189
+ def removal_mode_link_ownership_context(alignment)
1190
+ needed = Set.new
1191
+ available = Set.new
1192
+
1193
+ alignment.each do |entry|
1194
+ kept_node, analysis = kept_node_for_link_ownership(entry)
1195
+ next unless kept_node && analysis
1196
+
1197
+ link_reference_signatures_within(kept_node, analysis).each { |signature| needed << signature }
1198
+ link_definition_signatures_within(kept_node, analysis).each { |signature| available << signature }
1199
+ end
1200
+
1201
+ { needed: needed, available: available, preserved: Set.new }
1202
+ end
1203
+
1204
+ def removal_mode_comment_ownership_context(alignment)
1205
+ contexts = {}
1206
+ run_entries = []
1207
+
1208
+ alignment.each do |entry|
1209
+ if removal_mode_comment_run_entry?(entry)
1210
+ run_entries << entry
1211
+ next
1212
+ end
1213
+
1214
+ merge_removal_comment_ownership_context!(contexts, run_entries)
1215
+ run_entries = []
1216
+ end
1217
+
1218
+ merge_removal_comment_ownership_context!(contexts, run_entries)
1219
+ contexts
1220
+ end
1221
+
1222
+ def merge_removal_comment_ownership_context!(contexts, run_entries)
1223
+ ownership = removal_comment_ownership_for_run(run_entries)
1224
+ return contexts unless ownership
1225
+
1226
+ Array(run_entries).each do |entry|
1227
+ contexts[entry[:dest_index]] = ownership
1228
+ end
1229
+
1230
+ contexts
1231
+ end
1232
+
1233
+ def removal_comment_ownership_for_run(run_entries)
1234
+ entries = Array(run_entries)
1235
+ return if entries.empty?
1236
+ return unless entries.any? { |entry| removal_mode_removable_structural_node?(entry[:dest_node]) }
1237
+
1238
+ remove_plan = removal_mode_remove_plan_for_entries(entries)
1239
+ return unless remove_plan
1240
+
1241
+ {
1242
+ remove_plan: remove_plan,
1243
+ owned_comment_region_keys: remove_plan_preserved_comment_keys(remove_plan),
1244
+ owned_comment_node_keys: removal_mode_owned_comment_node_keys(remove_plan, entries)
1245
+ }.freeze
1246
+ end
1247
+
1248
+ def removal_mode_comment_run_entry?(entry)
1249
+ return false unless entry[:type] == :dest_only
1250
+
1251
+ node = entry[:dest_node]
1252
+ preserve_removed_separator_gap_line?(node) ||
1253
+ standalone_comment_node?(node, @dest_analysis) ||
1254
+ removal_mode_removable_structural_node?(node)
1255
+ end
1256
+
1257
+ def removal_mode_removable_structural_node?(node)
1258
+ removable_destination_only_node?(node) && !preserve_removed_dest_only_node?(node)
1259
+ end
1260
+
1261
+ def removal_mode_remove_plan_for_entries(entries)
1262
+ first_entry = entries.first
1263
+ last_entry = entries.last
1264
+ return unless first_entry && last_entry
1265
+
1266
+ first_dest_index = first_entry[:dest_index]
1267
+ last_dest_index = last_entry[:dest_index]
1268
+ return if first_dest_index.nil? || last_dest_index.nil?
1269
+
1270
+ statements = entries.map { |entry| entry[:dest_node] }
1271
+
1272
+ leading_statement = preceding_boundary_statement(first_dest_index)
1273
+ trailing_statement = following_boundary_statement(last_dest_index)
1274
+
1275
+ Ast::Merge::StructuralEdit::RemovePlanSupport.build_remove_plan(
1276
+ analysis: @dest_analysis,
1277
+ statements: statements,
1278
+ leading_statement: leading_statement,
1279
+ trailing_statement: trailing_statement,
1280
+ source: :smart_merger_base_removal_mode
1281
+ )
1282
+ end
1283
+
1284
+ def preceding_boundary_statement(dest_index)
1285
+ @dest_analysis.statements[0...dest_index].reverse_each.find { |statement| boundary_owner_statement?(statement) }
1286
+ end
1287
+
1288
+ def following_boundary_statement(dest_index)
1289
+ Array(@dest_analysis.statements[(dest_index + 1)..]).find { |statement| boundary_owner_statement?(statement) }
1290
+ end
1291
+
1292
+ def boundary_owner_statement?(statement)
1293
+ statement && !preserve_removed_separator_gap_line?(statement)
1294
+ end
1295
+
1296
+ def removal_mode_owned_comment_node_keys(remove_plan, run_entries)
1297
+ remove_plan_preserved_comment_keys_for_nodes(
1298
+ remove_plan,
1299
+ nodes: Array(run_entries).map { |entry| entry[:dest_node] },
1300
+ analysis: @dest_analysis
1301
+ )
1302
+ end
1303
+
1304
+ def preserved_removal_comment_node?(node, removal_comment_ownership = nil)
1305
+ return false unless standalone_comment_node?(node, @dest_analysis)
1306
+ return true unless removal_comment_ownership
1307
+
1308
+ remove_plan_owns_comment_node?(
1309
+ node,
1310
+ @dest_analysis,
1311
+ removal_comment_ownership.fetch(:remove_plan),
1312
+ preserved_comment_keys: removal_comment_ownership.fetch(:owned_comment_region_keys)
1313
+ ) || removal_comment_ownership.fetch(:owned_comment_node_keys).include?(preserved_comment_node_key(node,
1314
+ @dest_analysis))
1315
+ end
1316
+
1317
+ def kept_node_for_link_ownership(entry)
1318
+ case entry[:type]
1319
+ when :match
1320
+ template_node = apply_node_typing(entry[:template_node])
1321
+ dest_node = apply_node_typing(entry[:dest_node])
1322
+
1323
+ resolution = @resolver.resolve(
1324
+ template_node,
1325
+ dest_node,
1326
+ template_index: entry[:template_index],
1327
+ dest_index: entry[:dest_index]
1328
+ )
1329
+
1330
+ if resolution[:source] == :template
1331
+ [Ast::Merge::NodeTyping.unwrap(template_node), @template_analysis]
1332
+ else
1333
+ [Ast::Merge::NodeTyping.unwrap(dest_node), @dest_analysis]
1334
+ end
1335
+ when :template_only
1336
+ return unless should_add_template_only_node?(entry)
1337
+
1338
+ [Ast::Merge::NodeTyping.unwrap(entry[:template_node]), @template_analysis]
1339
+ when :dest_only
1340
+ node = entry[:dest_node]
1341
+ return unless preserve_removed_dest_only_node?(node)
1342
+ return if link_definition_node?(node)
1343
+
1344
+ [node, @dest_analysis]
1345
+ end
1346
+ end
1347
+
1348
+ def preserved_destination_link_definitions_for_removed_node(node, link_ownership_context)
1349
+ return [] unless link_ownership_context
1350
+
1351
+ destination_link_definitions = consumed_link_definitions_within(node, @dest_analysis)
1352
+ return [] if destination_link_definitions.empty?
1353
+
1354
+ needed_signatures = link_ownership_context.fetch(:needed)
1355
+ available_signatures = link_ownership_context.fetch(:available)
1356
+ preserved_signatures = link_ownership_context.fetch(:preserved)
1357
+
1358
+ destination_link_definitions.reject do |link_definition|
1359
+ signature = link_definition.signature
1360
+ !needed_signatures.include?(signature) ||
1361
+ available_signatures.include?(signature) ||
1362
+ preserved_signatures.include?(signature)
1363
+ end
1364
+ end
1365
+
1366
+ def preserve_removed_link_definition_node?(node, link_ownership_context)
1367
+ return true unless link_ownership_context
1368
+
1369
+ signature = node.signature
1370
+ !link_ownership_context.fetch(:available).include?(signature) &&
1371
+ !link_ownership_context.fetch(:preserved).include?(signature)
1372
+ end
1373
+
1374
+ def link_definition_signatures_within(node, analysis)
1375
+ return Set.new if standalone_comment_node?(node, analysis)
1376
+
1377
+ if node.is_a?(LinkDefinitionNode)
1378
+ Set[node.signature]
1379
+ else
1380
+ consumed_link_definitions_within(node, analysis).map(&:signature).to_set
1381
+ end
1382
+ end
1383
+
1384
+ def link_reference_signatures_within(node, analysis)
1385
+ return Set.new if skip_link_ownership_scanning_for_node?(node, analysis)
1386
+
1387
+ source = node_to_source(node, analysis)
1388
+ return Set.new if source.nil? || source.empty?
1389
+
1390
+ source.scan(/!?\[([^\]]+)\]\[([^\]]*)\]/).each_with_object(Set.new) do |(text_label, explicit_label), signatures|
1391
+ label = explicit_label.to_s.empty? ? text_label : explicit_label
1392
+ normalized_label = label.to_s.downcase
1393
+ next if normalized_label.empty?
1394
+
1395
+ signatures << [:link_definition, normalized_label]
1396
+ end
1397
+ end
1398
+
1399
+ def skip_link_ownership_scanning_for_node?(node, analysis)
1400
+ return true if gap_line_node?(node) || link_definition_node?(node)
1401
+ return true if node.respond_to?(:freeze_node?) && node.freeze_node?
1402
+ return true if standalone_comment_node?(node, analysis)
1403
+
1404
+ literal_link_ownership_context_node?(node)
1405
+ end
1406
+
1407
+ def unique_link_definitions_by_signature(link_definitions)
1408
+ seen = Set.new
1409
+
1410
+ link_definitions.each_with_object([]) do |link_definition, unique_definitions|
1411
+ signature = link_definition.signature
1412
+ next if seen.include?(signature)
1413
+
1414
+ seen << signature
1415
+ unique_definitions << link_definition
1416
+ end
1417
+ end
1418
+
1419
+ def literal_link_ownership_context_node?(node)
1420
+ return false unless node.respond_to?(:type)
1421
+
1422
+ %w[code_block html html_block custom_block].include?(node.type.to_s)
554
1423
  end
555
1424
 
556
1425
  # Convert a node to its source text.
@@ -590,11 +1459,11 @@ module Markdown
590
1459
  statements = analysis.statements
591
1460
  gap_index = statements.index(gap_line)
592
1461
 
593
- DebugLogger.debug("Checking if gap line is document-trailing", {
594
- gap_line_number: gap_line.line_number,
595
- gap_index: gap_index,
596
- total_statements: statements.length,
597
- })
1462
+ DebugLogger.debug('Checking if gap line is document-trailing', {
1463
+ gap_line_number: gap_line.line_number,
1464
+ gap_index: gap_index,
1465
+ total_statements: statements.length
1466
+ })
598
1467
 
599
1468
  return true if gap_index.nil? # Shouldn't happen, but treat as trailing if missing
600
1469
 
@@ -603,17 +1472,17 @@ module Markdown
603
1472
  (gap_index + 1...statements.length).each do |i|
604
1473
  node = statements[i]
605
1474
  # If we find a non-gap-line node, this gap line is NOT document-trailing
606
- unless node.is_a?(GapLineNode)
607
- DebugLogger.debug("Found content after gap line", {
608
- next_node_index: i,
609
- next_node_type: node.class.name,
610
- })
611
- return false
612
- end
1475
+ next if gap_line_node?(node)
1476
+
1477
+ DebugLogger.debug('Found content after gap line', {
1478
+ next_node_index: i,
1479
+ next_node_type: node.class.name
1480
+ })
1481
+ return false
613
1482
  end
614
1483
 
615
1484
  # All remaining nodes are gap lines (or no nodes after), so this is document-trailing
616
- DebugLogger.debug("Gap line IS document-trailing - no content after it")
1485
+ DebugLogger.debug('Gap line IS document-trailing - no content after it')
617
1486
  true
618
1487
  end
619
1488
  end