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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Alias for the shared normalizer module from ast-merge
6
+ NodeTypingNormalizer = Ast::Merge::NodeTyping::Normalizer
7
+
8
+ # Normalizes backend-specific node types to canonical markdown types.
9
+ #
10
+ # Uses Ast::Merge::NodeTyping::Wrapper to wrap nodes with canonical
11
+ # merge_type, allowing portable merge rules across backends.
12
+ #
13
+ # ## Thread Safety
14
+ #
15
+ # All backend registration and lookup operations are thread-safe via
16
+ # the shared Ast::Merge::NodeTyping::Normalizer module.
17
+ #
18
+ # ## Extensibility
19
+ #
20
+ # New backends can be registered at runtime:
21
+ #
22
+ # @example Registering a new backend
23
+ # NodeTypeNormalizer.register_backend(:tree_sitter_markdown, {
24
+ # atx_heading: :heading,
25
+ # setext_heading: :heading,
26
+ # fenced_code_block: :code_block,
27
+ # indented_code_block: :code_block,
28
+ # paragraph: :paragraph,
29
+ # bullet_list: :list,
30
+ # ordered_list: :list,
31
+ # block_quote: :block_quote,
32
+ # thematic_break: :thematic_break,
33
+ # html_block: :html_block,
34
+ # pipe_table: :table,
35
+ # })
36
+ #
37
+ # ## Canonical Types
38
+ #
39
+ # The following canonical types are used for portable merge rules:
40
+ # - `:heading` - Headers/headings (H1-H6)
41
+ # - `:paragraph` - Text paragraphs
42
+ # - `:code_block` - Fenced or indented code blocks
43
+ # - `:list` - Ordered or unordered lists
44
+ # - `:block_quote` - Block quotations
45
+ # - `:thematic_break` - Horizontal rules
46
+ # - `:html_block` - Raw HTML blocks
47
+ # - `:table` - Tables (GFM extension)
48
+ # - `:footnote_definition` - Footnote definitions
49
+ # - `:custom_block` - Custom/extension blocks
50
+ #
51
+ # @see Ast::Merge::NodeTyping::Wrapper
52
+ # @see Ast::Merge::NodeTyping::Normalizer
53
+ module NodeTypeNormalizer
54
+ extend NodeTypingNormalizer
55
+
56
+ # Configure default backend mappings.
57
+ # Maps backend-specific type symbols to canonical type symbols.
58
+ #
59
+ # Includes both top-level block types and child node types (table rows, cells, etc.)
60
+ # to enable consistent type checking across the entire AST.
61
+ configure_normalizer(
62
+ commonmarker: {
63
+ # Block types (top-level statements)
64
+ heading: :heading,
65
+ paragraph: :paragraph,
66
+ code_block: :code_block,
67
+ list: :list,
68
+ block_quote: :block_quote,
69
+ thematic_break: :thematic_break,
70
+ html_block: :html_block,
71
+ table: :table,
72
+ footnote_definition: :footnote_definition,
73
+ # Table child types
74
+ table_row: :table_row,
75
+ table_cell: :table_cell,
76
+ table_header: :table_header, # Some parsers distinguish header rows
77
+ # List child types
78
+ list_item: :list_item,
79
+ item: :list_item, # Alias
80
+ # Inline types (usually not top-level, but map them anyway)
81
+ text: :text,
82
+ softbreak: :softbreak,
83
+ linebreak: :linebreak,
84
+ code: :code,
85
+ code_inline: :code, # Alias used by some parsers
86
+ html_inline: :html_inline,
87
+ emph: :emph,
88
+ strong: :strong,
89
+ link: :link,
90
+ image: :image,
91
+ }.freeze,
92
+ markly: {
93
+ # Block types - note different names from commonmarker
94
+ header: :heading, # markly uses :header, not :heading
95
+ paragraph: :paragraph,
96
+ code_block: :code_block,
97
+ list: :list,
98
+ blockquote: :block_quote, # markly uses :blockquote, not :block_quote
99
+ hrule: :thematic_break, # markly uses :hrule, not :thematic_break
100
+ html: :html_block, # markly uses :html, not :html_block
101
+ table: :table,
102
+ footnote_definition: :footnote_definition,
103
+ custom_block: :custom_block,
104
+ # Table child types
105
+ table_row: :table_row,
106
+ table_cell: :table_cell,
107
+ table_header: :table_header,
108
+ # List child types
109
+ list_item: :list_item,
110
+ item: :list_item,
111
+ # Inline types
112
+ text: :text,
113
+ softbreak: :softbreak,
114
+ linebreak: :linebreak,
115
+ code: :code,
116
+ code_inline: :code,
117
+ html_inline: :html_inline,
118
+ emph: :emph,
119
+ strong: :strong,
120
+ link: :link,
121
+ image: :image,
122
+ }.freeze,
123
+ )
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Builds markdown output from merge operations.
6
+ #
7
+ # Handles markdown-specific concerns like:
8
+ # - Extracting source from original nodes
9
+ # - Reconstructing consumed link reference definitions
10
+ # - Preserving gap lines (blank line spacing)
11
+ # - Automatic structural spacing (blank lines between tables, headings, etc.)
12
+ # - Assembling final merged content
13
+ #
14
+ # Unlike Emitter classes used in JSON/YAML/etc, OutputBuilder focuses on
15
+ # source preservation and reconstruction rather than generation from scratch.
16
+ #
17
+ # @example Basic usage
18
+ # builder = OutputBuilder.new
19
+ # builder.add_node_source(node, analysis)
20
+ # builder.add_link_definition(link_def_node)
21
+ # builder.add_gap_line(count: 2)
22
+ # content = builder.to_s
23
+ class OutputBuilder
24
+ # Initialize a new OutputBuilder
25
+ #
26
+ # @param preserve_formatting [Boolean] Whether to preserve original formatting
27
+ # @param auto_spacing [Boolean] Whether to automatically insert blank lines between structural elements
28
+ def initialize(preserve_formatting: true, auto_spacing: true)
29
+ @parts = []
30
+ @preserve_formatting = preserve_formatting
31
+ @auto_spacing = auto_spacing
32
+ @last_node_type = nil # Track previous node type for spacing decisions
33
+ end
34
+
35
+ # Add a node's source content
36
+ #
37
+ # Automatically inserts structural blank lines when transitioning between
38
+ # certain node types (tables, headings, code blocks, etc.) if auto_spacing is enabled.
39
+ #
40
+ # @param node [Object] Node to add (can be parser node, FreezeNode, LinkDefinitionNode, etc.)
41
+ # @param analysis [FileAnalysisBase] Analysis for accessing source
42
+ def add_node_source(node, analysis)
43
+ # Determine node type for spacing decisions
44
+ current_type = MarkdownStructure.node_type(node)
45
+
46
+ # Auto-spacing logic:
47
+ # - Skip for gap_line and freeze_block (they handle their own spacing)
48
+ # - Skip if last node was a gap_line (we already have spacing)
49
+ # - Otherwise, check MarkdownStructure.needs_blank_between? which handles
50
+ # contiguous types (like link_definitions that shouldn't have blanks between them)
51
+ unless [:gap_line, :freeze_block].include?(current_type) ||
52
+ @last_node_type == :gap_line
53
+ if @auto_spacing && @last_node_type && current_type
54
+ if MarkdownStructure.needs_blank_between?(@last_node_type, current_type)
55
+ # Only add spacing if we don't already have adequate blank lines
56
+ # Check the last part to see if it already ends with blank line(s)
57
+ unless @parts.empty? || @parts.last&.end_with?("\n\n")
58
+ add_gap_line(count: 1)
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ content = extract_source(node, analysis)
65
+ if content && !content.empty?
66
+ @parts << content
67
+ # Update last node type (track all node types for proper spacing)
68
+ @last_node_type = current_type
69
+ end
70
+ end
71
+
72
+ # Add a reconstructed link definition
73
+ #
74
+ # @param node [LinkDefinitionNode] Link definition node
75
+ def add_link_definition(node)
76
+ formatted = LinkDefinitionFormatter.format(node)
77
+ @parts << formatted if formatted && !formatted.empty?
78
+ end
79
+
80
+ # Add gap lines (blank line preservation)
81
+ #
82
+ # @param count [Integer] Number of blank lines to add
83
+ def add_gap_line(count: 1)
84
+ @parts << ("\n" * count) if count > 0
85
+ end
86
+
87
+ # Add raw text content
88
+ #
89
+ # @param text [String] Raw text to add
90
+ def add_raw(text)
91
+ @parts << text if text && !text.empty?
92
+ end
93
+
94
+ # Get final content
95
+ #
96
+ # @return [String] Assembled markdown content
97
+ def to_s
98
+ @parts.join
99
+ end
100
+
101
+ # Check if builder has any content
102
+ #
103
+ # @return [Boolean]
104
+ def empty?
105
+ @parts.empty?
106
+ end
107
+
108
+ # Clear all content
109
+ def clear
110
+ @parts.clear
111
+ end
112
+
113
+ private
114
+
115
+ # Extract source content from a node
116
+ #
117
+ # @param node [Object] Node to extract from
118
+ # @param analysis [FileAnalysisBase] Analysis for source access
119
+ # @return [String, nil] Extracted content
120
+ def extract_source(node, analysis)
121
+ case node
122
+ when LinkDefinitionNode
123
+ # Link definitions need reconstruction with trailing newline
124
+ "#{LinkDefinitionFormatter.format(node)}\n"
125
+ when GapLineNode
126
+ # Gap lines are single blank lines
127
+ "\n"
128
+ when Ast::Merge::FreezeNodeBase
129
+ # Freeze blocks have their full text
130
+ node.full_text
131
+ else
132
+ # Regular nodes - extract from source
133
+ extract_parser_node_source(node, analysis)
134
+ end
135
+ end
136
+
137
+ # Extract source from a parser-specific node
138
+ #
139
+ # @param node [Object] Parser node
140
+ # @param analysis [FileAnalysisBase] Analysis for source access
141
+ # @return [String, nil] Extracted content
142
+ def extract_parser_node_source(node, analysis)
143
+ # Try source_position method first (used by some nodes)
144
+ if node.respond_to?(:source_position)
145
+ pos = node.source_position
146
+ start_line = pos&.dig(:start_line)
147
+ end_line = pos&.dig(:end_line)
148
+
149
+ if start_line && end_line
150
+ return analysis.source_range(start_line, end_line)
151
+ elsif node.respond_to?(:to_commonmark)
152
+ # Fallback to commonmark rendering
153
+ return node.to_commonmark
154
+ end
155
+ end
156
+
157
+ # Try direct start_line/end_line attributes
158
+ return unless node.respond_to?(:start_line) && node.respond_to?(:end_line)
159
+ return unless node.start_line && node.end_line
160
+
161
+ # Extract source range (formatting preservation handled elsewhere)
162
+ analysis.source_range(node.start_line, node.end_line)
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Markdown-specific implementation of PartialTemplateMerger.
6
+ #
7
+ # Merges a partial template into a specific section of a destination markdown document.
8
+ # This class extends the parser-agnostic base with markdown-specific logic for:
9
+ # - Heading-level-aware section boundaries
10
+ # - Source-based text extraction to preserve link references and table formatting
11
+ # - Backend-specific parser initialization (Markly, Commonmarker)
12
+ #
13
+ # @example Basic usage
14
+ # merger = Markdown::Merge::PartialTemplateMerger.new(
15
+ # template: template_content,
16
+ # destination: destination_content,
17
+ # anchor: { type: :heading, text: /Gem Family/ },
18
+ # backend: :markly
19
+ # )
20
+ # result = merger.merge
21
+ # puts result.content
22
+ #
23
+ # @example With boundary
24
+ # merger = Markdown::Merge::PartialTemplateMerger.new(
25
+ # template: template_content,
26
+ # destination: destination_content,
27
+ # anchor: { type: :heading, text: /Installation/ },
28
+ # boundary: { type: :heading }, # Stop at next heading
29
+ # backend: :markly
30
+ # )
31
+ #
32
+ class PartialTemplateMerger < Ast::Merge::PartialTemplateMergerBase
33
+ # Re-export Result class from base for convenience
34
+ Result = Ast::Merge::PartialTemplateMergerBase::Result
35
+
36
+ # @return [Symbol] Backend to use (:markly, :commonmarker)
37
+ attr_reader :backend
38
+
39
+ # Initialize a markdown PartialTemplateMerger.
40
+ #
41
+ # @param template [String] The template content (the section to merge in)
42
+ # @param destination [String] The destination content
43
+ # @param anchor [Hash] Anchor matcher: { type: :heading, text: /pattern/ }
44
+ # @param boundary [Hash, nil] Boundary matcher (defaults to same type as anchor)
45
+ # @param backend [Symbol] Backend to use (:markly, :commonmarker)
46
+ # @param preference [Symbol, Hash] Which content wins (:template, :destination, or per-type hash)
47
+ # @param add_missing [Boolean, Proc] Whether to add template nodes not in destination
48
+ # @param when_missing [Symbol] What to do if section not found (:skip, :append, :prepend)
49
+ # @param replace_mode [Boolean] If true, template replaces section entirely (no merge)
50
+ # @param signature_generator [Proc, nil] Custom signature generator for SmartMerger
51
+ # @param node_typing [Hash, nil] Node typing configuration for per-type preferences
52
+ # @param match_refiner [Object, nil] Match refiner for fuzzy matching (e.g., ContentMatchRefiner)
53
+ # @param normalize_whitespace [Boolean] If true, collapse excessive blank lines. Default: false
54
+ # @param rehydrate_link_references [Boolean] If true, convert inline links to reference style. Default: false
55
+ def initialize(
56
+ template:,
57
+ destination:,
58
+ anchor:,
59
+ boundary: nil,
60
+ backend: :markly,
61
+ preference: :template,
62
+ add_missing: true,
63
+ when_missing: :skip,
64
+ replace_mode: false,
65
+ signature_generator: nil,
66
+ node_typing: nil,
67
+ match_refiner: nil,
68
+ normalize_whitespace: false,
69
+ rehydrate_link_references: false
70
+ )
71
+ validate_backend!(backend)
72
+ @backend = backend
73
+ @normalize_whitespace = normalize_whitespace
74
+ @rehydrate_link_references = rehydrate_link_references
75
+ super(
76
+ template: template,
77
+ destination: destination,
78
+ anchor: anchor,
79
+ boundary: boundary,
80
+ preference: preference,
81
+ add_missing: add_missing,
82
+ when_missing: when_missing,
83
+ replace_mode: replace_mode,
84
+ signature_generator: signature_generator,
85
+ node_typing: node_typing,
86
+ match_refiner: match_refiner,
87
+ )
88
+ end
89
+
90
+ # Perform the partial template merge with post-processing.
91
+ #
92
+ # @return [Result] The merge result
93
+ def merge
94
+ result = super
95
+
96
+ # Apply post-processing if enabled
97
+ if result.changed && (@normalize_whitespace || @rehydrate_link_references)
98
+ content = result.content
99
+ problems = DocumentProblems.new
100
+
101
+ if @normalize_whitespace
102
+ normalizer = WhitespaceNormalizer.new(content)
103
+ content = normalizer.normalize
104
+ problems.merge!(normalizer.problems)
105
+ end
106
+
107
+ if @rehydrate_link_references
108
+ rehydrator = LinkReferenceRehydrator.new(content)
109
+ content = rehydrator.rehydrate
110
+ problems.merge!(rehydrator.problems)
111
+ end
112
+
113
+ # Return new result with transformed content and problems
114
+ Result.new(
115
+ content: content,
116
+ has_section: result.has_section,
117
+ changed: result.changed,
118
+ stats: result.stats.merge(problems: problems.all),
119
+ injection_point: result.injection_point,
120
+ message: result.message,
121
+ )
122
+ else
123
+ result
124
+ end
125
+ end
126
+
127
+ protected
128
+
129
+ # Validate the backend parameter.
130
+ #
131
+ # @param backend [Symbol] The backend to validate
132
+ # @raise [ArgumentError] If backend is not supported
133
+ def validate_backend!(backend)
134
+ valid_backends = [:markly, :commonmarker]
135
+ return if valid_backends.include?(backend.to_sym)
136
+
137
+ raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(", ")}"
138
+ end
139
+
140
+ # Create a FileAnalysis for the given content.
141
+ #
142
+ # @param content [String] The content to analyze
143
+ # @return [FileAnalysis] A FileAnalysis instance
144
+ def create_analysis(content)
145
+ FileAnalysis.new(content, backend: backend)
146
+ end
147
+
148
+ # Create a SmartMerger for merging the section.
149
+ #
150
+ # @param template_content [String] The template content
151
+ # @param destination_content [String] The destination section content
152
+ # @return [SmartMerger] A SmartMerger instance
153
+ def create_smart_merger(template_content, destination_content)
154
+ # Build options hash, only including non-nil values
155
+ options = {
156
+ preference: preference,
157
+ add_template_only_nodes: add_missing,
158
+ backend: backend,
159
+ }
160
+
161
+ # Use custom signature generator if provided, otherwise use position-based
162
+ # table matching to ensure tables with different structures still match
163
+ # within a section merge context.
164
+ options[:signature_generator] = signature_generator || build_position_based_signature_generator
165
+
166
+ options[:node_typing] = node_typing if node_typing
167
+ options[:match_refiner] = match_refiner if match_refiner
168
+
169
+ SmartMerger.new(template_content, destination_content, **options)
170
+ end
171
+
172
+ # Build a signature generator that uses type-based matching for tables.
173
+ #
174
+ # This ensures that tables within a section are matched by type alone,
175
+ # allowing template tables to replace destination tables regardless of
176
+ # their exact structure (different headers, columns, etc.).
177
+ #
178
+ # In the context of partial template merging, this is the desired behavior:
179
+ # - Sections typically contain one table of each logical role
180
+ # - Template table should replace the destination table
181
+ # - Different table structures should still match by ordinal position
182
+ #
183
+ # The algorithm uses a stateless approach that assigns the same signature
184
+ # to all tables. Since PartialTemplateMerger merges **one section at a time**,
185
+ # each section typically has few tables, and the first table in template
186
+ # will match and replace the first table in destination.
187
+ #
188
+ # For more precise control over multiple tables within a section, provide
189
+ # a custom signature_generator.
190
+ #
191
+ # @return [Proc] A signature generator proc
192
+ def build_position_based_signature_generator
193
+ # Simple stateless approach: all tables get the same base signature.
194
+ # When preference is :template, this causes template table to replace
195
+ # destination table, which is the desired behavior.
196
+ #
197
+ # NOTE: If a section has multiple tables, they will ALL match each other,
198
+ # potentially causing unexpected behavior. For such cases, users should
199
+ # provide a custom signature_generator.
200
+ lambda do |node|
201
+ type_str = node.type.to_s
202
+ if type_str == "table"
203
+ # All tables within a section merge get the same signature.
204
+ # This ensures template table replaces destination table.
205
+ [:table, :section_table]
206
+ else
207
+ # Return node for default signature computation
208
+ node
209
+ end
210
+ end
211
+ end
212
+
213
+ # Find where the section ends.
214
+ #
215
+ # For headings, finds the next heading of same or higher level.
216
+ # For other node types, finds the next node of the same type.
217
+ #
218
+ # NOTE: For headings, we ALWAYS use heading-level-aware logic, ignoring
219
+ # any boundary from InjectionPointFinder. This is because InjectionPointFinder
220
+ # uses tree_depth for boundary detection, but in Markdown all headings are
221
+ # siblings at the same tree depth regardless of their level (H2, H3, H4 etc).
222
+ # Heading level semantics require comparing the actual heading level numbers.
223
+ #
224
+ # @param statements [Array<Navigable::Statement>] All statements
225
+ # @param injection_point [Navigable::InjectionPoint] The injection point
226
+ # @return [Integer] Index of the last statement in the section
227
+ def find_section_end(statements, injection_point)
228
+ anchor = injection_point.anchor
229
+ anchor_type = anchor.type
230
+
231
+ # For headings, ALWAYS use heading-level-aware logic
232
+ # This overrides any boundary from InjectionPointFinder because tree_depth
233
+ # doesn't reflect heading level semantics in Markdown
234
+ if heading_type?(anchor_type)
235
+ anchor_level = get_heading_level(anchor)
236
+
237
+ ((anchor.index + 1)...statements.length).each do |idx|
238
+ stmt = statements[idx]
239
+ if heading_type?(stmt.type)
240
+ stmt_level = get_heading_level(stmt)
241
+ if stmt_level && anchor_level && stmt_level <= anchor_level
242
+ # Found next heading of same or higher level - section ends before it
243
+ return idx - 1
244
+ end
245
+ end
246
+ end
247
+
248
+ # No boundary heading found - section extends to end of document
249
+ return statements.length - 1
250
+ end
251
+
252
+ # For non-headings, use boundary if specified and found
253
+ if injection_point.boundary
254
+ return injection_point.boundary.index - 1
255
+ end
256
+
257
+ # Otherwise, find next node of same type
258
+ ((anchor.index + 1)...statements.length).each do |idx|
259
+ stmt = statements[idx]
260
+ if stmt.type == anchor_type
261
+ return idx - 1
262
+ end
263
+ end
264
+
265
+ # Section extends to end of document
266
+ statements.length - 1
267
+ end
268
+
269
+ # Convert a node to its source text.
270
+ #
271
+ # Prefers source-based extraction to preserve original formatting
272
+ # (link references, table padding, etc.). Falls back to to_commonmark.
273
+ #
274
+ # @param node [Object] The node to convert
275
+ # @param analysis [FileAnalysis, nil] The analysis object for source lookup
276
+ # @return [String] The source text
277
+ def node_to_text(node, analysis = nil)
278
+ # Unwrap if needed
279
+ inner = node
280
+ while inner.respond_to?(:inner_node) && inner.inner_node != inner
281
+ inner = inner.inner_node
282
+ end
283
+
284
+ # Prefer source-based extraction to preserve original formatting
285
+ # (link references, table padding, etc.)
286
+ if analysis&.respond_to?(:source_range)
287
+ pos = inner.source_position if inner.respond_to?(:source_position)
288
+ if pos
289
+ start_line = pos[:start_line]
290
+ end_line = pos[:end_line]
291
+ if start_line && end_line && start_line > 0
292
+ source_text = analysis.source_range(start_line, end_line)
293
+ # source_range already adds trailing newlines, don't add another
294
+ return source_text unless source_text.empty?
295
+ end
296
+ end
297
+ end
298
+
299
+ # Fallback to to_commonmark (for nodes without source position)
300
+ if inner.respond_to?(:to_commonmark)
301
+ inner.to_commonmark.to_s
302
+ elsif inner.respond_to?(:to_s)
303
+ inner.to_s
304
+ else
305
+ ""
306
+ end
307
+ end
308
+
309
+ private
310
+
311
+ # Check if a type represents a heading node.
312
+ #
313
+ # @param type [Symbol, String] The node type
314
+ # @return [Boolean] true if this is a heading type
315
+ def heading_type?(type)
316
+ type.to_s == "heading" || type == :heading || type == :header
317
+ end
318
+
319
+ # Get the heading level from a statement.
320
+ #
321
+ # @param stmt [NavigableStatement] The statement
322
+ # @return [Integer, nil] The heading level (1-6) or nil
323
+ def get_heading_level(stmt)
324
+ inner = stmt.respond_to?(:unwrapped_node) ? stmt.unwrapped_node : stmt.node
325
+
326
+ if inner.respond_to?(:header_level)
327
+ inner.header_level
328
+ elsif inner.respond_to?(:level)
329
+ inner.level
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end