markdown-merge 1.0.3 → 7.1.1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +99 -482
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
  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 +5 -3
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +489 -47
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +77 -6
  16. data/lib/markdown/merge/debug_logger.rb +2 -2
  17. data/lib/markdown/merge/document_problems.rb +3 -3
  18. data/lib/markdown/merge/file_aligner.rb +433 -133
  19. data/lib/markdown/merge/file_analysis.rb +387 -51
  20. data/lib/markdown/merge/file_analysis_base.rb +188 -51
  21. data/lib/markdown/merge/gap_line_node.rb +14 -8
  22. data/lib/markdown/merge/link_definition_node.rb +5 -5
  23. data/lib/markdown/merge/link_parser.rb +60 -60
  24. data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
  25. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  26. data/lib/markdown/merge/list_merger.rb +322 -0
  27. data/lib/markdown/merge/markdown_structure.rb +3 -3
  28. data/lib/markdown/merge/merge_result.rb +321 -4
  29. data/lib/markdown/merge/node_type_normalizer.rb +6 -6
  30. data/lib/markdown/merge/output_builder.rb +101 -19
  31. data/lib/markdown/merge/partial_template_merger.rb +248 -27
  32. data/lib/markdown/merge/preservation_support.rb +291 -0
  33. data/lib/markdown/merge/smart_merger.rb +62 -14
  34. data/lib/markdown/merge/smart_merger_base.rb +929 -60
  35. data/lib/markdown/merge/table_match_algorithm.rb +22 -27
  36. data/lib/markdown/merge/table_match_refiner.rb +6 -10
  37. data/lib/markdown/merge/version.rb +5 -4
  38. data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
  39. data/lib/markdown/merge/wrapper_support.rb +194 -0
  40. data/lib/markdown/merge.rb +669 -122
  41. data/lib/markdown-merge.rb +9 -4
  42. data/sig/markdown/merge.rbs +3 -336
  43. data.tar.gz.sig +0 -0
  44. metadata +104 -93
  45. metadata.gz.sig +0 -0
  46. data/CHANGELOG.md +0 -308
  47. data/CITATION.cff +0 -20
  48. data/CODE_OF_CONDUCT.md +0 -134
  49. data/CONTRIBUTING.md +0 -227
  50. data/FUNDING.md +0 -74
  51. data/LICENSE.txt +0 -21
  52. data/REEK +0 -0
  53. data/RUBOCOP.md +0 -71
  54. data/SECURITY.md +0 -21
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Shared boilerplate for backend-specific Markdown wrapper backends.
6
+ module BackendSupport
7
+ DEFAULT_CAPABILITIES = {
8
+ query: false,
9
+ bytes_field: false,
10
+ incremental: false,
11
+ pure_ruby: false,
12
+ markdown_only: true,
13
+ error_tolerant: true
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def install!(backend_module:, backend_name:, gem_name:, require_path:, capabilities: {})
19
+ install_availability_methods!(
20
+ backend_module: backend_module,
21
+ backend_name: backend_name,
22
+ gem_name: gem_name,
23
+ capabilities: capabilities
24
+ )
25
+ install_tree_wrapper!(backend_module)
26
+ register_backend!(
27
+ backend_module: backend_module,
28
+ backend_name: backend_name,
29
+ gem_name: gem_name,
30
+ require_path: require_path
31
+ )
32
+ end
33
+
34
+ def configure_markdown_only_language_class!(klass, backend_label:, factory_method: :markdown,
35
+ unsupported_language_message: nil)
36
+ klass.singleton_class.class_eval do
37
+ define_method(:from_library) do |_path = nil, symbol: nil, name: nil|
38
+ lang_name = name || symbol&.to_s&.sub(/^tree_sitter_/, '')&.to_sym || :markdown
39
+
40
+ unless lang_name == :markdown
41
+ message = if unsupported_language_message.respond_to?(:call)
42
+ unsupported_language_message.call(lang_name)
43
+ else
44
+ unsupported_language_message || "#{backend_label} backend only supports Markdown, not #{lang_name}."
45
+ end
46
+ raise TreeHaver::NotAvailable, message
47
+ end
48
+
49
+ public_send(factory_method)
50
+ end
51
+ end
52
+ end
53
+
54
+ def configure_node_link_and_navigation!(klass, next_sibling_selector:, prev_sibling_selector:,
55
+ parent_selector: :parent)
56
+ klass.class_eval do
57
+ define_method(:url) do
58
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :url)
59
+ end
60
+
61
+ define_method(:title) do
62
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :title)
63
+ end
64
+
65
+ define_method(:next_sibling) do
66
+ sibling = Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, next_sibling_selector)
67
+ sibling ? self.class.new(sibling, source: source, lines: lines) : nil
68
+ end
69
+
70
+ define_method(:prev_sibling) do
71
+ sibling = Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, prev_sibling_selector)
72
+ sibling ? self.class.new(sibling, source: source, lines: lines) : nil
73
+ end
74
+
75
+ define_method(:parent) do
76
+ parent_node = Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, parent_selector)
77
+ parent_node ? self.class.new(parent_node, source: source, lines: lines) : nil
78
+ end
79
+ end
80
+ end
81
+
82
+ def configure_node_heading_and_code_block_helpers!(klass, heading_matcher:, code_block_matcher:,
83
+ heading_level_extractor: nil,
84
+ code_block_info_extractor: nil)
85
+ klass.class_eval do
86
+ define_method(:header_level) do
87
+ return unless Markdown::Merge::BackendSupport.send(:node_helper_match?, self, heading_matcher)
88
+
89
+ if heading_level_extractor.respond_to?(:call)
90
+ heading_level_extractor.call(inner_node)
91
+ else
92
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :header_level)
93
+ end
94
+ end
95
+
96
+ define_method(:fence_info) do
97
+ return unless Markdown::Merge::BackendSupport.send(:node_helper_match?, self, code_block_matcher)
98
+
99
+ if code_block_info_extractor.respond_to?(:call)
100
+ code_block_info_extractor.call(inner_node)
101
+ else
102
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :fence_info)
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def install_availability_methods!(backend_module:, backend_name:, gem_name:, capabilities: {})
109
+ backend_module.instance_variable_set(:@load_attempted, false)
110
+ backend_module.instance_variable_set(:@loaded, false)
111
+
112
+ merged_capabilities = DEFAULT_CAPABILITIES.merge(backend: backend_name).merge(capabilities)
113
+
114
+ backend_module.singleton_class.class_eval do
115
+ define_method(:available?) do
116
+ return @loaded if @load_attempted
117
+
118
+ @load_attempted = true
119
+ begin
120
+ require gem_name
121
+ @loaded = true
122
+ rescue LoadError, StandardError
123
+ @loaded = false
124
+ end
125
+ @loaded
126
+ end
127
+
128
+ define_method(:reset!) do
129
+ @load_attempted = false
130
+ @loaded = false
131
+ end
132
+
133
+ define_method(:capabilities) do
134
+ return {} unless available?
135
+
136
+ merged_capabilities.dup
137
+ end
138
+ end
139
+ end
140
+ private_class_method :install_availability_methods!
141
+
142
+ def install_tree_wrapper!(backend_module)
143
+ return if backend_module.const_defined?(:Tree, false)
144
+
145
+ tree_class = Class.new(::TreeHaver::Base::Tree) do
146
+ define_method(:initialize) do |document, source|
147
+ super(document, source: source)
148
+ end
149
+
150
+ define_method(:root_node) do
151
+ backend_module.const_get(:Node).new(inner_tree, source: source, lines: lines)
152
+ end
153
+ end
154
+
155
+ backend_module.const_set(:Tree, tree_class)
156
+ backend_module.const_set(:Point, ::TreeHaver::Base::Point) unless backend_module.const_defined?(:Point, false)
157
+ end
158
+ private_class_method :install_tree_wrapper!
159
+
160
+ def register_backend!(backend_module:, backend_name:, gem_name:, require_path:)
161
+ ::TreeHaver::BackendRegistry.register(
162
+ ::TreeHaver::BackendReference.new(id: backend_name.to_s, family: 'native')
163
+ )
164
+
165
+ ::TreeHaver.register_language(
166
+ :markdown,
167
+ backend_type: backend_name,
168
+ backend_module: backend_module,
169
+ gem_name: gem_name
170
+ )
171
+
172
+ if ::TreeHaver::BackendRegistry.respond_to?(:register_tag)
173
+ ::TreeHaver::BackendRegistry.register_tag(
174
+ :"#{backend_name}_backend",
175
+ category: :backend,
176
+ backend_name: backend_name,
177
+ require_path: require_path
178
+ ) { backend_module.available? }
179
+ else
180
+ ::TreeHaver::BackendRegistry.register_availability_checker(backend_name) do
181
+ backend_module.available?
182
+ end
183
+ end
184
+ end
185
+ private_class_method :register_backend!
186
+
187
+ def safe_inner_node_call(node, method_name)
188
+ node.public_send(method_name)
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ private_class_method :safe_inner_node_call
193
+
194
+ def node_helper_match?(node, matcher)
195
+ matcher.respond_to?(:call) ? matcher.call(node) : false
196
+ end
197
+ private_class_method :node_helper_match?
198
+ end
199
+ end
200
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parslet"
3
+ require 'parslet'
4
4
 
