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,766 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Markdown
6
+ module Merge
7
+ # Base class for file analysis for Markdown files.
8
+ #
9
+ # Parses Markdown source code and extracts:
10
+ # - Top-level block elements (headings, paragraphs, lists, code blocks, etc.)
11
+ # - Freeze blocks marked with HTML comments
12
+ # - Structural signatures for matching elements between files
13
+ #
14
+ # Subclasses must implement parser-specific methods:
15
+ # - #parse_document(source) - Parse source and return document node
16
+ # - #next_sibling(node) - Get next sibling of a node
17
+ # - #compute_parser_signature(node) - Compute signature for parser-specific nodes
18
+ # - #node_type_name(type) - Map canonical type names if needed
19
+ #
20
+ # Freeze blocks are marked with HTML comments:
21
+ # <!-- markdown-merge:freeze -->
22
+ # ... content to preserve ...
23
+ # <!-- markdown-merge:unfreeze -->
24
+ #
25
+ # @example Basic usage (subclass)
26
+ # class FileAnalysis < Markdown::Merge::FileAnalysisBase
27
+ # def parse_document(source)
28
+ # Markly.parse(source, flags: @flags)
29
+ # end
30
+ #
31
+ # def next_sibling(node)
32
+ # node.next
33
+ # end
34
+ # end
35
+ #
36
+ # @abstract Subclass and implement parser-specific methods
37
+ class FileAnalysisBase
38
+ include Ast::Merge::FileAnalyzable
39
+
40
+ # Default freeze token for identifying freeze blocks
41
+ # @return [String]
42
+ DEFAULT_FREEZE_TOKEN = 'markdown-merge'
43
+
44
+ # @return [Object] The root document node
45
+ attr_reader :document
46
+
47
+ # @return [Array] Parse errors if any
48
+ attr_reader :errors
49
+
50
+ # @return [CommentTracker] Comment tracker for this file
51
+ attr_reader :comment_tracker
52
+
53
+ # NOTE: :source is inherited from Ast::Merge::FileAnalyzable
54
+
55
+ # Initialize file analysis
56
+ #
57
+ # @param source [String] Markdown source code to analyze
58
+ # @param freeze_token [String] Token for freeze block markers
59
+ # @param signature_generator [Proc, nil] Custom signature generator
60
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **parser_options)
61
+ @source = source
62
+ # Split by newlines, keeping trailing empty strings (-1)
63
+ # But remove the final empty string if source ends with newline
64
+ # (that empty string represents the "line after the last newline" which doesn't exist)
65
+ @lines = source.split("\n", -1)
66
+ @lines.pop if @lines.last == '' && source.end_with?("\n")
67
+ @comment_tracker = CommentTracker.new(@lines)
68
+
69
+ @freeze_token = freeze_token
70
+ @signature_generator = signature_generator
71
+ @parser_options = parser_options
72
+ @errors = []
73
+
74
+ # Parse the Markdown source - subclasses implement this
75
+ @document = DebugLogger.time('FileAnalysisBase#parse') do
76
+ parse_document(source)
77
+ end
78
+
79
+ # Extract and integrate all nodes including freeze blocks
80
+ @statements = extract_and_integrate_all_nodes
81
+
82
+ DebugLogger.debug('FileAnalysisBase initialized', {
83
+ signature_generator: signature_generator ? 'custom' : 'default',
84
+ document_children: count_children(@document),
85
+ statements_count: @statements.size,
86
+ freeze_blocks: freeze_blocks.size
87
+ })
88
+ end
89
+
90
+ # Parse the source document.
91
+ #
92
+ # @abstract Subclasses must implement this method
93
+ # @param source [String] Markdown source to parse
94
+ # @return [Object] Root document node
95
+ def parse_document(source)
96
+ raise NotImplementedError, "#{self.class} must implement #parse_document"
97
+ end
98
+
99
+ # Get the next sibling of a node.
100
+ #
101
+ # Different parsers use different methods (next vs next_sibling).
102
+ #
103
+ # @abstract Subclasses must implement this method
104
+ # @param node [Object] Current node
105
+ # @return [Object, nil] Next sibling or nil
106
+ def next_sibling(node)
107
+ raise NotImplementedError, "#{self.class} must implement #next_sibling"
108
+ end
109
+
110
+ # Check if parse was successful
111
+ # @return [Boolean]
112
+ def valid?
113
+ @errors.empty? && !@document.nil?
114
+ end
115
+
116
+ # Get shared comment capability information for this analysis.
117
+ #
118
+ # @return [Object]
119
+ def comment_capability
120
+ @comment_capability ||= comment_tracker.augment(owners: []).capability
121
+ end
122
+
123
+ # Describe how Markdown merges currently own and emit comments.
124
+ #
125
+ # Standalone HTML comments are source-augmented and emitted through the
126
+ # shared synthetic comment layer rather than parser-native comment AST.
127
+ #
128
+ # @return [Ast::Merge::Comment::SupportStyle]
129
+ def comment_support_style
130
+ @comment_support_style ||= shared_comment_support_style(
131
+ source: :markdown_source,
132
+ style: :html_comment,
133
+ read_strategy: :source_augmented_portable_write
134
+ )
135
+ end
136
+
137
+ # Get all tracked comments converted to shared comment nodes.
138
+ #
139
+ # @return [Array]
140
+ def comment_nodes
141
+ comment_tracker.comment_nodes
142
+ end
143
+
144
+ # Get a shared comment node at a specific line.
145
+ #
146
+ # @param line_num [Integer] 1-based line number
147
+ # @return [Object, nil]
148
+ def comment_node_at(line_num)
149
+ comment_tracker.comment_node_at(line_num)
150
+ end
151
+
152
+ # Get comments in a line range converted to a shared comment region.
153
+ #
154
+ # @param range [Range] Range of 1-based line numbers
155
+ # @param kind [Symbol] Region kind
156
+ # @param full_line_only [Boolean] Whether to keep only full-line comments
157
+ # @return [Object]
158
+ def comment_region_for_range(range, kind:, full_line_only: false)
159
+ comment_tracker.comment_region_for_range(
160
+ range,
161
+ kind: kind,
162
+ full_line_only: full_line_only
163
+ )
164
+ end
165
+
166
+ # Build a passive shared comment attachment for an owner.
167
+ #
168
+ # @param owner [Object] Structural owner for the attachment
169
+ # @param options [Hash] Additional metadata / lookup overrides
170
+ # @return [Object]
171
+ def comment_attachment_for(owner, **options)
172
+ augmented_attachment = comment_augmenter(**options).attachment_for(owner)
173
+
174
+ shared_comment_attachment_for(
175
+ owner,
176
+ tracker_attachment: augmented_attachment || comment_tracker.comment_attachment_for(owner, **options),
177
+ **options
178
+ )
179
+ end
180
+
181
+ # @return [Symbol]
182
+ def comment_attachment_strategy
183
+ :normalize_tracked_layout_merge
184
+ end
185
+
186
+ def ruleset_logical_owners
187
+ {
188
+ link_definition: :preserve_if_referenced
189
+ }
190
+ end
191
+
192
+ def ruleset_surfaces
193
+ [
194
+ { name: :fenced_code_block, selector: :language_tag }
195
+ ]
196
+ end
197
+
198
+ def ruleset_delegation_policies
199
+ [
200
+ { surface_name: :fenced_code_block, strategy: :by_language }
201
+ ]
202
+ end
203
+
204
+ # Build a passive shared comment augmenter for this analysis.
205
+ #
206
+ # @param owners [Array, nil] Owners used for attachment inference
207
+ # @param options [Hash] Additional augmenter options
208
+ # @return [Object]
209
+ def comment_augmenter(owners: nil, **options)
210
+ comment_tracker.augment(
211
+ owners: owners || comment_augmenter_default_owners,
212
+ **options
213
+ )
214
+ end
215
+
216
+ # Get all statements (block nodes outside freeze blocks + FreezeNode instances)
217
+ # @return [Array<Object, FreezeNode>]
218
+ attr_reader :statements
219
+
220
+ # Compute default signature for a node
221
+ # @param node [Object] The parser node or FreezeNode
222
+ # @return [Array, nil] Signature array
223
+ def compute_node_signature(node)
224
+ case node
225
+ when Ast::Merge::FreezeNodeBase
226
+ node.signature
227
+ when LinkDefinitionNode
228
+ node.signature
229
+ when GapLineNode
230
+ node.signature
231
+ else
232
+ compute_parser_signature(node)
233
+ end
234
+ end
235
+
236
+ # Override to detect parser nodes for signature generator fallthrough
237
+ # @param value [Object] The value to check
238
+ # @return [Boolean] true if this is a fallthrough node
239
+ def fallthrough_node?(value)
240
+ value.is_a?(Ast::Merge::FreezeNodeBase) ||
241
+ value.is_a?(LinkDefinitionNode) ||
242
+ value.is_a?(GapLineNode) ||
243
+ parser_node?(value) ||
244
+ super
245
+ end
246
+
247
+ # Check if value is a parser-specific node.
248
+ #
249
+ # @param value [Object] Value to check
250
+ # @return [Boolean] true if this is a parser node
251
+ def parser_node?(value)
252
+ # Default: check if it responds to :type (common for AST nodes)
253
+ value.respond_to?(:type)
254
+ end
255
+
256
+ # Compute signature for a parser-specific node.
257
+ #
258
+ # @abstract Subclasses should override this method
259
+ # @param node [Object] The parser node
260
+ # @return [Array, nil] Signature array
261
+ def compute_parser_signature(node)
262
+ type = node.type
263
+ case type
264
+ when :heading, :header
265
+ level = node.header_level
266
+ # H1 is the document title — treat as a singleton.
267
+ # A well-formed markdown document has exactly one H1. Matching by text
268
+ # would cause a generic template title ("AGENTS.md - Development Guide")
269
+ # and a project-qualified destination title ("AGENTS.md - myGem Development Guide")
270
+ # to be treated as different nodes, keeping both in the merged output.
271
+ # Using level-only for H1 makes them the same structural slot so the
272
+ # preferred version wins cleanly without duplication.
273
+ return [:heading, 1] if level == 1
274
+
275
+ # H2+ match by level and normalized text content
276
+ [:heading, level, extract_text_content(node)]
277
+ when :paragraph
278
+ # Content-based: Match paragraphs by content hash (first 32 chars of digest)
279
+ text = extract_text_content(node)
280
+ [:paragraph, Digest::SHA256.hexdigest(text)[0, 32]]
281
+ when :code_block
282
+ # Content-based: Match code blocks by fence info and content hash
283
+ content = safe_string_content(node)
284
+ fence_info = node.respond_to?(:fence_info) ? node.fence_info : nil
285
+ [:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
286
+ when :list
287
+ # Content-fingerprint: Match lists by type and a hash of the first few
288
+ # items' significant tokens. This lets two lists with similar (but not
289
+ # identical) content match by signature so item-level inner-merge can run,
290
+ # rather than the template list being appended as a template-only node.
291
+ list_type = node.respond_to?(:list_type) ? node.list_type : nil
292
+ items_text = []
293
+ child = node.first_child
294
+ while child
295
+ items_text << extract_text_content(child).downcase.gsub(/\W+/, ' ').strip
296
+ child = next_sibling(child)
297
+ break if items_text.size >= 5
298
+ end
299
+ fingerprint = Digest::SHA256.hexdigest(items_text.sort.join('|'))[0, 16]
300
+ [:list, list_type, fingerprint]
301
+ when :block_quote, :blockquote
302
+ # Content-based: Match block quotes by content hash
303
+ text = extract_text_content(node)
304
+ [:blockquote, Digest::SHA256.hexdigest(text)[0, 16]]
305
+ when :thematic_break, :hrule
306
+ # Structure-based: All thematic breaks are equivalent
307
+ [:hrule]
308
+ when :html_block, :html
309
+ # Content-based: Match HTML blocks by content hash
310
+ content = safe_string_content(node)
311
+ [:html, Digest::SHA256.hexdigest(content)[0, 16]]
312
+ when :table
313
+ # Content-based: Match tables by structure and header content
314
+ header_content = extract_table_header_content(node)
315
+ [:table, count_children(node), Digest::SHA256.hexdigest(header_content)[0, 16]]
316
+ when :footnote_definition
317
+ # Name/label-based: Match footnotes by name or label
318
+ label = node.respond_to?(:name) ? node.name : safe_string_content(node)
319
+ [:footnote_definition, label]
320
+ when :custom_block
321
+ # Content-based: Match custom blocks by content hash
322
+ text = extract_text_content(node)
323
+ [:custom_block, Digest::SHA256.hexdigest(text)[0, 16]]
324
+ else
325
+ # Unknown type - use type and position
326
+ pos = node.source_position
327
+ [:unknown, type, pos&.dig(:start_line)]
328
+ end
329
+ end
330
+
331
+ # Safely get string content from a node
332
+ # @param node [Object] The node
333
+ # @return [String] String content or empty string
334
+ def safe_string_content(node)
335
+ node.string_content.to_s
336
+ rescue TypeError
337
+ # Some node types don't support string_content
338
+ extract_text_content(node)
339
+ end
340
+
341
+ # Extract all text content from a node and its children
342
+ # @param node [Object] The node
343
+ # @return [String] Concatenated text content
344
+ def extract_text_content(node)
345
+ text_parts = []
346
+ node.walk do |child|
347
+ if child.type == :text
348
+ text_parts << child.string_content.to_s
349
+ elsif child.type == :code
350
+ text_parts << child.string_content.to_s
351
+ end
352
+ end
353
+ text_parts.join
354
+ end
355
+
356
+ # Get the source text for a range of lines
357
+ #
358
+ # Lines are joined with newlines, and each line gets a trailing newline
359
+ # except for the last line of the file (which may or may not have one in the original).
360
+ #
361
+ # @param start_line [Integer] Start line (1-indexed)
362
+ # @param end_line [Integer] End line (1-indexed)
363
+ # @return [String] Source text
364
+ def source_range(start_line, end_line)
365
+ return '' if start_line < 1 || end_line < start_line
366
+
367
+ extracted_lines = @lines[(start_line - 1)..(end_line - 1)]
368
+ return '' if extracted_lines.empty?
369
+
370
+ # Add newlines between and after lines, but not after the last line of the file
371
+ # unless it originally had one
372
+ result = extracted_lines.join("\n")
373
+
374
+ # Add trailing newline if this isn't the last line of the file
375
+ # (the last line may or may not have a trailing newline in the original source)
376
+ if end_line < @lines.length
377
+ result += "\n"
378
+ elsif @source&.end_with?("\n")
379
+ # Last line of file, but original source ends with newline
380
+ result += "\n"
381
+ end
382
+
383
+ result
384
+ end
385
+
386
+ protected
387
+
388
+ # Extract header content from a table node
389
+ # @param node [Object] The table node
390
+ # @return [String] Header row content
391
+ def extract_table_header_content(node)
392
+ # First row of a table is typically the header
393
+ first_row = node.first_child
394
+ return '' unless first_row
395
+
396
+ extract_text_content(first_row)
397
+ end
398
+
399
+ # Count children of a node
400
+ # @param node [Object] The node
401
+ # @return [Integer] Child count
402
+ def count_children(node)
403
+ count = 0
404
+ child = node.first_child
405
+ while child
406
+ count += 1
407
+ child = next_sibling(child)
408
+ end
409
+ count
410
+ end
411
+
412
+ private
413
+
414
+ def comment_augmenter_default_owners
415
+ @comment_augmenter_default_owners ||= @statements.select do |statement|
416
+ statement.respond_to?(:source_position) && statement.source_position &&
417
+ (!statement.respond_to?(:merge_type) || statement.merge_type != :gap_line) &&
418
+ !standalone_comment_statement?(statement)
419
+ end
420
+ end
421
+
422
+ def standalone_comment_statement?(statement)
423
+ pos = statement.respond_to?(:source_position) ? statement.source_position : nil
424
+ return false unless pos
425
+ return false unless pos[:start_line] && pos[:end_line] && pos[:start_line] == pos[:end_line]
426
+
427
+ comment_tracker.comment_node_at(pos[:start_line])
428
+ end
429
+
430
+ # Extract all nodes and integrate freeze blocks
431
+ # @return [Array<Object>] Integrated list of nodes and freeze blocks
432
+ def extract_and_integrate_all_nodes
433
+ freeze_markers = find_freeze_markers
434
+
435
+ # Use gap-aware collection to preserve link definitions and gap lines
436
+ base_nodes = collect_top_level_nodes_with_gaps
437
+
438
+ return base_nodes if freeze_markers.empty?
439
+
440
+ # Build freeze blocks from markers
441
+ freeze_blocks = build_freeze_blocks(freeze_markers)
442
+ return base_nodes if freeze_blocks.empty?
443
+
444
+ # Integrate nodes with freeze blocks
445
+ integrate_nodes_with_freeze_blocks(freeze_blocks, base_nodes)
446
+ end
447
+
448
+ # Collect top-level nodes from document
449
+ # @return [Array<Object>]
450
+ def collect_top_level_nodes
451
+ nodes = []
452
+ child = @document.first_child
453
+ while child
454
+ nodes << child
455
+ child = next_sibling(child)
456
+ end
457
+ nodes
458
+ end
459
+
460
+ # Collect top-level nodes with gap line detection.
461
+ #
462
+ # Markdown parsers consume certain content (like link reference definitions)
463
+ # during parsing. This method detects "gap" lines that aren't covered by any
464
+ # node and creates synthetic nodes for them.
465
+ #
466
+ # @return [Array<Object>] Nodes including gap line nodes
467
+ def collect_top_level_nodes_with_gaps
468
+ parser_nodes = collect_top_level_nodes
469
+ return parser_nodes if @lines.empty?
470
+
471
+ # Track which lines are covered by parser nodes
472
+ covered_lines = Set.new
473
+ parser_nodes.each do |node|
474
+ pos = node.source_position
475
+ next unless pos
476
+
477
+ start_line = pos[:start_line]
478
+ end_line = pos[:end_line]
479
+
480
+ # Handle Markly's buggy position reporting for :html nodes
481
+ # where end_line can be less than start_line (e.g., "lines 3-2")
482
+ if end_line < start_line
483
+ # Just mark the start_line as covered
484
+ covered_lines << start_line
485
+ else
486
+ (start_line..end_line).each { |l| covered_lines << l }
487
+ end
488
+ end
489
+
490
+ # Find gap lines (lines not covered by any node)
491
+ total_lines = @lines.size
492
+ gap_line_numbers = (1..total_lines).to_a - covered_lines.to_a
493
+
494
+ # Create nodes for gap lines
495
+ gap_nodes = create_gap_nodes(gap_line_numbers)
496
+
497
+ # Integrate gap nodes with parser nodes in line order
498
+ integrate_gap_nodes(parser_nodes, gap_nodes)
499
+ end
500
+
501
+ # Create nodes for gap lines.
502
+ #
503
+ # Link reference definitions get LinkDefinitionNode, others get GapLineNode.
504
+ # Every gap line gets a node so we can reconstruct the document exactly.
505
+ #
506
+ # @param line_numbers [Array<Integer>] Gap line numbers (1-based)
507
+ # @return [Array<Object>] Gap nodes
508
+ def create_gap_nodes(line_numbers)
509
+ line_numbers.map do |line_num|
510
+ content = @lines[line_num - 1] || ''
511
+
512
+ # Try to parse as link definition first
513
+ link_node = LinkDefinitionNode.parse(content, line_number: line_num)
514
+ link_node || GapLineNode.new(content, line_number: line_num)
515
+ end
516
+ end
517
+
518
+ # Integrate gap nodes with parser nodes in line order.
519
+ # Sets preceding_node for gap lines to enable context-aware signatures.
520
+ #
521
+ # @param parser_nodes [Array<Object>] Parser-generated nodes
522
+ # @param gap_nodes [Array<Object>] Gap line nodes
523
+ # @return [Array<Object>] All nodes in line order
524
+ def integrate_gap_nodes(parser_nodes, gap_nodes)
525
+ all_nodes = parser_nodes + gap_nodes
526
+
527
+ # Sort by start line
528
+ sorted_nodes = all_nodes.sort_by do |node|
529
+ pos = node.source_position
530
+ pos ? pos[:start_line] : 0
531
+ end
532
+
533
+ # Set preceding_node for gap lines based on their position in the sorted list
534
+ # This allows gap lines to have context-aware signatures
535
+ sorted_nodes.each_with_index do |node, idx|
536
+ next unless node.is_a?(GapLineNode) && idx > 0
537
+
538
+ # Find the previous non-gap-line node (structural node)
539
+ preceding = sorted_nodes[0...idx].reverse.find { |n| !n.is_a?(GapLineNode) }
540
+ node.preceding_node = preceding
541
+ next unless preceding
542
+
543
+ node.preceding_signature = begin
544
+ compute_node_signature(preceding)
545
+ rescue StandardError
546
+ nil
547
+ end
548
+ end
549
+
550
+ sorted_nodes
551
+ end
552
+
553
+ # Find freeze markers from parsed HTML nodes.
554
+ #
555
+ # Freeze markers are HTML comments that Markly parses as :html nodes.
556
+ # By analyzing the parsed nodes (not raw source lines), we automatically ignore
557
+ # freeze markers that appear inside code blocks (they're part of the code block's
558
+ # string content, not separate nodes).
559
+ #
560
+ # Note: We only support freeze markers as standalone HTML comment nodes.
561
+ # Freeze markers embedded inside other HTML tags (e.g., `<div><!-- freeze -->text</div>`)
562
+ # are not detected because they're part of a larger HTML node's content.
563
+ #
564
+ # @return [Array<Hash>] Marker information
565
+ def find_freeze_markers
566
+ markers = []
567
+ pattern = Ast::Merge::FreezeNodeBase.pattern_for(:html_comment, @freeze_token)
568
+
569
+ return markers unless @document
570
+
571
+ # Walk through top-level nodes looking for HTML nodes with freeze markers
572
+ child = @document.first_child
573
+ while child
574
+ node_type = child.type
575
+
576
+ # Check HTML nodes for freeze markers
577
+ # Handle both raw Markly (:html) and TreeHaver normalized ("html_block", :html_block) types
578
+ if [:html, :html_block, 'html_block', 'html'].include?(node_type)
579
+ # Try multiple content extraction methods:
580
+ # 1. string_content (raw Markly/Commonmarker)
581
+ # 2. to_commonmark on wrapper
582
+ # 3. inner_node.to_commonmark (TreeHaver Commonmarker wrapper)
583
+ content = nil
584
+
585
+ if child.respond_to?(:string_content)
586
+ begin
587
+ content = child.string_content.to_s
588
+ rescue TypeError
589
+ # Some nodes don't have string_content
590
+ content = nil
591
+ end
592
+ end
593
+
594
+ content = child.to_commonmark.to_s if (content.nil? || content.empty?) && child.respond_to?(:to_commonmark)
595
+
596
+ # TreeHaver Commonmarker wrapper stores content in inner_node
597
+ if (content.nil? || content.empty?) && child.respond_to?(:inner_node)
598
+ inner = child.inner_node
599
+ content = inner.to_commonmark.to_s if inner.respond_to?(:to_commonmark)
600
+ end
601
+
602
+ content ||= ''
603
+ match = content.match(pattern)
604
+
605
+ if match
606
+ pos = child.source_position
607
+ marker_type = match[1] # "freeze" or "unfreeze"
608
+ reason = match[2] # optional reason
609
+
610
+ markers << {
611
+ line: pos ? pos[:start_line] : 0,
612
+ type: marker_type.to_sym,
613
+ text: content.strip,
614
+ reason: reason,
615
+ node: child # Keep reference to the actual node
616
+ }
617
+ end
618
+ end
619
+
620
+ child = next_sibling(child)
621
+ end
622
+
623
+ DebugLogger.debug('Found freeze markers', { count: markers.size })
624
+ markers
625
+ end
626
+
627
+ # Build freeze blocks from markers
628
+ # @param markers [Array<Hash>] Marker information
629
+ # @return [Array<FreezeNode>] Freeze blocks
630
+ def build_freeze_blocks(markers)
631
+ blocks = []
632
+ stack = []
633
+
634
+ markers.each do |marker|
635
+ case marker[:type]
636
+ when :freeze
637
+ stack.push(marker)
638
+ when :unfreeze
639
+ if stack.any?
640
+ start_marker = stack.pop
641
+ blocks << create_freeze_block(start_marker, marker)
642
+ else
643
+ DebugLogger.debug('Unmatched unfreeze marker', { line: marker[:line] })
644
+ end
645
+ end
646
+ end
647
+
648
+ # Warn about unclosed freeze blocks
649
+ stack.each do |unclosed|
650
+ DebugLogger.debug('Unclosed freeze marker', { line: unclosed[:line] })
651
+ end
652
+
653
+ blocks.sort_by(&:start_line)
654
+ end
655
+
656
+ # Create a freeze block from start and end markers.
657
+ #
658
+ # Subclasses may override to provide parser-specific FreezeNode subclass.
659
+ #
660
+ # @param start_marker [Hash] Start marker info
661
+ # @param end_marker [Hash] End marker info
662
+ # @return [FreezeNode]
663
+ def create_freeze_block(start_marker, end_marker)
664
+ start_line = start_marker[:line]
665
+ end_line = end_marker[:line]
666
+
667
+ # Content is between the markers (exclusive)
668
+ content_start = start_line + 1
669
+ content_end = end_line - 1
670
+
671
+ content = if content_start <= content_end
672
+ source_range(content_start, content_end)
673
+ else
674
+ ''
675
+ end
676
+
677
+ # Parse the content to get nodes (for nested analysis)
678
+ parsed_nodes = parse_freeze_block_content(content)
679
+
680
+ freeze_node_class.new(
681
+ start_line: start_line,
682
+ end_line: end_line,
683
+ content: content,
684
+ start_marker: start_marker[:text],
685
+ end_marker: end_marker[:text],
686
+ nodes: parsed_nodes,
687
+ reason: start_marker[:reason]
688
+ )
689
+ end
690
+
691
+ # Returns the FreezeNode class to use.
692
+ #
693
+ # Subclasses should override this to return their own FreezeNode class.
694
+ #
695
+ # @return [Class] FreezeNode class
696
+ def freeze_node_class
697
+ Ast::Merge::FreezeNodeBase
698
+ end
699
+
700
+ # Parse content within a freeze block.
701
+ #
702
+ # Subclasses should override this to use their parser.
703
+ #
704
+ # @param content [String] Content to parse
705
+ # @return [Array<Object>] Parsed nodes
706
+ def parse_freeze_block_content(content)
707
+ return [] if content.empty?
708
+
709
+ begin
710
+ content_doc = parse_document(content)
711
+ nodes = []
712
+ child = content_doc.first_child
713
+ while child
714
+ nodes << child
715
+ child = next_sibling(child)
716
+ end
717
+ nodes
718
+ rescue StandardError => e
719
+ # simplecov:disable defensive - parser rarely fails on valid markdown subset
720
+ DebugLogger.debug('Failed to parse freeze block content', { error: e.message })
721
+ []
722
+ # simplecov:enable
723
+ end
724
+ end
725
+
726
+ # Integrate nodes with freeze blocks
727
+ # @param freeze_blocks [Array<FreezeNode>] Freeze blocks
728
+ # @param base_nodes [Array<Object>, nil] Base nodes (defaults to collect_top_level_nodes_with_gaps)
729
+ # @return [Array<Object>] Integrated list
730
+ def integrate_nodes_with_freeze_blocks(freeze_blocks, base_nodes = nil)
731
+ result = []
732
+ freeze_index = 0
733
+ current_freeze = freeze_blocks[freeze_index]
734
+
735
+ top_level_nodes = base_nodes || collect_top_level_nodes_with_gaps
736
+
737
+ top_level_nodes.each do |node|
738
+ node_start = node.source_position&.dig(:start_line) || 0
739
+ node_end = node.source_position&.dig(:end_line) || node_start
740
+
741
+ # Add any freeze blocks that come before this node
742
+ while current_freeze && current_freeze.start_line < node_start
743
+ result << current_freeze
744
+ freeze_index += 1
745
+ current_freeze = freeze_blocks[freeze_index]
746
+ end
747
+
748
+ # Skip nodes that are inside a freeze block
749
+ inside_freeze = freeze_blocks.any? do |fb|
750
+ node_start >= fb.start_line && node_end <= fb.end_line
751
+ end
752
+
753
+ result << node unless inside_freeze
754
+ end
755
+
756
+ # Add remaining freeze blocks
757
+ while freeze_index < freeze_blocks.size
758
+ result << freeze_blocks[freeze_index]
759
+ freeze_index += 1
760
+ end
761
+
762
+ result
763
+ end
764
+ end
765
+ end
766
+ end