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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module Markdown
4
6
  module Merge
5
7
  # Represents the result of a Markdown merge operation.
@@ -42,7 +44,7 @@ module Markdown
42
44
  # @param stats [Hash] Merge statistics
43
45
  # @param problems [DocumentProblems, nil] Document problems found
44
46
  # @param options [Hash] Additional options for forward compatibility
45
- def initialize(content:, conflicts: [], frozen_blocks: [], stats: {}, problems: nil, **options)
47
+ def initialize(content:, raw_content: nil, conflicts: [], frozen_blocks: [], stats: {}, problems: nil, **options)
46
48
  super(
47
49
  conflicts: conflicts,
48
50
  frozen_blocks: frozen_blocks,
@@ -50,6 +52,7 @@ module Markdown
50
52
  **options
51
53
  )
52
54
  @content_raw = content
55
+ @raw_content = raw_content || content
53
56
  @problems = problems || DocumentProblems.new
54
57
  end
55
58
 
@@ -136,7 +139,7 @@ module Markdown
136
139
  #
137
140
  # @return [String] Debug representation
138
141
  def inspect
139
- status = success? ? "success" : "failed"
142
+ status = success? ? 'success' : 'failed'
140
143
  "#<#{self.class.name} #{status} conflicts=#{conflicts.size} frozen=#{frozen_blocks.size} " \
141
144
  "added=#{nodes_added} removed=#{nodes_removed} modified=#{nodes_modified}>"
142
145
  end
@@ -145,11 +148,325 @@ module Markdown
145
148
  #
146
149
  # @return [String] The merged content or empty string
147
150
  def to_s
148
- content || ""
151
+ content || ''
152
+ end
153
+
154
+ def to_unresolved_review_state(selections: {}, metadata: {})
155
+ normalized_selections = normalize_unresolved_resolutions(selections)
156
+ Ast::Merge::UnresolvedReviewState.new(
157
+ cases: serializable_unresolved_cases,
158
+ selections: delegated_applied_selections.merge(normalized_selections),
159
+ metadata: review_state_metadata(metadata, normalized_selections)
160
+ )
161
+ end
162
+
163
+ def apply_unresolved_resolutions!(resolutions)
164
+ normalized = normalize_unresolved_resolutions(resolutions)
165
+ handled_case_ids = apply_grouped_delegated_resolutions!(normalized)
166
+ remaining = normalized.reject { |case_id, _| handled_case_ids.include?(case_id) }
167
+ apply_output_range_resolutions_descending!(remaining)
168
+ fallback = remaining.reject { |case_id, _| handled_case_ids.include?(case_id) }
169
+ super(fallback)
170
+ end
171
+
172
+ protected
173
+
174
+ def apply_non_provisional_unresolved_resolution!(resolution_case, selection:, selected_candidate:)
175
+ raw_output_range = resolution_case.metadata[:output_range]
176
+ if raw_output_range && @content_raw != @raw_content
177
+ raise ArgumentError,
178
+ "cannot apply non-provisional resolution for case #{resolution_case.case_id} after post-processing transformed markdown output"
179
+ end
180
+
181
+ output_range = normalize_output_range(raw_output_range)
182
+ return super unless output_range
183
+
184
+ selected_candidate = resolution_case.metadata.dig(:output_candidate_by_selection,
185
+ selection.to_sym) || selected_candidate
186
+ start_offset, end_offset = output_range
187
+ prefix = @content_raw.byteslice(0, start_offset).to_s
188
+ suffix = @content_raw.byteslice(end_offset..).to_s
189
+ @content_raw = "#{prefix}#{selected_candidate}#{suffix}"
190
+ @raw_content = @content_raw
149
191
  end
150
192
 
151
193
  private
152
194
 
