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,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Merges two Markdown list nodes at the item level.
6
+ #
7
+ # When a template list and destination list are matched (e.g., via fuzzy matching
8
+ # or a shared content fingerprint), this merger produces a result that is smarter
9
+ # than simply picking one whole list as the winner:
10
+ #
11
+ # - Items that appear in both lists are resolved by preference (template or dest).
12
+ # - Items that only appear in the destination are kept (project customisations).
13
+ # - Items that only appear in the template are added (new canonical steps).
14
+ #
15
+ # Item matching uses significant-token Jaccard overlap so minor wording differences
16
+ # (e.g., "Commit changes" vs "Commit your changes") still produce a match.
17
+ #
18
+ # The merged list is emitted as plain Markdown text (ordered `1. …` lines) and
19
+ # passed to the caller via `add_raw` on the OutputBuilder.
20
+ #
21
+ # @example Basic usage
22
+ # merger = ListMerger.new
23
+ # result = merger.merge_lists(template_node, dest_node,
24
+ # preference: :template,
25
+ # add_template_only_nodes: true,
26
+ # template_analysis: t_analysis,
27
+ # dest_analysis: d_analysis)
28
+ # if result[:merged]
29
+ # builder.add_raw(result[:content])
30
+ # end
31
+ #
32
+ # @see SmartMergerBase#try_inner_merge_list_to_builder
33
+ class ListMerger
34
+ include Ast::Merge::JaccardSimilarity
35
+
36
+ # Minimum Jaccard token overlap to consider two list items as matching.
37
+ ITEM_MATCH_THRESHOLD = 0.35
38
+
39
+ # Merge two list nodes.
40
+ #
41
+ # @param template_node [Object] Template list node (tree_haver / Markly node)
42
+ # @param dest_node [Object] Destination list node
43
+ # @param preference [Symbol] :template or :destination — which wins for matched items
44
+ # @param add_template_only_nodes [Boolean] Whether to append template-only items
45
+ # @param template_analysis [FileAnalysisBase] Template file analysis (for source text)
46
+ # @param dest_analysis [FileAnalysisBase] Destination file analysis (for source text)
47
+ # @return [Hash] { merged: Boolean, content: String } or { merged: false, reason: String }
48
+ def merge_lists(template_node, dest_node,
49
+ preference:,
50
+ add_template_only_nodes: true,
51
+ template_analysis: nil,
52
+ dest_analysis: nil,
53
+ resolution_mode: :eager,
54
+ unresolved_policy: nil)
55
+ t_items = extract_items(template_node)
56
+ d_items = extract_items(dest_node)
57
+
58
+ return not_merged('empty list') if t_items.empty? && d_items.empty?
59
+
60
+ alignment = align_items(t_items, d_items)
61
+ lines, unresolved_cases = emit_lines(
62
+ alignment,
63
+ template_node: template_node,
64
+ dest_node: dest_node,
65
+ preference: preference,
66
+ add_template_only: add_template_only_nodes,
67
+ template_analysis: template_analysis,
68
+ dest_analysis: dest_analysis,
69
+ resolution_mode: resolution_mode,
70
+ unresolved_policy: Ast::Merge::UnresolvedPolicy.coerce(unresolved_policy)
71
+ )
72
+ return not_merged('no lines emitted') if lines.empty?
73
+
74
+ {
75
+ merged: true,
76
+ content: lines.join("\n") + "\n",
77
+ stats: { decision: unresolved_cases.empty? ? :merged : :unresolved },
78
+ unresolved_cases: unresolved_cases
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ # --- item extraction ---
85
+
86
+ def extract_items(list_node)
87
+ raw = Ast::Merge::NodeTyping.unwrap(list_node)
88
+ children =
89
+ if raw.respond_to?(:to_a)
90
+ raw.to_a
91
+ elsif raw.respond_to?(:children)
92
+ raw.children
93
+ elsif raw.respond_to?(:each)
94
+ raw.each.to_a
95
+ else
96
+ []
97
+ end
98
+
99
+ children.select do |item|
100
+ item.respond_to?(:type) && %w[list_item item].include?(item.type.to_s)
101
+ end
102
+ end
103
+
104
+ # --- alignment ---
105
+
106
+ # Produce an ordered array of alignment entries, each one of:
107
+ # { type: :match, template_item: …, dest_item: … }
108
+ # { type: :dest_only, dest_item: … }
109
+ # { type: :template_only, template_item: … }
110
+ def align_items(t_items, d_items)
111
+ t_tokens = t_items.map { |i| item_tokens(i) }
112
+ d_tokens = d_items.map { |i| item_tokens(i) }
113
+
114
+ matched_t = Set.new
115
+ matched_d = Set.new
116
+ candidates = []
117
+
118
+ t_items.each_with_index do |_t_item, ti|
119
+ d_items.each_with_index do |_d_item, di|
120
+ score = jaccard(t_tokens[ti], d_tokens[di])
121
+ next if score < ITEM_MATCH_THRESHOLD
122
+
123
+ candidates << { score: score, ti: ti, di: di }
124
+ end
125
+ end
126
+
127
+ # Greedy best-first matching
128
+ matches = {} # ti => di
129
+ reverse = {} # di => ti
130
+ candidates.sort_by { |c| -c[:score] }.each do |c|
131
+ next if matched_t.include?(c[:ti]) || matched_d.include?(c[:di])
132
+
133
+ matches[c[:ti]] = c[:di]
134
+ reverse[c[:di]] = c[:ti]
135
+ matched_t << c[:ti]
136
+ matched_d << c[:di]
137
+ end
138
+
139
+ # Walk destination order, interleaving template-only items before the
140
+ # dest item they were adjacent to in the template.
141
+ result = []
142
+ inserted_t = Set.new
143
+
144
+ d_items.each_with_index do |d_item, di|
145
+ # Before this dest item, insert any template-only items whose nearest
146
+ # matched template neighbour falls before this point.
147
+ t_items.each_with_index do |t_item, ti|
148
+ next if matched_t.include?(ti) || inserted_t.include?(ti)
149
+
150
+ # Find the first matched dest index for template items after ti
151
+ next_matched_di = (ti + 1..t_items.size - 1).find { |k| matches.key?(k) }&.then { |k| matches[k] }
152
+ insert_before = next_matched_di.nil? ? d_items.size : next_matched_di
153
+ next if insert_before > di
154
+
155
+ result << { type: :template_only, template_item: t_item }
156
+ inserted_t << ti
157
+ end
158
+
159
+ if reverse.key?(di)
160
+ ti = reverse[di]
161
+ result << { type: :match, template_item: t_items[ti], dest_item: d_item }
162
+ else
163
+ result << { type: :dest_only, dest_item: d_item }
164
+ end
165
+ end
166
+
167
+ # Append remaining template-only items that come after the last dest item
168
+ t_items.each_with_index do |t_item, ti|
169
+ next if matched_t.include?(ti) || inserted_t.include?(ti)
170
+
171
+ result << { type: :template_only, template_item: t_item }
172
+ end
173
+
174
+ result
175
+ end
176
+
177
+ # --- emission ---
178
+
179
+ def emit_lines(alignment, template_node:, dest_node:, preference:, add_template_only:,
180
+ template_analysis:, dest_analysis:, resolution_mode:, unresolved_policy:)
181
+ counter = 1
182
+ lines = []
183
+ unresolved_cases = []
184
+ current_offset = 0
185
+
186
+ alignment.each do |entry|
187
+ case entry[:type]
188
+ when :match
189
+ template_text = item_bare_text(entry[:template_item], template_analysis)
190
+ dest_text = item_bare_text(entry[:dest_item], dest_analysis)
191
+ if unresolved_match?(template_text, dest_text, resolution_mode: resolution_mode,
192
+ unresolved_policy: unresolved_policy)
193
+ provisional_winner = unresolved_policy.provisional_winner_for(:matched_list_item, fallback: preference)
194
+ selected_text = provisional_winner == :template ? template_text : dest_text
195
+ line = format_list_line(counter, selected_text)
196
+ lines << line
197
+ unresolved_cases << build_unresolved_case(
198
+ template_node: template_node,
199
+ dest_node: dest_node,
200
+ template_item: entry[:template_item],
201
+ dest_item: entry[:dest_item],
202
+ template_text: format_list_line(counter, template_text),
203
+ dest_text: format_list_line(counter, dest_text),
204
+ provisional_winner: provisional_winner,
205
+ output_range: [current_offset, current_offset + line.bytesize],
206
+ list_index: counter
207
+ )
208
+ else
209
+ node = preference == :template ? entry[:template_item] : entry[:dest_item]
210
+ analysis = preference == :template ? template_analysis : dest_analysis
211
+ text = item_bare_text(node, analysis)
212
+ lines << format_list_line(counter, text)
213
+ end
214
+ counter += 1
215
+ current_offset += lines.last.bytesize + 1
216
+ when :dest_only
217
+ text = item_bare_text(entry[:dest_item], dest_analysis)
218
+ lines << format_list_line(counter, text)
219
+ counter += 1
220
+ current_offset += lines.last.bytesize + 1
221
+ when :template_only
222
+ next unless add_template_only
223
+
224
+ text = item_bare_text(entry[:template_item], template_analysis)
225
+ lines << format_list_line(counter, text)
226
+ counter += 1
227
+ current_offset += lines.last.bytesize + 1
228
+ end
229
+ end
230
+
231
+ [lines, unresolved_cases]
232
+ end
233
+
234
+ # Return the bare inline text of a list_item node (without the leading `1. ` marker).
235
+ def item_bare_text(item, analysis)
236
+ # Prefer source extraction for fidelity (preserves links, code spans, etc.)
237
+ if analysis && item.respond_to?(:source_position)
238
+ pos = item.source_position
239
+ if pos
240
+ raw = analysis.source_range(pos[:start_line], pos[:end_line]).strip
241
+ # Strip the ordered-list marker: `1. `, `2. `, `123. ` etc.
242
+ return raw.sub(/\A\d+\.\s+/, '')
243
+ end
244
+ end
245
+
246
+ # Fallback: use .text and strip marker
247
+ item.text.to_s.strip.sub(/\A\d+\.\s+/, '')
248
+ end
249
+
250
+ # --- token helpers ---
251
+
252
+ def item_tokens(item)
253
+ text = item.respond_to?(:text) ? item.text.to_s : ''
254
+ extract_tokens(text)
255
+ end
256
+
257
+ def unresolved_match?(template_text, dest_text, resolution_mode:, unresolved_policy:)
258
+ resolution_mode.to_sym == :unresolved &&
259
+ unresolved_policy.unresolved_for?(:matched_list_item) &&
260
+ template_text != dest_text
261
+ end
262
+
263
+ def format_list_line(counter, text)
264
+ "#{counter}. #{text}"
265
+ end
266
+
267
+ def build_unresolved_case(template_node:, dest_node:, template_item:, dest_item:, template_text:, dest_text:,
268
+ provisional_winner:, output_range:, list_index:)
269
+ template_lines = source_span_for(template_item)
270
+ dest_lines = source_span_for(dest_item)
271
+ reference_line = template_lines&.first || dest_lines&.first || list_index
272
+ case_id = "markdown-matched_list_item-#{reference_line}-#{list_index}"
273
+
274
+ Ast::Merge::Runtime::ResolutionCase.new(
275
+ case_id: case_id,
276
+ reason: :conflict,
277
+ candidates: {
278
+ template: template_text,
279
+ destination: dest_text
280
+ },
281
+ provisional_winner: provisional_winner,
282
+ surface_path: "#{list_surface_path(template_node, dest_node)} > matched_list_item[index=#{list_index}]",
283
+ metadata: {
284
+ template_lines: template_lines,
285
+ destination_lines: dest_lines,
286
+ match_kind: :matched_list_item,
287
+ list_index: list_index,
288
+ relative_output_range: output_range
289
+ }.compact
290
+ )
291
+ end
292
+
293
+ def list_surface_path(template_node, dest_node)
294
+ template_lines = source_span_for(template_node)
295
+ dest_lines = source_span_for(dest_node)
296
+ start_line, end_line = template_lines || dest_lines
297
+
298
+ if start_line && end_line
299
+ "document[0] > list[L#{start_line}-L#{end_line}]"
300
+ else
301
+ 'document[0] > list'
302
+ end
303
+ end
304
+
305
+ def source_span_for(node)
306
+ raw = Ast::Merge::NodeTyping.unwrap(node)
307
+ return unless raw.respond_to?(:source_position)
308
+
309
+ position = raw.source_position
310
+ start_line = position&.dig(:start_line)
311
+ end_line = position&.dig(:end_line)
312
+ return unless start_line && end_line
313
+
314
+ [start_line, end_line]
315
+ end
316
+
317
+ def not_merged(reason)
318
+ { merged: false, reason: reason }
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Defines structural spacing rules for markdown elements.
6
+ #
7
+ # When merging markdown from different sources, gap lines from the original
8
+ # sources may not exist at transition points (e.g., when a dest-only table
9
+ # is followed by a template-only table). This module defines which node types
10
+ # require spacing before/after them for proper markdown formatting.
11
+ #
12
+ # Node types are categorized by their spacing needs:
13
+ # - NEEDS_BLANK_BEFORE: Nodes that need a blank line before them (headings, tables, etc.)
14
+ # - NEEDS_BLANK_AFTER: Nodes that need a blank line after them
15
+ # - CONTIGUOUS_TYPES: Nodes that should NOT have blank lines between consecutive instances
16
+ # (e.g., link_definition blocks should be together)
17
+ #
18
+ # @example
19
+ # MarkdownStructure.needs_blank_before?(:table) # => true
20
+ # MarkdownStructure.needs_blank_after?(:heading) # => true
21
+ # MarkdownStructure.contiguous_type?(:link_definition) # => true
22
+ module MarkdownStructure
23
+ # Node types that should have a blank line BEFORE them
24
+ # (when preceded by other content)
25
+ NEEDS_BLANK_BEFORE = %i[
26
+ heading
27
+ paragraph
28
+ table
29
+ code_block
30
+ thematic_break
31
+ list
32
+ block_quote
33
+ ].freeze
34
+
35
+ # Node types that should have a blank line AFTER them
36
+ # (when followed by other content)
37
+ NEEDS_BLANK_AFTER = %i[
38
+ heading
39
+ paragraph
40
+ table
41
+ code_block
42
+ thematic_break
43
+ list
44
+ block_quote
45
+ link_definition
46
+ ].freeze
47
+
48
+ # Node types that should be contiguous (no blank lines between consecutive
49
+ # nodes of the same type). These form "blocks" that should stay together.
50
+ CONTIGUOUS_TYPES = %i[
51
+ link_definition
52
+ ].freeze
53
+
54
+ class << self
55
+ # Check if a node type needs a blank line before it
56
+ #
57
+ # @param node_type [Symbol, String] Node type to check
58
+ # @return [Boolean]
59
+ def needs_blank_before?(node_type)
60
+ NEEDS_BLANK_BEFORE.include?(node_type.to_sym)
61
+ end
62
+
63
+ # Check if a node type needs a blank line after it
64
+ #
65
+ # @param node_type [Symbol, String] Node type to check
66
+ # @return [Boolean]
67
+ def needs_blank_after?(node_type)
68
+ NEEDS_BLANK_AFTER.include?(node_type.to_sym)
69
+ end
70
+
71
+ # Check if a node type is a contiguous type (should not have blank lines
72
+ # between consecutive nodes of the same type).
73
+ #
74
+ # @param node_type [Symbol, String] Node type to check
75
+ # @return [Boolean]
76
+ def contiguous_type?(node_type)
77
+ CONTIGUOUS_TYPES.include?(node_type.to_sym)
78
+ end
79
+
80
+ # Check if we should insert a blank line between two node types
81
+ #
82
+ # Rules:
83
+ # 1. If both types are the same contiguous type, NO blank line
84
+ # 2. If previous node needs blank after, YES blank line
85
+ # 3. If next node needs blank before, YES blank line
86
+ #
87
+ # @param prev_type [Symbol, String, nil] Previous node type
88
+ # @param next_type [Symbol, String, nil] Next node type
89
+ # @return [Boolean]
90
+ def needs_blank_between?(prev_type, next_type)
91
+ return false if prev_type.nil? || next_type.nil?
92
+
93
+ prev_sym = prev_type.to_sym
94
+ next_sym = next_type.to_sym
95
+
96
+ # Same contiguous type - no blank line between them
97
+ return false if prev_sym == next_sym && contiguous_type?(prev_sym)
98
+
99
+ needs_blank_after?(prev_sym) || needs_blank_before?(next_sym)
100
+ end
101
+
102
+ # Get the node type from a node object
103
+ #
104
+ # Priority order:
105
+ # 1. merge_type - Explicit merge behavior classification (preferred)
106
+ # 2. type - Parser-specific type fallback
107
+ #
108
+ # @param node [Object] Node to get type from
109
+ # @return [Symbol, nil] Node type
110
+ def node_type(node)
111
+ return unless node
112
+
113
+ # Prefer merge_type when available - it's the explicit merge behavior classifier
114
+ if node.respond_to?(:merge_type)
115
+ node.merge_type.to_sym
116
+ elsif node.respond_to?(:type)
117
+ node.type.to_sym
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end