markdown-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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +673 -0
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
  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 +44 -0
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +742 -0
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +199 -0
  16. data/lib/markdown/merge/debug_logger.rb +26 -0
  17. data/lib/markdown/merge/document_problems.rb +190 -0
  18. data/lib/markdown/merge/file_aligner.rb +496 -0
  19. data/lib/markdown/merge/file_analysis.rb +689 -0
  20. data/lib/markdown/merge/file_analysis_base.rb +766 -0
  21. data/lib/markdown/merge/freeze_node.rb +93 -0
  22. data/lib/markdown/merge/gap_line_node.rb +142 -0
  23. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  24. data/lib/markdown/merge/link_definition_node.rb +157 -0
  25. data/lib/markdown/merge/link_parser.rb +421 -0
  26. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  27. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  28. data/lib/markdown/merge/list_merger.rb +322 -0
  29. data/lib/markdown/merge/markdown_structure.rb +123 -0
  30. data/lib/markdown/merge/merge_result.rb +483 -0
  31. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  32. data/lib/markdown/merge/output_builder.rb +248 -0
  33. data/lib/markdown/merge/partial_template_merger.rb +555 -0
  34. data/lib/markdown/merge/preservation_support.rb +291 -0
  35. data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
  36. data/lib/markdown/merge/smart_merger.rb +269 -0
  37. data/lib/markdown/merge/smart_merger_base.rb +1490 -0
  38. data/lib/markdown/merge/source_preserving_provider.rb +814 -0
  39. data/lib/markdown/merge/table_match_algorithm.rb +499 -0
  40. data/lib/markdown/merge/table_match_refiner.rb +132 -0
  41. data/lib/markdown/merge/version.rb +5 -3
  42. data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
  43. data/lib/markdown/merge/wrapper_support.rb +194 -0
  44. data/lib/markdown/merge.rb +271 -87
  45. data/lib/markdown-merge.rb +7 -1
  46. data/sig/markdown/merge.rbs +62 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +289 -15
  49. metadata.gz.sig +0 -0
