markdown-merge 1.0.0

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 (46) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +251 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +1087 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/markdown/merge/cleanse/block_spacing.rb +253 -0
  14. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  15. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +405 -0
  16. data/lib/markdown/merge/cleanse.rb +42 -0
  17. data/lib/markdown/merge/code_block_merger.rb +300 -0
  18. data/lib/markdown/merge/conflict_resolver.rb +128 -0
  19. data/lib/markdown/merge/debug_logger.rb +26 -0
  20. data/lib/markdown/merge/document_problems.rb +190 -0
  21. data/lib/markdown/merge/file_aligner.rb +196 -0
  22. data/lib/markdown/merge/file_analysis.rb +353 -0
  23. data/lib/markdown/merge/file_analysis_base.rb +629 -0
  24. data/lib/markdown/merge/freeze_node.rb +93 -0
  25. data/lib/markdown/merge/gap_line_node.rb +136 -0
  26. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  27. data/lib/markdown/merge/link_definition_node.rb +157 -0
  28. data/lib/markdown/merge/link_parser.rb +421 -0
  29. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  30. data/lib/markdown/merge/markdown_structure.rb +123 -0
  31. data/lib/markdown/merge/merge_result.rb +166 -0
  32. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  33. data/lib/markdown/merge/output_builder.rb +166 -0
  34. data/lib/markdown/merge/partial_template_merger.rb +334 -0
  35. data/lib/markdown/merge/smart_merger.rb +221 -0
  36. data/lib/markdown/merge/smart_merger_base.rb +621 -0
  37. data/lib/markdown/merge/table_match_algorithm.rb +504 -0
  38. data/lib/markdown/merge/table_match_refiner.rb +136 -0
  39. data/lib/markdown/merge/version.rb +12 -0
  40. data/lib/markdown/merge/whitespace_normalizer.rb +251 -0
  41. data/lib/markdown/merge.rb +149 -0
  42. data/lib/markdown-merge.rb +4 -0
  43. data/sig/markdown/merge.rbs +341 -0
  44. data.tar.gz.sig +0 -0
  45. metadata +365 -0
  46. metadata.gz.sig +0 -0