195
+ SERIALIZATION_OMIT = Object.new.freeze
196
+
197
+ def apply_grouped_delegated_resolutions!(normalized_resolutions)
198
+ handled_case_ids = []
199
+ delegated_groups = @unresolved_cases
200
+ .group_by { |resolution_case| resolution_case.metadata[:delegated_apply_group] }
201
+ .reject { |group_id, _| group_id.nil? }
202
+
203
+ delegated_groups.each_value do |cases|
204
+ selected_cases = cases.select { |resolution_case| normalized_resolutions.key?(resolution_case.case_id) }
205
+ next if selected_cases.empty?
206
+
207
+ apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
208
+ handled_case_ids.concat(selected_cases.map(&:case_id))
209
+ end
210
+
211
+ handled_case_ids
212
+ end
213
+
214
+ def apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
215
+ raw_output_range = cases.map { |resolution_case| resolution_case.metadata[:output_range] }.compact.uniq.fetch(0)
216
+ if @content_raw != @raw_content
217
+ raise ArgumentError,
218
+ "cannot apply non-provisional resolution for delegated block #{cases.first.metadata[:delegated_apply_group]} after post-processing transformed markdown output"
219
+ end
220
+
221
+ output_range = normalize_output_range(raw_output_range)
222
+ renderer = cases.map do |resolution_case|
223
+ resolution_case.metadata[:delegated_apply_renderer]
224
+ end.compact.uniq.fetch(0)
225
+ prior_selections = cases.map do |resolution_case|
226
+ resolution_case.metadata[:delegated_applied_selections]
227
+ end.compact.reduce({}) do |memo, selections|
228
+ memo.merge(selections.transform_keys(&:to_s))
229
+ end
230
+ prior_root_selections = cases.map do |resolution_case|
231
+ resolution_case.metadata[:delegated_root_applied_selections]
232
+ end.compact.reduce({}) do |memo, selections|
233
+ memo.merge(selections.transform_keys(&:to_s))
234
+ end
235
+ prior_root_identities = cases.map do |resolution_case|
236
+ resolution_case.metadata[:delegated_root_case_identities]
237
+ end.compact.reduce({}) do |memo, identities|
238
+ memo.merge(identities.transform_keys(&:to_s))
239
+ end
240
+ delegated_selections = selected_cases.each_with_object(prior_selections) do |resolution_case, hash|
241
+ hash[resolution_case.metadata[:delegated_case_id]] = normalized_resolutions.fetch(resolution_case.case_id)
242
+ end
243
+ delegated_root_selections = selected_cases.each_with_object(prior_root_selections) do |resolution_case, hash|
244
+ hash[resolution_case.case_id] = normalized_resolutions.fetch(resolution_case.case_id)
245
+ end
246
+ delegated_root_identities = selected_cases.each_with_object(prior_root_identities) do |resolution_case, hash|
247
+ hash[resolution_case.case_id] = review_identity_for_case(resolution_case)
248
+ end
249
+ delegated_result = renderer.call(delegated_selections)
250
+ updated_output_range = replace_output_range!(output_range, delegated_result.fetch(:content))
251
+ previous_case_ids = cases.map(&:case_id)
252
+ @unresolved_cases.reject! { |resolution_case| previous_case_ids.include?(resolution_case.case_id) }
253
+ @conflicts.reject! { |conflict| previous_case_ids.include?(conflict[:case_id].to_s) }
254
+ remapped_cases = remap_delegated_cases_after_apply(
255
+ delegated_result[:unresolved_cases],
256
+ template_case: cases.first,
257
+ output_range: updated_output_range,
258
+ renderer: renderer,
259
+ applied_selections: delegated_selections,
260
+ root_applied_selections: delegated_root_selections,
261
+ root_case_identities: delegated_root_identities
262
+ )
263
+ remapped_cases.each { |resolution_case| add_unresolved_case(resolution_case) }
264
+ @conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
265
+ end
266
+
267
+ def apply_output_range_resolutions_descending!(normalized_resolutions)
268
+ selected_cases = @unresolved_cases.select do |resolution_case|
269
+ normalized_resolutions.key?(resolution_case.case_id) && normalize_output_range(resolution_case.metadata[:output_range])
270
+ end
271
+ selected_cases
272
+ .sort_by { |resolution_case| -normalize_output_range(resolution_case.metadata[:output_range]).first }
273
+ .each do |resolution_case|
274
+ selection = normalized_resolutions.fetch(resolution_case.case_id)
275
+ apply_unresolved_resolution!(resolution_case, selection)
276
+ normalized_resolutions.delete(resolution_case.case_id)
277
+ @conflicts.reject! { |conflict| conflict[:case_id].to_s == resolution_case.case_id }
278
+ end
279
+ selected_case_ids = selected_cases.map(&:case_id)
280
+ @unresolved_cases.reject! { |resolution_case| selected_case_ids.include?(resolution_case.case_id) }
281
+ end
282
+
283
+ def normalize_output_range(value)
284
+ range = Array(value)
285
+ return unless range.length == 2
286
+
287
+ start_offset = range[0].to_i
288
+ end_offset = range[1].to_i
289
+ return unless start_offset >= 0 && end_offset >= start_offset && @raw_content
290
+ return unless end_offset <= @raw_content.bytesize
291
+
292
+ [start_offset, end_offset]
293
+ end
294
+
295
+ def replace_output_range!(output_range, replacement)
296
+ start_offset, end_offset = output_range
297
+ prefix = @content_raw.byteslice(0, start_offset).to_s
298
+ suffix = @content_raw.byteslice(end_offset..).to_s
299
+ @content_raw = "#{prefix}#{replacement}#{suffix}"
300
+ @raw_content = @content_raw
301
+ [start_offset, start_offset + replacement.to_s.bytesize]
302
+ end
303
+
304
+ def remap_delegated_cases_after_apply(unresolved_cases, template_case:, output_range:, renderer:,
305
+ applied_selections:, root_applied_selections:, root_case_identities:)
306
+ operation_id = template_case.metadata[:delegated_runtime_operation_id]
307
+ surface_prefix = template_case.metadata[:delegated_runtime_surface_path]
308
+ delegated_group = template_case.metadata[:delegated_apply_group]
309
+
310
+ Array(unresolved_cases).map do |resolution_case|
311
+ suffix = delegated_surface_suffix_for(resolution_case.surface_path)
312
+ Ast::Merge::Runtime::ResolutionCase.new(
313
+ case_id: "#{operation_id}-#{resolution_case.case_id}",
314
+ reason: resolution_case.reason,
315
+ candidates: resolution_case.candidates,
316
+ provisional_winner: resolution_case.provisional_winner,
317
+ surface_path: [surface_prefix, suffix].compact.join(' > '),
318
+ operation_id: operation_id,
319
+ metadata: resolution_case.metadata.merge(
320
+ delegated_case_id: resolution_case.case_id,
321
+ output_range: output_range,
322
+ delegated_apply_group: delegated_group,
323
+ delegated_apply_renderer: renderer,
324
+ delegated_applied_selections: applied_selections,
325
+ delegated_root_applied_selections: root_applied_selections,
326
+ delegated_root_case_identities: root_case_identities,
327
+ delegated_runtime_operation_id: operation_id,
328
+ delegated_runtime_surface_path: surface_prefix
329
+ )
330
+ )
331
+ end
332
+ end
333
+
334
+ def delegated_surface_suffix_for(surface_path)
335
+ path = surface_path.to_s
336
+ return if path.empty? || path == 'document[0]'
337
+
338
+ path.sub(/\Adocument\[0\]\s*>\s*/, '')
339
+ end
340
+
341
+ def conflict_for_resolution_case(resolution_case)
342
+ {
343
+ case_id: resolution_case.case_id,
344
+ reason: resolution_case.reason,
345
+ template: resolution_case.candidates[:template],
346
+ destination: resolution_case.candidates[:destination],
347
+ provisional_winner: resolution_case.provisional_winner,
348
+ surface_path: resolution_case.surface_path
349
+ }
350
+ end
351
+
352
+ def serializable_unresolved_cases
353
+ @unresolved_cases.map do |resolution_case|
354
+ Ast::Merge::Runtime::ResolutionCase.new(
355
+ case_id: resolution_case.case_id,
356
+ reason: resolution_case.reason,
357
+ candidates: resolution_case.candidates,
358
+ provisional_winner: resolution_case.provisional_winner,
359
+ surface_path: resolution_case.surface_path,
360
+ operation_id: resolution_case.operation_id,
361
+ metadata: sanitize_review_state_metadata(
362
+ resolution_case.metadata.merge(review_identity: review_identity_for_case(resolution_case))
363
+ )
364
+ )
365
+ end
366
+ end
367
+
368
+ def delegated_applied_selections
369
+ @unresolved_cases.each_with_object({}) do |resolution_case, selections|
370
+ selections.merge!(resolution_case.metadata[:delegated_root_applied_selections].to_h)
371
+ end
372
+ end
373
+
374
+ def review_state_metadata(metadata, normalized_selections)
375
+ metadata_hash = super(metadata, normalized_selections)
376
+ markdown_review_state = metadata_hash.fetch(:markdown_review_state,
377
+ metadata_hash.fetch('markdown_review_state', {})).to_h
378
+ metadata_hash.merge(
379
+ markdown_review_state: markdown_review_state.merge(
380
+ selection_identities: selection_review_identities(normalized_selections)
381
+ )
382
+ )
383
+ end
384
+
385
+ def selection_review_identities(normalized_selections)
386
+ delegated_identities = @unresolved_cases.each_with_object({}) do |resolution_case, identities|
387
+ identities.merge!(resolution_case.metadata[:delegated_root_case_identities].to_h)
388
+ end
389
+
390
+ normalized_selections.each_with_object(delegated_identities) do |(case_id, _selection), identities|
391
+ current_case = unresolved_case(case_id)
392
+ identities[case_id.to_s] = review_identity_for_case(current_case) if current_case
393
+ end
394
+ end
395
+
396
+ def persisted_selection_identities(metadata)
397
+ markdown_review_state = metadata.fetch(:markdown_review_state, metadata.fetch('markdown_review_state', {})).to_h
398
+ markdown_review_state.fetch(:selection_identities, markdown_review_state.fetch('selection_identities', {})).to_h
399
+ .transform_keys(&:to_s)
400
+ end
401
+
402
+ def review_identity_for_case(resolution_case)
403
+ persisted_review_identity = case_metadata_value(resolution_case, :review_identity)
404
+ return persisted_review_identity if persisted_review_identity
405
+
406
+ Digest::SHA256.hexdigest(
407
+ [
408
+ resolution_case.surface_path,
409
+ resolution_case.reason,
410
+ resolution_case.provisional_winner,
411
+ case_metadata_value(resolution_case, :match_kind),
412
+ resolution_case.candidates[:template],
413
+ resolution_case.candidates[:destination]
414
+ ].map(&:to_s).join("\u001f")
415
+ )
416
+ end
417
+
418
+ def sanitize_review_state_metadata(value)
419
+ case value
420
+ when Hash
421
+ value.each_with_object({}) do |(key, entry), hash|
422
+ sanitized = sanitize_review_state_metadata(entry)
423
+ next if sanitized.equal?(SERIALIZATION_OMIT)
424
+
425
+ hash[key] = sanitized
426
+ end
427
+ when Array
428
+ value.filter_map do |entry|
429
+ sanitized = sanitize_review_state_metadata(entry)
430
+ sanitized unless sanitized.equal?(SERIALIZATION_OMIT)
431
+ end
432
+ else
433
+ return SERIALIZATION_OMIT if value.respond_to?(:call)
434
+
435
+ value
436
+ end
437
+ end
438
+
439
+ def validate_review_state_compatibility!(state)
440
+ super
441
+
442
+ persisted_cases = state.cases.each_with_object({}) do |resolution_case, hash|
443
+ hash[resolution_case.case_id] = resolution_case
444
+ end
445
+
446
+ state.selections.each_key do |case_id|
447
+ current_case = unresolved_case(case_id)
448
+ persisted_case = persisted_cases[case_id]
449
+ if persisted_case && !case_metadata_value(persisted_case, :review_identity) &&
450
+ (persisted_case.surface_path != current_case.surface_path ||
451
+ case_metadata_value(persisted_case, :match_kind) != case_metadata_value(current_case, :match_kind))
452
+ raise ArgumentError,
453
+ "cannot apply markdown review state: case #{case_id} no longer matches the current unresolved markdown surface"
454
+ end
455
+
456
+ next unless current_case&.metadata&.[](:output_range)
457
+ next if @content_raw == @raw_content
458
+
459
+ raise ArgumentError,
460
+ "cannot apply markdown review state for case #{case_id} after post-processing transformed markdown output"
461
+ end
462
+ end
463
+
464
+ def case_metadata_value(resolution_case, key)
465
+ return unless resolution_case
466
+
467
+ resolution_case.metadata[key] || resolution_case.metadata[key.to_s]
468
+ end
469
+
153
470
  # Default statistics structure
