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,496 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Aligns Markdown block elements between template and destination files.
6
+ #
7
+ # Uses structural signatures to match headings, paragraphs, lists, code blocks,
8
+ # and other block elements. The alignment is then used by SmartMerger to
9
+ # determine how to combine the files.
10
+ #
11
+ # @example Basic usage
12
+ # aligner = FileAligner.new(template_analysis, dest_analysis)
13
+ # alignment = aligner.align
14
+ # alignment.each do |entry|
15
+ # case entry[:type]
16
+ # when :match
17
+ # # Both files have this element
18
+ # when :template_only
19
+ # # Only in template
20
+ # when :dest_only
21
+ # # Only in destination
22
+ # end
23
+ # end
24
+ #
25
+ # @see FileAnalysisBase
26
+ # @see SmartMergerBase
27
+ class FileAligner < ::Ast::Merge::FileAlignerBase
28
+ # @return [FileAnalysisBase] Template file analysis
29
+ attr_reader :template_analysis
30
+
31
+ # @return [FileAnalysisBase] Destination file analysis
32
+ attr_reader :dest_analysis
33
+
34
+ # @return [#call, nil] Optional match refiner for fuzzy matching
35
+ attr_reader :match_refiner
36
+
37
+ # Initialize a file aligner
38
+ #
39
+ # @param template_analysis [FileAnalysisBase] Analysis of the template file
40
+ # @param dest_analysis [FileAnalysisBase] Analysis of the destination file
41
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
42
+ def initialize(template_analysis, dest_analysis, match_refiner: nil, **options)
43
+ super(template_analysis, dest_analysis, match_refiner: match_refiner, **options)
44
+ end
45
+
46
+ private
47
+
48
+ def signature_for(analysis, index)
49
+ signature = analysis.signature_at(index)
50
+ return signature if signature.nil?
51
+
52
+ signature = contextual_list_signature(analysis, index, signature) if list_signature?(signature)
53
+
54
+ contextualize_signature_with_heading_owner_path(analysis, index, signature)
55
+ end
56
+
57
+ def apply_match_refiner!(alignment, template_statements:, dest_statements:, matched_template:, matched_dest:)
58
+ return unless match_refiner
59
+
60
+ unmatched_template = template_statements.each_with_index.reject do |_, i|
61
+ matched_template.include?(i)
62
+ end.map(&:first)
63
+ unmatched_dest = dest_statements.each_with_index.reject { |_, i| matched_dest.include?(i) }.map(&:first)
64
+ return if unmatched_template.empty? || unmatched_dest.empty?
65
+
66
+ refined_matches = match_refiner.call(
67
+ unmatched_template,
68
+ unmatched_dest,
69
+ {
70
+ template_analysis: template_analysis,
71
+ dest_analysis: dest_analysis
72
+ }
73
+ )
74
+
75
+ Array(refined_matches).each do |match|
76
+ template_statement = refined_match_template_statement(match)
77
+ dest_statement = refined_match_dest_statement(match)
78
+ template_index = template_statements.index(template_statement)
79
+ dest_index = dest_statements.index(dest_statement)
80
+
81
+ next unless template_index && dest_index
82
+ next if matched_template.include?(template_index) || matched_dest.include?(dest_index)
83
+ next unless refined_match_heading_owner_compatible?(template_index, dest_index)
84
+
85
+ entry = build_match_entry(
86
+ signature: refined_match_signature(match),
87
+ template_index: template_index,
88
+ dest_index: dest_index,
89
+ template_statement: template_statement,
90
+ dest_statement: dest_statement
91
+ )
92
+ repair_dest_index = adjacent_section_hijack_repair_sort_dest_index(template_index, dest_index)
93
+ entry[:repair_dest_index] = repair_dest_index if repair_dest_index
94
+ alignment << entry
95
+
96
+ matched_template << template_index
97
+ matched_dest << dest_index
98
+ end
99
+ end
100
+
101
+ def contextualize_signature_with_heading_owner_path(analysis, index, signature)
102
+ return signature unless section_owned_signature?(signature)
103
+ return signature if synthetic_section_label_paragraph?(analysis, index)
104
+ return signature if gap_line_after_synthetic_section_label?(analysis, index)
105
+
106
+ owner_path = heading_owner_path_for(analysis, index)
107
+ return signature if owner_path.empty?
108
+
109
+ [signature.first, owner_path, *signature.drop(1)]
110
+ end
111
+
112
+ def section_owned_signature?(signature)
113
+ signature.is_a?(Array) && %i[
114
+ paragraph
115
+ code_block
116
+ blockquote
117
+ block_quote
118
+ table
119
+ custom_block
120
+ hrule
121
+ thematic_break
122
+ gap_line_after
123
+ ].include?(signature.first)
124
+ end
125
+
126
+ def heading_owner_path_for(analysis, index)
127
+ heading_owner_paths_for(analysis).fetch(index, EMPTY_HEADING_OWNER_PATH)
128
+ end
129
+
130
+ def heading_owner_paths_for(analysis)
131
+ @heading_owner_paths ||= {}
132
+ @heading_owner_paths.fetch(analysis.object_id) do
133
+ @heading_owner_paths[analysis.object_id] = build_heading_owner_paths(analysis)
134
+ end
135
+ end
136
+
137
+ EMPTY_HEADING_OWNER_PATH = [].freeze
138
+
139
+ def build_heading_owner_paths(analysis)
140
+ statements = statements_for(analysis)
141
+ stack = []
142
+
143
+ statements.each_with_index.each_with_object([]) do |(_statement, index), paths|
144
+ signature = analysis.signature_at(index)
145
+ if heading_signature?(signature)
146
+ level = signature[1]
147
+ stack.pop while stack.any? && stack.last[:level] >= level
148
+ paths << stack.map { |entry| entry[:signature] }.freeze
149
+ stack << { level: level, signature: signature }
150
+ else
151
+ paths << stack.map { |entry| entry[:signature] }.freeze
152
+ end
153
+ end.freeze
154
+ end
155
+
156
+ def refined_match_heading_owner_compatible?(template_index, dest_index)
157
+ template_owner_path = heading_owner_path_for(template_analysis, template_index)
158
+ dest_owner_path = heading_owner_path_for(dest_analysis, dest_index)
159
+ return true if template_owner_path == dest_owner_path
160
+
161
+ refined_list_repair_compatible?(template_index, dest_index) ||
162
+ adjacent_section_hijack_repair_compatible?(template_index, dest_index)
163
+ end
164
+
165
+ def refined_list_repair_compatible?(template_index, dest_index)
166
+ template_signature = template_analysis.signature_at(template_index)
167
+ dest_signature = dest_analysis.signature_at(dest_index)
168
+ return false unless list_signature?(template_signature) && list_signature?(dest_signature)
169
+ return true if synthetic_label_scoped_list?(template_analysis, template_index)
170
+
171
+ template_count = template_signature[2].to_i
172
+ dest_count = dest_signature[2].to_i
173
+ [template_count, dest_count].max >= [template_count, dest_count].min + 2
174
+ end
175
+
176
+ def adjacent_section_hijack_repair_compatible?(template_index, dest_index)
177
+ template_signature = template_analysis.signature_at(template_index)
178
+ dest_signature = dest_analysis.signature_at(dest_index)
179
+ return false unless repairable_hijacked_section_signature?(template_signature, dest_signature)
180
+ return false unless exact_statement_text_match?(template_analysis, template_index, dest_analysis, dest_index)
181
+
182
+ template_owner = section_owner_for(template_analysis, template_index)
183
+ dest_owner = section_owner_for(dest_analysis, dest_index)
184
+ return false unless template_owner && dest_owner
185
+ return false if template_owner[:signature] == dest_owner[:signature]
186
+ return false unless section_empty_for_heading_signature?(dest_analysis, template_owner[:signature])
187
+
188
+ neighboring_section_signatures?(template_analysis, template_owner[:signature], dest_owner[:signature]) &&
189
+ neighboring_section_signatures?(dest_analysis, template_owner[:signature], dest_owner[:signature])
190
+ end
191
+
192
+ def template_only_entry_context(template_index:, matched_entries_by_template_position:, **)
193
+ next_match = next_match_for_template_only_entry(
194
+ template_index,
195
+ matched_entries_by_template_position
196
+ )
197
+
198
+ {
199
+ anchor_dest_index: anchor_dest_index_for_entry(next_match),
200
+ anchor_position: next_match ? :before : :append
201
+ }
202
+ end
203
+
204
+ def next_match_for_template_only_entry(template_index, matched_entries_by_template_position)
205
+ same_owner_match = next_same_owner_match_for_template_only_entry(
206
+ template_index,
207
+ matched_entries_by_template_position
208
+ )
209
+ return same_owner_match if same_owner_match
210
+
211
+ section_boundary_match = next_section_boundary_match_for_template_only_entry(
212
+ template_index,
213
+ matched_entries_by_template_position
214
+ )
215
+ return section_boundary_match if section_boundary_match
216
+
217
+ _previous_match, next_match = surrounding_matched_entries(matched_entries_by_template_position, template_index)
218
+ next_match
219
+ end
220
+
221
+ def next_same_owner_match_for_template_only_entry(template_index, matched_entries_by_template_position)
222
+ owner = section_owner_for(template_analysis, template_index)
223
+ return unless owner
224
+
225
+ matched_entries_by_template_position.find do |entry|
226
+ candidate_index = entry[:template_index]
227
+ next false unless candidate_index && candidate_index > template_index
228
+
229
+ candidate_signature = template_analysis.signature_at(candidate_index)
230
+ next false if candidate_signature.is_a?(Array) && candidate_signature.first == :gap_line_after
231
+
232
+ candidate_owner = section_owner_for(template_analysis, candidate_index)
233
+ candidate_owner && candidate_owner[:signature] == owner[:signature]
234
+ end
235
+ end
236
+
237
+ def next_section_boundary_match_for_template_only_entry(template_index, matched_entries_by_template_position)
238
+ owner = section_owner_for(template_analysis, template_index)
239
+ return unless owner
240
+
241
+ matched_entries_by_template_position.find do |entry|
242
+ candidate_index = entry[:template_index]
243
+ next false unless candidate_index && candidate_index > owner[:heading_index]
244
+
245
+ candidate_signature = template_analysis.signature_at(candidate_index)
246
+ heading_signature?(candidate_signature) && candidate_signature[1] <= owner[:level]
247
+ end
248
+ end
249
+
250
+ def section_owner_for(analysis, index)
251
+ statements = statements_for(analysis)
252
+ statement = statements[index]
253
+ signature = analysis.signature_at(index)
254
+ return unless statement && signature
255
+
256
+ if heading_signature?(signature)
257
+ { heading_index: index, level: signature[1], owner_path: heading_owner_path_for(analysis, index),
258
+ signature: signature }
259
+ else
260
+ owner_path = heading_owner_path_for(analysis, index)
261
+ return if owner_path.empty?
262
+
263
+ owner_signature = owner_path.last
264
+ owner_index = nearest_owner_heading_index_for(analysis, index, owner_signature)
265
+ return unless owner_index
266
+
267
+ { heading_index: owner_index, level: owner_signature[1], owner_path: owner_path, signature: owner_signature }
268
+ end
269
+ end
270
+
271
+ def nearest_owner_heading_index_for(analysis, index, owner_signature)
272
+ index.downto(0) do |current_index|
273
+ signature = analysis.signature_at(current_index)
274
+ next unless heading_signature?(signature)
275
+ return current_index if signature == owner_signature
276
+ end
277
+
278
+ nil
279
+ end
280
+
281
+ def log_alignment(alignment)
282
+ DebugLogger.debug('Alignment complete', {
283
+ total: alignment.size,
284
+ matches: alignment.count { |e| e[:type] == :match },
285
+ template_only: alignment.count { |e| e[:type] == :template_only },
286
+ dest_only: alignment.count { |e| e[:type] == :dest_only }
287
+ })
288
+ end
289
+
290
+ def match_sort_key(entry)
291
+ [0, anchor_dest_index_for_entry(entry), 0, entry[:template_index] || 0]
292
+ end
293
+
294
+ def template_only_sort_key(entry, _dest_size)
295
+ anchor_dest_index = entry[:anchor_dest_index]
296
+
297
+ case entry[:anchor_position]
298
+ when :before
299
+ [0, anchor_dest_index, -1, entry[:template_index]]
300
+ else
301
+ [1, entry[:template_index], 0, 0]
302
+ end
303
+ end
304
+
305
+ def list_signature?(signature)
306
+ signature.is_a?(Array) && signature.first == :list
307
+ end
308
+
309
+ def contextual_list_signature(analysis, index, signature)
310
+ statement = statements_for(analysis)[index]
311
+ list_type = signature[1]
312
+ preceding_context_index = nearest_list_context_index(analysis, index)
313
+ preceding_context = preceding_context_index ? analysis.signature_at(preceding_context_index) : nil
314
+ owner_path = if preceding_context_index && synthetic_section_label_paragraph?(analysis, preceding_context_index)
315
+ EMPTY_HEADING_OWNER_PATH
316
+ else
317
+ heading_owner_path_for(analysis, index)
318
+ end
319
+ first_anchor = first_list_item_anchor(statement, analysis)
320
+ [:list, owner_path, list_type, preceding_context, first_anchor]
321
+ end
322
+
323
+ def heading_signature?(signature)
324
+ signature.is_a?(Array) && signature.first == :heading
325
+ end
326
+
327
+ def nearest_list_context_index(analysis, index)
328
+ (index - 1).downto(0) do |current_index|
329
+ candidate = analysis.signature_at(current_index)
330
+ next unless contextual_predecessor_signature?(candidate)
331
+
332
+ return current_index
333
+ end
334
+
335
+ nil
336
+ end
337
+
338
+ def contextual_predecessor_signature?(signature)
339
+ signature.is_a?(Array) && %i[heading paragraph code_block].include?(signature.first)
340
+ end
341
+
342
+ def synthetic_section_label_paragraph?(analysis, index)
343
+ signature = analysis.signature_at(index)
344
+ return false unless signature.is_a?(Array) && signature.first == :paragraph
345
+
346
+ statement = statements_for(analysis)[index]
347
+ return false unless statement&.respond_to?(:text)
348
+
349
+ text = statement.text.to_s.strip
350
+ return false if text.empty? || text.length > 120
351
+ return false if text.include?("\n")
352
+ return false if text.match?(/[.!?:]\z/)
353
+
354
+ text.split(/\s+/).length <= 12
355
+ end
356
+
357
+ def synthetic_label_scoped_list?(analysis, index)
358
+ return false unless list_signature?(analysis.signature_at(index))
359
+
360
+ context_index = nearest_list_context_index(analysis, index)
361
+ context_index && synthetic_section_label_paragraph?(analysis, context_index)
362
+ end
363
+
364
+ def gap_line_after_synthetic_section_label?(analysis, index)
365
+ signature = analysis.signature_at(index)
366
+ return false unless signature.is_a?(Array) && signature.first == :gap_line_after
367
+ return false unless index.positive?
368
+
369
+ synthetic_section_label_paragraph?(analysis, index - 1)
370
+ end
371
+
372
+ def repairable_hijacked_section_signature?(template_signature, dest_signature)
373
+ return false unless template_signature == dest_signature
374
+
375
+ paragraph_signature?(template_signature) || list_signature?(template_signature)
376
+ end
377
+
378
+ def paragraph_signature?(signature)
379
+ signature.is_a?(Array) && signature.first == :paragraph
380
+ end
381
+
382
+ def exact_statement_text_match?(template_analysis, template_index, dest_analysis, dest_index)
383
+ template_statement = statements_for(template_analysis)[template_index]
384
+ dest_statement = statements_for(dest_analysis)[dest_index]
385
+ return false unless template_statement&.respond_to?(:text) && dest_statement&.respond_to?(:text)
386
+
387
+ template_statement.text.to_s == dest_statement.text.to_s
388
+ end
389
+
390
+ def adjacent_section_hijack_repair_sort_dest_index(template_index, dest_index)
391
+ return unless adjacent_section_hijack_repair_compatible?(template_index, dest_index)
392
+
393
+ template_owner = section_owner_for(template_analysis, template_index)
394
+ return unless template_owner
395
+
396
+ heading_index = heading_index_for_signature(dest_analysis, template_owner[:signature])
397
+ return unless heading_index
398
+
399
+ boundary_index = next_section_boundary_heading_index(dest_analysis, heading_index)
400
+ return heading_index + 0.5 unless boundary_index
401
+
402
+ boundary_index - 0.5
403
+ end
404
+
405
+ def anchor_dest_index_for_entry(entry)
406
+ entry&.fetch(:repair_dest_index, entry[:dest_index])
407
+ end
408
+
409
+ def section_empty_for_heading_signature?(analysis, heading_signature)
410
+ heading_index = heading_index_for_signature(analysis, heading_signature)
411
+ return false unless heading_index
412
+
413
+ next_heading_index = next_section_boundary_heading_index(analysis, heading_index)
414
+ last_index = next_heading_index ? next_heading_index - 1 : statements_for(analysis).length - 1
415
+ ((heading_index + 1)..last_index).none? do |index|
416
+ signature = analysis.signature_at(index)
417
+ signature && signature.first != :gap_line_after && !heading_signature?(signature)
418
+ end
419
+ end
420
+
421
+ def neighboring_section_signatures?(analysis, first_signature, second_signature)
422
+ first_index = heading_index_for_signature(analysis, first_signature)
423
+ second_index = heading_index_for_signature(analysis, second_signature)
424
+ return false unless first_index && second_index
425
+
426
+ neighboring_heading_signatures(analysis, first_index).include?(second_signature)
427
+ end
428
+
429
+ def neighboring_heading_signatures(analysis, heading_index)
430
+ signature = analysis.signature_at(heading_index)
431
+ return [] unless heading_signature?(signature)
432
+
433
+ level = signature[1]
434
+ parent_path = heading_owner_path_for(analysis, heading_index)
435
+ headings = statements_for(analysis).each_index.filter_map do |index|
436
+ candidate = analysis.signature_at(index)
437
+ next unless heading_signature?(candidate)
438
+ next unless candidate[1] == level
439
+ next unless heading_owner_path_for(analysis, index) == parent_path
440
+
441
+ candidate
442
+ end
443
+ current_position = headings.index(signature)
444
+ return [] unless current_position
445
+
446
+ [headings[current_position - 1], headings[current_position + 1]].compact
447
+ end
448
+
449
+ def heading_index_for_signature(analysis, heading_signature)
450
+ statements_for(analysis).each_index.find do |index|
451
+ analysis.signature_at(index) == heading_signature
452
+ end
453
+ end
454
+
455
+ def next_section_boundary_heading_index(analysis, heading_index)
456
+ signature = analysis.signature_at(heading_index)
457
+ return unless heading_signature?(signature)
458
+
459
+ level = signature[1]
460
+ ((heading_index + 1)...statements_for(analysis).length).find do |index|
461
+ candidate = analysis.signature_at(index)
462
+ heading_signature?(candidate) && candidate[1] <= level
463
+ end
464
+ end
465
+
466
+ def first_list_item_anchor(statement, analysis)
467
+ raw = Ast::Merge::NodeTyping.unwrap(statement)
468
+ first_item = nil
469
+
470
+ raw.each do |child|
471
+ next unless child.respond_to?(:type) && %w[list_item item].include?(child.type.to_s)
472
+
473
+ first_item = child
474
+ break
475
+ end
476
+
477
+ return '' unless first_item
478
+
479
+ text = if analysis && first_item.respond_to?(:source_position) && first_item.source_position
480
+ pos = first_item.source_position
481
+ analysis.source_range(pos[:start_line], pos[:end_line]).to_s
482
+ else
483
+ first_item.respond_to?(:text) ? first_item.text.to_s : ''
484
+ end
485
+
486
+ text
487
+ .strip
488
+ .sub(/\A(?:[-*+]|\d+\.)\s+/, '')
489
+ .gsub(/[^\p{L}\p{N}]+/u, ' ')
490
+ .gsub(/\s+/, ' ')
491
+ .downcase
492
+ .strip
493
+ end
494
+ end
495
+ end
496
+ end