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,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require 'digest'
4
4
 
5
5
  module Markdown
6
6
  module Merge
@@ -41,18 +41,54 @@ module Markdown
41
41
  class FileAnalysis < FileAnalysisBase
42
42
  # Default freeze token for identifying freeze blocks
43
43
  # @return [String]
44
- DEFAULT_FREEZE_TOKEN = "markdown-merge"
44
+ DEFAULT_FREEZE_TOKEN = 'markdown-merge'
45
45
 
46
- # @return [Symbol] The backend being used (:commonmarker, :markly)
46
+ class << self
47
+ def default_backend
48
+ :auto
49
+ end
50
+
51
+ def default_freeze_token
52
+ self::DEFAULT_FREEZE_TOKEN
53
+ end
54
+
55
+ def default_parser_options
56
+ {}
57
+ end
58
+
59
+ def default_freeze_node_class
60
+ Markdown::Merge::FreezeNode
61
+ end
62
+ end
63
+
64
+ # @return [Symbol] The backend being used (:commonmarker, :markly, :kramdown)
47
65
  attr_reader :backend
48
66
 
49
67
  # @return [Hash] Parser-specific options
50
68
  attr_reader :parser_options
51
69
 
70
+ Location = Struct.new(:start_line, :end_line, keyword_init: true)
71
+ HeadingSectionOwner = Struct.new(:location, :heading_text, :heading_source, :level, :base, keyword_init: true)
72
+ LinkDefinitionOwner = Struct.new(:location, :label, :url, :title, :source, keyword_init: true)
73
+ HtmlCommentOwner = Struct.new(:location, :text, :source, keyword_init: true)
74
+ ListItemOwner = Struct.new(:location, :source, :text, :depth, :marker, keyword_init: true)
75
+ InlineReferenceOwner = Struct.new(
76
+ :location,
77
+ :line,
78
+ :start_column,
79
+ :end_column,
80
+ :source,
81
+ :reference_kind,
82
+ :label,
83
+ :labels,
84
+ keyword_init: true
85
+ )
86
+ TableRowOwner = Struct.new(:location, :source, :text, keyword_init: true)
87
+
52
88
  # Initialize file analysis with tree_haver backend.
53
89
  #
54
90
  # @param source [String] Markdown source code to analyze
55
- # @param backend [Symbol] Backend to use (:commonmarker, :markly, :auto)
91
+ # @param backend [Symbol] Backend to use (:commonmarker, :markly, :kramdown, :auto)
56
92
  # @param freeze_token [String] Token for freeze block markers
57
93
  # @param signature_generator [Proc, nil] Custom signature generator
58
94
  # @param parser_options [Hash] Backend-specific parser options
@@ -60,13 +96,13 @@ module Markdown
60
96
  # For markly: { flags: Markly::DEFAULT, extensions: [:table] }
61
97
  def initialize(
62
98
  source,
63
- backend: :auto,
64
- freeze_token: DEFAULT_FREEZE_TOKEN,
99
+ backend: self.class.default_backend,
100
+ freeze_token: self.class.default_freeze_token,
65
101
  signature_generator: nil,
66
102
  **parser_options
67
103
  )
68
104
  @requested_backend = backend
69
- @parser_options = parser_options
105
+ @parser_options = self.class.default_parser_options.merge(parser_options)
70
106
 
71
107
  # Resolve and initialize the backend
72
108
  @backend = resolve_backend(backend)
@@ -116,7 +152,7 @@ module Markdown
116
152
  #
117
153
  # @return [Class] Markdown::Merge::FreezeNode
118
154
  def freeze_node_class
119
- FreezeNode
155
+ self.class.default_freeze_node_class
120
156
  end
121
157
 
122
158
  # Check if value is a tree_haver node.
@@ -150,18 +186,21 @@ module Markdown
150
186
  def compute_parser_signature(node)
151
187
  # Get canonical type from wrapper or normalize raw type
152
188
  canonical_type = if Ast::Merge::NodeTyping.typed_node?(node)
