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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Builds markdown output from merge operations.
6
+ #
7
+ # Handles markdown-specific concerns like:
8
+ # - Extracting source from original nodes
9
+ # - Reconstructing consumed link reference definitions
10
+ # - Preserving gap lines (blank line spacing)
11
+ # - Automatic structural spacing (blank lines between tables, headings, etc.)
12
+ # - Assembling final merged content
13
+ #
14
+ # Unlike Emitter classes used in JSON/YAML/etc, OutputBuilder focuses on
15
+ # source preservation and reconstruction rather than generation from scratch.
16
+ #
17
+ # @example Basic usage
18
+ # builder = OutputBuilder.new
19
+ # builder.add_node_source(node, analysis)
20
+ # builder.add_link_definition(link_def_node)
21
+ # builder.add_gap_line(count: 2)
22
+ # content = builder.to_s
23
+ class OutputBuilder
24
+ # Initialize a new OutputBuilder
25
+ #
26
+ # @param preserve_formatting [Boolean] Whether to preserve original formatting
27
+ # @param auto_spacing [Boolean] Whether to automatically insert blank lines between structural elements
28
+ def initialize(preserve_formatting: true, auto_spacing: true)
29
+ @parts = []
30
+ @length = 0
31
+ @preserve_formatting = preserve_formatting
32
+ @auto_spacing = auto_spacing
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
36
+ end
37
+
38
+ # Add a node's source content
39
+ #
40
+ # Automatically inserts structural blank lines when transitioning between
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.
44
+ #
45
+ # @param node [Object] Node to add (can be parser node, FreezeNode, LinkDefinitionNode, etc.)
46
+ # @param analysis [FileAnalysisBase] Analysis for accessing source
47
+ def add_node_source(node, analysis)
48
+ # Determine node type for spacing decisions
49
+ current_type = MarkdownStructure.node_type(node)
50
+
51
+ # Auto-spacing logic:
52
+ # - Skip for gap_line and freeze_block (they handle their own spacing)
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)
55
+ # - Otherwise, check MarkdownStructure.needs_blank_between? which handles
56
+ # contiguous types (like link_definitions that shouldn't have blanks between them)
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)
67
+ end
68
+
69
+ content = extract_source(node, analysis)
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
78
+ end
79
+
80
+ # Add a reconstructed link definition
81
+ #
82
+ # @param node [LinkDefinitionNode] Link definition node
83
+ def add_link_definition(node)
84
+ formatted = LinkDefinitionFormatter.format(node)
85
+ append_part(formatted) if formatted && !formatted.empty?
86
+ end
87
+
88
+ # Add gap lines (blank line preservation)
89
+ #
90
+ # @param count [Integer] Number of blank lines to add
91
+ def add_gap_line(count: 1)
92
+ append_part("\n" * count) if count > 0
93
+ end
94
+
95
+ # Add raw text content
96
+ #
97
+ # @param text [String] Raw text to add
98
+ def add_raw(text)
99
+ append_part(text) if text && !text.empty?
100
+ end
101
+
102
+ # Get final content
103
+ #
104
+ # @return [String] Assembled markdown content
105
+ def to_s
106
+ @parts.join
107
+ end
108
+
109
+ # Check if builder has any content
110
+ #
111
+ # @return [Boolean]
112
+ def empty?
113
+ @parts.empty?
114
+ end
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
+
140
+ # Clear all content
141
+ def clear
142
+ @parts.clear
143
+ @length = 0
144
+ end
145
+
146
+ private
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
+
197
+ # Extract source content from a node
198
+ #
199
+ # @param node [Object] Node to extract from
200
+ # @param analysis [FileAnalysisBase] Analysis for source access
201
+ # @return [String, nil] Extracted content
202
+ def extract_source(node, analysis)
203
+ case node
204
+ when LinkDefinitionNode
205
+ # Link definitions need reconstruction with trailing newline
206
+ "#{LinkDefinitionFormatter.format(node)}\n"
207
+ when GapLineNode
208
+ # Gap lines are single blank lines
209
+ "\n"
210
+ when Ast::Merge::FreezeNodeBase
211
+ # Freeze blocks have their full text
212
+ node.full_text
213
+ else
214
+ # Regular nodes - extract from source
215
+ extract_parser_node_source(node, analysis)
216
+ end
217
+ end
218
+
219
+ # Extract source from a parser-specific node
220
+ #
221
+ # @param node [Object] Parser node
222
+ # @param analysis [FileAnalysisBase] Analysis for source access
223
+ # @return [String, nil] Extracted content
224
+ def extract_parser_node_source(node, analysis)
225
+ # Try source_position method first (used by some nodes)
226
+ if node.respond_to?(:source_position)
227
+ pos = node.source_position
228
+ start_line = pos&.dig(:start_line)
229
+ end_line = pos&.dig(:end_line)
230
+
231
+ if start_line && end_line
232
+ return analysis.source_range(start_line, end_line)
233
+ elsif node.respond_to?(:to_commonmark)
234
+ # Fallback to commonmark rendering
235
+ return node.to_commonmark
236
+ end
237
+ end
238
+
239
+ # Try direct start_line/end_line attributes
240
+ return unless node.respond_to?(:start_line) && node.respond_to?(:end_line)
241
+ return unless node.start_line && node.end_line
242
+
243
+ # Extract source range (formatting preservation handled elsewhere)
244
+ analysis.source_range(node.start_line, node.end_line)
245
+ end
246
+ end
247
+ end
248
+ end