154
471
  #
155
472
  # @return [Hash] Default stats hash
@@ -158,7 +475,7 @@ module Markdown
158
475
  nodes_added: 0,
159
476
  nodes_removed: 0,
160
477
  nodes_modified: 0,
161
- merge_time_ms: 0,
478
+ merge_time_ms: 0
162
479
  }
163
480
  end
164
481
  end
@@ -73,21 +73,21 @@ module Markdown
73
73
  # Table child types
74
74
  table_row: :table_row,
75
75
  table_cell: :table_cell,
76
- table_header: :table_header, # Some parsers distinguish header rows
76
+ table_header: :table_header, # Some parsers distinguish header rows
77
77
  # List child types
78
78
  list_item: :list_item,
79
- item: :list_item, # Alias
79
+ item: :list_item, # Alias
80
80
  # Inline types (usually not top-level, but map them anyway)
81
81
  text: :text,
82
82
  softbreak: :softbreak,
83
83
  linebreak: :linebreak,
84
84
  code: :code,
85
- code_inline: :code, # Alias used by some parsers
85
+ code_inline: :code, # Alias used by some parsers
86
86
  html_inline: :html_inline,
87
87
  emph: :emph,
88
88
  strong: :strong,
89
89
  link: :link,
90
- image: :image,
90
+ image: :image
91
91
  }.freeze,