153
- Ast::Merge::NodeTyping.merge_type_for(node)
154
- else
155
- NodeTypeNormalizer.canonical_type(node.type, @backend)
156
- end
189
+ Ast::Merge::NodeTyping.merge_type_for(node)
190
+ else
191
+ NodeTypeNormalizer.canonical_type(node.type, @backend)
192
+ end
157
193
 
158
194
  # Unwrap to access underlying node methods
159
195
  raw_node = Ast::Merge::NodeTyping.unwrap(node)
160
196
 
161
197
  case canonical_type
162
198
  when :heading
163
- # Content-based: Match headings by level and text content
164
- [:heading, raw_node.header_level, extract_text_content(raw_node)]
199
+ level = raw_node.header_level
200
+ # H1 is the document title — treat as a singleton (see FileAnalysisBase for rationale)
201
+ return [:heading, 1] if level == 1
202
+
203
+ [:heading, level, extract_text_content(raw_node)]
165
204
  when :paragraph
166
205
  # Content-based: Match paragraphs by content hash (first 32 chars of digest)
167
206
  text = extract_text_content(raw_node)
@@ -251,8 +290,300 @@ module Markdown
251
290
  nodes
252
291
  end
253
292
 
293
+ def heading_section_owners
294
+ headings = Array(statements).filter_map do |statement|
295
+ next unless heading_statement?(statement)
296
+
297
+ build_heading_owner(statement)
298
+ end
299
+
300
+ headings.each_with_index.map do |owner, index|
301
+ branch_end_line = branch_end_line(headings, index)
302
+ HeadingSectionOwner.new(
303
+ location: Location.new(start_line: owner.location.start_line, end_line: branch_end_line),
304
+ heading_text: owner.heading_text,
305
+ heading_source: owner.heading_source,
306
+ level: owner.level,
307
+ base: owner.base
308
+ )
309
+ end
310
+ end
311
+
312
+ def link_definition_owners
313
+ Array(statements).filter_map do |statement|
314
+ next unless statement.respond_to?(:merge_type) && statement.merge_type == :link_definition
315
+
316
+ position = statement.source_position
317
+ next unless position
318
+
319
+ LinkDefinitionOwner.new(
320
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
321
+ label: statement.label,
322
+ url: statement.url,
323
+ title: statement.title,
324
+ source: if statement.respond_to?(:content)
325
+ statement.content
326
+ else
327
+ source_range(position[:start_line], position[:end_line]).chomp
328
+ end
329
+ )
330
+ end
331
+ end
332
+
333
+ def html_comment_owners
334
+ comment_tracker.comment_nodes.map do |comment|
335
+ HtmlCommentOwner.new(
336
+ location: Location.new(start_line: comment.location.start_line, end_line: comment.location.end_line),
337
+ text: comment.content,
338
+ source: comment.text
339
+ )
340
+ end
341
+ end
342
+
343
+ def list_item_owners
344
+ Array(statements).flat_map do |statement|
345
+ collect_list_item_owners(unwrap_markdown_statement(statement), depth: 0)
346
+ end.sort_by { |owner| [owner.location.start_line, owner.location.end_line] }
347
+ end
348
+
349
+ def inline_reference_owners
350
+ source.to_s.lines.each_with_index.flat_map do |line, index|
351
+ inline_references_for_line(line.chomp, index + 1)
352
+ end
353
+ end
354
+
355
+ def table_row_owners
356
+ ast_table_lines = {}
357
+ ast_rows = Array(statements).flat_map do |statement|
358
+ node = unwrap_markdown_statement(statement)
359
+ next [] unless node.respond_to?(:type) && node.type.to_s == 'table'
360
+
361
+ table_position = node.source_position
362
+ if table_position
363
+ (table_position[:start_line]..table_position[:end_line]).each do |line|
364
+ ast_table_lines[line] = true
365
+ end
366
+ end
367
+ Array(node.children).filter_map do |child|
368
+ next unless child.respond_to?(:type) && child.type.to_s == 'table_row'
369
+
370
+ position = child.source_position
371
+ next unless position
372
+
373
+ TableRowOwner.new(
374
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
375
+ source: source_range(position[:start_line], position[:end_line]),
376
+ text: extract_text_content(child)
377
+ )
378
+ end
379
+ end
380
+ ast_lines = ast_rows.map { |owner| owner.location.start_line }.to_h { |line| [line, true] }
381
+ loose_rows = source.to_s.lines.each_with_index.filter_map do |line, index|
382
+ line_number = index + 1
383
+ next if ast_lines[line_number]
384
+ next if ast_table_lines[line_number]
385
+ next unless loose_table_row_line?(line)
386
+
387
+ TableRowOwner.new(
388
+ location: Location.new(start_line: line_number, end_line: line_number),
389
+ source: line,
390
+ text: line
391
+ )
392
+ end
393
+ ast_rows + loose_rows
394
+ end
395
+
254
396
  private
