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
@@ -30,9 +30,25 @@ module Markdown
30
30
  # )
31
31
  #
32
32
  class PartialTemplateMerger < Ast::Merge::PartialTemplateMergerBase
33
+ include PreservationSupport
34
+
33
35
  # Re-export Result class from base for convenience
34
36
  Result = Ast::Merge::PartialTemplateMergerBase::Result
35
37
 
38
+ class << self
39
+ def default_backend
40
+ :markly
41
+ end
42
+
43
+ def file_analysis_class
44
+ FileAnalysis
45
+ end
46
+
47
+ def smart_merger_class
48
+ SmartMerger
49
+ end
50
+ end
51
+
36
52
  # @return [Symbol] Backend to use (:markly, :commonmarker)
37
53
  attr_reader :backend
38
54
 
@@ -57,7 +73,7 @@ module Markdown
57
73
  destination:,
58
74
  anchor:,
59
75
  boundary: nil,
60
- backend: :markly,
76
+ backend: self.class.default_backend,
61
77
  preference: :template,
62
78
  add_missing: true,
63
79
  when_missing: :skip,
@@ -117,7 +133,7 @@ module Markdown
117
133
  changed: result.changed,
118
134
  stats: result.stats.merge(problems: problems.all),
119
135
  injection_point: result.injection_point,
120
- message: result.message,
136
+ message: result.message
121
137
  )
122
138
  else
123
139
  result
@@ -126,15 +142,37 @@ module Markdown
126
142
 
127
143
  protected
128
144
 
145
+ def merge_section_content(section_content, section_context: nil)
146
+ return super unless replace_mode?
147
+
148
+ template_analysis = create_analysis(template)
149
+ preserved_fragment_insertions = preserved_destination_insertions(
150
+ create_analysis(section_content),
151
+ template_analysis,
152
+ source_remove_plan: section_context&.fetch(:source_remove_plan, nil),
153
+ destination_section_statements: section_context&.fetch(:section_statements, nil),
154
+ destination_section_analysis: section_context&.fetch(:analysis, nil)
155
+ )
156
+
157
+ return [template, { mode: :replace }] if preserved_fragment_insertions.empty?
158
+
159
+ preservation_stats = replace_mode_preservation_stats(preserved_fragment_insertions)
160
+
161
+ [
162
+ render_template_with_preserved_insertions(template_analysis, preserved_fragment_insertions),
163
+ preservation_stats
164
+ ]
165
+ end
166
+
129
167
  # Validate the backend parameter.
130
168
  #
131
169
  # @param backend [Symbol] The backend to validate
132
170
  # @raise [ArgumentError] If backend is not supported
133
171
  def validate_backend!(backend)
134
- valid_backends = [:markly, :commonmarker]
172
+ valid_backends = %i[auto markly commonmarker kramdown]
135
173
  return if valid_backends.include?(backend.to_sym)
136
174
 
137
- raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(", ")}"
175
+ raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(', ')}"
138
176
  end
139
177
 
140
178
  # Create a FileAnalysis for the given content.
@@ -142,7 +180,7 @@ module Markdown
142
180
  # @param content [String] The content to analyze
143
181
  # @return [FileAnalysis] A FileAnalysis instance
144
182
  def create_analysis(content)
145
- FileAnalysis.new(content, backend: backend)
183
+ self.class.file_analysis_class.new(content, backend: backend)
146
184
  end
147
185
 
148
186
  # Create a SmartMerger for merging the section.
@@ -155,7 +193,7 @@ module Markdown
155
193
  options = {
156
194
  preference: preference,
157
195
  add_template_only_nodes: add_missing,
158
- backend: backend,
196
+ backend: backend
159
197
  }
160
198
 
161
199
  # Use custom signature generator if provided, otherwise use position-based
@@ -166,7 +204,7 @@ module Markdown
166
204
  options[:node_typing] = node_typing if node_typing
167
205
  options[:match_refiner] = match_refiner if match_refiner
168
206
 
169
- SmartMerger.new(template_content, destination_content, **options)
207
+ self.class.smart_merger_class.new(template_content, destination_content, **options)
170
208
  end
171
209
 
172
210
  # Build a signature generator that uses type-based matching for tables.
@@ -199,10 +237,10 @@ module Markdown
199
237
  # provide a custom signature_generator.
200
238
  lambda do |node|
201
239
  type_str = node.type.to_s