92
92
  markly: {
93
93
  # Block types - note different names from commonmarker
@@ -118,8 +118,8 @@ module Markdown
118
118
  emph: :emph,
119
119
  strong: :strong,
120
120
  link: :link,
121
- image: :image,
122
- }.freeze,
121
+ image: :image
122
+ }.freeze
123
123
  )
124
124
  end
125
125
  end
@@ -27,15 +27,20 @@ module Markdown
27
27
  # @param auto_spacing [Boolean] Whether to automatically insert blank lines between structural elements
28
28
  def initialize(preserve_formatting: true, auto_spacing: true)
29
29
  @parts = []
30
+ @length = 0
30
31
  @preserve_formatting = preserve_formatting
31
32
  @auto_spacing = auto_spacing
32
33
  @last_node_type = nil # Track previous node type for spacing decisions
34
+ @last_end_line = nil # Track previous node's end line for adjacency detection
35
+ @last_analysis = nil # Track previous node's analysis for same-source detection
33
36
  end
34
37
 
35
38
  # Add a node's source content
36
39
  #
37
40
  # Automatically inserts structural blank lines when transitioning between
38
41
  # certain node types (tables, headings, code blocks, etc.) if auto_spacing is enabled.
42
+ # Skips auto-spacing when nodes are adjacent in the same source — the original
43
+ # formatting is preserved by the source extraction.
39
44
  #