255
397
 
398
+ def loose_table_row_line?(line)
399
+ stripped = line.to_s.lstrip
400
+ return false unless stripped.start_with?('|')
401
+
402
+ stripped.include?(' |') || stripped.include?('| ')
403
+ end
404
+
405
+ def collect_list_item_owners(node, depth:)
406
+ return [] unless node.respond_to?(:type)
407
+
408
+ canonical_type = NodeTypeNormalizer.canonical_type(node.type, @backend)
409
+ next_depth = %i[list].include?(canonical_type) ? depth + 1 : depth
410
+ owners = []
411
+ if canonical_type == :list_item
412
+ position = node.source_position if node.respond_to?(:source_position)
413
+ if position
414
+ source_text = source_range(position[:start_line], position[:end_line])
415
+ owners << ListItemOwner.new(
416
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
417
+ source: source_text,
418
+ text: source_text,
419
+ depth: depth,
420
+ marker: list_item_marker(source_text)
421
+ )
422
+ end
423
+ end
424
+
425
+ Array(node.children).each do |child|
426
+ owners.concat(collect_list_item_owners(child, depth: next_depth))
427
+ end
428
+ owners
429
+ end
430
+
431
+ def list_item_marker(source_text)
432
+ stripped = source_text.to_s.lines.first.to_s.lstrip
433
+ return stripped[0, 2].strip if stripped.start_with?('- ', '* ')
434
+
435
+ marker = stripped.split(' ', 2).first.to_s
436
+ marker.end_with?('.') ? marker : nil
437
+ end
438
+
439
+ def heading_statement?(statement)
440
+ merge_type = if statement.respond_to?(:merge_type)
441
+ statement.merge_type
442
+ else
443
+ unwrap_markdown_statement(statement)&.type
444
+ end
445
+
446
+ %w[heading header].include?(merge_type.to_s)
447
+ end
448
+
449
+ def build_heading_owner(statement)
450
+ node = unwrap_markdown_statement(statement)
451
+ position = node&.source_position
452
+ return unless node && position
453
+
454
+ heading_source = source_range(position[:start_line], position[:end_line]).sub(/\n\z/, '')
455
+ heading_text = node.to_plaintext.to_s.sub(/\n+\z/, '')
456
+ HeadingSectionOwner.new(
457
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
458
+ heading_text: heading_text,
459
+ heading_source: heading_source,
460
+ level: node.header_level,
461
+ base: normalize_heading_base(heading_text)
462
+ )
463
+ rescue StandardError
464
+ nil
465
+ end
466
+
467
+ def branch_end_line(headings, index)
468
+ current = headings[index]
469
+ cursor = index + 1
470
+ while cursor < headings.length
471
+ return headings[cursor].location.start_line - 1 if headings[cursor].level <= current.level
472
+
473
+ cursor += 1
474
+ end
475
+
476
+ source.to_s.lines.length
477
+ end
478
+
479
+ def unwrap_markdown_statement(statement)
480
+ Ast::Merge::NodeTyping.unwrap(statement)
481
+ rescue StandardError
482
+ statement
483
+ end
484
+
485
+ def normalize_heading_base(text)
486
+ text.to_s.sub(/\A(?:\d\uFE0F?\u20E3|[^[:alnum:][:space:]])+[ \t]*/u, '').strip.downcase
487
+ end
488
+
489
+ def inline_references_for_line(line, line_number)
490
+ owners = []
491
+ index = 0
492
+ while index < line.length
493
+ image = inline_image_reference_at(line, index, line_number)
494
+ if image
495
+ owners << image
496
+ index = image.end_column
497
+ next
498
+ end
499
+
500
+ link = inline_link_reference_at(line, index, line_number)
501
+ if link
502
+ owners << link
503
+ index = link.end_column
504
+ next
505
+ end
506
+
507
+ index += 1
508
+ end
509
+ owners
510
+ end
511
+
512
+ def inline_image_reference_at(line, index, line_number)
513
+ return unless line[index] == '!' && line[index + 1] == '['
514
+
515
+ alt_end = closing_bracket_index(line, index + 1)
516
+ return unless alt_end && line[alt_end + 1] == '['
517
+
518
+ label_end = closing_bracket_index(line, alt_end + 1)
519
+ return unless label_end
520
+
521
+ label = line[(alt_end + 2)...label_end]
522
+ inline_reference_owner(
523
+ line: line,
524
+ line_number: line_number,
525
+ start_column: index,
526
+ end_column: label_end + 1,
527
+ reference_kind: :image_reference,
528
+ label: label,
529
+ labels: [label]
530
+ )
531
+ end
532
+
533
+ def inline_link_reference_at(line, index, line_number)
534
+ return unless line[index] == '['
535
+
536
+ text_end = closing_bracket_index(line, index)
537
+ return unless text_end && line[text_end + 1] == '['
538
+
539
+ label_end = closing_bracket_index(line, text_end + 1)
540
+ return unless label_end
541
+
542
+ text = line[(index + 1)...text_end]
543
+ label = line[(text_end + 2)...label_end]
544
+ image_owner = inline_image_reference_at(text, 0, line_number)
545
+ labels = [label]
546
+ labels.unshift(image_owner.label) if image_owner && image_owner.source == text
547
+ inline_reference_owner(
548
+ line: line,
549
+ line_number: line_number,
550
+ start_column: index,
551
+ end_column: label_end + 1,
552
+ reference_kind: labels.length > 1 ? :linked_image_reference : :link_reference,
553
+ label: label,
554
+ labels: labels
555
+ )
556
+ end
557
+
558
+ def inline_reference_owner(line:, line_number:, start_column:, end_column:, reference_kind:, label:, labels:)
559
+ InlineReferenceOwner.new(
560
+ location: Location.new(start_line: line_number, end_line: line_number),
561
+ line: line_number,
562
+ start_column: start_column,
563
+ end_column: end_column,
564
+ source: line[start_column...end_column],
565
+ reference_kind: reference_kind,
566
+ label: label,
567
+ labels: labels.compact.uniq
568
+ )
569
+ end
570
+
571
+ def closing_bracket_index(text, opening_index)
572
+ depth = 0
573
+ index = opening_index
574
+ while index < text.length
575
+ case text[index]
576
+ when '['
577
+ depth += 1
578
+ when ']'
579
+ depth -= 1
580
+ return index if depth.zero?
581
+ end
582
+ index += 1
583
+ end
584
+ nil
585
+ end
586
+
256
587
  # Recursively collect text content from a node and its descendants.