202
- if type_str == "table"
240
+ if type_str == 'table'
203
241
  # All tables within a section merge get the same signature.
204
242
  # This ensures template table replaces destination table.
205
- [:table, :section_table]
243
+ %i[table section_table]
206
244
  else
207
245
  # Return node for default signature computation
208
246
  node
@@ -236,12 +274,12 @@ module Markdown
236
274
 
237
275
  ((anchor.index + 1)...statements.length).each do |idx|
238
276
  stmt = statements[idx]
239
- if heading_type?(stmt.type)
240
- stmt_level = get_heading_level(stmt)
241
- if stmt_level && anchor_level && stmt_level <= anchor_level
242
- # Found next heading of same or higher level - section ends before it
243
- return idx - 1
244
- end
277
+ next unless heading_type?(stmt.type)
278
+
279
+ stmt_level = get_heading_level(stmt)
280
+ if stmt_level && anchor_level && stmt_level <= anchor_level
281
+ # Found next heading of same or higher level - section ends before it
282
+ return idx - 1
245
283
  end
246
284
  end
247
285
 
@@ -250,16 +288,12 @@ module Markdown
250
288
  end
251
289
 
252
290
  # For non-headings, use boundary if specified and found
253
- if injection_point.boundary
254
- return injection_point.boundary.index - 1
255
- end
291
+ return injection_point.boundary.index - 1 if injection_point.boundary
256
292
 
257
293
  # Otherwise, find next node of same type
258
294
  ((anchor.index + 1)...statements.length).each do |idx|
259
295
  stmt = statements[idx]
260
- if stmt.type == anchor_type
261
- return idx - 1
262
- end
296
+ return idx - 1 if stmt.type == anchor_type
263
297
  end
264
298
 
265
299
  # Section extends to end of document
@@ -274,12 +308,10 @@ module Markdown
274
308
  # @param node [Object] The node to convert
275
309
  # @param analysis [FileAnalysis, nil] The analysis object for source lookup
276
310
  # @return [String] The source text
277
- def node_to_text(node, analysis = nil)
311
+ def node_to_source(node, analysis = nil)
278
312
  # Unwrap if needed
279
313
  inner = node
280
- while inner.respond_to?(:inner_node) && inner.inner_node != inner
281
- inner = inner.inner_node
282
- end
314
+ inner = inner.inner_node while inner.respond_to?(:inner_node) && inner.inner_node != inner
283
315
 
284
316
  # Prefer source-based extraction to preserve original formatting
285
317
  # (link references, table padding, etc.)
@@ -302,18 +334,207 @@ module Markdown
302
334
  elsif inner.respond_to?(:to_s)
303
335
  inner.to_s
304
336
  else
305
- ""
337
+ ''
306
338
  end
307
339
  end
308
340
 
341
+ alias node_to_text node_to_source
342
+
309
343
  private
310
344
 
