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,555 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Markdown-specific implementation of PartialTemplateMerger.
6
+ #
7
+ # Merges a partial template into a specific section of a destination markdown document.
8
+ # This class extends the parser-agnostic base with markdown-specific logic for:
9
+ # - Heading-level-aware section boundaries
10
+ # - Source-based text extraction to preserve link references and table formatting
11
+ # - Backend-specific parser initialization (Markly, Commonmarker)
12
+ #
13
+ # @example Basic usage
14
+ # merger = Markdown::Merge::PartialTemplateMerger.new(
15
+ # template: template_content,
16
+ # destination: destination_content,
17
+ # anchor: { type: :heading, text: /Gem Family/ },
18
+ # backend: :markly
19
+ # )
20
+ # result = merger.merge
21
+ # puts result.content
22
+ #
23
+ # @example With boundary
24
+ # merger = Markdown::Merge::PartialTemplateMerger.new(
25
+ # template: template_content,
26
+ # destination: destination_content,
27
+ # anchor: { type: :heading, text: /Installation/ },
28
+ # boundary: { type: :heading }, # Stop at next heading
29
+ # backend: :markly
30
+ # )
31
+ #
32
+ class PartialTemplateMerger < Ast::Merge::PartialTemplateMergerBase
33
+ include PreservationSupport
34
+
35
+ # Re-export Result class from base for convenience
36
+ Result = Ast::Merge::PartialTemplateMergerBase::Result
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
+
52
+ # @return [Symbol] Backend to use (:markly, :commonmarker)
53
+ attr_reader :backend
54
+
55
+ # Initialize a markdown PartialTemplateMerger.
56
+ #
57
+ # @param template [String] The template content (the section to merge in)
58
+ # @param destination [String] The destination content
59
+ # @param anchor [Hash] Anchor matcher: { type: :heading, text: /pattern/ }
60
+ # @param boundary [Hash, nil] Boundary matcher (defaults to same type as anchor)
61
+ # @param backend [Symbol] Backend to use (:markly, :commonmarker)
62
+ # @param preference [Symbol, Hash] Which content wins (:template, :destination, or per-type hash)
63
+ # @param add_missing [Boolean, Proc] Whether to add template nodes not in destination
64
+ # @param when_missing [Symbol] What to do if section not found (:skip, :append, :prepend)
65
+ # @param replace_mode [Boolean] If true, template replaces section entirely (no merge)
66
+ # @param signature_generator [Proc, nil] Custom signature generator for SmartMerger
67
+ # @param node_typing [Hash, nil] Node typing configuration for per-type preferences
68
+ # @param match_refiner [Object, nil] Match refiner for fuzzy matching (e.g., ContentMatchRefiner)
69
+ # @param normalize_whitespace [Boolean] If true, collapse excessive blank lines. Default: false
70
+ # @param rehydrate_link_references [Boolean] If true, convert inline links to reference style. Default: false
71
+ def initialize(
72
+ template:,
73
+ destination:,
74
+ anchor:,
75
+ boundary: nil,
76
+ backend: self.class.default_backend,
77
+ preference: :template,
78
+ add_missing: true,
79
+ when_missing: :skip,
80
+ replace_mode: false,
81
+ signature_generator: nil,
82
+ node_typing: nil,
83
+ match_refiner: nil,
84
+ normalize_whitespace: false,
85
+ rehydrate_link_references: false
86
+ )
87
+ validate_backend!(backend)
88
+ @backend = backend
89
+ @normalize_whitespace = normalize_whitespace
90
+ @rehydrate_link_references = rehydrate_link_references
91
+ super(
92
+ template: template,
93
+ destination: destination,
94
+ anchor: anchor,
95
+ boundary: boundary,
96
+ preference: preference,
97
+ add_missing: add_missing,
98
+ when_missing: when_missing,
99
+ replace_mode: replace_mode,
100
+ signature_generator: signature_generator,
101
+ node_typing: node_typing,
102
+ match_refiner: match_refiner,
103
+ )
104
+ end
105
+
106
+ # Perform the partial template merge with post-processing.
107
+ #
108
+ # @return [Result] The merge result
109
+ def merge
110
+ result = super
111
+
112
+ # Apply post-processing if enabled
113
+ if result.changed && (@normalize_whitespace || @rehydrate_link_references)
114
+ content = result.content
115
+ problems = DocumentProblems.new
116
+
117
+ if @normalize_whitespace
118
+ normalizer = WhitespaceNormalizer.new(content)
119
+ content = normalizer.normalize
120
+ problems.merge!(normalizer.problems)
121
+ end
122
+
123
+ if @rehydrate_link_references
124
+ rehydrator = LinkReferenceRehydrator.new(content)
125
+ content = rehydrator.rehydrate
126
+ problems.merge!(rehydrator.problems)
127
+ end
128
+
129
+ # Return new result with transformed content and problems
130
+ Result.new(
131
+ content: content,
132
+ has_section: result.has_section,
133
+ changed: result.changed,
134
+ stats: result.stats.merge(problems: problems.all),
135
+ injection_point: result.injection_point,
136
+ message: result.message
137
+ )
138
+ else
139
+ result
140
+ end
141
+ end
142
+
143
+ protected
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
+
167
+ # Validate the backend parameter.
168
+ #
169
+ # @param backend [Symbol] The backend to validate
170
+ # @raise [ArgumentError] If backend is not supported
171
+ def validate_backend!(backend)
172
+ valid_backends = %i[auto markly commonmarker kramdown]
173
+ return if valid_backends.include?(backend.to_sym)
174
+
175
+ raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(', ')}"
176
+ end
177
+
178
+ # Create a FileAnalysis for the given content.
179
+ #
180
+ # @param content [String] The content to analyze
181
+ # @return [FileAnalysis] A FileAnalysis instance
182
+ def create_analysis(content)
183
+ self.class.file_analysis_class.new(content, backend: backend)
184
+ end
185
+
186
+ # Create a SmartMerger for merging the section.
187
+ #
188
+ # @param template_content [String] The template content
189
+ # @param destination_content [String] The destination section content
190
+ # @return [SmartMerger] A SmartMerger instance
191
+ def create_smart_merger(template_content, destination_content)
192
+ # Build options hash, only including non-nil values
193
+ options = {
194
+ preference: preference,
195
+ add_template_only_nodes: add_missing,
196
+ backend: backend
197
+ }
198
+
199
+ # Use custom signature generator if provided, otherwise use position-based
200
+ # table matching to ensure tables with different structures still match
201
+ # within a section merge context.
202
+ options[:signature_generator] = signature_generator || build_position_based_signature_generator
203
+
204
+ options[:node_typing] = node_typing if node_typing
205
+ options[:match_refiner] = match_refiner if match_refiner
206
+
207
+ self.class.smart_merger_class.new(template_content, destination_content, **options)
208
+ end
209
+
210
+ # Build a signature generator that uses type-based matching for tables.
211
+ #
212
+ # This ensures that tables within a section are matched by type alone,
213
+ # allowing template tables to replace destination tables regardless of
214
+ # their exact structure (different headers, columns, etc.).
215
+ #
216
+ # In the context of partial template merging, this is the desired behavior:
217
+ # - Sections typically contain one table of each logical role
218
+ # - Template table should replace the destination table
219
+ # - Different table structures should still match by ordinal position
220
+ #
221
+ # The algorithm uses a stateless approach that assigns the same signature
222
+ # to all tables. Since PartialTemplateMerger merges **one section at a time**,
223
+ # each section typically has few tables, and the first table in template
224
+ # will match and replace the first table in destination.
225
+ #
226
+ # For more precise control over multiple tables within a section, provide
227
+ # a custom signature_generator.
228
+ #
229
+ # @return [Proc] A signature generator proc
230
+ def build_position_based_signature_generator
231
+ # Simple stateless approach: all tables get the same base signature.
232
+ # When preference is :template, this causes template table to replace
233
+ # destination table, which is the desired behavior.
234
+ #
235
+ # NOTE: If a section has multiple tables, they will ALL match each other,
236
+ # potentially causing unexpected behavior. For such cases, users should
237
+ # provide a custom signature_generator.
238
+ lambda do |node|
239
+ type_str = node.type.to_s
240
+ if type_str == 'table'
241
+ # All tables within a section merge get the same signature.
242
+ # This ensures template table replaces destination table.
243
+ %i[table section_table]
244
+ else
245
+ # Return node for default signature computation
246
+ node
247
+ end
248
+ end
249
+ end
250
+
251
+ # Find where the section ends.
252
+ #
253
+ # For headings, finds the next heading of same or higher level.
254
+ # For other node types, finds the next node of the same type.
255
+ #
256
+ # NOTE: For headings, we ALWAYS use heading-level-aware logic, ignoring
257
+ # any boundary from InjectionPointFinder. This is because InjectionPointFinder
258
+ # uses tree_depth for boundary detection, but in Markdown all headings are
259
+ # siblings at the same tree depth regardless of their level (H2, H3, H4 etc).
260
+ # Heading level semantics require comparing the actual heading level numbers.
261
+ #
262
+ # @param statements [Array<Navigable::Statement>] All statements
263
+ # @param injection_point [Navigable::InjectionPoint] The injection point
264
+ # @return [Integer] Index of the last statement in the section
265
+ def find_section_end(statements, injection_point)
266
+ anchor = injection_point.anchor
267
+ anchor_type = anchor.type
268
+
269
+ # For headings, ALWAYS use heading-level-aware logic
270
+ # This overrides any boundary from InjectionPointFinder because tree_depth
271
+ # doesn't reflect heading level semantics in Markdown
272
+ if heading_type?(anchor_type)
273
+ anchor_level = get_heading_level(anchor)
274
+
275
+ ((anchor.index + 1)...statements.length).each do |idx|
276
+ stmt = statements[idx]
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
283
+ end
284
+ end
285
+
286
+ # No boundary heading found - section extends to end of document
287
+ return statements.length - 1
288
+ end
289
+
290
+ # For non-headings, use boundary if specified and found
291
+ return injection_point.boundary.index - 1 if injection_point.boundary
292
+
293
+ # Otherwise, find next node of same type
294
+ ((anchor.index + 1)...statements.length).each do |idx|
295
+ stmt = statements[idx]
296
+ return idx - 1 if stmt.type == anchor_type
297
+ end
298
+
299
+ # Section extends to end of document
300
+ statements.length - 1
301
+ end
302
+
303
+ # Convert a node to its source text.
304
+ #
305
+ # Prefers source-based extraction to preserve original formatting
306
+ # (link references, table padding, etc.). Falls back to to_commonmark.
307
+ #
308
+ # @param node [Object] The node to convert
309
+ # @param analysis [FileAnalysis, nil] The analysis object for source lookup
310
+ # @return [String] The source text
311
+ def node_to_source(node, analysis = nil)
312
+ # Unwrap if needed
313
+ inner = node
314
+ inner = inner.inner_node while inner.respond_to?(:inner_node) && inner.inner_node != inner
315
+
316
+ # Prefer source-based extraction to preserve original formatting
317
+ # (link references, table padding, etc.)
318
+ if analysis&.respond_to?(:source_range)
319
+ pos = inner.source_position if inner.respond_to?(:source_position)
320
+ if pos
321
+ start_line = pos[:start_line]
322
+ end_line = pos[:end_line]
323
+ if start_line && end_line && start_line > 0
324
+ source_text = analysis.source_range(start_line, end_line)
325
+ # source_range already adds trailing newlines, don't add another
326
+ return source_text unless source_text.empty?
327
+ end
328
+ end
329
+ end
330
+
331
+ # Fallback to to_commonmark (for nodes without source position)
332
+ if inner.respond_to?(:to_commonmark)
333
+ inner.to_commonmark.to_s
334
+ elsif inner.respond_to?(:to_s)
335
+ inner.to_s
336
+ else
337
+ ''
338
+ end
339
+ end
340
+
341
+ alias node_to_text node_to_source
342
+
343
+ private
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
+
532
+ # Check if a type represents a heading node.
533
+ #
534
+ # @param type [Symbol, String] The node type
535
+ # @return [Boolean] true if this is a heading type
536
+ def heading_type?(type)
537
+ type.to_s == 'heading' || type == :heading || type == :header
538
+ end
539
+
540
+ # Get the heading level from a statement.
541
+ #
542
+ # @param stmt [NavigableStatement] The statement
543
+ # @return [Integer, nil] The heading level (1-6) or nil
544
+ def get_heading_level(stmt)
545
+ inner = stmt.respond_to?(:unwrapped_node) ? stmt.unwrapped_node : stmt.node
546
+
547
+ if inner.respond_to?(:header_level)
548
+ inner.header_level
549
+ elsif inner.respond_to?(:level)
550
+ inner.level
551
+ end
552
+ end
553
+ end
554
+ end
555
+ end