257
588
  #
258
589
  # Uses NodeTypeNormalizer to map backend-specific types to canonical types,
@@ -271,14 +602,14 @@ module Markdown
271
602
  canonical_type = NodeTypeNormalizer.canonical_type(node.type, @backend)
272
603
 
273
604
  # Collect text from text and code nodes
274
- if canonical_type == :text || canonical_type == :code
605
+ if %i[text code].include?(canonical_type)
275
606
  content = if node.respond_to?(:string_content)
276
- node.string_content.to_s
277
- elsif node.respond_to?(:text)
278
- node.text.to_s
279
- else
280
- ""
281
- end
607
+ node.string_content.to_s
608
+ elsif node.respond_to?(:text)
609
+ node.text.to_s
610
+ else
611
+ ''
612
+ end
282
613
  text_parts << content unless content.empty?
283
614
  end
284
615
 
@@ -290,63 +621,68 @@ module Markdown
290
621
 
291
622
  # Resolve the backend to use.
292
623
  #
293
- # For :auto, attempts commonmarker first, then markly.
294
- # tree_haver handles the actual availability checking.
624
+ # For :auto, use the same backend selection as the Markdown substrate facade.
625
+ # tree_haver handles the final availability checking.
295
626
  #
296
627
  # @param backend [Symbol] Requested backend
