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,689 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Markdown
|
|
6
|
+
module Merge
|
|
7
|
+
# File analysis for Markdown files using tree_haver backends.
|
|
8
|
+
#
|
|
9
|
+
# Extends FileAnalysisBase with backend-agnostic parsing via tree_haver.
|
|
10
|
+
# Supports both Commonmarker and Markly backends through tree_haver's
|
|
11
|
+
# unified API.
|
|
12
|
+
#
|
|
13
|
+
# Parses Markdown source code and extracts:
|
|
14
|
+
# - Top-level block elements (headings, paragraphs, lists, code blocks, etc.)
|
|
15
|
+
# - Freeze blocks marked with HTML comments
|
|
16
|
+
# - Structural signatures for matching elements between files
|
|
17
|
+
#
|
|
18
|
+
# All nodes are wrapped with canonical types via NodeTypeNormalizer,
|
|
19
|
+
# enabling portable merge rules across backends.
|
|
20
|
+
#
|
|
21
|
+
# Freeze blocks are marked with HTML comments:
|
|
22
|
+
# <!-- markdown-merge:freeze -->
|
|
23
|
+
# ... content to preserve ...
|
|
24
|
+
# <!-- markdown-merge:unfreeze -->
|
|
25
|
+
#
|
|
26
|
+
# @example Basic usage with auto backend
|
|
27
|
+
# analysis = FileAnalysis.new(markdown_source)
|
|
28
|
+
# analysis.statements.each do |node|
|
|
29
|
+
# puts "#{node.merge_type}: #{node.type}"
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example With specific backend
|
|
33
|
+
# analysis = FileAnalysis.new(markdown_source, backend: :markly)
|
|
34
|
+
#
|
|
35
|
+
# @example With custom freeze token
|
|
36
|
+
# analysis = FileAnalysis.new(source, freeze_token: "my-merge")
|
|
37
|
+
# # Looks for: <!-- my-merge:freeze --> / <!-- my-merge:unfreeze -->
|
|
38
|
+
#
|
|
39
|
+
# @see FileAnalysisBase Base class
|
|
40
|
+
# @see NodeTypeNormalizer Type normalization
|
|
41
|
+
class FileAnalysis < FileAnalysisBase
|
|
42
|
+
# Default freeze token for identifying freeze blocks
|
|
43
|
+
# @return [String]
|
|
44
|
+
DEFAULT_FREEZE_TOKEN = 'markdown-merge'
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
def default_backend
|
|
48
|
+
:auto
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def default_freeze_token
|
|
52
|
+
self::DEFAULT_FREEZE_TOKEN
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_parser_options
|
|
56
|
+
{}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def default_freeze_node_class
|
|
60
|
+
Markdown::Merge::FreezeNode
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Symbol] The backend being used (:commonmarker, :markly, :kramdown)
|
|
65
|
+
attr_reader :backend
|
|
66
|
+
|
|
67
|
+
# @return [Hash] Parser-specific options
|
|
68
|
+
attr_reader :parser_options
|
|
69
|
+
|
|
70
|
+
Location = Struct.new(:start_line, :end_line, keyword_init: true)
|
|
71
|
+
HeadingSectionOwner = Struct.new(:location, :heading_text, :heading_source, :level, :base, keyword_init: true)
|
|
72
|
+
LinkDefinitionOwner = Struct.new(:location, :label, :url, :title, :source, keyword_init: true)
|
|
73
|
+
HtmlCommentOwner = Struct.new(:location, :text, :source, keyword_init: true)
|
|
74
|
+
ListItemOwner = Struct.new(:location, :source, :text, :depth, :marker, keyword_init: true)
|
|
75
|
+
InlineReferenceOwner = Struct.new(
|
|
76
|
+
:location,
|
|
77
|
+
:line,
|
|
78
|
+
:start_column,
|
|
79
|
+
:end_column,
|
|
80
|
+
:source,
|
|
81
|
+
:reference_kind,
|
|
82
|
+
:label,
|
|
83
|
+
:labels,
|
|
84
|
+
keyword_init: true
|
|
85
|
+
)
|
|
86
|
+
TableRowOwner = Struct.new(:location, :source, :text, keyword_init: true)
|
|
87
|
+
|
|
88
|
+
# Initialize file analysis with tree_haver backend.
|
|
89
|
+
#
|
|
90
|
+
# @param source [String] Markdown source code to analyze
|
|
91
|
+
# @param backend [Symbol] Backend to use (:commonmarker, :markly, :kramdown, :auto)
|
|
92
|
+
# @param freeze_token [String] Token for freeze block markers
|
|
93
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
94
|
+
# @param parser_options [Hash] Backend-specific parser options
|
|
95
|
+
# For commonmarker: { options: {} }
|
|
96
|
+
# For markly: { flags: Markly::DEFAULT, extensions: [:table] }
|
|
97
|
+
def initialize(
|
|
98
|
+
source,
|
|
99
|
+
backend: self.class.default_backend,
|
|
100
|
+
freeze_token: self.class.default_freeze_token,
|
|
101
|
+
signature_generator: nil,
|
|
102
|
+
**parser_options
|
|
103
|
+
)
|
|
104
|
+
@requested_backend = backend
|
|
105
|
+
@parser_options = self.class.default_parser_options.merge(parser_options)
|
|
106
|
+
|
|
107
|
+
# Resolve and initialize the backend
|
|
108
|
+
@backend = resolve_backend(backend)
|
|
109
|
+
@parser = create_parser
|
|
110
|
+
|
|
111
|
+
super(source, freeze_token: freeze_token, signature_generator: signature_generator)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Parse the source document using tree_haver backend.
|
|
115
|
+
#
|
|
116
|
+
# Error handling follows the same pattern as other *-merge gems:
|
|
117
|
+
# - TreeHaver::Error (which inherits from Exception, not StandardError) is caught
|
|
118
|
+
# - TreeHaver::NotAvailable is a subclass of TreeHaver::Error, so it's also caught
|
|
119
|
+
# - When an error occurs, the error is stored in @errors and nil is returned
|
|
120
|
+
# - SmartMergerBase#parse_and_analyze checks valid? and raises the appropriate parse error
|
|
121
|
+
#
|
|
122
|
+
# @param source [String] Markdown source to parse
|
|
123
|
+
# @return [Object, nil] Root document node from tree_haver, or nil on error
|
|
124
|
+
def parse_document(source)
|
|
125
|
+
tree = @parser.parse(source)
|
|
126
|
+
tree.root_node
|
|
127
|
+
rescue TreeHaver::Error => e
|
|
128
|
+
# TreeHaver::Error inherits from Exception, not StandardError.
|
|
129
|
+
# This also catches TreeHaver::NotAvailable (subclass of Error).
|
|
130
|
+
@errors << e.message
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Get the next sibling of a node.
|
|
135
|
+
#
|
|
136
|
+
# Handles differences between backends:
|
|
137
|
+
# - Commonmarker: node.next_sibling
|
|
138
|
+
# - Markly: node.next
|
|
139
|
+
#
|
|
140
|
+
# @param node [Object] Current node
|
|
141
|
+
# @return [Object, nil] Next sibling or nil
|
|
142
|
+
def next_sibling(node)
|
|
143
|
+
# tree_haver normalizes this, but handle both patterns for safety
|
|
144
|
+
if node.respond_to?(:next_sibling)
|
|
145
|
+
node.next_sibling
|
|
146
|
+
elsif node.respond_to?(:next)
|
|
147
|
+
node.next
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns the FreezeNode class to use.
|
|
152
|
+
#
|
|
153
|
+
# @return [Class] Markdown::Merge::FreezeNode
|
|
154
|
+
def freeze_node_class
|
|
155
|
+
self.class.default_freeze_node_class
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check if value is a tree_haver node.
|
|
159
|
+
#
|
|
160
|
+
# @param value [Object] Value to check
|
|
161
|
+
# @return [Boolean] true if this is a parser node
|
|
162
|
+
def parser_node?(value)
|
|
163
|
+
# Check for tree_haver node or wrapped node
|
|
164
|
+
return true if value.respond_to?(:type) && value.respond_to?(:source_position)
|
|
165
|
+
return true if Ast::Merge::NodeTyping.typed_node?(value)
|
|
166
|
+
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Override to detect tree_haver nodes for signature generator fallthrough
|
|
171
|
+
# @param value [Object] The value to check
|
|
172
|
+
# @return [Boolean] true if this is a fallthrough node
|
|
173
|
+
def fallthrough_node?(value)
|
|
174
|
+
Ast::Merge::NodeTyping.typed_node?(value) ||
|
|
175
|
+
value.is_a?(Ast::Merge::FreezeNodeBase) ||
|
|
176
|
+
parser_node?(value) ||
|
|
177
|
+
super
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Compute signature for a tree_haver node.
|
|
181
|
+
#
|
|
182
|
+
# Uses canonical types from NodeTypeNormalizer for portable signatures.
|
|
183
|
+
#
|
|
184
|
+
# @param node [Object] The node (may be wrapped)
|
|
185
|
+
# @return [Array, nil] Signature array
|
|
186
|
+
def compute_parser_signature(node)
|
|
187
|
+
# Get canonical type from wrapper or normalize raw type
|
|
188
|
+
canonical_type = if Ast::Merge::NodeTyping.typed_node?(node)
|
|
189
|
+
Ast::Merge::NodeTyping.merge_type_for(node)
|
|
190
|
+
else
|
|
191
|
+
NodeTypeNormalizer.canonical_type(node.type, @backend)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Unwrap to access underlying node methods
|
|
195
|
+
raw_node = Ast::Merge::NodeTyping.unwrap(node)
|
|
196
|
+
|
|
197
|
+
case canonical_type
|
|
198
|
+
when :heading
|
|
199
|
+
level = raw_node.header_level
|
|
200
|
+
# H1 is the document title — treat as a singleton (see FileAnalysisBase for rationale)
|
|
201
|
+
return [:heading, 1] if level == 1
|
|
202
|
+
|
|
203
|
+
[:heading, level, extract_text_content(raw_node)]
|
|
204
|
+
when :paragraph
|
|
205
|
+
# Content-based: Match paragraphs by content hash (first 32 chars of digest)
|
|
206
|
+
text = extract_text_content(raw_node)
|
|
207
|
+
[:paragraph, Digest::SHA256.hexdigest(text)[0, 32]]
|
|
208
|
+
when :code_block
|
|
209
|
+
# Content-based: Match code blocks by fence info and content hash
|
|
210
|
+
content = safe_string_content(raw_node)
|
|
211
|
+
fence_info = raw_node.respond_to?(:fence_info) ? raw_node.fence_info : nil
|
|
212
|
+
[:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
|
|
213
|
+
when :list
|
|
214
|
+
# Structure-based: Match lists by type and item count (content may differ)
|
|
215
|
+
list_type = raw_node.respond_to?(:list_type) ? raw_node.list_type : nil
|
|
216
|
+
[:list, list_type, count_children(raw_node)]
|
|
217
|
+
when :block_quote
|
|
218
|
+
# Content-based: Match block quotes by content hash
|
|
219
|
+
text = extract_text_content(raw_node)
|
|
220
|
+
[:block_quote, Digest::SHA256.hexdigest(text)[0, 16]]
|
|
221
|
+
when :thematic_break
|
|
222
|
+
# Structure-based: All thematic breaks are equivalent
|
|
223
|
+
[:thematic_break]
|
|
224
|
+
when :html_block
|
|
225
|
+
# Content-based: Match HTML blocks by content hash
|
|
226
|
+
content = safe_string_content(raw_node)
|
|
227
|
+
[:html_block, Digest::SHA256.hexdigest(content)[0, 16]]
|
|
228
|
+
when :table
|
|
229
|
+
# Content-based: Match tables by structure and header content
|
|
230
|
+
header_content = extract_table_header_content(raw_node)
|
|
231
|
+
[:table, count_children(raw_node), Digest::SHA256.hexdigest(header_content)[0, 16]]
|
|
232
|
+
when :footnote_definition
|
|
233
|
+
# Name/label-based: Match footnotes by name or label
|
|
234
|
+
label = raw_node.respond_to?(:name) ? raw_node.name : safe_string_content(raw_node)
|
|
235
|
+
[:footnote_definition, label]
|
|
236
|
+
when :custom_block
|
|
237
|
+
# Content-based: Match custom blocks by content hash
|
|
238
|
+
text = extract_text_content(raw_node)
|
|
239
|
+
[:custom_block, Digest::SHA256.hexdigest(text)[0, 16]]
|
|
240
|
+
else
|
|
241
|
+
# Unknown type - use canonical type and position
|
|
242
|
+
pos = raw_node.source_position
|
|
243
|
+
[:unknown, canonical_type, pos&.dig(:start_line)]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Extract all text content from a node and its children.
|
|
248
|
+
#
|
|
249
|
+
# Override for tree_haver nodes which don't have a `walk` method.
|
|
250
|
+
# Uses recursive traversal via `children` instead.
|
|
251
|
+
#
|
|
252
|
+
# @param node [Object] The node
|
|
253
|
+
# @return [String] Concatenated text content
|
|
254
|
+
def extract_text_content(node)
|
|
255
|
+
text_parts = []
|
|
256
|
+
collect_text_recursive(node, text_parts)
|
|
257
|
+
text_parts.join
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Safely get string content from a node.
|
|
261
|
+
#
|
|
262
|
+
# Override for tree_haver nodes which use `text` instead of `string_content`.
|
|
263
|
+
#
|
|
264
|
+
# @param node [Object] The node
|
|
265
|
+
# @return [String] String content or empty string
|
|
266
|
+
def safe_string_content(node)
|
|
267
|
+
if node.respond_to?(:string_content)
|
|
268
|
+
node.string_content.to_s
|
|
269
|
+
elsif node.respond_to?(:text)
|
|
270
|
+
node.text.to_s
|
|
271
|
+
else
|
|
272
|
+
extract_text_content(node)
|
|
273
|
+
end
|
|
274
|
+
rescue TypeError, NoMethodError
|
|
275
|
+
extract_text_content(node)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Collect top-level nodes from document, wrapping with canonical types.
|
|
279
|
+
#
|
|
280
|
+
# @return [Array<Object>] Wrapped nodes
|
|
281
|
+
def collect_top_level_nodes
|
|
282
|
+
nodes = []
|
|
283
|
+
child = @document.first_child
|
|
284
|
+
while child
|
|
285
|
+
# Wrap each node with its canonical type
|
|
286
|
+
wrapped = NodeTypeNormalizer.wrap(child, @backend)
|
|
287
|
+
nodes << wrapped
|
|
288
|
+
child = next_sibling(child)
|
|
289
|
+
end
|
|
290
|
+
nodes
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def heading_section_owners
|
|
294
|
+
headings = Array(statements).filter_map do |statement|
|
|
295
|
+
next unless heading_statement?(statement)
|
|
296
|
+
|
|
297
|
+
build_heading_owner(statement)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
headings.each_with_index.map do |owner, index|
|
|
301
|
+
branch_end_line = branch_end_line(headings, index)
|
|
302
|
+
HeadingSectionOwner.new(
|
|
303
|
+
location: Location.new(start_line: owner.location.start_line, end_line: branch_end_line),
|
|
304
|
+
heading_text: owner.heading_text,
|
|
305
|
+
heading_source: owner.heading_source,
|
|
306
|
+
level: owner.level,
|
|
307
|
+
base: owner.base
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def link_definition_owners
|
|
313
|
+
Array(statements).filter_map do |statement|
|
|
314
|
+
next unless statement.respond_to?(:merge_type) && statement.merge_type == :link_definition
|
|
315
|
+
|
|
316
|
+
position = statement.source_position
|
|
317
|
+
next unless position
|
|
318
|
+
|
|
319
|
+
LinkDefinitionOwner.new(
|
|
320
|
+
location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
|
|
321
|
+
label: statement.label,
|
|
322
|
+
url: statement.url,
|
|
323
|
+
title: statement.title,
|
|
324
|
+
source: if statement.respond_to?(:content)
|
|
325
|
+
statement.content
|
|
326
|
+
else
|
|
327
|
+
source_range(position[:start_line], position[:end_line]).chomp
|
|
328
|
+
end
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def html_comment_owners
|
|
334
|
+
comment_tracker.comment_nodes.map do |comment|
|
|
335
|
+
HtmlCommentOwner.new(
|
|
336
|
+
location: Location.new(start_line: comment.location.start_line, end_line: comment.location.end_line),
|
|
337
|
+
text: comment.content,
|
|
338
|
+
source: comment.text
|
|
339
|
+
)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def list_item_owners
|
|
344
|
+
Array(statements).flat_map do |statement|
|
|
345
|
+
collect_list_item_owners(unwrap_markdown_statement(statement), depth: 0)
|
|
346
|
+
end.sort_by { |owner| [owner.location.start_line, owner.location.end_line] }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def inline_reference_owners
|
|
350
|
+
source.to_s.lines.each_with_index.flat_map do |line, index|
|
|
351
|
+
inline_references_for_line(line.chomp, index + 1)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def table_row_owners
|
|
356
|
+
ast_table_lines = {}
|
|
357
|
+
ast_rows = Array(statements).flat_map do |statement|
|
|
358
|
+
node = unwrap_markdown_statement(statement)
|
|
359
|
+
next [] unless node.respond_to?(:type) && node.type.to_s == 'table'
|
|
360
|
+
|
|
361
|
+
table_position = node.source_position
|
|
362
|
+
if table_position
|
|
363
|
+
(table_position[:start_line]..table_position[:end_line]).each do |line|
|
|
364
|
+
ast_table_lines[line] = true
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
Array(node.children).filter_map do |child|
|
|
368
|
+
next unless child.respond_to?(:type) && child.type.to_s == 'table_row'
|
|
369
|
+
|
|
370
|
+
position = child.source_position
|
|
371
|
+
next unless position
|
|
372
|
+
|
|
373
|
+
TableRowOwner.new(
|
|
374
|
+
location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
|
|
375
|
+
source: source_range(position[:start_line], position[:end_line]),
|
|
376
|
+
text: extract_text_content(child)
|
|
377
|
+
)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
ast_lines = ast_rows.map { |owner| owner.location.start_line }.to_h { |line| [line, true] }
|
|
381
|
+
loose_rows = source.to_s.lines.each_with_index.filter_map do |line, index|
|
|
382
|
+
line_number = index + 1
|
|
383
|
+
next if ast_lines[line_number]
|
|
384
|
+
next if ast_table_lines[line_number]
|
|
385
|
+
next unless loose_table_row_line?(line)
|
|
386
|
+
|
|
387
|
+
TableRowOwner.new(
|
|
388
|
+
location: Location.new(start_line: line_number, end_line: line_number),
|
|
389
|
+
source: line,
|
|
390
|
+
text: line
|
|
391
|
+
)
|
|
392
|
+
end
|
|
393
|
+
ast_rows + loose_rows
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
private
|
|
397
|
+
|
|
398
|
+
def loose_table_row_line?(line)
|
|
399
|
+
stripped = line.to_s.lstrip
|
|
400
|
+
return false unless stripped.start_with?('|')
|
|
401
|
+
|
|
402
|
+
stripped.include?(' |') || stripped.include?('| ')
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def collect_list_item_owners(node, depth:)
|
|
406
|
+
return [] unless node.respond_to?(:type)
|
|
407
|
+
|
|
408
|
+
canonical_type = NodeTypeNormalizer.canonical_type(node.type, @backend)
|
|
409
|
+
next_depth = %i[list].include?(canonical_type) ? depth + 1 : depth
|
|
410
|
+
owners = []
|
|
411
|
+
if canonical_type == :list_item
|
|
412
|
+
position = node.source_position if node.respond_to?(:source_position)
|
|
413
|
+
if position
|
|
414
|
+
source_text = source_range(position[:start_line], position[:end_line])
|
|
415
|
+
owners << ListItemOwner.new(
|
|
416
|
+
location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
|
|
417
|
+
source: source_text,
|
|
418
|
+
text: source_text,
|
|
419
|
+
depth: depth,
|
|
420
|
+
marker: list_item_marker(source_text)
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
Array(node.children).each do |child|
|
|
426
|
+
owners.concat(collect_list_item_owners(child, depth: next_depth))
|
|
427
|
+
end
|
|
428
|
+
owners
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def list_item_marker(source_text)
|
|
432
|
+
stripped = source_text.to_s.lines.first.to_s.lstrip
|
|
433
|
+
return stripped[0, 2].strip if stripped.start_with?('- ', '* ')
|
|
434
|
+
|
|
435
|
+
marker = stripped.split(' ', 2).first.to_s
|
|
436
|
+
marker.end_with?('.') ? marker : nil
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def heading_statement?(statement)
|
|
440
|
+
merge_type = if statement.respond_to?(:merge_type)
|
|
441
|
+
statement.merge_type
|
|
442
|
+
else
|
|
443
|
+
unwrap_markdown_statement(statement)&.type
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
%w[heading header].include?(merge_type.to_s)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def build_heading_owner(statement)
|
|
450
|
+
node = unwrap_markdown_statement(statement)
|
|
451
|
+
position = node&.source_position
|
|
452
|
+
return unless node && position
|
|
453
|
+
|
|
454
|
+
heading_source = source_range(position[:start_line], position[:end_line]).sub(/\n\z/, '')
|
|
455
|
+
heading_text = node.to_plaintext.to_s.sub(/\n+\z/, '')
|
|
456
|
+
HeadingSectionOwner.new(
|
|
457
|
+
location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
|
|
458
|
+
heading_text: heading_text,
|
|
459
|
+
heading_source: heading_source,
|
|
460
|
+
level: node.header_level,
|
|
461
|
+
base: normalize_heading_base(heading_text)
|
|
462
|
+
)
|
|
463
|
+
rescue StandardError
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def branch_end_line(headings, index)
|
|
468
|
+
current = headings[index]
|
|
469
|
+
cursor = index + 1
|
|
470
|
+
while cursor < headings.length
|
|
471
|
+
return headings[cursor].location.start_line - 1 if headings[cursor].level <= current.level
|
|
472
|
+
|
|
473
|
+
cursor += 1
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
source.to_s.lines.length
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def unwrap_markdown_statement(statement)
|
|
480
|
+
Ast::Merge::NodeTyping.unwrap(statement)
|
|
481
|
+
rescue StandardError
|
|
482
|
+
statement
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def normalize_heading_base(text)
|
|
486
|
+
text.to_s.sub(/\A(?:\d\uFE0F?\u20E3|[^[:alnum:][:space:]])+[ \t]*/u, '').strip.downcase
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def inline_references_for_line(line, line_number)
|
|
490
|
+
owners = []
|
|
491
|
+
index = 0
|
|
492
|
+
while index < line.length
|
|
493
|
+
image = inline_image_reference_at(line, index, line_number)
|
|
494
|
+
if image
|
|
495
|
+
owners << image
|
|
496
|
+
index = image.end_column
|
|
497
|
+
next
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
link = inline_link_reference_at(line, index, line_number)
|
|
501
|
+
if link
|
|
502
|
+
owners << link
|
|
503
|
+
index = link.end_column
|
|
504
|
+
next
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
index += 1
|
|
508
|
+
end
|
|
509
|
+
owners
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def inline_image_reference_at(line, index, line_number)
|
|
513
|
+
return unless line[index] == '!' && line[index + 1] == '['
|
|
514
|
+
|
|
515
|
+
alt_end = closing_bracket_index(line, index + 1)
|
|
516
|
+
return unless alt_end && line[alt_end + 1] == '['
|
|
517
|
+
|
|
518
|
+
label_end = closing_bracket_index(line, alt_end + 1)
|
|
519
|
+
return unless label_end
|
|
520
|
+
|
|
521
|
+
label = line[(alt_end + 2)...label_end]
|
|
522
|
+
inline_reference_owner(
|
|
523
|
+
line: line,
|
|
524
|
+
line_number: line_number,
|
|
525
|
+
start_column: index,
|
|
526
|
+
end_column: label_end + 1,
|
|
527
|
+
reference_kind: :image_reference,
|
|
528
|
+
label: label,
|
|
529
|
+
labels: [label]
|
|
530
|
+
)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def inline_link_reference_at(line, index, line_number)
|
|
534
|
+
return unless line[index] == '['
|
|
535
|
+
|
|
536
|
+
text_end = closing_bracket_index(line, index)
|
|
537
|
+
return unless text_end && line[text_end + 1] == '['
|
|
538
|
+
|
|
539
|
+
label_end = closing_bracket_index(line, text_end + 1)
|
|
540
|
+
return unless label_end
|
|
541
|
+
|
|
542
|
+
text = line[(index + 1)...text_end]
|
|
543
|
+
label = line[(text_end + 2)...label_end]
|
|
544
|
+
image_owner = inline_image_reference_at(text, 0, line_number)
|
|
545
|
+
labels = [label]
|
|
546
|
+
labels.unshift(image_owner.label) if image_owner && image_owner.source == text
|
|
547
|
+
inline_reference_owner(
|
|
548
|
+
line: line,
|
|
549
|
+
line_number: line_number,
|
|
550
|
+
start_column: index,
|
|
551
|
+
end_column: label_end + 1,
|
|
552
|
+
reference_kind: labels.length > 1 ? :linked_image_reference : :link_reference,
|
|
553
|
+
label: label,
|
|
554
|
+
labels: labels
|
|
555
|
+
)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def inline_reference_owner(line:, line_number:, start_column:, end_column:, reference_kind:, label:, labels:)
|
|
559
|
+
InlineReferenceOwner.new(
|
|
560
|
+
location: Location.new(start_line: line_number, end_line: line_number),
|
|
561
|
+
line: line_number,
|
|
562
|
+
start_column: start_column,
|
|
563
|
+
end_column: end_column,
|
|
564
|
+
source: line[start_column...end_column],
|
|
565
|
+
reference_kind: reference_kind,
|
|
566
|
+
label: label,
|
|
567
|
+
labels: labels.compact.uniq
|
|
568
|
+
)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def closing_bracket_index(text, opening_index)
|
|
572
|
+
depth = 0
|
|
573
|
+
index = opening_index
|
|
574
|
+
while index < text.length
|
|
575
|
+
case text[index]
|
|
576
|
+
when '['
|
|
577
|
+
depth += 1
|
|
578
|
+
when ']'
|
|
579
|
+
depth -= 1
|
|
580
|
+
return index if depth.zero?
|
|
581
|
+
end
|
|
582
|
+
index += 1
|
|
583
|
+
end
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Recursively collect text content from a node and its descendants.
|
|
588
|
+
#
|
|
589
|
+
# Uses NodeTypeNormalizer to map backend-specific types to canonical types,
|
|
590
|
+
# enabling portable type checking across different markdown parsers.
|
|
591
|
+
#
|
|
592
|
+
# NOTE: We use `type` here instead of `merge_type` because this method operates
|
|
593
|
+
# on child nodes (text, code), not top-level statements.
|
|
594
|
+
# Only top-level statements are wrapped by NodeTypeNormalizer with `merge_type`.
|
|
595
|
+
# However, we use NodeTypeNormalizer.canonical_type to normalize the raw type.
|
|
596
|
+
#
|
|
597
|
+
# @param node [Object] The node to traverse
|
|
598
|
+
# @param text_parts [Array<String>] Array to accumulate text into
|
|
599
|
+
# @return [void]
|
|
600
|
+
def collect_text_recursive(node, text_parts)
|
|
601
|
+
# Normalize the type using NodeTypeNormalizer for backend portability
|
|
602
|
+
canonical_type = NodeTypeNormalizer.canonical_type(node.type, @backend)
|
|
603
|
+
|
|
604
|
+
# Collect text from text and code nodes
|
|
605
|
+
if %i[text code].include?(canonical_type)
|
|
606
|
+
content = if node.respond_to?(:string_content)
|
|
607
|
+
node.string_content.to_s
|
|
608
|
+
elsif node.respond_to?(:text)
|
|
609
|
+
node.text.to_s
|
|
610
|
+
else
|
|
611
|
+
''
|
|
612
|
+
end
|
|
613
|
+
text_parts << content unless content.empty?
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Recurse into children
|
|
617
|
+
node.children.each do |child|
|
|
618
|
+
collect_text_recursive(child, text_parts)
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Resolve the backend to use.
|
|
623
|
+
#
|
|
624
|
+
# For :auto, use the same backend selection as the Markdown substrate facade.
|
|
625
|
+
# tree_haver handles the final availability checking.
|
|
626
|
+
#
|
|
627
|
+
# @param backend [Symbol] Requested backend
|
|
628
|
+
# @return [Symbol] Resolved backend
|
|
629
|
+
def resolve_backend(backend)
|
|
630
|
+
return Markdown::Merge.resolve_backend(nil).to_sym if backend.to_s.empty? || backend == :auto
|
|
631
|
+
|
|
632
|
+
backend.to_sym
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Create a parser for the resolved backend.
|
|
636
|
+
#
|
|
637
|
+
# @return [Object] tree_haver parser instance
|
|
638
|
+
def create_parser
|
|
639
|
+
unless Markdown::Merge::BACKEND_REFERENCES.key?(@backend.to_s)
|
|
640
|
+
raise ArgumentError, "Unknown backend: #{@backend}"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
parser = TreeHaver.with_backend(@backend) { TreeHaver.parser_for(:markdown) }
|
|
644
|
+
|
|
645
|
+
case @backend
|
|
646
|
+
when :commonmarker
|
|
647
|
+
parser.language = commonmarker_language
|
|
648
|
+
when :markly
|
|
649
|
+
parser.language = markly_language
|
|
650
|
+
when :kramdown
|
|
651
|
+
parser.language = kramdown_language
|
|
652
|
+
else
|
|
653
|
+
return parser
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
parser
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Create a Commonmarker language config for the TreeHaver parser.
|
|
660
|
+
#
|
|
661
|
+
# @return [Commonmarker::Merge::Backend::Language]
|
|
662
|
+
def commonmarker_language
|
|
663
|
+
# Default options enable table extension for GFM compatibility
|
|
664
|
+
default_options = { extension: { table: true } }
|
|
665
|
+
options = default_options.merge(@parser_options[:options] || {})
|
|
666
|
+
Commonmarker::Merge::Backend::Language.markdown(options: options)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Create a Markly language config for the TreeHaver parser.
|
|
670
|
+
#
|
|
671
|
+
# @return [Markly::Merge::Backend::Language]
|
|
672
|
+
def markly_language
|
|
673
|
+
flags = @parser_options[:flags]
|
|
674
|
+
extensions = @parser_options[:extensions] || [:table]
|
|
675
|
+
Markly::Merge::Backend::Language.markdown(
|
|
676
|
+
flags: flags,
|
|
677
|
+
extensions: extensions
|
|
678
|
+
)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Create a Kramdown language config for the TreeHaver parser.
|
|
682
|
+
#
|
|
683
|
+
# @return [Kramdown::Merge::Backend::Language]
|
|
684
|
+
def kramdown_language
|
|
685
|
+
Kramdown::Merge::Backend::Language.markdown(options: @parser_options[:options] || {})
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|