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,621 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Base class for smart Markdown file merging.
6
+ #
7
+ # Orchestrates the smart merge process for Markdown files using
8
+ # FileAnalysisBase, FileAligner, ConflictResolver, and MergeResult to
9
+ # merge two Markdown files intelligently. Freeze blocks marked with
10
+ # HTML comments are preserved exactly as-is.
11
+ #
12
+ # Subclasses must implement:
13
+ # - #create_file_analysis(content, **options) - Create parser-specific FileAnalysis
14
+ # - #node_to_source(node, analysis) - Convert a node to source text
15
+ #
16
+ # SmartMergerBase provides flexible configuration for different merge scenarios:
17
+ # - Preserve destination customizations (default)
18
+ # - Apply template updates
19
+ # - Add new sections from template
20
+ # - Inner-merge fenced code blocks using language-specific mergers (optional)
21
+ #
22
+ # @example Subclass implementation
23
+ # class SmartMerger < Markdown::Merge::SmartMergerBase
24
+ # def create_file_analysis(content, **options)
25
+ # FileAnalysis.new(content, **options)
26
+ # end
27
+ #
28
+ # def node_to_source(node, analysis)
29
+ # case node
30
+ # when FreezeNode
31
+ # node.full_text
32
+ # else
33
+ # analysis.source_range(node.start_line, node.end_line)
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # @abstract Subclass and implement parser-specific methods
39
+ # @see FileAnalysisBase
40
+ # @see FileAligner
41
+ # @see ConflictResolver
42
+ # @see MergeResult
43
+ class SmartMergerBase
44
+ # @return [FileAnalysisBase] Analysis of the template file
45
+ attr_reader :template_analysis
46
+
47
+ # @return [FileAnalysisBase] Analysis of the destination file
48
+ attr_reader :dest_analysis
49
+
50
+ # @return [FileAligner] Aligner for finding matches and differences
51
+ attr_reader :aligner
52
+
53
+ # @return [ConflictResolver] Resolver for handling conflicting content
54
+ attr_reader :resolver
55
+
56
+ # @return [CodeBlockMerger, nil] Merger for fenced code blocks
57
+ attr_reader :code_block_merger
58
+
59
+ # @return [Hash{Symbol,String => #call}, nil] Node typing configuration
60
+ attr_reader :node_typing
61
+
62
+ # Creates a new SmartMerger for intelligent Markdown file merging.
63
+ #
64
+ # @param template_content [String] Template Markdown source code
65
+ # @param dest_content [String] Destination Markdown source code
66
+ #
67
+ # @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
68
+ # The proc receives a node and should return one of:
69
+ # - An array representing the node's signature
70
+ # - `nil` to indicate the node should have no signature
71
+ # - The original node to fall through to default signature computation
72
+ #
73
+ # @param preference [Symbol, Hash] Controls which version to use when nodes
74
+ # have matching signatures but different content:
75
+ # - `:destination` (default) - Use destination version (preserves customizations)
76
+ # - `:template` - Use template version (applies updates)
77
+ # - Hash for per-type preferences: `{ default: :destination, gem_table: :template }`
78
+ #
79
+ # @param add_template_only_nodes [Boolean, #call] Controls whether to add nodes that only
80
+ # exist in template:
81
+ # - `false` (default) - Skip template-only nodes
82
+ # - `true` - Add all template-only nodes to result
83
+ # - Callable (Proc/Lambda) - Called with (node, entry) for each template-only node.
84
+ # Return truthy to add the node, falsey to skip it.
85
+ # @example Filter to only add gem family link refs
86
+ # add_template_only_nodes: ->(node, entry) {
87
+ # sig = entry[:signature]
88
+ # sig.is_a?(Array) && sig.first == :gem_family
89
+ # }
90
+ #
91
+ # @param inner_merge_code_blocks [Boolean, CodeBlockMerger] Controls inner-merge for
92
+ # fenced code blocks:
93
+ # - `true` - Enable inner-merge using default CodeBlockMerger
94
+ # - `false` (default) - Disable inner-merge (use standard conflict resolution)
95
+ # - `CodeBlockMerger` instance - Use custom CodeBlockMerger
96
+ #
97
+ # @param freeze_token [String] Token to use for freeze block markers.
98
+ # Default: "markdown-merge"
99
+ #
100
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching of
101
+ # unmatched nodes. Default: nil (fuzzy matching disabled).
102
+ # Set to TableMatchRefiner.new to enable fuzzy table matching.
103
+ #
104
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
105
+ # for per-node-type merge preferences. Maps node type names to callables that
106
+ # can wrap nodes with custom merge_types for use with Hash-based preference.
107
+ # @example
108
+ # node_typing = {
109
+ # table: ->(node) {
110
+ # text = node.to_plaintext
111
+ # if text.include?("tree_haver")
112
+ # Ast::Merge::NodeTyping.with_merge_type(node, :gem_family_table)
113
+ # else
114
+ # node
115
+ # end
116
+ # }
117
+ # }
118
+ # merger = SmartMerger.new(template, dest,
119
+ # node_typing: node_typing,
120
+ # preference: { default: :destination, gem_family_table: :template })
121
+ #
122
+ # @param normalize_whitespace [Boolean, Symbol] Whitespace normalization mode:
123
+ # - `false` (default) - No normalization
124
+ # - `true` or `:basic` - Collapse excessive blank lines (3+ → 2)
125
+ # - `:link_refs` - Basic + remove blank lines between consecutive link reference definitions
126
+ # - `:strict` - All normalizations (same as :link_refs currently)
127
+ #
128
+ # @param rehydrate_link_references [Boolean] If true, convert inline links/images
129
+ # to reference-style when a matching link reference definition exists. Default: false
130
+ #
131
+ # @param parser_options [Hash] Additional parser-specific options
132
+ #
133
+ # @raise [TemplateParseError] If template has syntax errors
134
+ # @raise [DestinationParseError] If destination has syntax errors
135
+ def initialize(
136
+ template_content,
137
+ dest_content,
138
+ signature_generator: nil,
139
+ preference: :destination,
140
+ add_template_only_nodes: false,
141
+ inner_merge_code_blocks: false,
142
+ freeze_token: FileAnalysisBase::DEFAULT_FREEZE_TOKEN,
143
+ match_refiner: nil,
144
+ node_typing: nil,
145
+ normalize_whitespace: false,
146
+ rehydrate_link_references: false,
147
+ **parser_options
148
+ )
149
+ @preference = preference
150
+ @add_template_only_nodes = add_template_only_nodes
151
+ @match_refiner = match_refiner
152
+ @node_typing = node_typing
153
+ @normalize_whitespace = normalize_whitespace
154
+ @rehydrate_link_references = rehydrate_link_references
155
+
156
+ # Validate node_typing if provided
157
+ Ast::Merge::NodeTyping.validate!(node_typing) if node_typing
158
+
159
+ # Set up code block merger
160
+ @code_block_merger = case inner_merge_code_blocks
161
+ when true
162
+ CodeBlockMerger.new
163
+ when false
164
+ nil
165
+ when CodeBlockMerger
166
+ inner_merge_code_blocks
167
+ else
168
+ raise ArgumentError, "inner_merge_code_blocks must be true, false, or a CodeBlockMerger instance"
169
+ end
170
+
171
+ # Parse template
172
+ begin
173
+ @template_analysis = create_file_analysis(
174
+ template_content,
175
+ freeze_token: freeze_token,
176
+ signature_generator: signature_generator,
177
+ **parser_options,
178
+ )
179
+ rescue StandardError => e
180
+ raise template_parse_error_class.new(errors: [e])
181
+ end
182
+
183
+ # Parse destination
184
+ begin
185
+ @dest_analysis = create_file_analysis(
186
+ dest_content,
187
+ freeze_token: freeze_token,
188
+ signature_generator: signature_generator,
189
+ **parser_options,
190
+ )
191
+ rescue StandardError => e
192
+ raise destination_parse_error_class.new(errors: [e])
193
+ end
194
+
195
+ @aligner = FileAligner.new(@template_analysis, @dest_analysis, match_refiner: @match_refiner)
196
+ @resolver = ConflictResolver.new(
197
+ preference: @preference,
198
+ template_analysis: @template_analysis,
199
+ dest_analysis: @dest_analysis,
200
+ )
201
+ end
202
+
203
+ # Create a FileAnalysis instance for the given content.
204
+ #
205
+ # @abstract Subclasses must implement this method
206
+ # @param content [String] Markdown content to analyze
207
+ # @param options [Hash] Analysis options
208
+ # @return [FileAnalysisBase] File analysis instance
209
+ def create_file_analysis(content, **options)
210
+ raise NotImplementedError, "#{self.class} must implement #create_file_analysis"
211
+ end
212
+
213
+ # Returns the TemplateParseError class to use.
214
+ #
215
+ # Subclasses should override to return their parser-specific error class.
216
+ #
217
+ # @return [Class] TemplateParseError class
218
+ def template_parse_error_class
219
+ TemplateParseError
220
+ end
221
+
222
+ # Returns the DestinationParseError class to use.
223
+ #
224
+ # Subclasses should override to return their parser-specific error class.
225
+ #
226
+ # @return [Class] DestinationParseError class
227
+ def destination_parse_error_class
228
+ DestinationParseError
229
+ end
230
+
231
+ # Perform the merge operation and return the merged content as a string.
232
+ #
233
+ # @return [String] The merged Markdown content
234
+ def merge
235
+ merge_result.content
236
+ end
237
+
238
+ # Perform the merge operation and return the full MergeResult object.
239
+ #
240
+ # @return [MergeResult] The merge result containing merged content and metadata
241
+ def merge_result
242
+ return @merge_result if @merge_result
243
+
244
+ @merge_result = DebugLogger.time("SmartMergerBase#merge") do
245
+ alignment = DebugLogger.time("SmartMergerBase#align") do
246
+ @aligner.align
247
+ end
248
+
249
+ DebugLogger.debug("Alignment complete", {
250
+ total_entries: alignment.size,
251
+ matches: alignment.count { |e| e[:type] == :match },
252
+ template_only: alignment.count { |e| e[:type] == :template_only },
253
+ dest_only: alignment.count { |e| e[:type] == :dest_only },
254
+ })
255
+
256
+ # Process alignment using OutputBuilder
257
+ builder, stats, frozen_blocks, conflicts = DebugLogger.time("SmartMergerBase#process") do
258
+ process_alignment(alignment)
259
+ end
260
+
261
+ # Get content from OutputBuilder
262
+ content = builder.to_s
263
+
264
+ # Collect problems from post-processing
265
+ problems = DocumentProblems.new
266
+
267
+ # Apply post-processing transformations
268
+ content, problems = apply_post_processing(content, problems)
269
+
270
+ # Get final content from OutputBuilder
271
+ MergeResult.new(
272
+ content: content,
273
+ conflicts: conflicts,
274
+ frozen_blocks: frozen_blocks,
275
+ stats: stats,
276
+ problems: problems,
277
+ )
278
+ end
279
+ end
280
+
281
+ # Get merge statistics (convenience method).
282
+ #
283
+ # @return [Hash] Statistics from the merge result
284
+ def stats
285
+ merge_result.stats
286
+ end
287
+
288
+ private
289
+
290
+ # Apply post-processing transformations to merged content.
291
+ #
292
+ # @param content [String] The merged content
293
+ # @param problems [DocumentProblems] Problems collector to add to
294
+ # @return [Array<String, DocumentProblems>] [transformed_content, problems]
295
+ def apply_post_processing(content, problems)
296
+ # Apply whitespace normalization if enabled
297
+ if @normalize_whitespace
298
+ # Support both boolean and symbol modes
299
+ mode = (@normalize_whitespace == true) ? :basic : @normalize_whitespace
300
+ normalizer = WhitespaceNormalizer.new(content, mode: mode)
301
+ content = normalizer.normalize
302
+ problems.merge!(normalizer.problems)
303
+ end
304
+
305
+ # Apply link reference rehydration if enabled
306
+ if @rehydrate_link_references
307
+ rehydrator = LinkReferenceRehydrator.new(content)
308
+ content = rehydrator.rehydrate
309
+ problems.merge!(rehydrator.problems)
310
+ end
311
+
312
+ [content, problems]
313
+ end
314
+
315
+ # Process alignment entries and build result using OutputBuilder
316
+ #
317
+ # @param alignment [Array<Hash>] Alignment entries
318
+ # @return [Array] [output_builder, stats, frozen_blocks, conflicts]
319
+ def process_alignment(alignment)
320
+ builder = OutputBuilder.new
321
+ frozen_blocks = []
322
+ conflicts = []
323
+ stats = {nodes_added: 0, nodes_removed: 0, nodes_modified: 0}
324
+
325
+ alignment.each do |entry|
326
+ case entry[:type]
327
+ when :match
328
+ frozen = process_match_to_builder(entry, builder, stats)
329
+ frozen_blocks << frozen if frozen
330
+ when :template_only
331
+ process_template_only_to_builder(entry, builder, stats)
332
+ when :dest_only
333
+ frozen = process_dest_only_to_builder(entry, builder, stats)
334
+ frozen_blocks << frozen if frozen
335
+ end
336
+ end
337
+
338
+ [builder, stats, frozen_blocks, conflicts]
339
+ end
340
+
341
+ # Process a matched node pair, adding to OutputBuilder
342
+ #
343
+ # @param entry [Hash] Alignment entry
344
+ # @param builder [OutputBuilder] Output builder to add to
345
+ # @param stats [Hash] Statistics hash to update
346
+ # @return [Hash, nil] Frozen block info if applicable
347
+ def process_match_to_builder(entry, builder, stats)
348
+ template_node = apply_node_typing(entry[:template_node])
349
+ dest_node = apply_node_typing(entry[:dest_node])
350
+
351
+ # Try inner-merge for code blocks first
352
+ if @code_block_merger && code_block_node?(template_node) && code_block_node?(dest_node)
353
+ inner_result = try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats)
354
+ return if inner_result
355
+ end
356
+
357
+ resolution = @resolver.resolve(
358
+ template_node,
359
+ dest_node,
360
+ template_index: entry[:template_index],
361
+ dest_index: entry[:dest_index],
362
+ )
363
+
364
+ frozen_info = nil
365
+
366
+ # Use unwrapped node for source extraction
367
+ raw_template_node = Ast::Merge::NodeTyping.unwrap(template_node)
368
+ raw_dest_node = Ast::Merge::NodeTyping.unwrap(dest_node)
369
+
370
+ case resolution[:source]
371
+ when :template
372
+ stats[:nodes_modified] += 1 if resolution[:decision] != :identical
373
+ builder.add_node_source(raw_template_node, @template_analysis)
374
+ when :destination
375
+ if raw_dest_node.respond_to?(:freeze_node?) && raw_dest_node.freeze_node?
376
+ frozen_info = {
377
+ start_line: raw_dest_node.start_line,
378
+ end_line: raw_dest_node.end_line,
379
+ reason: raw_dest_node.reason,
380
+ }
381
+ end
382
+ builder.add_node_source(raw_dest_node, @dest_analysis)
383
+ end
384
+
385
+ frozen_info
386
+ end
387
+
388
+ # Apply node typing to a node if node_typing is configured.
389
+ #
390
+ # For markdown nodes, this supports matching by:
391
+ # 1. Node class name (standard NodeTyping behavior)
392
+ # 2. Canonical node type (e.g., :heading, :table, :paragraph)
393
+ #
394
+ # Note: Markdown nodes are pre-wrapped with canonical merge_type by
395
+ # NodeTypeNormalizer during parsing. This method allows custom node_typing
396
+ # to override or refine that canonical type.
397
+ #
398
+ # @param node [Object] The node to potentially wrap with merge_type
399
+ # @return [Object] The node, possibly wrapped with NodeTyping::Wrapper
400
+ def apply_node_typing(node)
401
+ return node unless @node_typing
402
+ return node unless node
403
+
404
+ # For markdown nodes, check if there's a custom callable for the canonical type.
405
+ # This takes precedence because nodes are pre-wrapped by NodeTypeNormalizer.
406
+ if node.respond_to?(:type)
407
+ canonical_type = node.type
408
+ callable = @node_typing[canonical_type] ||
409
+ @node_typing[canonical_type.to_s] ||
410
+ @node_typing[canonical_type.to_sym]
411
+ if callable
412
+ # Call the custom lambda - it may return a refined typed node
413
+ # or the original node unchanged
414
+ return callable.call(node)
415
+ end
416
+ end
417
+
418
+ # Fall back to standard class-name-based matching
419
+ result = Ast::Merge::NodeTyping.process(node, @node_typing)
420
+ return result if Ast::Merge::NodeTyping.typed_node?(result)
421
+
422
+ node
423
+ end
424
+
425
+ # Check if a node is a code block.
426
+ #
427
+ # @param node [Object] Node to check
428
+ # @return [Boolean] true if the node is a code block
429
+ def code_block_node?(node)
430
+ return false if node.respond_to?(:freeze_node?) && node.freeze_node?
431
+
432
+ node.respond_to?(:type) && node.type == :code_block
433
+ end
434
+
435
+ # Try to inner-merge two code block nodes, adding to OutputBuilder
436
+ #
437
+ # @param template_node [Object] Template code block
438
+ # @param dest_node [Object] Destination code block
439
+ # @param builder [OutputBuilder] Output builder to add to
440
+ # @param stats [Hash] Statistics hash to update
441
+ # @return [Boolean] true if merged, false to fall back to standard resolution
442
+ def try_inner_merge_code_block_to_builder(template_node, dest_node, builder, stats)
443
+ result = @code_block_merger.merge_code_blocks(
444
+ template_node,
445
+ dest_node,
446
+ preference: @preference,
447
+ add_template_only_nodes: @add_template_only_nodes,
448
+ )
449
+
450
+ if result[:merged]
451
+ stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
452
+ stats[:inner_merges] ||= 0
453
+ stats[:inner_merges] += 1
454
+ builder.add_raw(result[:content])
455
+ true
456
+ else
457
+ DebugLogger.debug("Inner-merge skipped", {reason: result[:reason]})
458
+ false # Fall back to standard resolution
459
+ end
460
+ end
461
+
462
+ # Try to inner-merge two code block nodes.
463
+ #
464
+ # @deprecated Use try_inner_merge_code_block_to_builder instead
465
+ # @param template_node [Object] Template code block
466
+ # @param dest_node [Object] Destination code block
467
+ # @param stats [Hash] Statistics hash to update
468
+ # @return [Array, nil] [content_string, nil] if merged, nil to fall back to standard resolution
469
+ def try_inner_merge_code_block(template_node, dest_node, stats)
470
+ result = @code_block_merger.merge_code_blocks(
471
+ template_node,
472
+ dest_node,
473
+ preference: @preference,
474
+ add_template_only_nodes: @add_template_only_nodes,
475
+ )
476
+
477
+ if result[:merged]
478
+ stats[:nodes_modified] += 1 unless result.dig(:stats, :decision) == :identical
479
+ stats[:inner_merges] ||= 0
480
+ stats[:inner_merges] += 1
481
+ [result[:content], nil]
482
+ else
483
+ DebugLogger.debug("Inner-merge skipped", {reason: result[:reason]})
484
+ nil # Fall back to standard resolution
485
+ end
486
+ end
487
+
488
+ # Process a template-only node, adding to OutputBuilder
489
+ #
490
+ # @param entry [Hash] Alignment entry
491
+ # @param builder [OutputBuilder] Output builder to add to
492
+ # @param stats [Hash] Statistics hash to update
493
+ # @return [void]
494
+ def process_template_only_to_builder(entry, builder, stats)
495
+ return unless should_add_template_only_node?(entry)
496
+
497
+ stats[:nodes_added] += 1
498
+ builder.add_node_source(entry[:template_node], @template_analysis)
499
+ end
500
+
501
+ # Determine if a template-only node should be added.
502
+ #
503
+ # Gap lines (blank lines/whitespace) represent formatting. Document-trailing gap lines
504
+ # (at the very end with no more content after them) follow preference. Other gap lines
505
+ # Determine if a template-only node should be added.
506
+ #
507
+ # Gap lines (blank lines) and all other nodes follow the add_template_only_nodes setting.
508
+ # When false (default), template-only content is skipped.
509
+ # When true, all template-only content including gap lines is included.
510
+ #
511
+ # @param entry [Hash] Alignment entry with :template_node and :signature
512
+ # @return [Boolean] true if the node should be added
513
+ def should_add_template_only_node?(entry)
514
+ node = entry[:template_node]
515
+
516
+ case @add_template_only_nodes
517
+ when false, nil
518
+ false
519
+ when true
520
+ true
521
+ else
522
+ # Callable filter
523
+ if @add_template_only_nodes.respond_to?(:call)
524
+ @add_template_only_nodes.call(node, entry)
525
+ else
526
+ true
527
+ end
528
+ end
529
+ end
530
+
531
+ # Process a destination-only node, adding to OutputBuilder.
532
+ #
533
+ # All dest-only nodes are included, including gap lines (formatting).
534
+ #
535
+ # @param entry [Hash] Alignment entry
536
+ # @param builder [OutputBuilder] Output builder to add to
537
+ # @param stats [Hash] Statistics hash to update
538
+ # @return [Hash, nil] Frozen block info if applicable
539
+ def process_dest_only_to_builder(entry, builder, stats)
540
+ node = entry[:dest_node]
541
+
542
+ frozen_info = nil
543
+
544
+ if node.respond_to?(:freeze_node?) && node.freeze_node?
545
+ frozen_info = {
546
+ start_line: node.start_line,
547
+ end_line: node.end_line,
548
+ reason: node.reason,
549
+ }
550
+ end
551
+
552
+ builder.add_node_source(node, @dest_analysis)
553
+ frozen_info
554
+ end
555
+
556
+ # Convert a node to its source text.
557
+ #
558
+ # Default implementation uses source positions and falls back to to_commonmark.
559
+ # Subclasses may override for parser-specific behavior.
560
+ #
561
+ # @param node [Object] Node to convert
562
+ # @param analysis [FileAnalysisBase] Analysis for source lookup
563
+ # @return [String] Source text
564
+ def node_to_source(node, analysis)
565
+ # Check for any FreezeNode type (base class or subclass)
566
+ if node.is_a?(Ast::Merge::FreezeNodeBase)
567
+ node.full_text
568
+ else
569
+ pos = node.source_position
570
+ start_line = pos&.dig(:start_line)
571
+ end_line = pos&.dig(:end_line)
572
+
573
+ return node.to_commonmark unless start_line && end_line
574
+
575
+ analysis.source_range(start_line, end_line)
576
+ end
577
+ end
578
+
579
+ # Check if a gap line is document-trailing (no more content after it).
580
+ #
581
+ # A gap line is document-trailing if there are no more content nodes after it
582
+ # in the statements list. We check all siblings after this gap line - if they're
583
+ # all gap lines (no content), then this is document-trailing.
584
+ #
585
+ # @param gap_line [GapLineNode] The gap line to check
586
+ # @param analysis [FileAnalysisBase] The analysis containing the gap line
587
+ # @return [Boolean] true if the gap line is document-trailing
588
+ def gap_line_is_document_trailing?(gap_line, analysis)
589
+ # Find this gap line's index in the statements
590
+ statements = analysis.statements
591
+ gap_index = statements.index(gap_line)
592
+
593
+ DebugLogger.debug("Checking if gap line is document-trailing", {
594
+ gap_line_number: gap_line.line_number,
595
+ gap_index: gap_index,
596
+ total_statements: statements.length,
597
+ })
598
+
599
+ return true if gap_index.nil? # Shouldn't happen, but treat as trailing if missing
600
+
601
+ # Check all statements after this gap line
602
+ # If they're ALL gap lines (no content nodes), then this is document-trailing
603
+ (gap_index + 1...statements.length).each do |i|
604
+ node = statements[i]
605
+ # If we find a non-gap-line node, this gap line is NOT document-trailing
606
+ unless node.is_a?(GapLineNode)
607
+ DebugLogger.debug("Found content after gap line", {
608
+ next_node_index: i,
609
+ next_node_type: node.class.name,
610
+ })
611
+ return false
612
+ end
613
+ end
614
+
615
+ # All remaining nodes are gap lines (or no nodes after), so this is document-trailing
616
+ DebugLogger.debug("Gap line IS document-trailing - no content after it")
617
+ true
618
+ end
619
+ end
620
+ end
621
+ end