markdown-merge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +251 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +1087 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/markdown/merge/cleanse/block_spacing.rb +253 -0
  14. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  15. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +405 -0
  16. data/lib/markdown/merge/cleanse.rb +42 -0
  17. data/lib/markdown/merge/code_block_merger.rb +300 -0
  18. data/lib/markdown/merge/conflict_resolver.rb +128 -0
  19. data/lib/markdown/merge/debug_logger.rb +26 -0
  20. data/lib/markdown/merge/document_problems.rb +190 -0
  21. data/lib/markdown/merge/file_aligner.rb +196 -0
  22. data/lib/markdown/merge/file_analysis.rb +353 -0
  23. data/lib/markdown/merge/file_analysis_base.rb +629 -0
  24. data/lib/markdown/merge/freeze_node.rb +93 -0
  25. data/lib/markdown/merge/gap_line_node.rb +136 -0
  26. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  27. data/lib/markdown/merge/link_definition_node.rb +157 -0
  28. data/lib/markdown/merge/link_parser.rb +421 -0
  29. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  30. data/lib/markdown/merge/markdown_structure.rb +123 -0
  31. data/lib/markdown/merge/merge_result.rb +166 -0
  32. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  33. data/lib/markdown/merge/output_builder.rb +166 -0
  34. data/lib/markdown/merge/partial_template_merger.rb +334 -0
  35. data/lib/markdown/merge/smart_merger.rb +221 -0
  36. data/lib/markdown/merge/smart_merger_base.rb +621 -0
  37. data/lib/markdown/merge/table_match_algorithm.rb +504 -0
  38. data/lib/markdown/merge/table_match_refiner.rb +136 -0
  39. data/lib/markdown/merge/version.rb +12 -0
  40. data/lib/markdown/merge/whitespace_normalizer.rb +251 -0
  41. data/lib/markdown/merge.rb +149 -0
  42. data/lib/markdown-merge.rb +4 -0
  43. data/sig/markdown/merge.rbs +341 -0
  44. data.tar.gz.sig +0 -0
  45. metadata +365 -0
  46. metadata.gz.sig +0 -0