345
+ def replace_mode_preservation_stats(insertions)
346
+ comment_count = 0
347
+ link_definition_count = 0
348
+
349
+ insertions.each_value do |fragments|
350
+ fragments.each do |fragment|
351
+ case fragment[:kind]
352
+ when :standalone_comment
353
+ comment_count += 1
354
+ when :link_definition
355
+ link_definition_count += 1
356
+ end
357
+ end
358
+ end
359
+
360
+ {
361
+ mode: :replace,
362
+ preserved_destination_comment_fragments: comment_count,
363
+ preserved_destination_link_definitions: link_definition_count
364
+ }.reject { |_key, value| value == 0 }
365
+ end
366
+
367
+ def preserved_destination_insertions(destination_analysis, template_analysis, source_remove_plan: nil,
368
+ destination_section_statements: nil, destination_section_analysis: nil)
369
+ insertions = Hash.new { |hash, key| hash[key] = [] }
370
+ remove_plan_owned_comment_region_keys = if source_remove_plan
371
+ rebase_preserved_comment_keys(
372
+ remove_plan_preserved_comment_keys(source_remove_plan),
373
+ line_offset: source_remove_plan.remove_start_line - 1
374
+ )
375
+ else
376
+ Set.new
377
+ end
378
+ template_has_standalone_comments = template_analysis.statements.any? do |statement|
379
+ standalone_comment_node?(statement, template_analysis)
380
+ end
381
+ template_link_definition_signatures = template_analysis.statements.each_with_object(Set.new) do |statement, signatures|
382
+ signatures << statement.signature if link_definition_node?(statement)
383
+ end
384
+
385
+ preserve_comment_insertions_from_remove_plan(
386
+ insertions,
387
+ source_remove_plan,
388
+ destination_section_statements,
389
+ destination_section_analysis || destination_analysis,
390
+ template_has_standalone_comments: template_has_standalone_comments
391
+ )
392
+
393
+ structural_index = 0
394
+ pending_gap_count = 0
395
+
396
+ destination_analysis.statements.each do |statement|
397
+ if structural_preservation_statement?(statement, destination_analysis)
398
+ structural_index += 1
399
+ pending_gap_count = 0
400
+ elsif gap_line_node?(statement)
401
+ pending_gap_count += 1 if insertions[structural_index].any?
402
+ else
403
+ next if remove_plan_owns_comment_node?(
404
+ statement,
405
+ destination_analysis,
406
+ source_remove_plan,
407
+ preserved_comment_keys: remove_plan_owned_comment_region_keys
408
+ )
409
+
410
+ fragment = preserved_fragment_for_node(
411
+ statement,
412
+ destination_analysis,
413
+ template_has_standalone_comments: template_has_standalone_comments,
414
+ template_link_definition_signatures: template_link_definition_signatures
415
+ )
416
+ next unless fragment
417
+
418
+ append_preserved_fragment(insertions, structural_index, fragment, gap_count: pending_gap_count)
419
+ pending_gap_count = 0
420
+ end
421
+ end
422
+
423
+ insertions.reject { |_index, fragments| fragments.empty? }
424
+ end
425
+
426
+ def preserve_comment_insertions_from_remove_plan(insertions, source_remove_plan, destination_section_statements,
427
+ destination_section_analysis, template_has_standalone_comments:)
428
+ return if template_has_standalone_comments
429
+ return unless source_remove_plan && destination_section_statements && destination_section_analysis
430
+
431
+ structural_index_by_owner, final_structural_index = structural_index_lookup(destination_section_statements,
432
+ destination_section_analysis)
433
+ remove_plan_comment_insertion_specs(
434
+ source_remove_plan,
435
+ insertion_index_by_owner: structural_index_by_owner,
436
+ final_insertion_index: final_structural_index
437
+ ).each do |spec|
438
+ append_preserved_fragment(
439
+ insertions,
440
+ spec.fetch(:insertion_index),
441
+ spec.fetch(:fragment),
442
+ gap_count: spec.fetch(:gap_count)
443
+ )
444
+ end
445
+ end
446
+
447
+ def structural_index_lookup(statements, analysis)
448
+ structural_index = 0
449
+ lookup = {}
450
+
451
+ Array(statements).each do |statement|
452
+ owner = statement.respond_to?(:node) ? statement.node : statement
453
+ lookup[attachment_owner_key(owner)] = structural_index
454
+
455
+ next unless structural_preservation_statement?(statement, analysis)
456
+
457
+ structural_index += 1
458
+ end
459
+
460
+ [lookup, structural_index]
461
+ end
462
+
463
+ def append_preserved_fragment(insertions, structural_index, fragment, gap_count: 0)
464
+ if insertions[structural_index].any?
465
+ fragment[:separator] = preserved_fragment_separator(
466
+ gap_count: gap_count,
467
+ previous_kind: insertions[structural_index].last.fetch(:kind),
468
+ current_kind: fragment.fetch(:kind)
469
+ )
470
+ end
471
+
472
+ insertions[structural_index] << fragment
473
+ end
474
+
475
+ def render_template_with_preserved_insertions(template_analysis, insertions)
476
+ result = +''
477
+ structural_index = 0
478
+ statements = template_analysis.statements
479
+ index = 0
480
+
481
+ while index < statements.length
482
+ statement = statements[index]
483
+
484
+ if gap_line_node?(statement) && insertions.key?(structural_index) && next_structural_statement_after?(
485
+ statements, index, template_analysis
486
+ )
487
+ index += 1 while index < statements.length && gap_line_node?(statements[index])
488
+ result = append_preserved_fragments(result, insertions.delete(structural_index))
489
+ next
490
+ end
491
+
492
+ result << node_to_source(statement, template_analysis)
493
+ structural_index += 1 if structural_preservation_statement?(statement, template_analysis)
494
+ index += 1
495
+ end
496
+
497
+ if insertions.key?(structural_index)
498
+ result = append_preserved_fragments(result, insertions.delete(structural_index))
499
+ end
500
+
501
+ result
502
+ end
503
+
504
+ def append_preserved_fragments(content, fragments)
505
+ result = content.sub(/\n+\z/, "\n")
506
+
507
+ unless result.empty?
508
+ if result.end_with?("\n")
509
+ result << "\n" unless result.end_with?("\n\n")
510
+ else
511
+ result << "\n\n"
512
+ end
513
+ end
514
+
515
+ result << render_preserved_fragment_block(fragments)
516
+ result << "\n"
517
+ result << "\n" unless result.end_with?("\n\n")
518
+ result
519
+ end
520
+
521
+ def render_preserved_fragment_block(fragments)
522
+ fragments.each_with_object(+'').with_index do |(fragment, result), index|
523
+ result << fragment[:separator] if index.positive?
524
+ result << fragment.fetch(:text)
525
+ end
526
+ end
527
+
528
+ def next_structural_statement_after?(statements, start_index, analysis)
529
+ statements[(start_index + 1)..]&.any? { |statement| structural_preservation_statement?(statement, analysis) }
530
+ end
531
+
311
532
  # Check if a type represents a heading node.