5
5
  module Markdown
6
6
  module Merge
@@ -34,9 +34,9 @@ module Markdown
34
34
  THEMATIC_BREAK = /\A\s*(?:---+|\*\*\*+|___+)\s*\z/
35
35
  HEADING = /\A\s*\#{1,6}\s+/
36
36
  LIST_ITEM = /\A\s*(?:[-*+]|\d+\.)\s+/
37
- HTML_CLOSE_TAG = /\A\s*<\/[a-zA-Z][a-zA-Z0-9]*>\s*\z/
37
+ HTML_CLOSE_TAG = %r{\A\s*</[a-zA-Z][a-zA-Z0-9]*>\s*\z}
38
38
  HTML_OPEN_TAG = /\A\s*<[a-zA-Z][a-zA-Z0-9]*(?:\s|>)/
39
- HTML_ANY_TAG = /\A\s*<\/?[a-zA-Z]/
39
+ HTML_ANY_TAG = %r{\A\s*</?[a-zA-Z]}
40
40
  LINK_REF_DEF = /\A\s*\[[^\]]+\]:\s*/
41
41
 
42
42
  # Block-level HTML elements that can span multiple lines
@@ -75,24 +75,25 @@ module Markdown
75
75
  ].freeze
76
76
 
77
77
  # Pattern to match opening block-level HTML tags