297
- # @return [Symbol] Resolved backend (:commonmarker or :markly)
628
+ # @return [Symbol] Resolved backend
298
629
  def resolve_backend(backend)
299
- return backend unless backend == :auto
630
+ return Markdown::Merge.resolve_backend(nil).to_sym if backend.to_s.empty? || backend == :auto
300
631
 
301
- # Try commonmarker first, then markly
302
- if TreeHaver::BackendRegistry.available?(:commonmarker)
303
- :commonmarker
304
- elsif TreeHaver::BackendRegistry.available?(:markly)
305
- :markly
306
- else
307
- # Let tree_haver raise the appropriate error
308
- :commonmarker
309
- end
632
+ backend.to_sym
310
633
  end
311
634
 
312
635
  # Create a parser for the resolved backend.
313
636
  #
314
637
  # @return [Object] tree_haver parser instance
315
638
  def create_parser
639
+ unless Markdown::Merge::BACKEND_REFERENCES.key?(@backend.to_s)
640
+ raise ArgumentError, "Unknown backend: #{@backend}"
641
+ end
642
+
643
+ parser = TreeHaver.with_backend(@backend) { TreeHaver.parser_for(:markdown) }
644
+
316
645
  case @backend
317
646
  when :commonmarker
318
- create_commonmarker_parser
647
+ parser.language = commonmarker_language
319
648
  when :markly
320
- create_markly_parser
649
+ parser.language = markly_language
650
+ when :kramdown
651
+ parser.language = kramdown_language
321
652
  else
322
- raise ArgumentError, "Unknown backend: #{@backend}"
653
+ return parser
323
654
  end
655
+
656
+ parser
324
657
  end
325
658
 
326
- # Create a Commonmarker parser via commonmarker-merge backend.
659
+ # Create a Commonmarker language config for the TreeHaver parser.
327
660
  #
328
- # @return [Commonmarker::Merge::Backend::Parser]
329
- def create_commonmarker_parser
330
- parser = Commonmarker::Merge::Backend::Parser.new
661
+ # @return [Commonmarker::Merge::Backend::Language]
662
+ def commonmarker_language
331
663
  # Default options enable table extension for GFM compatibility
332
- default_options = {extension: {table: true}}
664
+ default_options = { extension: { table: true } }
333
665
  options = default_options.merge(@parser_options[:options] || {})
334
- parser.language = Commonmarker::Merge::Backend::Language.markdown(options: options)
335
- parser
666
+ Commonmarker::Merge::Backend::Language.markdown(options: options)
336
667
  end
337
668
 
338
- # Create a Markly parser via markly-merge backend.
669
+ # Create a Markly language config for the TreeHaver parser.
339
670
  #
340
- # @return [Markly::Merge::Backend::Parser]
341
- def create_markly_parser
342
- parser = Markly::Merge::Backend::Parser.new
671
+ # @return [Markly::Merge::Backend::Language]
672
+ def markly_language
343
673
  flags = @parser_options[:flags]
344
674
  extensions = @parser_options[:extensions] || [:table]
345
- parser.language = Markly::Merge::Backend::Language.markdown(
675
+ Markly::Merge::Backend::Language.markdown(
346
676
  flags: flags,
347
- extensions: extensions,
677
+ extensions: extensions
348
678
  )
349
- parser
679
+ end
680
+
681
+ # Create a Kramdown language config for the TreeHaver parser.
682
+ #
683
+ # @return [Kramdown::Merge::Backend::Language]
684
+ def kramdown_language
685
+ Kramdown::Merge::Backend::Language.markdown(options: @parser_options[:options] || {})
350
686
  end
351
687
  end
352
688
  end