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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +673 -0
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
- data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
- data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
- data/lib/markdown/merge/cleanse.rb +44 -0
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +742 -0
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +199 -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 +496 -0
- data/lib/markdown/merge/file_analysis.rb +689 -0
- data/lib/markdown/merge/file_analysis_base.rb +766 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +142 -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/list_match_refiner.rb +98 -0
- data/lib/markdown/merge/list_merger.rb +322 -0
- data/lib/markdown/merge/markdown_structure.rb +123 -0
- data/lib/markdown/merge/merge_result.rb +483 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +248 -0
- data/lib/markdown/merge/partial_template_merger.rb +555 -0
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/smart_merger.rb +269 -0
- data/lib/markdown/merge/smart_merger_base.rb +1490 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/table_match_algorithm.rb +499 -0
- data/lib/markdown/merge/table_match_refiner.rb +132 -0
- data/lib/markdown/merge/version.rb +5 -3
- data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +271 -87
- data/lib/markdown-merge.rb +7 -1
- data/sig/markdown/merge.rbs +62 -0
- data.tar.gz.sig +0 -0
- metadata +289 -15
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parslet'
|
|
4
|
+
|
|
5
|
+
module Markdown
|
|
6
|
+
module Merge
|
|
7
|
+
module Cleanse
|
|
8
|
+
# Parslet-based parser for fixing malformed fenced code blocks in Markdown.
|
|
9
|
+
#
|
|
10
|
+
# == The Problem
|
|
11
|
+
#
|
|
12
|
+
# This class fixes **improperly formatted fenced code blocks** where there is
|
|
13
|
+
# unwanted whitespace between the fence markers (``` or ~~~) and the language
|
|
14
|
+
# identifier.
|
|
15
|
+
#
|
|
16
|
+
# A bug in ast-merge (or its dependencies) caused fenced code blocks to be
|
|
17
|
+
# rendered with a space between the fence markers and the language identifier.
|
|
18
|
+
#
|
|
19
|
+
# == Bug Pattern
|
|
20
|
+
#
|
|
21
|
+
# CommonMark and most Markdown parsers expect NO space between fence and language:
|
|
22
|
+
# - **Correct:** ` ```ruby` or ` ~~~python`
|
|
23
|
+
# - **Incorrect:** ` ``` ruby` or ` ~~~ python` (extra space)
|
|
24
|
+
#
|
|
25
|
+
# The extra space can cause:
|
|
26
|
+
# - Syntax highlighting to fail
|
|
27
|
+
# - The language identifier to be ignored
|
|
28
|
+
# - Rendering issues in various Markdown processors
|
|
29
|
+
#
|
|
30
|
+
# @example Malformed (buggy) input
|
|
31
|
+
# "``` console\nsome code\n```"
|
|
32
|
+
#
|
|
33
|
+
# @example Fixed output
|
|
34
|
+
# "```console\nsome code\n```"
|
|
35
|
+
#
|
|
36
|
+
# == Scope
|
|
37
|
+
#
|
|
38
|
+
# This fixer handles:
|
|
39
|
+
# - **Any indentation level** (0+ spaces before fence)
|
|
40
|
+
# - Top-level: ` ```ruby`
|
|
41
|
+
# - In lists: ` ```python` (4 spaces)
|
|
42
|
+
# - **Both fence types:** backticks (```) and tildes (~~~)
|
|
43
|
+
# - **Any fence length:** 3+ markers (````, ~~~~~, etc.)
|
|
44
|
+
#
|
|
45
|
+
# == How It Works
|
|
46
|
+
#
|
|
47
|
+
# The parser uses a **PEG grammar** (via Parslet) to:
|
|
48
|
+
# - Detect fence opening lines with optional indentation
|
|
49
|
+
# - Identify spacing between fence and language identifier
|
|
50
|
+
# - Track opening/closing fence pairs to avoid false positives
|
|
51
|
+
# - Reconstruct fences with proper formatting (no space)
|
|
52
|
+
#
|
|
53
|
+
# **Why PEG?** The previous regex-based implementation used patterns like
|
|
54
|
+
# `([ \t]*)` which can cause polynomial backtracking (ReDoS vulnerability)
|
|
55
|
+
# when processing malicious input with many tabs/spaces. PEG parsers are
|
|
56
|
+
# linear-time and immune to ReDoS attacks.
|
|
57
|
+
#
|
|
58
|
+
# @example Basic usage
|
|
59
|
+
# parser = Markdown::Merge::Cleanse::CodeFenceSpacing.new(content)
|
|
60
|
+
# fixed_content = parser.fix
|
|
61
|
+
#
|
|
62
|
+
# @example Check if content has malformed fences
|
|
63
|
+
# parser = Markdown::Merge::Cleanse::CodeFenceSpacing.new(content)
|
|
64
|
+
# parser.malformed? # => true/false
|
|
65
|
+
#
|
|
66
|
+
# @example Process a file
|
|
67
|
+
# content = File.read("README.md")
|
|
68
|
+
# parser = Markdown::Merge::Cleanse::CodeFenceSpacing.new(content)
|
|
69
|
+
# if parser.malformed?
|
|
70
|
+
# File.write("README.md", parser.fix)
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# @example Get details about code blocks
|
|
74
|
+
# parser = Markdown::Merge::Cleanse::CodeFenceSpacing.new(content)
|
|
75
|
+
# parser.code_blocks.each do |block|
|
|
76
|
+
# puts "#{block[:fence]}#{block[:language]}: malformed=#{block[:malformed]}"
|
|
77
|
+
# end
|
|
78
|
+
#
|
|
79
|
+
# @api public
|
|
80
|
+
class CodeFenceSpacing
|
|
81
|
+
# Grammar for parsing fenced code blocks with PEG parser.
|
|
82
|
+
#
|
|
83
|
+
# Recognizes:
|
|
84
|
+
# - Any amount of indentation (handles nested lists)
|
|
85
|
+
# - Backtick fences (```) and tilde fences (~~~)
|
|
86
|
+
# - Optional info string (language identifier)
|
|
87
|
+
# - Properly handles spacing issues
|
|
88
|
+
#
|
|
89
|
+
# This PEG grammar is linear-time and cannot have polynomial backtracking,
|
|
90
|
+
# eliminating ReDoS vulnerabilities.
|
|
91
|
+
#
|
|
92
|
+
# @api private
|
|
93
|
+
class CodeFenceGrammar < Parslet::Parser
|
|
94
|
+
# Any amount of indentation (handles code blocks in lists)
|
|
95
|
+
# Captured as string, not array
|
|
96
|
+
rule(:indent) { match('[ ]').repeat }
|
|
97
|
+
|
|
98
|
+
# Fence markers - 3+ backticks or tildes
|
|
99
|
+
rule(:backtick) { str('`') }
|
|
100
|
+
rule(:tilde) { str('~') }
|
|
101
|
+
rule(:backtick_fence) { backtick.repeat(3, nil) }
|
|
102
|
+
rule(:tilde_fence) { tilde.repeat(3, nil) }
|
|
103
|
+
rule(:fence) { backtick_fence | tilde_fence }
|
|
104
|
+
|
|
105
|
+
# Whitespace after fence (the bug we're fixing)
|
|
106
|
+
rule(:space) { match('[ \t]') }
|
|
107
|
+
rule(:spaces) { space.repeat(1) }
|
|
108
|
+
rule(:spaces?) { space.repeat }
|
|
109
|
+
|
|
110
|
+
# Info string (language identifier + optional attributes)
|
|
111
|
+
# Cannot contain backticks or tildes per CommonMark
|
|
112
|
+
rule(:info_char) { match('[^\r\n`~]') }
|
|
113
|
+
rule(:info_string) { info_char.repeat(1) }
|
|
114
|
+
|
|
115
|
+
# Line ending
|
|
116
|
+
rule(:line_end) { str("\r").maybe >> str("\n").maybe >> any.absent? }
|
|
117
|
+
|
|
118
|
+
# Fence line with optional indentation, optional spacing, optional info
|
|
119
|
+
# Capture: indent (raw), fence (as :fence), spacing (as :spacing), info (as :info)
|
|
120
|
+
rule(:fence_line) do
|
|
121
|
+
indent.as(:indent) >> fence.as(:fence) >> spaces?.as(:spacing) >> info_string.maybe.as(:info) >> line_end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
root(:fence_line)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [String] the input text to parse
|
|
128
|
+
attr_reader :source
|
|
129
|
+
|
|
130
|
+
# Create a new parser for the given text.
|
|
131
|
+
#
|
|
132
|
+
# @param source [String] the text that may contain malformed code fences
|
|
133
|
+
def initialize(source)
|
|
134
|
+
@source = source.to_s
|
|
135
|
+
@grammar = CodeFenceGrammar.new
|
|
136
|
+
@code_blocks = nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Check if the source contains malformed fenced code blocks.
|
|
140
|
+
#
|
|
141
|
+
# Detects the pattern where there's whitespace between the fence
|
|
142
|
+
# markers and the language identifier.
|
|
143
|
+
#
|
|
144
|
+
# @return [Boolean] true if malformed fences are detected
|
|
145
|
+
def malformed?
|
|
146
|
+
code_blocks.any? { |block| block[:malformed] }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Parse and return information about all fenced code blocks.
|
|
150
|
+
#
|
|
151
|
+
# Only returns opening fences (not closing fences).
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<Hash>] Array of code block info
|
|
154
|
+
# - :indent [String] The indentation before the fence
|
|
155
|
+
# - :fence [String] The fence markers (e.g., "```" or "~~~")
|
|
156
|
+
# - :language [String, nil] The language identifier
|
|
157
|
+
# - :spacing [String] Any spacing between fence and language
|
|
158
|
+
# - :malformed [Boolean] Whether this block has improper spacing
|
|
159
|
+
# - :line_number [Integer] Line number where block starts (1-based)
|
|
160
|
+
# - :original [String] The original opening fence line
|
|
161
|
+
def code_blocks
|
|
162
|
+
return @code_blocks if @code_blocks
|
|
163
|
+
|
|
164
|
+
@code_blocks = []
|
|
165
|
+
line_number = 0
|
|
166
|
+
in_code_block = false
|
|
167
|
+
current_fence_char = nil
|
|
168
|
+
|
|
169
|
+
source.each_line do |line|
|
|
170
|
+
line_number += 1
|
|
171
|
+
|
|
172
|
+
# Try to parse as fence line using PEG grammar
|
|
173
|
+
parsed = parse_fence_line(line)
|
|
174
|
+
next unless parsed
|
|
175
|
+
|
|
176
|
+
fence = parsed[:fence]
|
|
177
|
+
fence_char = fence[0]
|
|
178
|
+
spacing = parsed[:spacing] || ''
|
|
179
|
+
info = parsed[:info] || ''
|
|
180
|
+
indent = parsed[:indent] || ''
|
|
181
|
+
|
|
182
|
+
# Closing fence: matches current fence type and has no info
|
|
183
|
+
if in_code_block && fence_char == current_fence_char && info.empty?
|
|
184
|
+
in_code_block = false
|
|
185
|
+
current_fence_char = nil
|
|
186
|
+
next
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Opening fence
|
|
190
|
+
in_code_block = true
|
|
191
|
+
current_fence_char = fence_char
|
|
192
|
+
|
|
193
|
+
# Extract just the language (first word of info string)
|
|
194
|
+
language = info.strip.split(/\s+/).first
|
|
195
|
+
language = nil if language && language.empty?
|
|
196
|
+
|
|
197
|
+
@code_blocks << {
|
|
198
|
+
indent: indent,
|
|
199
|
+
fence: fence,
|
|
200
|
+
language: language,
|
|
201
|
+
info_string: info.strip,
|
|
202
|
+
spacing: spacing,
|
|
203
|
+
malformed: !spacing.empty? && !language.nil?,
|
|
204
|
+
line_number: line_number,
|
|
205
|
+
original: line.chomp
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
@code_blocks
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Fix malformed fenced code blocks by removing improper spacing.
|
|
213
|
+
#
|
|
214
|
+
# @return [String] the source with code fences fixed
|
|
215
|
+
def fix
|
|
216
|
+
return source unless malformed?
|
|
217
|
+
|
|
218
|
+
result = source.dup
|
|
219
|
+
|
|
220
|
+
# Process line by line, fixing malformed fences
|
|
221
|
+
lines = result.lines
|
|
222
|
+
fixed_lines = lines.map do |line|
|
|
223
|
+
fix_fence_line(line)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
fixed_lines.join
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Count the number of malformed code blocks.
|
|
230
|
+
#
|
|
231
|
+
# @return [Integer] number of malformed fences found
|
|
232
|
+
def malformed_count
|
|
233
|
+
code_blocks.count { |block| block[:malformed] }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Count the total number of code blocks.
|
|
237
|
+
#
|
|
238
|
+
# @return [Integer] total number of fenced code blocks
|
|
239
|
+
def count
|
|
240
|
+
code_blocks.size
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
# Parse a single line as a fence using PEG grammar.
|
|
246
|
+
#
|
|
247
|
+
# @param line [String] the line to parse
|
|
248
|
+
# @return [Hash, nil] parsed fence data or nil if not a fence
|
|
249
|
+
def parse_fence_line(line)
|
|
250
|
+
tree = @grammar.parse(line)
|
|
251
|
+
|
|
252
|
+
# Convert Parslet tree to simple hash
|
|
253
|
+
# Note: Parslet returns [] for empty repeats, we convert to empty string
|
|
254
|
+
indent_val = tree[:indent]
|
|
255
|
+
indent_str = indent_val.is_a?(Array) ? indent_val.join : indent_val.to_s
|
|
256
|
+
|
|
257
|
+
spacing_val = tree[:spacing]
|
|
258
|
+
spacing_str = spacing_val.is_a?(Array) ? spacing_val.join : spacing_val.to_s
|
|
259
|
+
|
|
260
|
+
info_val = tree[:info]
|
|
261
|
+
info_str = if info_val.is_a?(Array)
|
|
262
|
+
info_val.join
|
|
263
|
+
else
|
|
264
|
+
(info_val ? info_val.to_s : '')
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
{
|
|
268
|
+
indent: indent_str,
|
|
269
|
+
fence: tree[:fence].to_s,
|
|
270
|
+
spacing: spacing_str,
|
|
271
|
+
info: info_str
|
|
272
|
+
}
|
|
273
|
+
rescue Parslet::ParseFailed
|
|
274
|
+
nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Fix a single line if it's a malformed fence.
|
|
278
|
+
#
|
|
279
|
+
# @param line [String] the line to potentially fix
|
|
280
|
+
# @return [String] the fixed line (or original if not malformed)
|
|
281
|
+
def fix_fence_line(line)
|
|
282
|
+
parsed = parse_fence_line(line)
|
|
283
|
+
return line unless parsed
|
|
284
|
+
|
|
285
|
+
# Only fix if there's spacing AND info string
|
|
286
|
+
return line if parsed[:spacing].empty? || parsed[:info].empty?
|
|
287
|
+
|
|
288
|
+
# Reconstruct: indent + fence + info (no spacing)
|
|
289
|
+
"#{parsed[:indent]}#{parsed[:fence]}#{parsed[:info]}\n"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parslet'
|
|
4
|
+
|
|
5
|
+
module Markdown
|
|
6
|
+
module Merge
|
|
7
|
+
module Cleanse
|
|
8
|
+
# Parslet-based parser for fixing condensed Markdown link reference definitions.
|
|
9
|
+
#
|
|
10
|
+
# == The Problem
|
|
11
|
+
#
|
|
12
|
+
# This class fixes **corrupted Markdown files** where link reference definitions
|
|
13
|
+
# that were originally on separate lines got smashed together by having their
|
|
14
|
+
# separating newlines removed.
|
|
15
|
+
#
|
|
16
|
+
# A previous bug in ast-merge caused link reference definitions at the bottom
|
|
17
|
+
# of Markdown files to be merged together into a single line without newlines
|
|
18
|
+
# or whitespace between them.
|
|
19
|
+
#
|
|
20
|
+
# == Corruption Patterns
|
|
21
|
+
#
|
|
22
|
+
# Two types of corruption are detected and fixed:
|
|
23
|
+
#
|
|
24
|
+
# 1. **Multiple definitions condensed on one line:**
|
|
25
|
+
# - Corrupted: `[label1]: url1[label2]: url2`
|
|
26
|
+
# - Fixed: Each definition on its own line
|
|
27
|
+
#
|
|
28
|
+
# 2. **Content followed by definition without newline:**
|
|
29
|
+
# - Corrupted: `Some text or URL[label]: url`
|
|
30
|
+
# - Fixed: Newline inserted before `[label]:`
|
|
31
|
+
#
|
|
32
|
+
# @example Condensed definitions (Pattern 1)
|
|
33
|
+
# # Before (corrupted):
|
|
34
|
+
# "[⛳liberapay-img]: https://example.com/img.svg[⛳liberapay]: https://example.com"
|
|
35
|
+
#
|
|
36
|
+
# # After (fixed):
|
|
37
|
+
# "[⛳liberapay-img]: https://example.com/img.svg\n[⛳liberapay]: https://example.com"
|
|
38
|
+
#
|
|
39
|
+
# @example Content before definition (Pattern 2)
|
|
40
|
+
# # Before (corrupted):
|
|
41
|
+
# "https://donate.codeberg.org/[🤝contributing]: CONTRIBUTING.md"
|
|
42
|
+
#
|
|
43
|
+
# # After (fixed):
|
|
44
|
+
# "https://donate.codeberg.org/\n[🤝contributing]: CONTRIBUTING.md"
|
|
45
|
+
#
|
|
46
|
+
# == How It Works
|
|
47
|
+
#
|
|
48
|
+
# The parser uses a **PEG grammar** (via Parslet) to:
|
|
49
|
+
# - Recognize link reference definition patterns: `[label]: url`
|
|
50
|
+
# - Detect when multiple definitions are on the same line
|
|
51
|
+
# - Detect when content precedes a definition without newline separation
|
|
52
|
+
# - Parse and reconstruct definitions with proper newlines
|
|
53
|
+
#
|
|
54
|
+
# **Why PEG?** The previous regex-based implementation had potential ReDoS
|
|
55
|
+
# (Regular Expression Denial of Service) vulnerabilities due to complex
|
|
56
|
+
# lookahead/lookbehind patterns. PEG parsers are linear-time and immune to
|
|
57
|
+
# ReDoS attacks.
|
|
58
|
+
#
|
|
59
|
+
# The grammar extends the pattern from {LinkParser::DefinitionGrammar} but
|
|
60
|
+
# handles the case where definitions are concatenated without separators.
|
|
61
|
+
#
|
|
62
|
+
# @example Basic usage
|
|
63
|
+
# parser = Markdown::Merge::Cleanse::CondensedLinkRefs.new(condensed_text)
|
|
64
|
+
# fixed_text = parser.expand
|
|
65
|
+
#
|
|
66
|
+
# @example Check if text contains condensed refs
|
|
67
|
+
# parser = Markdown::Merge::Cleanse::CondensedLinkRefs.new(text)
|
|
68
|
+
# parser.condensed? # => true/false
|
|
69
|
+
#
|
|
70
|
+
# @example Process a file
|
|
71
|
+
# content = File.read("README.md")
|
|
72
|
+
# parser = Markdown::Merge::Cleanse::CondensedLinkRefs.new(content)
|
|
73
|
+
# if parser.condensed?
|
|
74
|
+
# File.write("README.md", parser.expand)
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# @example Get parsed definitions
|
|
78
|
+
# parser = Markdown::Merge::Cleanse::CondensedLinkRefs.new(condensed_text)
|
|
79
|
+
# parser.definitions.each do |defn|
|
|
80
|
+
# puts "#{defn[:label]} => #{defn[:url]}"
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# @see LinkParser For parsing properly-formatted link definitions
|
|
84
|
+
# @api public
|
|
85
|
+
class CondensedLinkRefs
|
|
86
|
+
# Grammar for parsing multiple condensed link reference definitions.
|
|
87
|
+
#
|
|
88
|
+
# This grammar handles the specific bug pattern where link definitions
|
|
89
|
+
# are concatenated without newlines or whitespace between them.
|
|
90
|
+
#
|
|
91
|
+
# Key insight: A bare URL ends at any character that's not valid in a URL.
|
|
92
|
+
# The `[` character that starts the next definition is NOT valid in a bare URL,
|
|
93
|
+
# so we can use it as the delimiter.
|
|
94
|
+
#
|
|
95
|
+
# This PEG grammar is linear-time and cannot have polynomial backtracking,
|
|
96
|
+
# eliminating ReDoS vulnerabilities.
|
|
97
|
+
#
|
|
98
|
+
# @api private
|
|
99
|
+
class CondensedDefinitionsGrammar < Parslet::Parser
|
|
100
|
+
rule(:space) { match('[ \t]') }
|
|
101
|
+
rule(:spaces) { space.repeat(1) }
|
|
102
|
+
rule(:spaces?) { space.repeat }
|
|
103
|
+
rule(:newline) { match('[\r\n]') }
|
|
104
|
+
rule(:newlines?) { newline.repeat }
|
|
105
|
+
|
|
106
|
+
# Bracket content: handles nested brackets recursively
|
|
107
|
+
# Same as LinkParser::DefinitionGrammar
|
|
108
|
+
rule(:bracket_content) do
|
|
109
|
+
(
|
|
110
|
+
str('[') >> bracket_content.maybe >> str(']') |
|
|
111
|
+
str(']').absent? >> any
|
|
112
|
+
).repeat
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
rule(:label) { str('[') >> bracket_content.as(:label) >> str(']') }
|
|
116
|
+
|
|
117
|
+
# URL characters - everything except whitespace, >, and [
|
|
118
|
+
# The [ is excluded because it signals the start of the next definition
|
|
119
|
+
rule(:url_char) { match('[^\s>\[]') }
|
|
120
|
+
rule(:bare_url) { url_char.repeat(1) }
|
|
121
|
+
|
|
122
|
+
# Angled URLs can contain [ since they're delimited by <>
|
|
123
|
+
rule(:angled_url_char) { match('[^>]') }
|
|
124
|
+
rule(:angled_url) { str('<') >> angled_url_char.repeat(1) >> str('>') }
|
|
125
|
+
|
|
126
|
+
rule(:url) { (angled_url | bare_url).as(:url) }
|
|
127
|
+
|
|
128
|
+
# Title handling (same as LinkParser)
|
|
129
|
+
rule(:title_content_double) { (str('"').absent? >> any).repeat }
|
|
130
|
+
rule(:title_content_single) { (str("'").absent? >> any).repeat }
|
|
131
|
+
rule(:title_content_paren) { (str(')').absent? >> any).repeat }
|
|
132
|
+
|
|
133
|
+
rule(:title_double) { str('"') >> title_content_double.as(:title) >> str('"') }
|
|
134
|
+
rule(:title_single) { str("'") >> title_content_single.as(:title) >> str("'") }
|
|
135
|
+
rule(:title_paren) { str('(') >> title_content_paren.as(:title) >> str(')') }
|
|
136
|
+
rule(:title) { title_double | title_single | title_paren }
|
|
137
|
+
|
|
138
|
+
# A single definition
|
|
139
|
+
rule(:definition) do
|
|
140
|
+
spaces? >>
|
|
141
|
+
label >>
|
|
142
|
+
str(':') >>
|
|
143
|
+
spaces? >>
|
|
144
|
+
url >>
|
|
145
|
+
(spaces >> title).maybe >>
|
|
146
|
+
spaces?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Multiple definitions, possibly with or without newlines between them
|
|
150
|
+
rule(:definitions) do
|
|
151
|
+
(definition.as(:definition) >> newlines?).repeat(1)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
root(:definitions)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [String] the input text to parse
|
|
158
|
+
attr_reader :source
|
|
159
|
+
|
|
160
|
+
# Create a new parser for the given text.
|
|
161
|
+
#
|
|
162
|
+
# @param source [String] the text that may contain condensed link refs
|
|
163
|
+
def initialize(source)
|
|
164
|
+
@source = source.to_s
|
|
165
|
+
@grammar = CondensedDefinitionsGrammar.new
|
|
166
|
+
@parsed = nil
|
|
167
|
+
@definitions = nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Check if the source contains condensed link reference definitions.
|
|
171
|
+
#
|
|
172
|
+
# Detects patterns where link definitions are not properly separated:
|
|
173
|
+
# 1. Multiple link defs on same line: `[l1]: url1[l2]: url2`
|
|
174
|
+
# 2. Content followed by link def without newline: `text[label]: url`
|
|
175
|
+
#
|
|
176
|
+
# Uses the PEG grammar to parse and detect condensed sequences.
|
|
177
|
+
#
|
|
178
|
+
# @return [Boolean] true if condensed refs are detected
|
|
179
|
+
def condensed?
|
|
180
|
+
source.each_line do |line|
|
|
181
|
+
# Pattern 1: Line contains 2+ link definitions (condensed together)
|
|
182
|
+
return true if contains_multiple_definitions?(line)
|
|
183
|
+
|
|
184
|
+
# Pattern 2: Line has content before first link definition
|
|
185
|
+
# (indicates corruption where newline before def was removed)
|
|
186
|
+
return true if has_content_before_definition?(line)
|
|
187
|
+
end
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Parse the source into individual link reference definitions that are condensed.
|
|
192
|
+
#
|
|
193
|
+
# This finds link refs that are part of corrupted patterns:
|
|
194
|
+
# 1. Multiple refs on same line without newlines
|
|
195
|
+
# 2. Content followed by ref without newline
|
|
196
|
+
#
|
|
197
|
+
# Uses the PEG grammar to properly parse link definitions.
|
|
198
|
+
#
|
|
199
|
+
# @return [Array<Hash>] Array of { label:, url:, title: (optional) }
|
|
200
|
+
def definitions
|
|
201
|
+
return @definitions if @definitions
|
|
202
|
+
|
|
203
|
+
@definitions = []
|
|
204
|
+
|
|
205
|
+
# Find all condensed sequences line by line
|
|
206
|
+
source.each_line do |line|
|
|
207
|
+
# Try to parse as definitions
|
|
208
|
+
parsed = parse_line(line)
|
|
209
|
+
next unless parsed && !parsed.empty?
|
|
210
|
+
|
|
211
|
+
# Check if line has content before first definition
|
|
212
|
+
first_bracket = line.index('[')
|
|
213
|
+
has_prefix = first_bracket&.positive? && !line[0...first_bracket].strip.empty?
|
|
214
|
+
|
|
215
|
+
# Include if: multiple definitions OR single definition with prefix
|
|
216
|
+
next unless parsed.size > 1 || has_prefix
|
|
217
|
+
|
|
218
|
+
# Extract definition info from parse tree
|
|
219
|
+
parsed.each do |def_tree|
|
|
220
|
+
@definitions << extract_definition(def_tree)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
@definitions
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Expand condensed link reference definitions to separate lines.
|
|
228
|
+
#
|
|
229
|
+
# Fixes only the condensed patterns (where a URL is immediately followed
|
|
230
|
+
# by a new link ref definition without a newline). All other content
|
|
231
|
+
# is preserved exactly as-is.
|
|
232
|
+
#
|
|
233
|
+
# Uses the PEG grammar to properly parse and reconstruct definitions.
|
|
234
|
+
#
|
|
235
|
+
# @return [String] the source with condensed link refs expanded to separate lines
|
|
236
|
+
def expand
|
|
237
|
+
return source unless condensed?
|
|
238
|
+
|
|
239
|
+
lines = source.lines.map do |line|
|
|
240
|
+
expand_line(line)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
lines.join
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Count the number of link reference definitions in the source.
|
|
247
|
+
#
|
|
248
|
+
# @return [Integer] number of link ref definitions found
|
|
249
|
+
def count
|
|
250
|
+
definitions.size
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
# Check if a line contains multiple link definitions (condensed).
|
|
256
|
+
#
|
|
257
|
+
# @param line [String] the line to check
|
|
258
|
+
# @return [Boolean] true if line has 2+ definitions
|
|
259
|
+
def contains_multiple_definitions?(line)
|
|
260
|
+
parsed = parse_line(line)
|
|
261
|
+
parsed && parsed.size > 1
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Check if a line has content before the first link definition.
|
|
265
|
+
#
|
|
266
|
+
# This indicates corruption where a newline was removed between
|
|
267
|
+
# regular content and a link definition.
|
|
268
|
+
#
|
|
269
|
+
# Example: `https://example.com[label]: url` (should be on separate lines)
|
|
270
|
+
#
|
|
271
|
+
# @param line [String] the line to check
|
|
272
|
+
# @return [Boolean] true if there's content before first `[label]:`
|
|
273
|
+
def has_content_before_definition?(line)
|
|
274
|
+
# Skip if no link definition pattern
|
|
275
|
+
return false unless line.include?(']:')
|
|
276
|
+
|
|
277
|
+
# Find first occurrence of [label]:
|
|
278
|
+
first_bracket = line.index('[')
|
|
279
|
+
return false unless first_bracket
|
|
280
|
+
return false if inside_inline_code?(line, first_bracket)
|
|
281
|
+
|
|
282
|
+
# Check if there's non-whitespace content before it
|
|
283
|
+
prefix = line[0...first_bracket].strip
|
|
284
|
+
return false if prefix.empty?
|
|
285
|
+
|
|
286
|
+
# Verify what follows is actually a link definition by trying to parse
|
|
287
|
+
parsed = parse_line(line)
|
|
288
|
+
!parsed.nil? && !parsed.empty?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Parse a line into link definitions using PEG grammar.
|
|
292
|
+
#
|
|
293
|
+
# Handles lines that may have content before the first definition.
|
|
294
|
+
# For example: "https://example.com[label]: url.txt"
|
|
295
|
+
#
|
|
296
|
+
# @param line [String] the line to parse
|
|
297
|
+
# @return [Array<Hash>, nil] array of definition parse trees, or nil if parse fails
|
|
298
|
+
def parse_line(line)
|
|
299
|
+
# Skip lines that don't look like link definitions
|
|
300
|
+
return unless line.include?(']:')
|
|
301
|
+
|
|
302
|
+
# First, try to find where the first link definition starts
|
|
303
|
+
# Look for pattern: [anything]:
|
|
304
|
+
first_bracket = line.index('[')
|
|
305
|
+
return unless first_bracket
|
|
306
|
+
return if inside_inline_code?(line, first_bracket)
|
|
307
|
+
|
|
308
|
+
# Try parsing from the first bracket onward
|
|
309
|
+
candidate = line[first_bracket..]
|
|
310
|
+
|
|
311
|
+
begin
|
|
312
|
+
tree = @grammar.parse(candidate)
|
|
313
|
+
|
|
314
|
+
# Extract the definitions array from parse tree
|
|
315
|
+
# Parslet returns either a single item or array
|
|
316
|
+
defs = tree.is_a?(Array) ? tree : [tree]
|
|
317
|
+
|
|
318
|
+
# Filter out non-definition nodes and return only definitions
|
|
319
|
+
defs.select { |node| node.is_a?(Hash) && node.key?(:definition) }
|
|
320
|
+
.map { |node| node[:definition] }
|
|
321
|
+
rescue Parslet::ParseFailed
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Extract definition data from a parse tree node.
|
|
327
|
+
#
|
|
328
|
+
# @param def_tree [Hash] the definition parse tree
|
|
329
|
+
# @return [Hash] definition with :label and :url
|
|
330
|
+
def extract_definition(def_tree)
|
|
331
|
+
label_tree = def_tree[:label]
|
|
332
|
+
url_tree = def_tree[:url]
|
|
333
|
+
|
|
334
|
+
# Convert Parslet slices to strings
|
|
335
|
+
label = label_tree.is_a?(Array) ? label_tree.map(&:to_s).join : label_tree.to_s
|
|
336
|
+
url = url_tree.to_s
|
|
337
|
+
|
|
338
|
+
{
|
|
339
|
+
label: label,
|
|
340
|
+
url: clean_url(url)
|
|
341
|
+
}
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Expand a single line if it contains condensed definitions.
|
|
345
|
+
#
|
|
346
|
+
# Handles two cases:
|
|
347
|
+
# 1. Multiple definitions on same line (always needs expansion)
|
|
348
|
+
# 2. Single definition with content before it (needs newline before def)
|
|
349
|
+
#
|
|
350
|
+
# @param line [String] the line to expand
|
|
351
|
+
# @return [String] expanded line with newlines between definitions
|
|
352
|
+
def expand_line(line)
|
|
353
|
+
parsed = parse_line(line)
|
|
354
|
+
return line unless parsed && !parsed.empty?
|
|
355
|
+
|
|
356
|
+
# Find where the first definition starts
|
|
357
|
+
first_bracket = line.index('[')
|
|
358
|
+
prefix = first_bracket&.positive? ? line[0...first_bracket].strip : ''
|
|
359
|
+
|
|
360
|
+
# Case 1: Multiple definitions - always expand
|
|
361
|
+
if parsed.size > 1
|
|
362
|
+
definitions = parsed.map { |def_tree| reconstruct_definition(def_tree) }
|
|
363
|
+
|
|
364
|
+
# First definition gets the prefix if present
|
|
365
|
+
result = if prefix && !prefix.empty?
|
|
366
|
+
"#{prefix}\n#{definitions.join("\n")}"
|
|
367
|
+
else
|
|
368
|
+
definitions.join("\n")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
result += "\n" if line.end_with?("\n")
|
|
372
|
+
return result
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Case 2: Single definition with prefix content - add newline before it
|
|
376
|
+
if parsed.size == 1 && prefix && !prefix.empty?
|
|
377
|
+
defn = reconstruct_definition(parsed[0])
|
|
378
|
+
result = "#{prefix}\n#{defn}"
|
|
379
|
+
result += "\n" if line.end_with?("\n")
|
|
380
|
+
return result
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# No expansion needed
|
|
384
|
+
line
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Reconstruct a single definition from parse tree.
|
|
388
|
+
#
|
|
389
|
+
# @param def_tree [Hash] the definition parse tree
|
|
390
|
+
# @return [String] reconstructed definition string
|
|
391
|
+
def reconstruct_definition(def_tree)
|
|
392
|
+
defn = extract_definition(def_tree)
|
|
393
|
+
"[#{defn[:label]}]: #{defn[:url]}"
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Clean a URL (strip angle brackets if present).
|
|
397
|
+
#
|
|
398
|
+
# @param url [String] the URL to clean
|
|
399
|
+
# @return [String] cleaned URL
|
|
400
|
+
def clean_url(url)
|
|
401
|
+
url = url.strip
|
|
402
|
+
url.start_with?('<') && url.end_with?('>') ? url[1..-2] : url
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def inside_inline_code?(line, index)
|
|
406
|
+
line[0...index].count('`').odd?
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|