40
45
  # @param node [Object] Node to add (can be parser node, FreezeNode, LinkDefinitionNode, etc.)
41
46
  # @param analysis [FileAnalysisBase] Analysis for accessing source
@@ -46,27 +51,30 @@ module Markdown
46
51
  # Auto-spacing logic:
47
52
  # - Skip for gap_line and freeze_block (they handle their own spacing)
48
53
  # - Skip if last node was a gap_line (we already have spacing)
54
+ # - Skip if nodes are adjacent in the same source (original spacing is correct)
49
55
  # - Otherwise, check MarkdownStructure.needs_blank_between? which handles
50
56
  # contiguous types (like link_definitions that shouldn't have blanks between them)
51
- unless [:gap_line, :freeze_block].include?(current_type) ||
52
- @last_node_type == :gap_line
53
- if @auto_spacing && @last_node_type && current_type
54
- if MarkdownStructure.needs_blank_between?(@last_node_type, current_type)
55
- # Only add spacing if we don't already have adequate blank lines
56
- # Check the last part to see if it already ends with blank line(s)
57
- unless @parts.empty? || @parts.last&.end_with?("\n\n")
58
- add_gap_line(count: 1)
59
- end
60
- end
61
- end
57
+ # Skip auto-spacing when nodes are adjacent lines in the same source.
58
+ # The original source formatting is correct — no blank line was there.
59
+ if !(%i[gap_line freeze_block].include?(current_type) ||
60
+ @last_node_type == :gap_line) && @auto_spacing && @last_node_type && current_type && MarkdownStructure.needs_blank_between?(@last_node_type,
61
+ current_type) && !same_source_adjacent?(
62
+ node, analysis
63
+ ) && !(@parts.empty? || blank_line_terminated?)
64
+ # Only add spacing if we don't already have adequate blank lines
65
+ # Check the last part to see if it already ends with blank line(s)
66
+ add_gap_line(count: 1)
62
67
  end
63
68
 
64
69
  content = extract_source(node, analysis)
