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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +251 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +1087 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +253 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +405 -0
- data/lib/markdown/merge/cleanse.rb +42 -0
- data/lib/markdown/merge/code_block_merger.rb +300 -0
- data/lib/markdown/merge/conflict_resolver.rb +128 -0
- data/lib/markdown/merge/debug_logger.rb +26 -0
- data/lib/markdown/merge/document_problems.rb +190 -0
- data/lib/markdown/merge/file_aligner.rb +196 -0
- data/lib/markdown/merge/file_analysis.rb +353 -0
- data/lib/markdown/merge/file_analysis_base.rb +629 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +136 -0
- data/lib/markdown/merge/link_definition_formatter.rb +49 -0
- data/lib/markdown/merge/link_definition_node.rb +157 -0
- data/lib/markdown/merge/link_parser.rb +421 -0
- data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
- data/lib/markdown/merge/markdown_structure.rb +123 -0
- data/lib/markdown/merge/merge_result.rb +166 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +166 -0
- data/lib/markdown/merge/partial_template_merger.rb +334 -0
- data/lib/markdown/merge/smart_merger.rb +221 -0
- data/lib/markdown/merge/smart_merger_base.rb +621 -0
- data/lib/markdown/merge/table_match_algorithm.rb +504 -0
- data/lib/markdown/merge/table_match_refiner.rb +136 -0
- data/lib/markdown/merge/version.rb +12 -0
- data/lib/markdown/merge/whitespace_normalizer.rb +251 -0
- data/lib/markdown/merge.rb +149 -0
- data/lib/markdown-merge.rb +4 -0
- data/sig/markdown/merge.rbs +341 -0
- data.tar.gz.sig +0 -0
- metadata +365 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Merges fenced code blocks using language-specific *-merge gems.
|
|
6
|
+
#
|
|
7
|
+
# When two code blocks with the same signature are matched, this class
|
|
8
|
+
# delegates the merge to the appropriate language-specific merger:
|
|
9
|
+
# - Ruby code → prism-merge
|
|
10
|
+
# - YAML code → psych-merge
|
|
11
|
+
# - JSON code → json-merge
|
|
12
|
+
# - TOML code → toml-merge
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# merger = CodeBlockMerger.new
|
|
16
|
+
# result = merger.merge_code_blocks(template_node, dest_node, preference: :destination)
|
|
17
|
+
# if result[:merged]
|
|
18
|
+
# puts result[:content]
|
|
19
|
+
# else
|
|
20
|
+
# # Fall back to standard resolution
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example With custom mergers
|
|
24
|
+
# merger = CodeBlockMerger.new(
|
|
25
|
+
# mergers: {
|
|
26
|
+
# "ruby" => ->(template, dest, pref) { MyCustomRubyMerger.merge(template, dest, pref) },
|
|
27
|
+
# }
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# @see SmartMergerBase
|
|
31
|
+
# @api public
|
|
32
|
+
class CodeBlockMerger
|
|
33
|
+
# Default language-to-merger mapping
|
|
34
|
+
# Each merger is a lambda that takes (template_content, dest_content, preference)
|
|
35
|
+
# and returns { merged: true/false, content: String, stats: Hash }
|
|
36
|
+
# :nocov: integration - DEFAULT_MERGERS lambdas require external gems
|
|
37
|
+
DEFAULT_MERGERS = {
|
|
38
|
+
# Ruby code blocks
|
|
39
|
+
"ruby" => ->(template, dest, preference, **opts) {
|
|
40
|
+
require "prism/merge"
|
|
41
|
+
CodeBlockMerger.merge_with_prism(template, dest, preference, **opts)
|
|
42
|
+
},
|
|
43
|
+
"rb" => ->(template, dest, preference, **opts) {
|
|
44
|
+
require "prism/merge"
|
|
45
|
+
CodeBlockMerger.merge_with_prism(template, dest, preference, **opts)
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
# YAML code blocks
|
|
49
|
+
"yaml" => ->(template, dest, preference, **opts) {
|
|
50
|
+
require "psych/merge"
|
|
51
|
+
CodeBlockMerger.merge_with_psych(template, dest, preference, **opts)
|
|
52
|
+
},
|
|
53
|
+
"yml" => ->(template, dest, preference, **opts) {
|
|
54
|
+
require "psych/merge"
|
|
55
|
+
CodeBlockMerger.merge_with_psych(template, dest, preference, **opts)
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
# JSON code blocks
|
|
59
|
+
"json" => ->(template, dest, preference, **opts) {
|
|
60
|
+
require "json/merge"
|
|
61
|
+
CodeBlockMerger.merge_with_json(template, dest, preference, **opts)
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
# TOML code blocks
|
|
65
|
+
"toml" => ->(template, dest, preference, **opts) {
|
|
66
|
+
require "toml/merge"
|
|
67
|
+
CodeBlockMerger.merge_with_toml(template, dest, preference, **opts)
|
|
68
|
+
},
|
|
69
|
+
}.freeze
|
|
70
|
+
# :nocov:
|
|
71
|
+
|
|
72
|
+
# @return [Hash<String, Proc>] Language to merger mapping
|
|
73
|
+
attr_reader :mergers
|
|
74
|
+
|
|
75
|
+
# @return [Boolean] Whether inner-merge is enabled
|
|
76
|
+
attr_reader :enabled
|
|
77
|
+
|
|
78
|
+
# Creates a new CodeBlockMerger.
|
|
79
|
+
#
|
|
80
|
+
# @param mergers [Hash<String, Proc>] Custom language-to-merger mapping.
|
|
81
|
+
# Mergers are merged with defaults, allowing selective overrides.
|
|
82
|
+
# @param enabled [Boolean] Whether to enable inner-merge (default: true)
|
|
83
|
+
def initialize(mergers: {}, enabled: true)
|
|
84
|
+
@mergers = DEFAULT_MERGERS.merge(mergers)
|
|
85
|
+
@enabled = enabled
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if inner-merge is available for a language.
|
|
89
|
+
#
|
|
90
|
+
# @param language [String] The language identifier from fence_info
|
|
91
|
+
# @return [Boolean] true if a merger exists for this language
|
|
92
|
+
def supports_language?(language)
|
|
93
|
+
return false unless @enabled
|
|
94
|
+
return false if language.nil? || language.empty?
|
|
95
|
+
|
|
96
|
+
@mergers.key?(language.downcase)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Merge two code blocks using the appropriate language-specific merger.
|
|
100
|
+
#
|
|
101
|
+
# @param template_node [Object] Template code block node
|
|
102
|
+
# @param dest_node [Object] Destination code block node
|
|
103
|
+
# @param preference [Symbol] :destination or :template
|
|
104
|
+
# @param opts [Hash] Additional options passed to the merger
|
|
105
|
+
# @return [Hash] { merged: Boolean, content: String, stats: Hash }
|
|
106
|
+
def merge_code_blocks(template_node, dest_node, preference:, **opts)
|
|
107
|
+
return not_merged("inner-merge disabled") unless @enabled
|
|
108
|
+
|
|
109
|
+
language = extract_language(template_node) || extract_language(dest_node)
|
|
110
|
+
return not_merged("no language specified") unless language
|
|
111
|
+
|
|
112
|
+
merger = @mergers[language.downcase]
|
|
113
|
+
return not_merged("no merger for language: #{language}") unless merger
|
|
114
|
+
|
|
115
|
+
template_content = extract_content(template_node)
|
|
116
|
+
dest_content = extract_content(dest_node)
|
|
117
|
+
|
|
118
|
+
# If content is identical, no need to merge
|
|
119
|
+
if template_content == dest_content
|
|
120
|
+
return {
|
|
121
|
+
merged: true,
|
|
122
|
+
content: rebuild_code_block(language, dest_content, dest_node),
|
|
123
|
+
stats: {decision: :identical},
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
result = merger.call(template_content, dest_content, preference, **opts)
|
|
129
|
+
if result[:merged]
|
|
130
|
+
{
|
|
131
|
+
merged: true,
|
|
132
|
+
content: rebuild_code_block(language, result[:content], dest_node),
|
|
133
|
+
stats: result[:stats] || {},
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
not_merged(result[:reason] || "merger declined")
|
|
137
|
+
end
|
|
138
|
+
rescue LoadError => e
|
|
139
|
+
not_merged("merger gem not available: #{e.message}")
|
|
140
|
+
rescue TreeHaver::Error => e
|
|
141
|
+
# TreeHaver::NotAvailable and TreeHaver::Error inherit from Exception (not StandardError)
|
|
142
|
+
# for safety reasons related to backend conflicts. We catch them here to handle
|
|
143
|
+
# gracefully when a backend isn't properly configured.
|
|
144
|
+
not_merged("backend not available: #{e.message}")
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
# :nocov: defensive - Prism::Merge::ParseError handling when prism/merge is loaded
|
|
147
|
+
# Check for Prism::Merge::ParseError if prism/merge is loaded
|
|
148
|
+
if defined?(::Prism::Merge::ParseError) && e.is_a?(::Prism::Merge::ParseError)
|
|
149
|
+
not_merged("Ruby parse error: #{e.message}")
|
|
150
|
+
else
|
|
151
|
+
not_merged("merge failed: #{e.class}: #{e.message}")
|
|
152
|
+
end
|
|
153
|
+
# :nocov:
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Extract language from a code block node.
|
|
160
|
+
#
|
|
161
|
+
# @param node [Object] The code block node
|
|
162
|
+
# @return [String, nil] The language identifier
|
|
163
|
+
def extract_language(node)
|
|
164
|
+
return unless node.respond_to?(:fence_info)
|
|
165
|
+
|
|
166
|
+
info = node.fence_info
|
|
167
|
+
return if info.nil? || info.empty?
|
|
168
|
+
|
|
169
|
+
# fence_info may contain additional info after the language (e.g., "ruby linenos")
|
|
170
|
+
info.split(/\s+/).first
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Extract content from a code block node.
|
|
174
|
+
#
|
|
175
|
+
# @param node [Object] The code block node
|
|
176
|
+
# @return [String] The code content
|
|
177
|
+
def extract_content(node)
|
|
178
|
+
node.string_content || ""
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Rebuild a fenced code block with merged content.
|
|
182
|
+
#
|
|
183
|
+
# @param language [String] The language identifier
|
|
184
|
+
# @param content [String] The merged content
|
|
185
|
+
# @param reference_node [Object] Node to copy fence style from
|
|
186
|
+
# @return [String] The reconstructed code block
|
|
187
|
+
def rebuild_code_block(language, content, reference_node)
|
|
188
|
+
# Ensure content ends with newline for proper fence closing
|
|
189
|
+
content = content.chomp + "\n" unless content.end_with?("\n")
|
|
190
|
+
|
|
191
|
+
# Use backticks as default fence
|
|
192
|
+
fence = "```"
|
|
193
|
+
|
|
194
|
+
"#{fence}#{language}\n#{content}#{fence}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Return a not-merged result.
|
|
198
|
+
#
|
|
199
|
+
# @param reason [String] Why merge was not performed
|
|
200
|
+
# @return [Hash] Not-merged result hash
|
|
201
|
+
def not_merged(reason)
|
|
202
|
+
{merged: false, reason: reason}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class << self
|
|
206
|
+
# Merge Ruby code using prism-merge.
|
|
207
|
+
#
|
|
208
|
+
# @param template [String] Template Ruby code
|
|
209
|
+
# @param dest [String] Destination Ruby code
|
|
210
|
+
# @param preference [Symbol] :destination or :template
|
|
211
|
+
# @return [Hash] Merge result
|
|
212
|
+
# @raise [Prism::Merge::ParseError] If template or dest has syntax errors
|
|
213
|
+
# @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
|
|
214
|
+
def merge_with_prism(template, dest, preference, **opts)
|
|
215
|
+
merger = ::Prism::Merge::SmartMerger.new(
|
|
216
|
+
template,
|
|
217
|
+
dest,
|
|
218
|
+
preference: preference,
|
|
219
|
+
add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
merged: true,
|
|
224
|
+
content: merger.merge,
|
|
225
|
+
stats: merger.stats,
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Merge YAML code using psych-merge.
|
|
230
|
+
#
|
|
231
|
+
# @param template [String] Template YAML code
|
|
232
|
+
# @param dest [String] Destination YAML code
|
|
233
|
+
# @param preference [Symbol] :destination or :template
|
|
234
|
+
# @return [Hash] Merge result
|
|
235
|
+
# @raise [Psych::Merge::ParseError] If template or dest has syntax errors
|
|
236
|
+
# @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
|
|
237
|
+
def merge_with_psych(template, dest, preference, **opts)
|
|
238
|
+
merger = ::Psych::Merge::SmartMerger.new(
|
|
239
|
+
template,
|
|
240
|
+
dest,
|
|
241
|
+
preference: preference,
|
|
242
|
+
add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
merged: true,
|
|
247
|
+
content: merger.merge,
|
|
248
|
+
stats: merger.stats,
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Merge JSON code using json-merge.
|
|
253
|
+
#
|
|
254
|
+
# @param template [String] Template JSON code
|
|
255
|
+
# @param dest [String] Destination JSON code
|
|
256
|
+
# @param preference [Symbol] :destination or :template
|
|
257
|
+
# @return [Hash] Merge result
|
|
258
|
+
# @raise [Json::Merge::ParseError] If template or dest has syntax errors
|
|
259
|
+
# @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
|
|
260
|
+
def merge_with_json(template, dest, preference, **opts)
|
|
261
|
+
merger = ::Json::Merge::SmartMerger.new(
|
|
262
|
+
template,
|
|
263
|
+
dest,
|
|
264
|
+
preference: preference,
|
|
265
|
+
add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
merged: true,
|
|
270
|
+
content: merger.merge,
|
|
271
|
+
stats: merger.stats,
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Merge TOML code using toml-merge.
|
|
276
|
+
#
|
|
277
|
+
# @param template [String] Template TOML code
|
|
278
|
+
# @param dest [String] Destination TOML code
|
|
279
|
+
# @param preference [Symbol] :destination or :template
|
|
280
|
+
# @return [Hash] Merge result
|
|
281
|
+
# @raise [Toml::Merge::ParseError] If template or dest has syntax errors
|
|
282
|
+
# @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
|
|
283
|
+
def merge_with_toml(template, dest, preference, **opts)
|
|
284
|
+
merger = ::Toml::Merge::SmartMerger.new(
|
|
285
|
+
template,
|
|
286
|
+
dest,
|
|
287
|
+
preference: preference,
|
|
288
|
+
add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
{
|
|
292
|
+
merged: true,
|
|
293
|
+
content: merger.merge,
|
|
294
|
+
stats: merger.stats,
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Resolves conflicts between matching Markdown elements from template and destination.
|
|
6
|
+
#
|
|
7
|
+
# When two elements have the same signature but different content, the resolver
|
|
8
|
+
# determines which version to use based on the configured preference.
|
|
9
|
+
#
|
|
10
|
+
# Inherits from Ast::Merge::ConflictResolverBase using the :node strategy,
|
|
11
|
+
# which resolves conflicts on a per-node-pair basis.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# resolver = ConflictResolver.new(
|
|
15
|
+
# preference: :destination,
|
|
16
|
+
# template_analysis: template_analysis,
|
|
17
|
+
# dest_analysis: dest_analysis
|
|
18
|
+
# )
|
|
19
|
+
# resolution = resolver.resolve(template_node, dest_node, template_index: 0, dest_index: 0)
|
|
20
|
+
# case resolution[:source]
|
|
21
|
+
# when :template
|
|
22
|
+
# # Use template version
|
|
23
|
+
# when :destination
|
|
24
|
+
# # Use destination version
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @see SmartMergerBase
|
|
28
|
+
# @see Ast::Merge::ConflictResolverBase
|
|
29
|
+
class ConflictResolver < Ast::Merge::ConflictResolverBase
|
|
30
|
+
# Initialize a conflict resolver
|
|
31
|
+
#
|
|
32
|
+
# @param preference [Symbol] Which version to prefer (:destination or :template)
|
|
33
|
+
# @param template_analysis [FileAnalysisBase] Analysis of the template file
|
|
34
|
+
# @param dest_analysis [FileAnalysisBase] Analysis of the destination file
|
|
35
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
36
|
+
def initialize(preference:, template_analysis:, dest_analysis:, **options)
|
|
37
|
+
super(
|
|
38
|
+
strategy: :node,
|
|
39
|
+
preference: preference,
|
|
40
|
+
template_analysis: template_analysis,
|
|
41
|
+
dest_analysis: dest_analysis,
|
|
42
|
+
**options
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
protected
|
|
47
|
+
|
|
48
|
+
# Resolve a conflict between template and destination nodes
|
|
49
|
+
#
|
|
50
|
+
# @param template_node [Object] Node from template
|
|
51
|
+
# @param dest_node [Object] Node from destination
|
|
52
|
+
# @param template_index [Integer] Index in template statements
|
|
53
|
+
# @param dest_index [Integer] Index in destination statements
|
|
54
|
+
# @return [Hash] Resolution with :source, :decision, and node references
|
|
55
|
+
def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
|
|
56
|
+
# Frozen blocks always win
|
|
57
|
+
if freeze_node?(dest_node)
|
|
58
|
+
return frozen_resolution(
|
|
59
|
+
source: :destination,
|
|
60
|
+
template_node: template_node,
|
|
61
|
+
dest_node: dest_node,
|
|
62
|
+
reason: dest_node.reason,
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if freeze_node?(template_node)
|
|
67
|
+
return frozen_resolution(
|
|
68
|
+
source: :template,
|
|
69
|
+
template_node: template_node,
|
|
70
|
+
dest_node: dest_node,
|
|
71
|
+
reason: template_node.reason,
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if content is identical
|
|
76
|
+
if content_identical?(template_node, dest_node)
|
|
77
|
+
return identical_resolution(
|
|
78
|
+
template_node: template_node,
|
|
79
|
+
dest_node: dest_node,
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Use preference to decide
|
|
84
|
+
preference_resolution(
|
|
85
|
+
template_node: template_node,
|
|
86
|
+
dest_node: dest_node,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Check if two nodes have identical content
|
|
93
|
+
#
|
|
94
|
+
# @param template_node [Object] Template node
|
|
95
|
+
# @param dest_node [Object] Destination node
|
|
96
|
+
# @return [Boolean] True if content is identical
|
|
97
|
+
def content_identical?(template_node, dest_node)
|
|
98
|
+
template_text = node_to_text(template_node, @template_analysis)
|
|
99
|
+
dest_text = node_to_text(dest_node, @dest_analysis)
|
|
100
|
+
template_text == dest_text
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Convert a node to its source text
|
|
104
|
+
#
|
|
105
|
+
# @param node [Object] Node to convert
|
|
106
|
+
# @param analysis [FileAnalysisBase] Analysis for source lookup
|
|
107
|
+
# @return [String] Source text
|
|
108
|
+
def node_to_text(node, analysis)
|
|
109
|
+
# Check for any FreezeNode type (base class or subclass)
|
|
110
|
+
if node.is_a?(Ast::Merge::FreezeNodeBase)
|
|
111
|
+
node.full_text
|
|
112
|
+
else
|
|
113
|
+
pos = node.source_position
|
|
114
|
+
start_line = pos&.dig(:start_line)
|
|
115
|
+
end_line = pos&.dig(:end_line)
|
|
116
|
+
|
|
117
|
+
if start_line && end_line
|
|
118
|
+
analysis.source_range(start_line, end_line)
|
|
119
|
+
else
|
|
120
|
+
# :nocov: defensive - Markdown nodes typically have source positions
|
|
121
|
+
node.to_commonmark
|
|
122
|
+
# :nocov:
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Debug logging utility for Markdown::Merge operations.
|
|
6
|
+
#
|
|
7
|
+
# Extends Ast::Merge::DebugLogger to provide consistent logging
|
|
8
|
+
# across all merge gems. Logs are controlled via environment variables.
|
|
9
|
+
#
|
|
10
|
+
# @example Enable debug logging
|
|
11
|
+
# ENV["MARKDOWN_MERGE_DEBUG"] = "1"
|
|
12
|
+
# DebugLogger.debug("Parsing markdown", { file: "README.md" })
|
|
13
|
+
#
|
|
14
|
+
# @example Time an operation
|
|
15
|
+
# result = DebugLogger.time("parse") { Markly.parse(source) }
|
|
16
|
+
#
|
|
17
|
+
# @see Ast::Merge::DebugLogger Base module
|
|
18
|
+
module DebugLogger
|
|
19
|
+
extend Ast::Merge::DebugLogger
|
|
20
|
+
|
|
21
|
+
# Configure for markdown-merge
|
|
22
|
+
self.env_var_name = "MARKDOWN_MERGE_DEBUG"
|
|
23
|
+
self.log_prefix = "[markdown-merge]"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Container for document issues found during processing.
|
|
6
|
+
#
|
|
7
|
+
# Collects problems discovered during merge operations, link reference
|
|
8
|
+
# rehydration, whitespace normalization, and other document transformations.
|
|
9
|
+
# Problems are categorized and have severity levels for filtering and reporting.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# problems = DocumentProblems.new
|
|
13
|
+
# problems.add(:duplicate_link_definition, label: "example", url: "https://example.com")
|
|
14
|
+
# problems.add(:excessive_whitespace, line: 42, count: 5, severity: :warning)
|
|
15
|
+
# problems.empty? # => false
|
|
16
|
+
# problems.count # => 2
|
|
17
|
+
#
|
|
18
|
+
# @example Filtering by category
|
|
19
|
+
# problems.by_category(:duplicate_link_definition)
|
|
20
|
+
# # => [{ category: :duplicate_link_definition, label: "example", ... }]
|
|
21
|
+
#
|
|
22
|
+
# @example Filtering by severity
|
|
23
|
+
# problems.by_severity(:error)
|
|
24
|
+
# problems.warnings
|
|
25
|
+
# problems.errors
|
|
26
|
+
#
|
|
27
|
+
class DocumentProblems
|
|
28
|
+
# Problem entry struct
|
|
29
|
+
Problem = Struct.new(:category, :severity, :details, keyword_init: true) do
|
|
30
|
+
def to_h
|
|
31
|
+
{category: category, severity: severity, **details}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def warning?
|
|
35
|
+
severity == :warning
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def error?
|
|
39
|
+
severity == :error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def info?
|
|
43
|
+
severity == :info
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Valid severity levels
|
|
48
|
+
SEVERITIES = %i[info warning error].freeze
|
|
49
|
+
|
|
50
|
+
# Valid problem categories
|
|
51
|
+
CATEGORIES = %i[
|
|
52
|
+
duplicate_link_definition
|
|
53
|
+
excessive_whitespace
|
|
54
|
+
link_has_title
|
|
55
|
+
image_has_title
|
|
56
|
+
link_ref_spacing
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
# @return [Array<Problem>] All collected problems
|
|
60
|
+
attr_reader :problems
|
|
61
|
+
|
|
62
|
+
def initialize
|
|
63
|
+
@problems = []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Add a problem to the collection.
|
|
67
|
+
#
|
|
68
|
+
# @param category [Symbol] Problem category (see CATEGORIES)
|
|
69
|
+
# @param severity [Symbol] Severity level (:info, :warning, :error), default :warning
|
|
70
|
+
# @param details [Hash] Additional details about the problem
|
|
71
|
+
# @return [Problem] The added problem
|
|
72
|
+
def add(category, severity: :warning, **details)
|
|
73
|
+
validate_category!(category)
|
|
74
|
+
validate_severity!(severity)
|
|
75
|
+
|
|
76
|
+
problem = Problem.new(category: category, severity: severity, details: details)
|
|
77
|
+
@problems << problem
|
|
78
|
+
problem
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get all problems as an array of hashes.
|
|
82
|
+
#
|
|
83
|
+
# @return [Array<Hash>] All problems
|
|
84
|
+
def all
|
|
85
|
+
@problems.map(&:to_h)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get problems by category.
|
|
89
|
+
#
|
|
90
|
+
# @param category [Symbol] Category to filter by
|
|
91
|
+
# @return [Array<Problem>] Problems in that category
|
|
92
|
+
def by_category(category)
|
|
93
|
+
@problems.select { |p| p.category == category }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get problems by severity.
|
|
97
|
+
#
|
|
98
|
+
# @param severity [Symbol] Severity to filter by
|
|
99
|
+
# @return [Array<Problem>] Problems with that severity
|
|
100
|
+
def by_severity(severity)
|
|
101
|
+
@problems.select { |p| p.severity == severity }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get all info-level problems.
|
|
105
|
+
#
|
|
106
|
+
# @return [Array<Problem>] Info problems
|
|
107
|
+
def infos
|
|
108
|
+
by_severity(:info)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get all warning-level problems.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<Problem>] Warning problems
|
|
114
|
+
def warnings
|
|
115
|
+
by_severity(:warning)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get all error-level problems.
|
|
119
|
+
#
|
|
120
|
+
# @return [Array<Problem>] Error problems
|
|
121
|
+
def errors
|
|
122
|
+
by_severity(:error)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Check if there are any problems.
|
|
126
|
+
#
|
|
127
|
+
# @return [Boolean] true if no problems
|
|
128
|
+
def empty?
|
|
129
|
+
@problems.empty?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get the count of problems.
|
|
133
|
+
#
|
|
134
|
+
# @param category [Symbol, nil] Optional category filter
|
|
135
|
+
# @param severity [Symbol, nil] Optional severity filter
|
|
136
|
+
# @return [Integer] Problem count
|
|
137
|
+
def count(category: nil, severity: nil)
|
|
138
|
+
filtered = @problems
|
|
139
|
+
filtered = filtered.select { |p| p.category == category } if category
|
|
140
|
+
filtered = filtered.select { |p| p.severity == severity } if severity
|
|
141
|
+
filtered.size
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Merge another DocumentProblems into this one.
|
|
145
|
+
#
|
|
146
|
+
# @param other [DocumentProblems] Problems to merge
|
|
147
|
+
# @return [self]
|
|
148
|
+
def merge!(other)
|
|
149
|
+
@problems.concat(other.problems)
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Clear all problems.
|
|
154
|
+
#
|
|
155
|
+
# @return [self]
|
|
156
|
+
def clear
|
|
157
|
+
@problems.clear
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Get a summary of problems by category.
|
|
162
|
+
#
|
|
163
|
+
# @return [Hash<Symbol, Integer>] Counts by category
|
|
164
|
+
def summary_by_category
|
|
165
|
+
@problems.group_by(&:category).transform_values(&:size)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Get a summary of problems by severity.
|
|
169
|
+
#
|
|
170
|
+
# @return [Hash<Symbol, Integer>] Counts by severity
|
|
171
|
+
def summary_by_severity
|
|
172
|
+
@problems.group_by(&:severity).transform_values(&:size)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def validate_category!(category)
|
|
178
|
+
return if CATEGORIES.include?(category)
|
|
179
|
+
|
|
180
|
+
raise ArgumentError, "Invalid category: #{category}. Valid: #{CATEGORIES.join(", ")}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_severity!(severity)
|
|
184
|
+
return if SEVERITIES.include?(severity)
|
|
185
|
+
|
|
186
|
+
raise ArgumentError, "Invalid severity: #{severity}. Valid: #{SEVERITIES.join(", ")}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|