312
533
  #
313
534
  # @param type [Symbol, String] The node type
314
535
  # @return [Boolean] true if this is a heading type
315
536
  def heading_type?(type)
316
- type.to_s == "heading" || type == :heading || type == :header
537
+ type.to_s == 'heading' || type == :heading || type == :header
317
538
  end
318
539
 
319
540
  # Get the heading level from a statement.
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Shared Markdown-local helper methods for statement classification,
6
+ # standalone comment fragment preservation, and remove-plan-backed range filtering.
7
+ #
8
+ # Provides the canonical gap-line / blank-gap-line / non-blank-gap-line /
9
+ # structural-for-preservation predicate set used by both SmartMergerBase
10
+ # (removal mode) and PartialTemplateMerger (replace-mode insertion indexing).
11
+ # Both classes should call these predicates rather than performing direct
12
+ # is_a?(GapLineNode) checks.
13
+ module PreservationSupport
14
+ private
15
+
16
+ def normalized_preserved_fragment_text(text)
17
+ text.to_s.sub(/\n+\z/, '')
18
+ end
19
+
20
+ def standalone_comment_text?(text)
21
+ normalized_preserved_fragment_text(text).strip.match?(CommentTracker::STANDALONE_HTML_COMMENT_REGEX)
22
+ end
23
+
24
+ def standalone_comment_node?(node, analysis)
25
+ return false unless node.respond_to?(:source_position)
26
+ return false unless analysis.respond_to?(:comment_tracker)
27
+ return false unless analysis.respond_to?(:comment_node_at)
28
+ return false unless analysis.comment_tracker
29
+
30
+ pos = node.source_position
31
+ start_line = pos&.dig(:start_line)
32
+ end_line = pos&.dig(:end_line)
33
+ return false unless start_line && end_line
34
+ return false unless start_line == end_line
35
+
36
+ !!analysis.comment_node_at(start_line)
37
+ end
38
+
39
+ def link_definition_node?(node)
40
+ node.is_a?(LinkDefinitionNode) || (node.respond_to?(:merge_type) && node.merge_type == :link_definition)
41
+ end
42
+
43
+ def gap_line_node?(node)
44
+ node.is_a?(GapLineNode) ||
45
+ (node.respond_to?(:merge_type) && node.merge_type == :gap_line) ||
46
+ (node.respond_to?(:type) && node.type == :gap_line)
47
+ end
48
+
49
+ def blank_gap_line_node?(node)
50
+ return false unless gap_line_node?(node)
51
+ return node.blank? if node.respond_to?(:blank?)
52
+
53
+ text = if node.respond_to?(:text)
54
+ node.text
55
+ elsif node.respond_to?(:content)
56
+ node.content
57
+ else
58
+ ''
59
+ end
60
+
61
+ text.to_s.strip.empty?
62
+ end
63
+
64
+ # Returns true when the node is a gap line whose content is non-blank
65
+ # (e.g. a consumed link-reference definition or similar inline content
66
+ # that was mapped to a GapLineNode).
67
+ #
68
+ # This is the canonical replacement for `node.is_a?(GapLineNode) && !node.blank?`
69
+ # and handles wrapped gap-line statement nodes uniformly alongside real
70
+ # GapLineNode instances.
71
+ #
72
+ # @param node [Object] The node to classify
73
+ # @return [Boolean] true when the node is a gap line with non-blank content
74
+ def non_blank_gap_line_node?(node)
75
+ gap_line_node?(node) && !blank_gap_line_node?(node)
76
+ end
77
+
78
+ def structural_preservation_statement?(statement, analysis)
79
+ !gap_line_node?(statement) &&
80
+ !standalone_comment_node?(statement, analysis) &&
81
+ !link_definition_node?(statement)
82
+ end
83
+
84
+ def standalone_comment_region?(region)
85
+ standalone_comment_text?(region.respond_to?(:text) ? region.text : nil)
86
+ end
87
+
88
+ def preserved_comment_region_key(region)
89
+ [
90
+ region.respond_to?(:start_line) ? region.start_line : nil,
91
+ region.respond_to?(:end_line) ? region.end_line : nil,
92
+ normalized_preserved_fragment_text(region.respond_to?(:text) ? region.text : nil)
93
+ ]
94
+ end
95
+
96
+ def preserved_comment_node_key(node, analysis, text: nil)
97
+ pos = node.respond_to?(:source_position) ? node.source_position : nil
98
+
99
+ [
100
+ pos&.dig(:start_line),
101
+ pos&.dig(:end_line),
102
+ normalized_preserved_fragment_text(text || source_text_for_preserved_node(node, analysis))
103
+ ]
104
+ end
105
+
106
+ def region_within_removed_range?(region, remove_plan)
107
+ return false unless region
108
+
109
+ start_line = region.respond_to?(:start_line) ? region.start_line : nil
110
+ end_line = region.respond_to?(:end_line) ? region.end_line : nil
111
+ return true unless start_line && end_line
112
+
113
+ start_line >= remove_plan.remove_start_line && end_line <= remove_plan.remove_end_line
114
+ end
115
+
116
+ def remove_plan_preserved_comment_regions(remove_plan)
117
+ return [] unless remove_plan
118
+
119
+ regions = Array(remove_plan.promoted_comment_regions)
120
+ regions << remove_plan.trailing_boundary&.comment_attachment&.leading_region
121
+
122
+ seen = Set.new
123
+ regions.each_with_object([]) do |region, preserved_regions|
124
+ next unless standalone_comment_region?(region)
125
+ next unless region_within_removed_range?(region, remove_plan)
126
+
127
+ region_key = preserved_comment_region_key(region)
128
+ next if seen.include?(region_key)
129
+
130
+ seen << region_key
131
+ preserved_regions << region
132
+ end
133
+ end
134
+
135
+ def remove_plan_preserved_comment_keys(remove_plan)
136
+ remove_plan_preserved_comment_regions(remove_plan).each_with_object(Set.new) do |region, keys|
137
+ keys << preserved_comment_region_key(region)
138
+ end
139
+ end
140
+
141
+ def rebase_preserved_comment_keys(keys, line_offset:)
142
+ Array(keys).each_with_object(Set.new) do |key, rebased_keys|
143
+ start_line, end_line, text = Array(key)
144
+ rebased_keys << [
145
+ start_line ? start_line - line_offset : nil,
146
+ end_line ? end_line - line_offset : nil,
147
+ text
148
+ ]
149
+ end
150
+ end
151
+
152
+ def remove_plan_owns_comment_node?(node, analysis, remove_plan, preserved_comment_keys: nil)
153
+ return false unless remove_plan
154
+ return false unless standalone_comment_node?(node, analysis)
155
+
156
+ keys = preserved_comment_keys || remove_plan_preserved_comment_keys(remove_plan)
157
+ region = comment_region_for_node(node, analysis, kind: :orphan)
158
+ return false unless region_within_removed_range?(region, remove_plan)
159
+
160
+ keys.include?(preserved_comment_region_key(region))
161
+ end
162
+
163
+ def remove_plan_preserved_comment_keys_for_nodes(remove_plan, nodes:, analysis:)
164
+ keys = remove_plan_preserved_comment_keys(remove_plan)
165
+
166
+ Array(nodes).each do |node|
167
+ next unless standalone_comment_node?(node, analysis)
168
+
169
+ region = comment_region_for_node(node, analysis, kind: :orphan)
170
+ next unless region_within_removed_range?(region, remove_plan)
171
+
172
+ keys << preserved_comment_node_key(node, analysis)
173
+ end
174
+
175
+ keys
176
+ end
177
+
178
+ def remove_plan_comment_insertion_specs(remove_plan, insertion_index_by_owner:, final_insertion_index:)
179
+ return [] unless remove_plan
180
+
181
+ allowed_region_keys = remove_plan_preserved_comment_keys(remove_plan)
182
+ seen_region_keys = Set.new
183
+
184
+ Array(remove_plan.removed_attachments).each_with_object([]) do |attachment, specs|
185
+ append_remove_plan_comment_insertion_spec(
186
+ specs,
187
+ region: attachment.respond_to?(:leading_region) ? attachment.leading_region : nil,
188
+ insertion_index: insertion_index_by_owner[attachment_owner_key(attachment)],
189
+ gap_count: blank_gap_count(attachment.respond_to?(:leading_gap) ? attachment.leading_gap : nil),
190
+ allowed_region_keys: allowed_region_keys,
191
+ seen_region_keys: seen_region_keys
192
+ )
193
+ end.tap do |specs|
194
+ append_remove_plan_comment_insertion_spec(
195
+ specs,
196
+ region: remove_plan.trailing_boundary&.comment_attachment&.leading_region,
197
+ insertion_index: final_insertion_index,
198
+ gap_count: blank_gap_count(remove_plan.trailing_boundary&.comment_attachment&.leading_gap),
199
+ allowed_region_keys: allowed_region_keys,
200
+ seen_region_keys: seen_region_keys
201
+ )
202
+ end
203
+ end
204
+
205
+ def comment_region_for_node(node, analysis, kind: :orphan, full_line_only: true)
206
+ return unless node.respond_to?(:source_position)
207
+ return unless analysis.respond_to?(:comment_region_for_range)
208
+
209
+ pos = node.source_position
210
+ start_line = pos&.dig(:start_line)
211
+ end_line = pos&.dig(:end_line)
212
+ return unless start_line && end_line
213
+
214
+ analysis.comment_region_for_range(start_line..end_line, kind: kind, full_line_only: full_line_only)
215
+ end
216
+
217
+ def preserved_fragment_for_node(
218
+ node,
219
+ analysis,
220
+ template_has_standalone_comments:,
221
+ template_link_definition_signatures:
222
+ )
223
+ if standalone_comment_node?(node, analysis)
224
+ return if template_has_standalone_comments
225
+
226
+ {
227
+ kind: :standalone_comment,
228
+ text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
229
+ }
230
+ elsif link_definition_node?(node)
231
+ return if template_link_definition_signatures.include?(node.signature)
232
+
233
+ {
234
+ kind: :link_definition,
235
+ text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
236
+ }
237
+ end
238
+ end
239
+
240
+ def preserved_fragment_separator(gap_count:, previous_kind:, current_kind:)
241
+ return "\n\n" if gap_count.positive?
242
+ return "\n" if previous_kind == :link_definition && current_kind == :link_definition
243
+
244
+ "\n\n"
245
+ end
246
+
247
+ def blank_gap_count(gap)
248
+ return 0 unless gap&.respond_to?(:blank_line_count)
249
+
250
+ gap.blank_line_count
251
+ end
252
+
253
+ def attachment_owner_key(owner_or_attachment)
254
+ owner = if owner_or_attachment.respond_to?(:owner)
255
+ owner_or_attachment.owner
256
+ else
257
+ owner_or_attachment
258
+ end
259
+
260
+ owner&.object_id
261
+ end
262
+
263
+ def source_text_for_preserved_node(node, analysis)
264
+ if respond_to?(:node_to_source, true)
265
+ node_to_source(node, analysis)
266
+ else
267
+ ''
268
+ end
269
+ end
270
+
271
+ def append_remove_plan_comment_insertion_spec(specs, region:, insertion_index:, gap_count:, allowed_region_keys:,
272
+ seen_region_keys:)
273
+ return unless region && insertion_index
274
+
275
+ region_key = preserved_comment_region_key(region)
276
+ return unless allowed_region_keys.include?(region_key)
277
+ return if seen_region_keys.include?(region_key)
278
+
279
+ seen_region_keys << region_key
280
+ specs << {
281
+ insertion_index: insertion_index,
282
+ fragment: {
283
+ kind: :standalone_comment,
284
+ text: normalized_preserved_fragment_text(region.text)
285
+ },
286
+ gap_count: gap_count
287
+ }
288
+ end
289
+ end
290
+ end
291
+ end