@@ -0,0 +1,629 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "set"
5
+
6
+ module Markdown
7
+ module Merge
8
+ # Base class for file analysis for Markdown files.
9
+ #
10
+ # Parses Markdown source code and extracts:
11
+ # - Top-level block elements (headings, paragraphs, lists, code blocks, etc.)
12
+ # - Freeze blocks marked with HTML comments
13
+ # - Structural signatures for matching elements between files
14
+ #
15
+ # Subclasses must implement parser-specific methods:
16
+ # - #parse_document(source) - Parse source and return document node
17
+ # - #next_sibling(node) - Get next sibling of a node
18
+ # - #compute_parser_signature(node) - Compute signature for parser-specific nodes
19
+ # - #node_type_name(type) - Map canonical type names if needed
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 (subclass)
27
+ # class FileAnalysis < Markdown::Merge::FileAnalysisBase
28
+ # def parse_document(source)
29
+ # Markly.parse(source, flags: @flags)
30
+ # end
31
+ #
32
+ # def next_sibling(node)
33
+ # node.next
34
+ # end
35
+ # end
36
+ #
37
+ # @abstract Subclass and implement parser-specific methods
38
+ class FileAnalysisBase
39
+ include Ast::Merge::FileAnalyzable
40
+
41
+ # Default freeze token for identifying freeze blocks
42
+ # @return [String]
43
+ DEFAULT_FREEZE_TOKEN = "markdown-merge"
44
+
45
+ # @return [Object] The root document node
46
+ attr_reader :document
47
+
48
+ # @return [Array] Parse errors if any
49
+ attr_reader :errors
50
+
51
+ # Note: :source is inherited from Ast::Merge::FileAnalyzable
52
+
53
+ # Initialize file analysis
54
+ #
55
+ # @param source [String] Markdown source code to analyze
56
+ # @param freeze_token [String] Token for freeze block markers
57
+ # @param signature_generator [Proc, nil] Custom signature generator
58
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **parser_options)
59
+ @source = source
60
+ # Split by newlines, keeping trailing empty strings (-1)
61
+ # But remove the final empty string if source ends with newline
62
+ # (that empty string represents the "line after the last newline" which doesn't exist)
63
+ @lines = source.split("\n", -1)
64
+ @lines.pop if @lines.last == "" && source.end_with?("\n")
65
+
66
+ @freeze_token = freeze_token
67
+ @signature_generator = signature_generator
68
+ @parser_options = parser_options
69
+ @errors = []
70
+
71
+ # Parse the Markdown source - subclasses implement this
72
+ @document = DebugLogger.time("FileAnalysisBase#parse") do
73
+ parse_document(source)
74
+ end
75
+
76
+ # Extract and integrate all nodes including freeze blocks
77
+ @statements = extract_and_integrate_all_nodes
78
+
79
+ DebugLogger.debug("FileAnalysisBase initialized", {
80
+ signature_generator: signature_generator ? "custom" : "default",
81
+ document_children: count_children(@document),
82
+ statements_count: @statements.size,
83
+ freeze_blocks: freeze_blocks.size,
84
+ })
85
+ end
86
+
87
+ # Parse the source document.
88
+ #
89
+ # @abstract Subclasses must implement this method
90
+ # @param source [String] Markdown source to parse
91
+ # @return [Object] Root document node
92
+ def parse_document(source)
93
+ raise NotImplementedError, "#{self.class} must implement #parse_document"
94
+ end
95
+
96
+ # Get the next sibling of a node.
97
+ #
98
+ # Different parsers use different methods (next vs next_sibling).
99
+ #
100
+ # @abstract Subclasses must implement this method
101
+ # @param node [Object] Current node
102
+ # @return [Object, nil] Next sibling or nil
103
+ def next_sibling(node)
104
+ raise NotImplementedError, "#{self.class} must implement #next_sibling"
105
+ end
106
+
107
+ # Check if parse was successful
108
+ # @return [Boolean]
109
+ def valid?
110
+ @errors.empty? && !@document.nil?
111
+ end
112
+
113
+ # Get all statements (block nodes outside freeze blocks + FreezeNode instances)
114
+ # @return [Array<Object, FreezeNode>]
115
+ attr_reader :statements
116
+
117
+ # Compute default signature for a node
118
+ # @param node [Object] The parser node or FreezeNode
119
+ # @return [Array, nil] Signature array
120
+ def compute_node_signature(node)
121
+ case node
122
+ when Ast::Merge::FreezeNodeBase
123
+ node.signature
124
+ when LinkDefinitionNode
125
+ node.signature
126
+ when GapLineNode
127
+ node.signature
128
+ else
129
+ compute_parser_signature(node)
130
+ end
131
+ end
132
+
133
+ # Override to detect parser nodes for signature generator fallthrough
134
+ # @param value [Object] The value to check
135
+ # @return [Boolean] true if this is a fallthrough node
136
+ def fallthrough_node?(value)
137
+ value.is_a?(Ast::Merge::FreezeNodeBase) ||
138
+ value.is_a?(LinkDefinitionNode) ||
139
+ value.is_a?(GapLineNode) ||
140
+ parser_node?(value) ||
141
+ super
142
+ end
143
+
144
+ # Check if value is a parser-specific node.
145
+ #
146
+ # @param value [Object] Value to check
147
+ # @return [Boolean] true if this is a parser node
148
+ def parser_node?(value)
149
+ # Default: check if it responds to :type (common for AST nodes)
150
+ value.respond_to?(:type)
151
+ end
152
+
153
+ # Compute signature for a parser-specific node.
154
+ #
155
+ # @abstract Subclasses should override this method
156
+ # @param node [Object] The parser node
157
+ # @return [Array, nil] Signature array
158
+ def compute_parser_signature(node)
159
+ type = node.type
160
+ case type
161
+ when :heading, :header
162
+ # Content-based: Match headings by level and text content
163
+ [:heading, node.header_level, extract_text_content(node)]
164
+ when :paragraph
165
+ # Content-based: Match paragraphs by content hash (first 32 chars of digest)
166
+ text = extract_text_content(node)
167
+ [:paragraph, Digest::SHA256.hexdigest(text)[0, 32]]
168
+ when :code_block
169
+ # Content-based: Match code blocks by fence info and content hash
170
+ content = safe_string_content(node)
171
+ fence_info = node.respond_to?(:fence_info) ? node.fence_info : nil
172
+ [:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
173
+ when :list
174
+ # Structure-based: Match lists by type and item count (content may differ)
175
+ list_type = node.respond_to?(:list_type) ? node.list_type : nil
176
+ [:list, list_type, count_children(node)]
177
+ when :block_quote, :blockquote
178
+ # Content-based: Match block quotes by content hash
179
+ text = extract_text_content(node)
180
+ [:blockquote, Digest::SHA256.hexdigest(text)[0, 16]]
181
+ when :thematic_break, :hrule
182
+ # Structure-based: All thematic breaks are equivalent
183
+ [:hrule]
184
+ when :html_block, :html
185
+ # Content-based: Match HTML blocks by content hash
186
+ content = safe_string_content(node)
187
+ [:html, Digest::SHA256.hexdigest(content)[0, 16]]
188
+ when :table
189
+ # Content-based: Match tables by structure and header content
190
+ header_content = extract_table_header_content(node)
191
+ [:table, count_children(node), Digest::SHA256.hexdigest(header_content)[0, 16]]
192
+ when :footnote_definition
193
+ # Name/label-based: Match footnotes by name or label
194
+ label = node.respond_to?(:name) ? node.name : safe_string_content(node)
195
+ [:footnote_definition, label]
196
+ when :custom_block
197
+ # Content-based: Match custom blocks by content hash
198
+ text = extract_text_content(node)
199
+ [:custom_block, Digest::SHA256.hexdigest(text)[0, 16]]
200
+ else
201
+ # Unknown type - use type and position
202
+ pos = node.source_position
203
+ [:unknown, type, pos&.dig(:start_line)]
204
+ end
205
+ end
206
+
207
+ # Safely get string content from a node
208
+ # @param node [Object] The node
209
+ # @return [String] String content or empty string
210
+ def safe_string_content(node)
211
+ node.string_content.to_s
212
+ rescue TypeError
213
+ # Some node types don't support string_content
214
+ extract_text_content(node)
215
+ end
216
+
217
+ # Extract all text content from a node and its children
218
+ # @param node [Object] The node
219
+ # @return [String] Concatenated text content
220
+ def extract_text_content(node)
221
+ text_parts = []
222
+ node.walk do |child|
223
+ if child.type == :text
224
+ text_parts << child.string_content.to_s
225
+ elsif child.type == :code
226
+ text_parts << child.string_content.to_s
227
+ end
228
+ end
229
+ text_parts.join
230
+ end
231
+
232
+ # Get the source text for a range of lines
233
+ #
234
+ # Lines are joined with newlines, and each line gets a trailing newline
235
+ # except for the last line of the file (which may or may not have one in the original).
236
+ #
237
+ # @param start_line [Integer] Start line (1-indexed)
238
+ # @param end_line [Integer] End line (1-indexed)
239
+ # @return [String] Source text
240
+ def source_range(start_line, end_line)
241
+ return "" if start_line < 1 || end_line < start_line
242
+
243
+ extracted_lines = @lines[(start_line - 1)..(end_line - 1)]
244
+ return "" if extracted_lines.empty?
245
+
246
+ # Add newlines between and after lines, but not after the last line of the file
247
+ # unless it originally had one
248
+ result = extracted_lines.join("\n")
249
+
250
+ # Add trailing newline if this isn't the last line of the file
251
+ # (the last line may or may not have a trailing newline in the original source)
252
+ if end_line < @lines.length
253
+ result += "\n"
254
+ elsif @source&.end_with?("\n")
255
+ # Last line of file, but original source ends with newline
256
+ result += "\n"
257
+ end
258
+
259
+ result
260
+ end
261
+
262
+ protected
263
+
264
+ # Extract header content from a table node
265
+ # @param node [Object] The table node
266
+ # @return [String] Header row content
267
+ def extract_table_header_content(node)
268
+ # First row of a table is typically the header
269
+ first_row = node.first_child
270
+ return "" unless first_row
271
+
272
+ extract_text_content(first_row)
273
+ end
274
+
275
+ # Count children of a node
276
+ # @param node [Object] The node
277
+ # @return [Integer] Child count
278
+ def count_children(node)
279
+ count = 0
280
+ child = node.first_child
281
+ while child
282
+ count += 1
283
+ child = next_sibling(child)
284
+ end
285
+ count
286
+ end
287
+
288
+ private
289
+
290
+ # Extract all nodes and integrate freeze blocks
291
+ # @return [Array<Object>] Integrated list of nodes and freeze blocks
292
+ def extract_and_integrate_all_nodes
293
+ freeze_markers = find_freeze_markers
294
+
295
+ # Use gap-aware collection to preserve link definitions and gap lines
296
+ base_nodes = collect_top_level_nodes_with_gaps
297
+
298
+ return base_nodes if freeze_markers.empty?
299
+
300
+ # Build freeze blocks from markers
301
+ freeze_blocks = build_freeze_blocks(freeze_markers)
302
+ return base_nodes if freeze_blocks.empty?
303
+
304
+ # Integrate nodes with freeze blocks
305
+ integrate_nodes_with_freeze_blocks(freeze_blocks, base_nodes)
306
+ end
307
+
308
+ # Collect top-level nodes from document
309
+ # @return [Array<Object>]
310
+ def collect_top_level_nodes
311
+ nodes = []
312
+ child = @document.first_child
313
+ while child
314
+ nodes << child
315
+ child = next_sibling(child)
316
+ end
317
+ nodes
318
+ end
319
+
320
+ # Collect top-level nodes with gap line detection.
321
+ #
322
+ # Markdown parsers consume certain content (like link reference definitions)
323
+ # during parsing. This method detects "gap" lines that aren't covered by any
324
+ # node and creates synthetic nodes for them.
325
+ #
326
+ # @return [Array<Object>] Nodes including gap line nodes
327
+ def collect_top_level_nodes_with_gaps
328
+ parser_nodes = collect_top_level_nodes
329
+ return parser_nodes if @lines.empty?
330
+
331
+ # Track which lines are covered by parser nodes
332
+ covered_lines = Set.new
333
+ parser_nodes.each do |node|
334
+ pos = node.source_position
335
+ next unless pos
336
+
337
+ start_line = pos[:start_line]
338
+ end_line = pos[:end_line]
339
+
340
+ # Handle Markly's buggy position reporting for :html nodes
341
+ # where end_line can be less than start_line (e.g., "lines 3-2")
342
+ if end_line < start_line
343
+ # Just mark the start_line as covered
344
+ covered_lines << start_line
345
+ else
346
+ (start_line..end_line).each { |l| covered_lines << l }
347
+ end
348
+ end
349
+
350
+ # Find gap lines (lines not covered by any node)
351
+ total_lines = @lines.size
352
+ gap_line_numbers = (1..total_lines).to_a - covered_lines.to_a
353
+
354
+ # Create nodes for gap lines
355
+ gap_nodes = create_gap_nodes(gap_line_numbers)
356
+
357
+ # Integrate gap nodes with parser nodes in line order
358
+ integrate_gap_nodes(parser_nodes, gap_nodes)
359
+ end
360
+
361
+ # Create nodes for gap lines.
362
+ #
363
+ # Link reference definitions get LinkDefinitionNode, others get GapLineNode.
364
+ # Every gap line gets a node so we can reconstruct the document exactly.
365
+ #
366
+ # @param line_numbers [Array<Integer>] Gap line numbers (1-based)
367
+ # @return [Array<Object>] Gap nodes
368
+ def create_gap_nodes(line_numbers)
369
+ line_numbers.map do |line_num|
370
+ content = @lines[line_num - 1] || ""
371
+
372
+ # Try to parse as link definition first
373
+ link_node = LinkDefinitionNode.parse(content, line_number: line_num)
374
+ if link_node
375
+ link_node
376
+ else
377
+ GapLineNode.new(content, line_number: line_num)
378
+ end
379
+ end
380
+ end
381
+
382
+ # Integrate gap nodes with parser nodes in line order.
383
+ # Sets preceding_node for gap lines to enable context-aware signatures.
384
+ #
385
+ # @param parser_nodes [Array<Object>] Parser-generated nodes
386
+ # @param gap_nodes [Array<Object>] Gap line nodes
387
+ # @return [Array<Object>] All nodes in line order
388
+ def integrate_gap_nodes(parser_nodes, gap_nodes)
389
+ all_nodes = parser_nodes + gap_nodes
390
+
391
+ # Sort by start line
392
+ sorted_nodes = all_nodes.sort_by do |node|
393
+ pos = node.source_position
394
+ pos ? pos[:start_line] : 0
395
+ end
396
+
397
+ # Set preceding_node for gap lines based on their position in the sorted list
398
+ # This allows gap lines to have context-aware signatures
399
+ sorted_nodes.each_with_index do |node, idx|
400
+ if node.is_a?(GapLineNode) && idx > 0
401
+ # Find the previous non-gap-line node (structural node)
402
+ preceding = sorted_nodes[0...idx].reverse.find { |n| !n.is_a?(GapLineNode) }
403
+ node.preceding_node = preceding
404
+ end
405
+ end
406
+
407
+ sorted_nodes
408
+ end
409
+
410
+ # Find freeze markers from parsed HTML nodes.
411
+ #
412
+ # Freeze markers are HTML comments that Markly parses as :html nodes.
413
+ # By analyzing the parsed nodes (not raw source lines), we automatically ignore
414
+ # freeze markers that appear inside code blocks (they're part of the code block's
415
+ # string content, not separate nodes).
416
+ #
417
+ # Note: We only support freeze markers as standalone HTML comment nodes.
418
+ # Freeze markers embedded inside other HTML tags (e.g., `<div><!-- freeze -->text</div>`)
419
+ # are not detected because they're part of a larger HTML node's content.
420
+ #
421
+ # @return [Array<Hash>] Marker information
422
+ def find_freeze_markers
423
+ markers = []
424
+ pattern = Ast::Merge::FreezeNodeBase.pattern_for(:html_comment, @freeze_token)
425
+
426
+ return markers unless @document
427
+
428
+ # Walk through top-level nodes looking for HTML nodes with freeze markers
429
+ child = @document.first_child
430
+ while child
431
+ node_type = child.type
432
+
433
+ # Check HTML nodes for freeze markers
434
+ # Handle both raw Markly (:html) and TreeHaver normalized ("html_block", :html_block) types
435
+ if node_type == :html || node_type == :html_block || node_type == "html_block" || node_type == "html"
436
+ # Try multiple content extraction methods:
437
+ # 1. string_content (raw Markly/Commonmarker)
438
+ # 2. to_commonmark on wrapper
439
+ # 3. inner_node.to_commonmark (TreeHaver Commonmarker wrapper)
440
+ content = nil
441
+
442
+ if child.respond_to?(:string_content)
443
+ begin
444
+ content = child.string_content.to_s
445
+ rescue TypeError
446
+ # Some nodes don't have string_content
447
+ content = nil
448
+ end
449
+ end
450
+
451
+ if content.nil? || content.empty?
452
+ if child.respond_to?(:to_commonmark)
453
+ content = child.to_commonmark.to_s
454
+ end
455
+ end
456
+
457
+ # TreeHaver Commonmarker wrapper stores content in inner_node
458
+ if (content.nil? || content.empty?) && child.respond_to?(:inner_node)
459
+ inner = child.inner_node
460
+ if inner.respond_to?(:to_commonmark)
461
+ content = inner.to_commonmark.to_s
462
+ end
463
+ end
464
+
465
+ content ||= ""
466
+ match = content.match(pattern)
467
+
468
+ if match
469
+ pos = child.source_position
470
+ marker_type = match[1] # "freeze" or "unfreeze"
471
+ reason = match[2] # optional reason
472
+
473
+ markers << {
474
+ line: pos ? pos[:start_line] : 0,
475
+ type: marker_type.to_sym,
476
+ text: content.strip,
477
+ reason: reason,
478
+ node: child, # Keep reference to the actual node
479
+ }
480
+ end
481
+ end
482
+
483
+ child = next_sibling(child)
484
+ end
485
+
486
+ DebugLogger.debug("Found freeze markers", {count: markers.size})
487
+ markers
488
+ end
489
+
490
+ # Build freeze blocks from markers
491
+ # @param markers [Array<Hash>] Marker information
492
+ # @return [Array<FreezeNode>] Freeze blocks
493
+ def build_freeze_blocks(markers)
494
+ blocks = []
495
+ stack = []
496
+
497
+ markers.each do |marker|
498
+ case marker[:type]
499
+ when :freeze
500
+ stack.push(marker)
501
+ when :unfreeze
502
+ if stack.any?
503
+ start_marker = stack.pop
504
+ blocks << create_freeze_block(start_marker, marker)
505
+ else
506
+ DebugLogger.debug("Unmatched unfreeze marker", {line: marker[:line]})
507
+ end
508
+ end
509
+ end
510
+
511
+ # Warn about unclosed freeze blocks
512
+ stack.each do |unclosed|
513
+ DebugLogger.debug("Unclosed freeze marker", {line: unclosed[:line]})
514
+ end
515
+
516
+ blocks.sort_by(&:start_line)
517
+ end
518
+
519
+ # Create a freeze block from start and end markers.
520
+ #
521
+ # Subclasses may override to provide parser-specific FreezeNode subclass.
522
+ #
523
+ # @param start_marker [Hash] Start marker info
524
+ # @param end_marker [Hash] End marker info
525
+ # @return [FreezeNode]
526
+ def create_freeze_block(start_marker, end_marker)
527
+ start_line = start_marker[:line]
528
+ end_line = end_marker[:line]
529
+
530
+ # Content is between the markers (exclusive)
531
+ content_start = start_line + 1
532
+ content_end = end_line - 1
533
+
534
+ content = if content_start <= content_end
535
+ source_range(content_start, content_end)
536
+ else
537
+ ""
538
+ end
539
+
540
+ # Parse the content to get nodes (for nested analysis)
541
+ parsed_nodes = parse_freeze_block_content(content)
542
+
543
+ freeze_node_class.new(
544
+ start_line: start_line,
545
+ end_line: end_line,
546
+ content: content,
547
+ start_marker: start_marker[:text],
548
+ end_marker: end_marker[:text],
549
+ nodes: parsed_nodes,
550
+ reason: start_marker[:reason],
551
+ )
552
+ end
553
+
554
+ # Returns the FreezeNode class to use.
555
+ #
556
+ # Subclasses should override this to return their own FreezeNode class.
557
+ #
558
+ # @return [Class] FreezeNode class
559
+ def freeze_node_class
560
+ Ast::Merge::FreezeNodeBase
561
+ end
562
+
563
+ # Parse content within a freeze block.
564
+ #
565
+ # Subclasses should override this to use their parser.
566
+ #
567
+ # @param content [String] Content to parse
568
+ # @return [Array<Object>] Parsed nodes
569
+ def parse_freeze_block_content(content)
570
+ return [] if content.empty?
571
+
572
+ begin
573
+ content_doc = parse_document(content)
574
+ nodes = []
575
+ child = content_doc.first_child
576
+ while child
577
+ nodes << child
578
+ child = next_sibling(child)
579
+ end
580
+ nodes
581
+ rescue StandardError => e
582
+ # :nocov: defensive - parser rarely fails on valid markdown subset
583
+ DebugLogger.debug("Failed to parse freeze block content", {error: e.message})
584
+ []
585
+ # :nocov:
586
+ end
587
+ end
588
+
589
+ # Integrate nodes with freeze blocks
590
+ # @param freeze_blocks [Array<FreezeNode>] Freeze blocks
591
+ # @param base_nodes [Array<Object>, nil] Base nodes (defaults to collect_top_level_nodes_with_gaps)
592
+ # @return [Array<Object>] Integrated list
593
+ def integrate_nodes_with_freeze_blocks(freeze_blocks, base_nodes = nil)
594
+ result = []
595
+ freeze_index = 0
596
+ current_freeze = freeze_blocks[freeze_index]
597
+
598
+ top_level_nodes = base_nodes || collect_top_level_nodes_with_gaps
599
+
600
+ top_level_nodes.each do |node|
601
+ node_start = node.source_position&.dig(:start_line) || 0
602
+ node_end = node.source_position&.dig(:end_line) || node_start
603
+
604
+ # Add any freeze blocks that come before this node
605
+ while current_freeze && current_freeze.start_line < node_start
606
+ result << current_freeze
607
+ freeze_index += 1
608
+ current_freeze = freeze_blocks[freeze_index]
609
+ end
610
+
611
+ # Skip nodes that are inside a freeze block
612
+ inside_freeze = freeze_blocks.any? do |fb|
613
+ node_start >= fb.start_line && node_end <= fb.end_line
614
+ end
615
+
616
+ result << node unless inside_freeze
617
+ end
618
+
619
+ # Add remaining freeze blocks
620
+ while freeze_index < freeze_blocks.size
621
+ result << freeze_blocks[freeze_index]
622
+ freeze_index += 1
623
+ end
624
+
625
+ result
626
+ end
627
+ end
628
+ end
629
+ end