78
- HTML_BLOCK_OPEN = /\A\s*<(#{HTML_BLOCK_ELEMENTS.join("|")})(?:\s|>)/i
78
+ HTML_BLOCK_OPEN = /\A\s*<(#{HTML_BLOCK_ELEMENTS.join('|')})(?:\s|>)/i
79
79
 
80
80
  # Pattern to match closing block-level HTML tags
81
- HTML_BLOCK_CLOSE = /\A\s*<\/(#{HTML_BLOCK_ELEMENTS.join("|")})>/i
81
+ HTML_BLOCK_CLOSE = %r{\A\s*</(#{HTML_BLOCK_ELEMENTS.join('|')})>}i
82
82
 
83
83
  # HTML elements that contain markdown content (not HTML content)
84
84
  # These should have blank lines before their closing tags
85
85
  MARKDOWN_CONTAINER_ELEMENTS = %w[details].freeze
86
86
 
87
87
  # Pattern to match closing tags for markdown containers
88
- MARKDOWN_CONTAINER_CLOSE = /\A\s*<\/(#{MARKDOWN_CONTAINER_ELEMENTS.join("|")})>/i
88
+ MARKDOWN_CONTAINER_CLOSE = %r{\A\s*</(#{MARKDOWN_CONTAINER_ELEMENTS.join('|')})>}i
89
89
 
90
90
  # Markdown content: anything that's not blank, not HTML, and not a link ref def
91
- MARKDOWN_CONTENT = ->(line) {
91
+ MARKDOWN_CONTENT = lambda { |line|
92
92
  stripped = line.strip
93
93
  return false if stripped.empty?
94
- return false if stripped.start_with?("<")
94
+ return false if stripped.start_with?('<')
95
95
  return false if line.match?(LINK_REF_DEF)
96
+
96
97
  true
97
98
  }
98
99
 
@@ -138,9 +139,7 @@ module Markdown
138
139
  lines.each_with_index do |line, idx|
139
140
  result << line
140
141
  # If this line needs a blank line after it, add one
141
- if insertions.include?(idx + 1) # issues use 1-based line numbers
142
- result << "\n" unless line.strip.empty?
143
- end
142
+ result << "\n" if insertions.include?(idx + 1) && !line.strip.empty? # issues use 1-based line numbers
144
143
  end
145
144
 
146
145
  result.join
@@ -158,7 +157,7 @@ module Markdown
158
157
 
159
158
  lines.each_with_index do |line, idx|
160
159
  next_line = lines[idx + 1]
161
- prev_line = (idx > 0) ? lines[idx - 1] : nil
160
+ prev_line = idx.positive? ? lines[idx - 1] : nil
162
161
 
163
162
  # Special case: closing tags for markdown containers like </details>
164
163
  # These contain markdown content, so we need blank lines before them
@@ -175,9 +174,7 @@ module Markdown
175
174
  end
176
175
 
177
176
  # Check for issues that need blank line BEFORE current line
178
- if prev_line && !prev_line.strip.empty?
179
- check_markdown_before_html(prev_line, line, idx)
180
- end
177
+ check_markdown_before_html(prev_line, line, idx) if prev_line && !prev_line.strip.empty?
181
178
  end
182
179
 
183
180
  # Special case: always check for blank line before </details> etc.
@@ -188,13 +185,11 @@ module Markdown
188
185
 
189
186
  # Update HTML block depth AFTER checking for issues
190
187
  # Count opening block-level tags
191
- if line.match?(HTML_BLOCK_OPEN)
192
- html_block_depth += 1
193
- end
188
+ html_block_depth += 1 if line.match?(HTML_BLOCK_OPEN)
194
189
 
195
190
  # Check for closing block-level tags
196
191
  line.scan(HTML_BLOCK_CLOSE) do
197
- html_block_depth -= 1 if html_block_depth > 0
192
+ html_block_depth -= 1 if html_block_depth.positive?
198
193
  end
199
194
  end
200
195
  end
@@ -206,7 +201,7 @@ module Markdown
206
201
  @issues << {
207
202
  type: :thematic_break_needs_blank,
208
203
  line: idx + 1,
209
- description: "Thematic break should be followed by blank line",
204
+ description: 'Thematic break should be followed by blank line'
210
205
  }
211
206
  end
212
207
 
@@ -217,7 +212,7 @@ module Markdown
217
212
  @issues << {
218
213
  type: :list_before_heading,
219
214
  line: idx + 1,
220
- description: "List item should be followed by blank line before heading",
215
+ description: 'List item should be followed by blank line before heading'
221
216
  }
222
217
  end
223
218
 
@@ -231,7 +226,7 @@ module Markdown
231
226
  @issues << {
232
227
  type: :html_before_markdown,
233
228
  line: idx + 1,
234
- description: "HTML close tag should be followed by blank line before markdown",
229
+ description: 'HTML close tag should be followed by blank line before markdown'
235
230
  }
236
231
  end
237
232
 
@@ -244,7 +239,7 @@ module Markdown
244
239
  @issues << {
245
240
  type: :markdown_before_html,
246
241
  line: idx, # Insert blank line BEFORE this line (so after prev_line)
247
- description: "Markdown content should be followed by blank line before HTML",
242
+ description: 'Markdown content should be followed by blank line before HTML'
248
243
  }
249
244
  end
250
245
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parslet"
3
+ require 'parslet'
4
4
 
5
5
  module Markdown
6
6
  module Merge
@@ -93,11 +93,11 @@ module Markdown
93
93
  class CodeFenceGrammar < Parslet::Parser
94
94
  # Any amount of indentation (handles code blocks in lists)
95
95
  # Captured as string, not array
96
- rule(:indent) { match("[ ]").repeat }
96
+ rule(:indent) { match('[ ]').repeat }
97
97
 
98
98
  # Fence markers - 3+ backticks or tildes
99
- rule(:backtick) { str("`") }
100
- rule(:tilde) { str("~") }
99
+ rule(:backtick) { str('`') }
100
+ rule(:tilde) { str('~') }
101
101
  rule(:backtick_fence) { backtick.repeat(3, nil) }
102
102
  rule(:tilde_fence) { tilde.repeat(3, nil) }
103
103
  rule(:fence) { backtick_fence | tilde_fence }
@@ -117,9 +117,9 @@ module Markdown
117
117
 
118
118
  # Fence line with optional indentation, optional spacing, optional info
119
119
  # Capture: indent (raw), fence (as :fence), spacing (as :spacing), info (as :info)
120
- rule(:fence_line) {
120
+ rule(:fence_line) do
121
121
  indent.as(:indent) >> fence.as(:fence) >> spaces?.as(:spacing) >> info_string.maybe.as(:info) >> line_end
122
- }
122
+ end
123
123
 
124
124
  root(:fence_line)
125
125
  end
@@ -175,9 +175,9 @@ module Markdown
175
175
 
176
176
  fence = parsed[:fence]
177
177
  fence_char = fence[0]
178
- spacing = parsed[:spacing] || ""
179
- info = parsed[:info] || ""
180
- indent = parsed[:indent] || ""
178
+ spacing = parsed[:spacing] || ''
179
+ info = parsed[:info] || ''
180
+ indent = parsed[:indent] || ''
181
181
 
182
182
  # Closing fence: matches current fence type and has no info
183
183
  if in_code_block && fence_char == current_fence_char && info.empty?
@@ -192,7 +192,7 @@ module Markdown
192
192
 
193
193
  # Extract just the language (first word of info string)
194
194
  language = info.strip.split(/\s+/).first
195
- language = nil if language&.empty?
195
+ language = nil if language && language.empty?
196
196
 
197
197
  @code_blocks << {
198
198
  indent: indent,
@@ -202,7 +202,7 @@ module Markdown
202
202
  spacing: spacing,
203
203
  malformed: !spacing.empty? && !language.nil?,
204
204
  line_number: line_number,
205
- original: line.chomp,
205
+ original: line.chomp
206
206
  }
207
207
  end
208
208
 
@@ -259,16 +259,16 @@ module Markdown
259
259
 
260
260
  info_val = tree[:info]
261
261
  info_str = if info_val.is_a?(Array)
262
- info_val.join
263
- else
264
- (info_val ? info_val.to_s : "")
265
- end
262
+ info_val.join
263
+ else
264
+ (info_val ? info_val.to_s : '')
265
+ end
266
266
 
267
267
  {
268
268
  indent: indent_str,
269
269
  fence: tree[:fence].to_s,
270
270
  spacing: spacing_str,
271
- info: info_str,
271
+ info: info_str
272
272
  }
273
273
  rescue Parslet::ParseFailed
274
274
  nil
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parslet"
3
+ require 'parslet'
4
4
 
5
5
  module Markdown
6
6
  module Merge
@@ -105,14 +105,14 @@ module Markdown
105
105
 
106
106
  # Bracket content: handles nested brackets recursively
107
107
  # Same as LinkParser::DefinitionGrammar
108
- rule(:bracket_content) {
108
+ rule(:bracket_content) do
109
109
  (
110
- str("[") >> bracket_content.maybe >> str("]") |
111
- str("]").absent? >> any
110
+ str('[') >> bracket_content.maybe >> str(']') |
111
+ str(']').absent? >> any
112
112
  ).repeat
113
- }
113
+ end
114
114
 
115
- rule(:label) { str("[") >> bracket_content.as(:label) >> str("]") }
115
+ rule(:label) { str('[') >> bracket_content.as(:label) >> str(']') }
116
116
 
117
117
  # URL characters - everything except whitespace, >, and [
118
118
  # The [ is excluded because it signals the start of the next definition
@@ -120,36 +120,36 @@ module Markdown
120
120
  rule(:bare_url) { url_char.repeat(1) }
121
121
 
122
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(">") }
123
+ rule(:angled_url_char) { match('[^>]') }
124
+ rule(:angled_url) { str('<') >> angled_url_char.repeat(1) >> str('>') }
125
125
 
126
126
  rule(:url) { (angled_url | bare_url).as(:url) }
127
127
 
128
128
  # Title handling (same as LinkParser)
129
129
  rule(:title_content_double) { (str('"').absent? >> any).repeat }
130
130
  rule(:title_content_single) { (str("'").absent? >> any).repeat }
131
- rule(:title_content_paren) { (str(")").absent? >> any).repeat }
131
+ rule(:title_content_paren) { (str(')').absent? >> any).repeat }
132
132
 
133
133
  rule(:title_double) { str('"') >> title_content_double.as(:title) >> str('"') }
134
134
  rule(:title_single) { str("'") >> title_content_single.as(:title) >> str("'") }
135
- rule(:title_paren) { str("(") >> title_content_paren.as(:title) >> str(")") }
135
+ rule(:title_paren) { str('(') >> title_content_paren.as(:title) >> str(')') }
136
136
  rule(:title) { title_double | title_single | title_paren }
137
137
 
138
138
  # A single definition
139
- rule(:definition) {
139
+ rule(:definition) do
140
140
  spaces? >>
141
141
  label >>
142
- str(":") >>
142
+ str(':') >>
143
143
  spaces? >>
144
144
  url >>
145
145
  (spaces >> title).maybe >>
146
146
  spaces?
147
- }
147
+ end
148
148
 
149
149
  # Multiple definitions, possibly with or without newlines between them
150
- rule(:definitions) {
150
+ rule(:definitions) do
151
151
  (definition.as(:definition) >> newlines?).repeat(1)
152
- }
152
+ end
153
153
 
154
154
  root(:definitions)
155
155
  end
@@ -209,8 +209,8 @@ module Markdown
209
209
  next unless parsed && !parsed.empty?
210
210
 
211
211
  # Check if line has content before first definition
212
- first_bracket = line.index("[")
213
- has_prefix = first_bracket && first_bracket > 0 && !line[0...first_bracket].strip.empty?
212
+ first_bracket = line.index('[')
213
+ has_prefix = first_bracket&.positive? && !line[0...first_bracket].strip.empty?
214
214
 
215
215
  # Include if: multiple definitions OR single definition with prefix
216
216
  next unless parsed.size > 1 || has_prefix
@@ -272,11 +272,12 @@ module Markdown
272
272
  # @return [Boolean] true if there's content before first `[label]:`
273
273
  def has_content_before_definition?(line)
274
274
  # Skip if no link definition pattern
275
- return false unless line.include?("]:")
275
+ return false unless line.include?(']:')
276
276
 
277
277
  # Find first occurrence of [label]:
278
- first_bracket = line.index("[")
278
+ first_bracket = line.index('[')
279
279
  return false unless first_bracket
280
+ return false if inside_inline_code?(line, first_bracket)
280
281
 
281
282
  # Check if there's non-whitespace content before it
282
283
  prefix = line[0...first_bracket].strip
@@ -296,12 +297,13 @@ module Markdown
296
297
  # @return [Array<Hash>, nil] array of definition parse trees, or nil if parse fails
297
298
  def parse_line(line)
298
299
  # Skip lines that don't look like link definitions
299
- return unless line.include?("]:")
300
+ return unless line.include?(']:')
300
301
 
301
302
  # First, try to find where the first link definition starts
302
303
  # Look for pattern: [anything]:
303
- first_bracket = line.index("[")
304
+ first_bracket = line.index('[')
304
305
  return unless first_bracket
306
+ return if inside_inline_code?(line, first_bracket)
305
307
 
306
308
  # Try parsing from the first bracket onward
307
309
  candidate = line[first_bracket..]
@@ -315,7 +317,7 @@ module Markdown
315
317
 
316
318
  # Filter out non-definition nodes and return only definitions
317
319
  defs.select { |node| node.is_a?(Hash) && node.key?(:definition) }
318
- .map { |node| node[:definition] }
320
+ .map { |node| node[:definition] }
319
321
  rescue Parslet::ParseFailed
320
322
  nil
321
323
  end
@@ -335,7 +337,7 @@ module Markdown
335
337
 
336
338
  {
337
339
  label: label,
338
- url: clean_url(url),
340
+ url: clean_url(url)
339
341
  }
340
342
  end
341
343
 
@@ -352,8 +354,8 @@ module Markdown
352
354
  return line unless parsed && !parsed.empty?
353
355
 
354
356
  # Find where the first definition starts
355
- first_bracket = line.index("[")
356
- prefix = (first_bracket && first_bracket > 0) ? line[0...first_bracket].strip : ""
357
+ first_bracket = line.index('[')
358
+ prefix = first_bracket&.positive? ? line[0...first_bracket].strip : ''
357
359
 
358
360
  # Case 1: Multiple definitions - always expand
359
361
  if parsed.size > 1
@@ -361,10 +363,10 @@ module Markdown
361
363
 
362
364
  # First definition gets the prefix if present
363
365
  result = if prefix && !prefix.empty?
364
- "#{prefix}\n#{definitions.join("\n")}"
365
- else
366
- definitions.join("\n")
367
- end
366
+ "#{prefix}\n#{definitions.join("\n")}"
367
+ else
368
+ definitions.join("\n")
369
+ end
368
370
 
369
371
  result += "\n" if line.end_with?("\n")
370
372
  return result
@@ -397,7 +399,11 @@ module Markdown
397
399
  # @return [String] cleaned URL
398
400
  def clean_url(url)
399
401
  url = url.strip
400
- (url.start_with?("<") && url.end_with?(">")) ? url[1..-2] : url
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?
401
407
  end
402
408
  end
403
409
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ module Cleanse
6
+ # Repairs templating corruption where an ordered-list marker was prefixed
7
+ # onto an existing unordered-list marker, producing lines like:
8
+ #
9
+ # 1. - item
10
+ # 2. * item
11
+ #
12
+ # The repair keeps the original inner bullet marker and removes the
13
+ # synthetic ordered marker.
14
+ class ListMarkerDuplication
15
+ DUPLICATED_MARKER = /\A(?<indent>\s*)(?<number>\d+)\.\s+(?<bullet>[-*+])(?<tail>(?:\s+.*)?\s*)\z/
16
+
17
+ attr_reader :source, :issues
18
+
19
+ def initialize(source)
20
+ @source = source.to_s
21
+ @issues = []
22
+ analyze
23
+ end
24
+
25
+ def malformed?
26
+ issues.any?
27
+ end
28
+
29
+ def issue_count
30
+ issues.length
31
+ end
32
+
33
+ def fix
34
+ return source unless malformed?
35
+
36
+ source.each_line.with_index(1).map do |line, line_number|
37
+ duplicated_marker_line?(line) ? repaired_line(line, line_number) : line
38
+ end.join
39
+ end
40
+
41
+ private
42
+
43
+ def analyze
44
+ source.each_line.with_index(1) do |line, line_number|
45
+ next unless duplicated_marker_line?(line)
46
+
47
+ issues << {
48
+ type: :duplicated_list_marker,
49
+ line: line_number,
50
+ description: 'Ordered-list marker duplicated an existing unordered-list marker'
51
+ }
52
+ end
53
+ end
54
+
55
+ def duplicated_marker_line?(line)
56
+ line.match?(DUPLICATED_MARKER)
57
+ end
58
+
59
+ def repaired_line(line, _line_number)
60
+ match = line.match(DUPLICATED_MARKER)
61
+ "#{match[:indent]}#{match[:bullet]}#{match[:tail]}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end