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,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Orchestrates the smart merge process for Markdown files using tree_haver backends.
6
+ #
7
+ # Extends SmartMergerBase with backend-agnostic parsing via tree_haver.
8
+ # Supports both Commonmarker and Markly backends.
9
+ #
10
+ # Uses FileAnalysis, FileAligner, ConflictResolver, and MergeResult to
11
+ # merge two Markdown files intelligently. Freeze blocks marked with
12
+ # HTML comments are preserved exactly as-is.
13
+ #
14
+ # SmartMerger provides flexible configuration for different merge scenarios:
15
+ # - Preserve destination customizations (default)
16
+ # - Apply template updates
17
+ # - Add new sections from template
18
+ # - Inner-merge fenced code blocks using language-specific mergers (optional)
19
+ #
20
+ # @example Basic merge (destination customizations preserved)
21
+ # merger = SmartMerger.new(template_content, dest_content)
22
+ # result = merger.merge
23
+ # if result.success?
24
+ # File.write("output.md", result.content)
25
+ # end
26
+ #
27
+ # @example With specific backend
28
+ # merger = SmartMerger.new(
29
+ # template_content,
30
+ # dest_content,
31
+ # backend: :markly
32
+ # )
33
+ # result = merger.merge
34
+ #
35
+ # @example Template updates win
36
+ # merger = SmartMerger.new(
37
+ # template_content,
38
+ # dest_content,
39
+ # preference: :template,
40
+ # add_template_only_nodes: true
41
+ # )
42
+ # result = merger.merge
43
+ #
44
+ # @example Custom signature matching
45
+ # sig_gen = ->(node) {
46
+ # canonical_type = Ast::Merge::NodeTyping.merge_type_for(node) || node.type
47
+ # if canonical_type == :heading
48
+ # [:heading, node.header_level] # Match by level only, not content
49
+ # else
50
+ # node # Fall through to default
51
+ # end
52
+ # }
53
+ # merger = SmartMerger.new(
54
+ # template_content,
55
+ # dest_content,
56
+ # signature_generator: sig_gen
57
+ # )
58
+ #
59
+ # @see FileAnalysis
60
+ # @see SmartMergerBase
61
+ class SmartMerger < SmartMergerBase
62
+ VALID_BACKENDS = %i[auto commonmarker markly kramdown].freeze
63
+
64
+ class << self
65
+ def default_backend
66
+ :auto
67
+ end
68
+
69
+ def default_freeze_token
70
+ FileAnalysis::DEFAULT_FREEZE_TOKEN
71
+ end
72
+
73
+ def default_inner_merge_code_blocks
74
+ false
75
+ end
76
+
77
+ def default_parser_options
78
+ {}
79
+ end
80
+
81
+ def file_analysis_class
82
+ FileAnalysis
83
+ end
84
+
85
+ def template_parse_error_class
86
+ TemplateParseError
87
+ end
88
+
89
+ def destination_parse_error_class
90
+ DestinationParseError
91
+ end
92
+ end
93
+
94
+ # @return [Symbol] The backend being used (:commonmarker, :markly)
95
+ attr_reader :backend
96
+
97
+ # Creates a new SmartMerger for intelligent Markdown file merging.
98
+ #
99
+ # @param template_content [String] Template Markdown source code
100
+ # @param dest_content [String] Destination Markdown source code
101
+ #
102
+ # @param backend [Symbol] Backend to use for parsing:
103
+ # - `:commonmarker` - Use Commonmarker (comrak Rust parser)
104
+ # - `:markly` - Use Markly (cmark-gfm C library)
105
+ # - `:auto` (default) - Auto-detect available backend
106
+ #
107
+ # @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
108
+ # The proc receives a node (wrapped with canonical merge_type) and should return one of:
109
+ # - An array representing the node's signature
110
+ # - `nil` to indicate the node should have no signature
111
+ # - The original node to fall through to default signature computation
112
+ #
113
+ # @param preference [Symbol] Controls which version to use when nodes
114
+ # have matching signatures but different content:
115
+ # - `:destination` (default) - Use destination version (preserves customizations)
116
+ # - `:template` - Use template version (applies updates)
117
+ #
118
+ # @param add_template_only_nodes [Boolean] Controls whether to add nodes that only
119
+ # exist in template:
120
+ # - `false` (default) - Skip template-only nodes
121
+ # - `true` - Add template-only nodes to result
122
+ #
123
+ # @param inner_merge_code_blocks [Boolean, CodeBlockMerger] Controls inner-merge for
124
+ # fenced code blocks:
125
+ # - `true` - Enable inner-merge using default CodeBlockMerger
126
+ # - `false` (default) - Disable inner-merge (use standard conflict resolution)
127
+ # - `CodeBlockMerger` instance - Use custom CodeBlockMerger
128
+ #
129
+ # @param remove_template_missing_nodes [Boolean] Controls whether destination-only
130
+ # structural nodes should be removed instead of preserved. Standalone HTML
131
+ # comment-only fragments, freeze blocks, and link reference definitions remain
132
+ # preserved when enabled.
133
+ #
134
+ # @param freeze_token [String] Token to use for freeze block markers.
135
+ # Default: "markdown-merge"
136
+ # Looks for: <!-- markdown-merge:freeze --> / <!-- markdown-merge:unfreeze -->
137
+ #
138
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching of
139
+ # unmatched nodes. Default: nil (fuzzy matching disabled).
140
+ # Set to TableMatchRefiner.new to enable fuzzy table matching.
141
+ #
142
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
143
+ # for per-node-type merge preferences. Maps node type names to callables.
144
+ #
145
+ # @param parser_options [Hash] Backend-specific parser options.
146
+ # For commonmarker: { options: {} }
147
+ # For markly: { flags: Markly::DEFAULT, extensions: [:table] }
148
+ #
149
+ # @raise [TemplateParseError] If template has syntax errors
150
+ # @raise [DestinationParseError] If destination has syntax errors
151
+ def initialize(
152
+ template_content,
153
+ dest_content,
154
+ backend: self.class.default_backend,
155
+ signature_generator: nil,
156
+ preference: :destination,
157
+ add_template_only_nodes: false,
158
+ inner_merge_code_blocks: self.class.default_inner_merge_code_blocks,
159
+ inner_merge_lists: false,
160
+ remove_template_missing_nodes: false,
161
+ freeze_token: self.class.default_freeze_token,
162
+ match_refiner: nil,
163
+ node_typing: nil,
164
+ **parser_options
165
+ )
166
+ validate_backend!(backend)
167
+
168
+ @requested_backend = backend
169
+ @parser_options = self.class.default_parser_options.merge(parser_options)
170
+
171
+ super(
172
+ template_content,
173
+ dest_content,
174
+ signature_generator: signature_generator,
175
+ preference: preference,
176
+ add_template_only_nodes: add_template_only_nodes,
177
+ inner_merge_code_blocks: inner_merge_code_blocks,
178
+ inner_merge_lists: inner_merge_lists,
179
+ remove_template_missing_nodes: remove_template_missing_nodes,
180
+ freeze_token: freeze_token,
181
+ match_refiner: match_refiner,
182
+ node_typing: node_typing,
183
+ # Pass through for FileAnalysis
184
+ backend: backend,
185
+ **parser_options,
186
+ )
187
+
188
+ # Capture the resolved backend from template analysis
189
+ @backend = @template_analysis.backend
190
+ end
191
+
192
+ # Create a FileAnalysis instance for parsing.
193
+ #
194
+ # @param content [String] Markdown content to analyze
195
+ # @param options [Hash] Analysis options
196
+ # @return [FileAnalysis] File analysis instance
197
+ def create_file_analysis(content, **opts)
198
+ self.class.file_analysis_class.new(
199
+ content,
200
+ backend: opts[:backend] || @requested_backend,
201
+ freeze_token: opts[:freeze_token],
202
+ signature_generator: opts[:signature_generator],
203
+ **@parser_options
204
+ )
205
+ end
206
+
207
+ # Returns the TemplateParseError class to use.
208
+ #
209
+ # @return [Class] Markdown::Merge::TemplateParseError
210
+ def template_parse_error_class
211
+ self.class.template_parse_error_class
212
+ end
213
+
214
+ # Returns the DestinationParseError class to use.
215
+ #
216
+ # @return [Class] Markdown::Merge::DestinationParseError
217
+ def destination_parse_error_class
218
+ self.class.destination_parse_error_class
219
+ end
220
+
221
+ # Convert a node to its source text.
222
+ #
223
+ # Handles wrapped nodes from NodeTypeNormalizer, gap line nodes,
224
+ # and link definition nodes created during gap detection.
225
+ #
226
+ # @param node [Object] Node to convert (may be wrapped)
227
+ # @param analysis [FileAnalysis] Analysis for source lookup
228
+ # @return [String] Source text
229
+ def node_to_source(node, analysis)
230
+ # Check for any FreezeNode type (base class or subclass)
231
+ return node.full_text if node.is_a?(Ast::Merge::FreezeNodeBase)
232
+
233
+ # Handle gap line nodes (created for blank lines and link definitions)
234
+ return node.content if node.is_a?(LinkDefinitionNode) || node.is_a?(GapLineNode)
235
+
236
+ # Unwrap if needed to access source_position
237
+ raw_node = Ast::Merge::NodeTyping.unwrap(node)
238
+
239
+ pos = raw_node.source_position
240
+ start_line = pos&.dig(:start_line)
241
+ end_line = pos&.dig(:end_line)
242
+
243
+ # Fall back to to_commonmark if no position info
244
+ return raw_node.to_commonmark unless start_line && end_line
245
+
246
+ # Get source from line range
247
+ source = analysis.source_range(start_line, end_line)
248
+
249
+ # Handle Markly's buggy position reporting for :html nodes
250
+ # where end_line < start_line results in empty source_range.
251
+ # Fall back to to_commonmark in that case.
252
+ if source.empty? && raw_node.respond_to?(:to_commonmark)
253
+ raw_node.to_commonmark.chomp
254
+ else
255
+ source
256
+ end
257
+ end
258
+
259
+ private
260
+
261
+ def validate_backend!(backend)
262
+ normalized_backend = backend.respond_to?(:to_sym) ? backend.to_sym : backend
263
+ return if VALID_BACKENDS.include?(normalized_backend)
264
+
265
+ raise ArgumentError, "Unknown backend: #{backend}"
266
+ end
267
+ end
268
+ end
269
+ end