@@ -0,0 +1,221 @@
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
+ # @return [Symbol] The backend being used (:commonmarker, :markly)
63
+ attr_reader :backend
64
+
65
+ # Creates a new SmartMerger for intelligent Markdown file merging.
66
+ #
67
+ # @param template_content [String] Template Markdown source code
68
+ # @param dest_content [String] Destination Markdown source code
69
+ #
70
+ # @param backend [Symbol] Backend to use for parsing:
71
+ # - `:commonmarker` - Use Commonmarker (comrak Rust parser)
72
+ # - `:markly` - Use Markly (cmark-gfm C library)
73
+ # - `:auto` (default) - Auto-detect available backend
74
+ #
75
+ # @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
76
+ # The proc receives a node (wrapped with canonical merge_type) and should return one of:
77
+ # - An array representing the node's signature
78
+ # - `nil` to indicate the node should have no signature
79
+ # - The original node to fall through to default signature computation
80
+ #
81
+ # @param preference [Symbol] Controls which version to use when nodes
82
+ # have matching signatures but different content:
83
+ # - `:destination` (default) - Use destination version (preserves customizations)
84
+ # - `:template` - Use template version (applies updates)
85
+ #
86
+ # @param add_template_only_nodes [Boolean] Controls whether to add nodes that only
87
+ # exist in template:
88
+ # - `false` (default) - Skip template-only nodes
89
+ # - `true` - Add template-only nodes to result
90
+ #
91
+ # @param inner_merge_code_blocks [Boolean, CodeBlockMerger] Controls inner-merge for
92
+ # fenced code blocks:
93
+ # - `true` - Enable inner-merge using default CodeBlockMerger
94
+ # - `false` (default) - Disable inner-merge (use standard conflict resolution)
95
+ # - `CodeBlockMerger` instance - Use custom CodeBlockMerger
96
+ #
97
+ # @param freeze_token [String] Token to use for freeze block markers.
98
+ # Default: "markdown-merge"
99
+ # Looks for: <!-- markdown-merge:freeze --> / <!-- markdown-merge:unfreeze -->
100
+ #
101
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching of
102
+ # unmatched nodes. Default: nil (fuzzy matching disabled).
103
+ # Set to TableMatchRefiner.new to enable fuzzy table matching.
104
+ #
105
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
106
+ # for per-node-type merge preferences. Maps node type names to callables.
107
+ #
108
+ # @param parser_options [Hash] Backend-specific parser options.
109
+ # For commonmarker: { options: {} }
110
+ # For markly: { flags: Markly::DEFAULT, extensions: [:table] }
111
+ #
112
+ # @raise [TemplateParseError] If template has syntax errors
113
+ # @raise [DestinationParseError] If destination has syntax errors
114
+ def initialize(
115
+ template_content,
116
+ dest_content,
117
+ backend: :auto,
118
+ signature_generator: nil,
119
+ preference: :destination,
120
+ add_template_only_nodes: false,
121
+ inner_merge_code_blocks: false,
122
+ freeze_token: FileAnalysis::DEFAULT_FREEZE_TOKEN,
123
+ match_refiner: nil,
124
+ node_typing: nil,
125
+ **parser_options
126
+ )
127
+ @requested_backend = backend
128
+ @parser_options = parser_options
129
+
130
+ super(
131
+ template_content,
132
+ dest_content,
133
+ signature_generator: signature_generator,
134
+ preference: preference,
135
+ add_template_only_nodes: add_template_only_nodes,
136
+ inner_merge_code_blocks: inner_merge_code_blocks,
137
+ freeze_token: freeze_token,
138
+ match_refiner: match_refiner,
139
+ node_typing: node_typing,
140
+ # Pass through for FileAnalysis
141
+ backend: backend,
142
+ **parser_options,
143
+ )
144
+
145
+ # Capture the resolved backend from template analysis
146
+ @backend = @template_analysis.backend
147
+ end
148
+
149
+ # Create a FileAnalysis instance for parsing.
150
+ #
151
+ # @param content [String] Markdown content to analyze
152
+ # @param options [Hash] Analysis options
153
+ # @return [FileAnalysis] File analysis instance
154
+ def create_file_analysis(content, **opts)
155
+ FileAnalysis.new(
156
+ content,
157
+ backend: opts[:backend] || @requested_backend,
158
+ freeze_token: opts[:freeze_token],
159
+ signature_generator: opts[:signature_generator],
160
+ **@parser_options,
161
+ )
162
+ end
163
+
164
+ # Returns the TemplateParseError class to use.
165
+ #
166
+ # @return [Class] Markdown::Merge::TemplateParseError
167
+ def template_parse_error_class
168
+ TemplateParseError
169
+ end
170
+
171
+ # Returns the DestinationParseError class to use.
172
+ #
173
+ # @return [Class] Markdown::Merge::DestinationParseError
174
+ def destination_parse_error_class
175
+ DestinationParseError
176
+ end
177
+
178
+ # Convert a node to its source text.
179
+ #
180
+ # Handles wrapped nodes from NodeTypeNormalizer, gap line nodes,
181
+ # and link definition nodes created during gap detection.
182
+ #
183
+ # @param node [Object] Node to convert (may be wrapped)
184
+ # @param analysis [FileAnalysis] Analysis for source lookup
185
+ # @return [String] Source text
186
+ def node_to_source(node, analysis)
187
+ # Check for any FreezeNode type (base class or subclass)
188
+ if node.is_a?(Ast::Merge::FreezeNodeBase)
189
+ return node.full_text
190
+ end
191
+
192
+ # Handle gap line nodes (created for blank lines and link definitions)
193
+ if node.is_a?(LinkDefinitionNode) || node.is_a?(GapLineNode)
194
+ return node.content
195
+ end
196
+
197
+ # Unwrap if needed to access source_position
198
+ raw_node = Ast::Merge::NodeTyping.unwrap(node)
199
+
200
+ pos = raw_node.source_position
201
+ start_line = pos&.dig(:start_line)
202
+ end_line = pos&.dig(:end_line)
203
+
204
+ # Fall back to to_commonmark if no position info
205
+ return raw_node.to_commonmark unless start_line && end_line
206
+
207
+ # Get source from line range
208
+ source = analysis.source_range(start_line, end_line)
209
+
210
+ # Handle Markly's buggy position reporting for :html nodes
211
+ # where end_line < start_line results in empty source_range.
212
+ # Fall back to to_commonmark in that case.
213
+ if source.empty? && raw_node.respond_to?(:to_commonmark)
214
+ raw_node.to_commonmark.chomp
215
+ else
216
+ source
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end