@@ -0,0 +1,1490 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Base class for smart Markdown file merging.
6
+ #
7
+ # Orchestrates the smart merge process for Markdown files using
8
+ # FileAnalysisBase, FileAligner, ConflictResolver, and MergeResult to
9
+ # merge two Markdown files intelligently. Freeze blocks marked with
10
+ # HTML comments are preserved exactly as-is.
11
+ #
12
+ # Subclasses must implement:
13
+ # - #create_file_analysis(content, **options) - Create parser-specific FileAnalysis
14
+ # - #node_to_source(node, analysis) - Convert a node to source text
15
+ #
16
+ # SmartMergerBase provides flexible configuration for different merge scenarios:
17
+ # - Preserve destination customizations (default)
18
+ # - Apply template updates
19
+ # - Add new sections from template
20
+ # - Inner-merge fenced code blocks using language-specific mergers (optional)
21
+ #
22
+ # @example Subclass implementation
23
+ # class SmartMerger < Markdown::Merge::SmartMergerBase
24
+ # def create_file_analysis(content, **options)
25
+ # FileAnalysis.new(content, **options)
26
+ # end
27
+ #
28
+ # def node_to_source(node, analysis)
29
+ # case node
30
+ # when FreezeNode
31
+ # node.full_text
32
+ # else
33
+ # analysis.source_range(node.start_line, node.end_line)
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # @abstract Subclass and implement parser-specific methods
39
+ # @see FileAnalysisBase
40
+ # @see FileAligner
41
+ # @see ConflictResolver
42
+ # @see MergeResult
43
+ class SmartMergerBase
44
+ include PreservationSupport
45
+
46
+ # @return [FileAnalysisBase] Analysis of the template file
47
+ attr_reader :template_analysis
48
+
49
+ # @return [FileAnalysisBase] Analysis of the destination file
50
+ attr_reader :dest_analysis
51
+
52
+ # @return [FileAligner] Aligner for finding matches and differences
53
+ attr_reader :aligner
54
+
55
+ # @return [ConflictResolver] Resolver for handling conflicting content
56
+ attr_reader :resolver
57
+
58
+ # @return [CodeBlockMerger, nil] Merger for fenced code blocks
59
+ attr_reader :code_block_merger
60
+
61
+ # @return [Hash{Symbol,String => #call}, nil] Node typing configuration
62
+ attr_reader :node_typing
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
+
68
+ # Creates a new SmartMerger for intelligent Markdown file merging.
69
+ #
70
+ # @param template_content [String] Template Markdown source code
71
+ # @param dest_content [String] Destination Markdown source code
72
+ #
73
+ # @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
74
+ # The proc receives a node and should return one of:
75
+ # - An array representing the node's signature
76
+ # - `nil` to indicate the node should have no signature
77
+ # - The original node to fall through to default signature computation
78
+ #
79
+ # @param preference [Symbol, Hash] Controls which version to use when nodes
80
+ # have matching signatures but different content:
81
+ # - `:destination` (default) - Use destination version (preserves customizations)
82
+ # - `:template` - Use template version (applies updates)
83
+ # - Hash for per-type preferences: `{ default: :destination, gem_table: :template }`
84
+ #
85
+ # @param add_template_only_nodes [Boolean, #call] Controls whether to add nodes that only
86
+ # exist in template:
87
+ # - `false` (default) - Skip template-only nodes
88
+ # - `true` - Add all template-only nodes to result
89
+ # - Callable (Proc/Lambda) - Called with (node, entry) for each template-only node.
90
+ # Return truthy to add the node, falsey to skip it.
91
+ # @example Filter to only add gem family link refs
92
+ # add_template_only_nodes: ->(node, entry) {
93
+ # sig = entry[:signature]
94
+ # sig.is_a?(Array) && sig.first == :gem_family
95
+ # }
96
+ #
97
+ # @param inner_merge_code_blocks [Boolean, CodeBlockMerger] Controls inner-merge for
98
+ # fenced code blocks:
99
+ # - `true` - Enable inner-merge using default CodeBlockMerger
100
+ # - `false` (default) - Disable inner-merge (use standard conflict resolution)
101
+ # - `CodeBlockMerger` instance - Use custom CodeBlockMerger
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
+ #
110
+ # @param freeze_token [String] Token to use for freeze block markers.
111
+ # Default: "markdown-merge"
112
+ #
113
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching of
114
+ # unmatched nodes. Default: nil (fuzzy matching disabled).
115
+ # Set to TableMatchRefiner.new to enable fuzzy table matching.
116
+ #
117
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
118
+ # for per-node-type merge preferences. Maps node type names to callables that
119
+ # can wrap nodes with custom merge_types for use with Hash-based preference.
120
+ # @example
121
+ # node_typing = {
122
+ # table: ->(node) {
123
+ # text = node.to_plaintext
124
+ # if text.include?("tree_haver")
125
+ # Ast::Merge::NodeTyping.with_merge_type(node, :gem_family_table)
126
+ # else
127
+ # node
128
+ # end
129
+ # }
130
+ # }
131
+ # merger = SmartMerger.new(template, dest,
132
+ # node_typing: node_typing,
133
+ # preference: { default: :destination, gem_family_table: :template })
134
+ #
135
+ # @param normalize_whitespace [Boolean, Symbol] Whitespace normalization mode:
136
+ # - `false` (default) - No normalization
137
+ # - `true` or `:basic` - Collapse excessive blank lines (3+ → 2)
138
+ # - `:link_refs` - Basic + remove blank lines between consecutive link reference definitions
139
+ # - `:strict` - All normalizations (same as :link_refs currently)
140
+ #
141
+ # @param rehydrate_link_references [Boolean] If true, convert inline links/images
142
+ # to reference-style when a matching link reference definition exists. Default: false
143
+ #
144
+ # @param parser_options [Hash] Additional parser-specific options
145
+ #
146
+ # @raise [TemplateParseError] If template has syntax errors
147
+ # @raise [DestinationParseError] If destination has syntax errors
148
+ def initialize(
149
+ template_content,
150
+ dest_content,
151
+ signature_generator: nil,
152
+ preference: :destination,
153
+ add_template_only_nodes: false,
154
+ inner_merge_code_blocks: false,
155
+ inner_merge_lists: false,
156
+ remove_template_missing_nodes: false,
157
+ corruption_handling: :heal,
158
+ freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN,
159
+ match_refiner: nil,
160
+ node_typing: nil,
161
+ resolution_mode: :eager,
162
+ unresolved_policy: nil,
163
+ normalize_whitespace: false,
164
+ rehydrate_link_references: false,
165
+ **parser_options
166
+ )
167
+ @preference = preference
168
+ @add_template_only_nodes = add_template_only_nodes
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
+ )
175
+ @node_typing = node_typing
176
+ @resolution_mode = resolution_mode
177
+ @unresolved_policy = Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
178
+ @normalize_whitespace = normalize_whitespace
179
+ @rehydrate_link_references = rehydrate_link_references
180
+
181
+ # Validate node_typing if provided
182
+ Ast::Merge::NodeTyping.validate!(node_typing) if node_typing
183
+ validate_resolution_mode!(resolution_mode)
184
+
185
+ # Set up code block merger
186
+ @code_block_merger = case inner_merge_code_blocks
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
209
+
210
+ # Parse template
211
+ begin
212
+ @template_analysis = create_file_analysis(
213
+ template_content,
214
+ freeze_token: freeze_token,
215
+ signature_generator: signature_generator,
216
+ **parser_options
217
+ )
218
+ rescue StandardError => e
219
+ raise template_parse_error_class.new(errors: [e])
220
+ end
221
+
222
+ # Parse destination
223
+ begin
224
+ @dest_analysis = create_file_analysis(
225
+ dest_content,
226
+ freeze_token: freeze_token,
227
+ signature_generator: signature_generator,
228
+ **parser_options
229
+ )
230
+ rescue StandardError => e
231
+ raise destination_parse_error_class.new(errors: [e])
232
+ end
233
+
234
+ @aligner = FileAligner.new(@template_analysis, @dest_analysis, match_refiner: @match_refiner)
235
+ @resolver = ConflictResolver.new(
236
+ preference: @preference,
237
+ template_analysis: @template_analysis,
238
+ dest_analysis: @dest_analysis,
239
+ resolution_mode: @resolution_mode,
240
+ unresolved_policy: @unresolved_policy
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)
254
+ end
255
+
256
+ # Create a FileAnalysis instance for the given content.
257
+ #
258
+ # @abstract Subclasses must implement this method
259
+ # @param content [String] Markdown content to analyze
260
+ # @param options [Hash] Analysis options
261
+ # @return [FileAnalysisBase] File analysis instance
262
+ def create_file_analysis(content, **options)
263
+ raise NotImplementedError, "#{self.class} must implement #create_file_analysis"
264
+ end
265
+
266
+ # Returns the TemplateParseError class to use.
267
+ #
268
+ # Subclasses should override to return their parser-specific error class.
269
+ #
270
+ # @return [Class] TemplateParseError class
271
+ def template_parse_error_class
272
+ TemplateParseError
273
+ end
274
+
275
+ # Returns the DestinationParseError class to use.
276
+ #
277
+ # Subclasses should override to return their parser-specific error class.
278
+ #
279
+ # @return [Class] DestinationParseError class
280
+ def destination_parse_error_class
281
+ DestinationParseError
282
+ end
283
+
284
+ # Perform the merge operation and return the merged content as a string.
285
+ #
286
+ # @return [String] The merged Markdown content
287
+ def merge
288
+ merge_result.content
289
+ end
290
+
291
+ # Perform the merge operation and return the full MergeResult object.
292
+ #
293
+ # @return [MergeResult] The merge result containing merged content and metadata
294
+ def merge_result
295
+ return @merge_result if @merge_result
296
+
297
+ @merge_result = DebugLogger.time('SmartMergerBase#merge') do
298
+ prepare_runtime_session!
299
+ alignment = DebugLogger.time('SmartMergerBase#align') do
300
+ @aligner.align
301
+ end
302
+
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
+ })
309
+
310
+ # Process alignment using OutputBuilder
311
+ builder, stats, frozen_blocks, conflicts, unresolved_cases = DebugLogger.time('SmartMergerBase#process') do
312
+ process_alignment(alignment)
313
+ end
314
+
315
+ # Get content from OutputBuilder
316
+ raw_content = builder.to_s
317
+ content = raw_content
318
+
319
+ # Collect problems from post-processing
320
+ problems = DocumentProblems.new
321
+
322
+ # Apply post-processing transformations
323
+ content, problems = apply_post_processing(content, problems)
324
+ complete_runtime_session!(content: content, stats: stats, problems: problems,
325
+ unresolved_cases: unresolved_cases)
326
+
327
+ # Get final content from OutputBuilder
328
+ MergeResult.new(
329
+ content: content,
330
+ raw_content: raw_content,
331
+ conflicts: conflicts,
332
+ frozen_blocks: frozen_blocks,
333
+ stats: stats,
334
+ problems: problems,
335
+ unresolved_cases: unresolved_cases
336
+ )
337
+ end
338
+ end
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
+
374
+ # Get merge statistics (convenience method).
375
+ #
376
+ # @return [Hash] Statistics from the merge result
377
+ def stats
378
+ merge_result.stats
379
+ end
380
+
381
+ private
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
+
524
+ # Apply post-processing transformations to merged content.
525
+ #
526
+ # @param content [String] The merged content
527
+ # @param problems [DocumentProblems] Problems collector to add to
528
+ # @return [Array<String, DocumentProblems>] [transformed_content, problems]
529
+ def apply_post_processing(content, problems)
530
+ content = collapse_cross_source_preamble_prefixes(content)
531
+
532
+ # Apply whitespace normalization if enabled
533
+ if @normalize_whitespace
534
+ # Support both boolean and symbol modes
535
+ mode = @normalize_whitespace == true ? :basic : @normalize_whitespace
536
+ normalizer = WhitespaceNormalizer.new(content, mode: mode)
537
+ content = normalizer.normalize
538
+ problems.merge!(normalizer.problems)
539
+ end
540
+
541
+ # Apply link reference rehydration if enabled
542
+ if @rehydrate_link_references
543
+ rehydrator = LinkReferenceRehydrator.new(content)
544
+ content = rehydrator.rehydrate
545
+ problems.merge!(rehydrator.problems)
546
+ end
547
+
548
+ [content, problems]
549
+ end
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
+
610
+ # Process alignment entries and build result using OutputBuilder
611
+ #
612
+ # @param alignment [Array<Hash>] Alignment entries
613
+ # @return [Array] [output_builder, stats, frozen_blocks, conflicts, unresolved_cases]
614
+ def process_alignment(alignment)
615
+ builder = OutputBuilder.new
616
+ frozen_blocks = []
617
+ conflicts = []
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
623
+
624
+ alignment.each_with_index do |entry, index|
625
+ case entry[:type]
626
+ when :match
627
+ preserve_removed_separator_gap = false
628
+ frozen = process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
629
+ frozen_blocks << frozen if frozen
630
+ when :template_only
631
+ preserve_removed_separator_gap = false
632
+ process_template_only_to_builder(entry, builder, stats)
633
+ when :dest_only
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
+ )
643
+ frozen_blocks << frozen if frozen
644
+ end
645
+ end
646
+
647
+ [builder, stats, frozen_blocks, conflicts, unresolved_cases]
648
+ end
649
+
650
+ # Process a matched node pair, adding to OutputBuilder
651
+ #
652
+ # @param entry [Hash] Alignment entry
653
+ # @param builder [OutputBuilder] Output builder to add to
654
+ # @param stats [Hash] Statistics hash to update
655
+ # @return [Hash, nil] Frozen block info if applicable
656
+ def process_match_to_builder(entry, builder, stats, conflicts, unresolved_cases)
657
+ template_node = apply_node_typing(entry[:template_node])
658
+ dest_node = apply_node_typing(entry[:dest_node])
659
+
660
+ # Try inner-merge for code blocks first
661
+ if @code_block_merger && code_block_node?(template_node) && code_block_node?(dest_node)
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)
671
+ return if inner_result
672
+ end
673
+
674
+ resolution = @resolver.resolve(
675
+ template_node,
676
+ dest_node,
677
+ template_index: entry[:template_index],
678
+ dest_index: entry[:dest_index]
679
+ )
680
+ conflicts << resolution[:conflict] if resolution[:conflict]
681
+
682
+ frozen_info = nil
683
+
684
+ # Use unwrapped node for source extraction
685
+ raw_template_node = Ast::Merge::NodeTyping.unwrap(template_node)
686
+ raw_dest_node = Ast::Merge::NodeTyping.unwrap(dest_node)
687
+
688
+ case resolution[:source]
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
699
+ stats[:nodes_modified] += 1 if resolution[:decision] != :identical
700
+ emitted_range = builder.add_node_source(raw_template_node, @template_analysis)
701
+ when :destination
702
+ if raw_dest_node.respond_to?(:freeze_node?) && raw_dest_node.freeze_node?
703
+ frozen_info = {
704
+ start_line: raw_dest_node.start_line,
705
+ end_line: raw_dest_node.end_line,
706
+ reason: raw_dest_node.reason
707
+ }
708
+ end
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)
715
+ end
716
+
717
+ frozen_info
718
+ end
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
+
785
+ # Apply node typing to a node if node_typing is configured.
786
+ #
787
+ # For markdown nodes, this supports matching by:
788
+ # 1. Node class name (standard NodeTyping behavior)
789
+ # 2. Canonical node type (e.g., :heading, :table, :paragraph)
790
+ #
791
+ # Note: Markdown nodes are pre-wrapped with canonical merge_type by
792
+ # NodeTypeNormalizer during parsing. This method allows custom node_typing
793
+ # to override or refine that canonical type.
794
+ #
795
+ # @param node [Object] The node to potentially wrap with merge_type
796
+ # @return [Object] The node, possibly wrapped with NodeTyping::Wrapper
797
+ def apply_node_typing(node)
798
+ return node unless @node_typing
799
+ return node unless node
800
+
801
+ # For markdown nodes, check if there's a custom callable for the canonical type.
802
+ # This takes precedence because nodes are pre-wrapped by NodeTypeNormalizer.
803
+ if node.respond_to?(:type)
804
+ canonical_type = node.type
805
+ callable = @node_typing[canonical_type] ||
806
+ @node_typing[canonical_type.to_s] ||
807
+ @node_typing[canonical_type.to_sym]
808
+ if callable
809
+ # Call the custom lambda - it may return a refined typed node
810
+ # or the original node unchanged
811
+ return callable.call(node)
812
+ end
813
+ end
814
+
815
+ # Fall back to standard class-name-based matching
816
+ result = Ast::Merge::NodeTyping.process(node, @node_typing)
817
+ return result if Ast::Merge::NodeTyping.typed_node?(result)
818
+
819
+ node
820
+ end
821
+
822
+ # Check if a node is a code block.
823
+ #
824
+ # @param node [Object] Node to check
825
+ # @return [Boolean] true if the node is a code block
826
+ def code_block_node?(node)
827
+ return false if node.respond_to?(:freeze_node?) && node.freeze_node?
828
+
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
876
+ end
877
+
878
+ # Try to inner-merge two code block nodes, adding to OutputBuilder
879
+ #
880
+ # @param template_node [Object] Template code block
881
+ # @param dest_node [Object] Destination code block
882
+ # @param builder [OutputBuilder] Output builder to add to
883
+ # @param stats [Hash] Statistics hash to update
884
+ # @return [Boolean] true if merged, false to fall back to standard resolution
885
+ def try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats, conflicts, unresolved_cases)
886
+ result = @code_block_merger.merge_code_blocks(
887
+ template_node,
888
+ dest_node,
889
+ preference: @preference,
890
+ runtime_session: @runtime_session,
891
+ parent_operation: @runtime_root_operation,
892
+ add_template_only_nodes: @add_template_only_nodes,
893
+ resolution_mode: @resolution_mode,
894
+ unresolved_policy: @unresolved_policy
895
+ )
896
+
897
+ if result[:merged]
898
+ stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
899
+ stats[:inner_merges] ||= 0
900
+ stats[:inner_merges] += 1
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) })
911
+ true
912
+ else
913
+ DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
914
+ false # Fall back to standard resolution
915
+ end
916
+ end
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
+
976
+ # Try to inner-merge two code block nodes.
977
+ #
978
+ # @deprecated Use try_inner_merge_code_block_to_builder instead
979
+ # @param template_node [Object] Template code block
980
+ # @param dest_node [Object] Destination code block
981
+ # @param stats [Hash] Statistics hash to update
982
+ # @return [Array, nil] [content_string, nil] if merged, nil to fall back to standard resolution
983
+ def try_inner_merge_code_block(template_node, dest_node, stats)
984
+ result = @code_block_merger.merge_code_blocks(
985
+ template_node,
986
+ dest_node,
987
+ preference: @preference,
988
+ add_template_only_nodes: @add_template_only_nodes
989
+ )
990
+
991
+ if result[:merged]
992
+ stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
993
+ stats[:inner_merges] ||= 0
994
+ stats[:inner_merges] += 1
995
+ [result[:content], nil]
996
+ else
997
+ DebugLogger.debug('Inner-merge skipped', { reason: result[:reason] })
998
+ nil # Fall back to standard resolution
999
+ end
1000
+ end
1001
+
1002
+ # Process a template-only node, adding to OutputBuilder
1003
+ #
1004
+ # @param entry [Hash] Alignment entry
1005
+ # @param builder [OutputBuilder] Output builder to add to
1006
+ # @param stats [Hash] Statistics hash to update
1007
+ # @return [void]
1008
+ def process_template_only_to_builder(entry, builder, stats)
1009
+ return unless should_add_template_only_node?(entry)
1010
+
1011
+ stats[:nodes_added] += 1
1012
+ builder.add_node_source(entry[:template_node], @template_analysis)
1013
+ end
1014
+
1015
+ # Determine if a template-only node should be added.
1016
+ #
1017
+ # Gap lines (blank lines/whitespace) represent formatting. Document-trailing gap lines
1018
+ # (at the very end with no more content after them) follow preference. Other gap lines
1019
+ # Determine if a template-only node should be added.
1020
+ #
1021
+ # Gap lines (blank lines) and all other nodes follow the add_template_only_nodes setting.
1022
+ # When false (default), template-only content is skipped.
1023
+ # When true, all template-only content including gap lines is included.
1024
+ #
1025
+ # @param entry [Hash] Alignment entry with :template_node and :signature
1026
+ # @return [Boolean] true if the node should be added
1027
+ def should_add_template_only_node?(entry)
1028
+ node = entry[:template_node]
1029
+
1030
+ case @add_template_only_nodes
1031
+ when false, nil
1032
+ false
1033
+ when true
1034
+ true
1035
+ else
1036
+ # Callable filter
1037
+ if @add_template_only_nodes.respond_to?(:call)
1038
+ @add_template_only_nodes.call(node, entry)
1039
+ else
1040
+ true
1041
+ end
1042
+ end
1043
+ end
1044
+
1045
+ # Process a destination-only node, adding to OutputBuilder.
1046
+ #
1047
+ # All dest-only nodes are included, including gap lines (formatting).
1048
+ #
1049
+ # @param entry [Hash] Alignment entry
1050
+ # @param builder [OutputBuilder] Output builder to add to
1051
+ # @param stats [Hash] Statistics hash to update
1052
+ # @return [Hash, nil] Frozen block info if applicable
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)
1055
+ node = entry[:dest_node]
1056
+
1057
+ frozen_info = nil
1058
+
1059
+ if node.respond_to?(:freeze_node?) && node.freeze_node?
1060
+ frozen_info = {
1061
+ start_line: node.start_line,
1062
+ end_line: node.end_line,
1063
+ reason: node.reason
1064
+ }
1065
+ end
1066
+
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)
1423
+ end
1424
+
1425
+ # Convert a node to its source text.
1426
+ #
1427
+ # Default implementation uses source positions and falls back to to_commonmark.
1428
+ # Subclasses may override for parser-specific behavior.
1429
+ #
1430
+ # @param node [Object] Node to convert
1431
+ # @param analysis [FileAnalysisBase] Analysis for source lookup
1432
+ # @return [String] Source text
1433
+ def node_to_source(node, analysis)
1434
+ # Check for any FreezeNode type (base class or subclass)
1435
+ if node.is_a?(Ast::Merge::FreezeNodeBase)
1436
+ node.full_text
1437
+ else
1438
+ pos = node.source_position
1439
+ start_line = pos&.dig(:start_line)
1440
+ end_line = pos&.dig(:end_line)
1441
+
1442
+ return node.to_commonmark unless start_line && end_line
1443
+
1444
+ analysis.source_range(start_line, end_line)
1445
+ end
1446
+ end
1447
+
1448
+ # Check if a gap line is document-trailing (no more content after it).
1449
+ #
1450
+ # A gap line is document-trailing if there are no more content nodes after it
1451
+ # in the statements list. We check all siblings after this gap line - if they're
1452
+ # all gap lines (no content), then this is document-trailing.
1453
+ #
1454
+ # @param gap_line [GapLineNode] The gap line to check
1455
+ # @param analysis [FileAnalysisBase] The analysis containing the gap line
1456
+ # @return [Boolean] true if the gap line is document-trailing
1457
+ def gap_line_is_document_trailing?(gap_line, analysis)
1458
+ # Find this gap line's index in the statements
1459
+ statements = analysis.statements
1460
+ gap_index = statements.index(gap_line)
1461
+
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
+ })
1467
+
1468
+ return true if gap_index.nil? # Shouldn't happen, but treat as trailing if missing
1469
+
1470
+ # Check all statements after this gap line
1471
+ # If they're ALL gap lines (no content nodes), then this is document-trailing
1472
+ (gap_index + 1...statements.length).each do |i|
1473
+ node = statements[i]
1474
+ # If we find a non-gap-line node, this gap line is NOT document-trailing
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
1482
+ end
1483
+
1484
+ # All remaining nodes are gap lines (or no nodes after), so this is document-trailing
1485
+ DebugLogger.debug('Gap line IS document-trailing - no content after it')
1486
+ true
1487
+ end
1488
+ end
1489
+ end
1490
+ end