65
- if content && !content.empty?
66
- @parts << content
67
- # Update last node type (track all node types for proper spacing)
68
- @last_node_type = current_type
69
- end
70
+ return unless content && !content.empty?
71
+
72
+ range = append_part(content)
73
+ # Update last node type (track all node types for proper spacing)
74
+ @last_node_type = current_type
75
+ @last_end_line = node_end_line(node)
76
+ @last_analysis = analysis
77
+ range
70
78
  end
71
79
 
72
80
  # Add a reconstructed link definition
@@ -74,21 +82,21 @@ module Markdown
74
82
  # @param node [LinkDefinitionNode] Link definition node
75
83
  def add_link_definition(node)
76
84
  formatted = LinkDefinitionFormatter.format(node)
77
- @parts << formatted if formatted && !formatted.empty?
85
+ append_part(formatted) if formatted && !formatted.empty?
78
86
  end
79
87
 
80
88
  # Add gap lines (blank line preservation)
81
89
  #
82
90
  # @param count [Integer] Number of blank lines to add
83
91
  def add_gap_line(count: 1)
84
- @parts << ("\n" * count) if count > 0
92
+ append_part("\n" * count) if count > 0
85
93
  end
86
94
 
87
95
  # Add raw text content
88
96
  #
89
97
  # @param text [String] Raw text to add
90
98
  def add_raw(text)
91
- @parts << text if text && !text.empty?
99
+ append_part(text) if text && !text.empty?
92
100
  end
93
101
 
94
102
  # Get final content
@@ -105,13 +113,87 @@ module Markdown
105
113
  @parts.empty?
106
114
  end
107
115
 
116
+ # Check whether the current output already ends with a blank-line separator.
117
+ #
118
+ # This looks across part boundaries so a trailing blank line represented as
119
+ # separate content + gap parts still counts as an existing separator.
120
+ #
121
+ # @return [Boolean]
122
+ def blank_line_terminated?
123
+ trailing_newlines = 0
124
+
125
+ @parts.reverse_each do |part|
126
+ next if part.nil? || part.empty?
127
+
128
+ idx = part.length - 1
129
+ while idx >= 0 && part[idx] == "\n"
130
+ trailing_newlines += 1
131
+ idx -= 1
132
+ end
133
+
134
+ break if idx >= 0
135
+ end
136
+
137
+ trailing_newlines >= 2
138
+ end
139
+
108
140
  # Clear all content
109
141
  def clear
110
142
  @parts.clear
143
+ @length = 0
111
144
  end
112
145
 
113
146
  private
114
147
 
148
+ def append_part(text)
149
+ start_offset = @length
150
+ @parts << text
151
+ @length += text.bytesize
152
+ [start_offset, @length]
153
+ end
154
+
155
+ # Check if the current node is adjacent (consecutive lines) to the previous
156
+ # node in the same source file. When true, the original source already has
157
+ # the correct inter-node spacing and auto-spacing should not add blank lines.
158
+ #
159
+ # @param node [Object] Current node being added
160
+ # @param analysis [FileAnalysisBase] Current node's analysis
161
+ # @return [Boolean] true if nodes are from the same source and adjacent
162
+ def same_source_adjacent?(node, analysis)
163
+ return false unless @last_end_line && @last_analysis
164
+ return false unless @last_analysis.equal?(analysis)
165
+
166
+ current_start = node_start_line(node)
167
+ return false unless current_start
168
+
169
+ # Adjacent: the current node starts on or immediately after the previous node ended
170
+ current_start <= @last_end_line + 1
171
+ end
172
+
173
+ # Extract start line from a node
174
+ #
175
+ # @param node [Object] Node to inspect
176
+ # @return [Integer, nil] 1-based start line
177
+ def node_start_line(node)
178
+ if node.respond_to?(:source_position)
179
+ node.source_position&.dig(:start_line)
180
+ elsif node.respond_to?(:start_line)
181
+ node.start_line
182
+ end
183
+ end
184
+
185
+ # Extract end line from a node
186
+ #
187
+ # @param node [Object] Node to inspect
188
+ # @return [Integer, nil] 1-based end line
189
+ def node_end_line(node)
190
+ if node.respond_to?(:source_position)
191
+ node.source_position&.dig(:end_line)
192
+ elsif node.respond_to?(:end_line)
193
+ node.end_line
194
+ end
195
+ end
196
+
115
197
  # Extract source content from a node
116
198
  #
117
199
  # @param node [Object] Node to extract from