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,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