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,483 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Markdown
6
+ module Merge
7
+ # Represents the result of a Markdown merge operation.
8
+ #
9
+ # Inherits from Ast::Merge::MergeResultBase to provide consistent result
10
+ # handling across all merge gems. Contains the merged content along
11
+ # with metadata about conflicts, frozen sections, and changes made.
12
+ #
13
+ # @example Successful merge
14
+ # result = SmartMerger.merge(source_a, source_b)
15
+ # if result.success?
16
+ # File.write("merged.md", result.content)
17
+ # end
18
+ #
19
+ # @example Handling conflicts
20
+ # result = SmartMerger.merge(source_a, source_b)
21
+ # if result.conflicts?
22
+ # result.conflicts.each do |conflict|
23
+ # puts "Conflict at: #{conflict[:location]}"
24
+ # end
25
+ # end
26
+ #
27
+ # @example Checking for document problems
28
+ # result = SmartMerger.merge(source_a, source_b, normalize_whitespace: true)
29
+ # result.problems.by_category(:excessive_whitespace).each do |problem|
30
+ # puts "Whitespace issue at line #{problem.details[:line]}"
31
+ # end
32
+ #
33
+ # @see Ast::Merge::MergeResultBase Base class
34
+ # @see DocumentProblems For problem tracking
35
+ class MergeResult < Ast::Merge::MergeResultBase
36
+ # @return [DocumentProblems] Problems found during merge
37
+ attr_reader :problems
38
+
39
+ # Initialize a new MergeResult
40
+ #
41
+ # @param content [String, nil] Merged content (nil if merge failed)
42
+ # @param conflicts [Array<Hash>] Conflict descriptions
43
+ # @param frozen_blocks [Array<Hash>] Preserved frozen block info
44
+ # @param stats [Hash] Merge statistics
45
+ # @param problems [DocumentProblems, nil] Document problems found
46
+ # @param options [Hash] Additional options for forward compatibility
47
+ def initialize(content:, raw_content: nil, conflicts: [], frozen_blocks: [], stats: {}, problems: nil, **options)
48
+ super(
49
+ conflicts: conflicts,
50
+ frozen_blocks: frozen_blocks,
51
+ stats: default_stats.merge(stats),
52
+ **options
53
+ )
54
+ @content_raw = content
55
+ @raw_content = raw_content || content
56
+ @problems = problems || DocumentProblems.new
57
+ end
58
+
59
+ # Get the merged content as a string.
60
+ # Overrides base class to return string content directly.
61
+ #
62
+ # @return [String, nil] The merged Markdown content
63
+ def content
64
+ @content_raw
65
+ end
66
+
67
+ # Check if content has been set (not nil).
68
+ # Overrides base class for string-based content.
69
+ #
70
+ # @return [Boolean]
71
+ def content?
72
+ !@content_raw.nil?
73
+ end
74
+
75
+ # Get content as a string (alias for content in this class).
76
+ #
77
+ # @return [String, nil] The merged content
78
+ def content_string
79
+ @content_raw
80
+ end
81
+
82
+ # Check if merge was successful (no unresolved conflicts)
83
+ #
84
+ # @return [Boolean] True if merge succeeded
85
+ def success?
86
+ conflicts.empty? && content?
87
+ end
88
+
89
+ # Check if there are unresolved conflicts
90
+ #
91
+ # @return [Boolean] True if conflicts exist
92
+ def conflicts?
93
+ !conflicts.empty?
94
+ end
95
+
96
+ # Check if any frozen blocks were preserved
97
+ #
98
+ # @return [Boolean] True if frozen blocks were preserved
99
+ def has_frozen_blocks?
100
+ !frozen_blocks.empty?
101
+ end
102
+
103
+ # Get count of nodes added during merge
104
+ #
105
+ # @return [Integer] Number of nodes added
106
+ def nodes_added
107
+ stats[:nodes_added] || 0
108
+ end
109
+
110
+ # Get count of nodes removed during merge
111
+ #
112
+ # @return [Integer] Number of nodes removed
113
+ def nodes_removed
114
+ stats[:nodes_removed] || 0
115
+ end
116
+
117
+ # Get count of nodes modified during merge
118
+ #
119
+ # @return [Integer] Number of nodes modified
120
+ def nodes_modified
121
+ stats[:nodes_modified] || 0
122
+ end
123
+
124
+ # Get merge duration in milliseconds
125
+ #
126
+ # @return [Float, nil] Merge time in milliseconds
127
+ def merge_time_ms
128
+ stats[:merge_time_ms]
129
+ end
130
+
131
+ # Get count of frozen blocks preserved
132
+ #
133
+ # @return [Integer] Number of frozen blocks
134
+ def frozen_count
135
+ frozen_blocks.size
136
+ end
137
+
138
+ # String representation for debugging
139
+ #
140
+ # @return [String] Debug representation
141
+ def inspect
142
+ status = success? ? 'success' : 'failed'
143
+ "#<#{self.class.name} #{status} conflicts=#{conflicts.size} frozen=#{frozen_blocks.size} " \
144
+ "added=#{nodes_added} removed=#{nodes_removed} modified=#{nodes_modified}>"
145
+ end
146
+
147
+ # Convert to string (returns merged content)
148
+ #
149
+ # @return [String] The merged content or empty string
150
+ def to_s
151
+ content || ''
152
+ end
153
+
154
+ def to_unresolved_review_state(selections: {}, metadata: {})
155
+ normalized_selections = normalize_unresolved_resolutions(selections)
156
+ Ast::Merge::UnresolvedReviewState.new(
157
+ cases: serializable_unresolved_cases,
158
+ selections: delegated_applied_selections.merge(normalized_selections),
159
+ metadata: review_state_metadata(metadata, normalized_selections)
160
+ )
161
+ end
162
+
163
+ def apply_unresolved_resolutions!(resolutions)
164
+ normalized = normalize_unresolved_resolutions(resolutions)
165
+ handled_case_ids = apply_grouped_delegated_resolutions!(normalized)
166
+ remaining = normalized.reject { |case_id, _| handled_case_ids.include?(case_id) }
167
+ apply_output_range_resolutions_descending!(remaining)
168
+ fallback = remaining.reject { |case_id, _| handled_case_ids.include?(case_id) }
169
+ super(fallback)
170
+ end
171
+
172
+ protected
173
+
174
+ def apply_non_provisional_unresolved_resolution!(resolution_case, selection:, selected_candidate:)
175
+ raw_output_range = resolution_case.metadata[:output_range]
176
+ if raw_output_range && @content_raw != @raw_content
177
+ raise ArgumentError,
178
+ "cannot apply non-provisional resolution for case #{resolution_case.case_id} after post-processing transformed markdown output"
179
+ end
180
+
181
+ output_range = normalize_output_range(raw_output_range)
182
+ return super unless output_range
183
+
184
+ selected_candidate = resolution_case.metadata.dig(:output_candidate_by_selection,
185
+ selection.to_sym) || selected_candidate
186
+ start_offset, end_offset = output_range
187
+ prefix = @content_raw.byteslice(0, start_offset).to_s
188
+ suffix = @content_raw.byteslice(end_offset..).to_s
189
+ @content_raw = "#{prefix}#{selected_candidate}#{suffix}"
190
+ @raw_content = @content_raw
191
+ end
192
+
193
+ private
194
+
195
+ SERIALIZATION_OMIT = Object.new.freeze
196
+
197
+ def apply_grouped_delegated_resolutions!(normalized_resolutions)
198
+ handled_case_ids = []
199
+ delegated_groups = @unresolved_cases
200
+ .group_by { |resolution_case| resolution_case.metadata[:delegated_apply_group] }
201
+ .reject { |group_id, _| group_id.nil? }
202
+
203
+ delegated_groups.each_value do |cases|
204
+ selected_cases = cases.select { |resolution_case| normalized_resolutions.key?(resolution_case.case_id) }
205
+ next if selected_cases.empty?
206
+
207
+ apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
208
+ handled_case_ids.concat(selected_cases.map(&:case_id))
209
+ end
210
+
211
+ handled_case_ids
212
+ end
213
+
214
+ def apply_grouped_delegated_block!(cases, selected_cases, normalized_resolutions)
215
+ raw_output_range = cases.map { |resolution_case| resolution_case.metadata[:output_range] }.compact.uniq.fetch(0)
216
+ if @content_raw != @raw_content
217
+ raise ArgumentError,
218
+ "cannot apply non-provisional resolution for delegated block #{cases.first.metadata[:delegated_apply_group]} after post-processing transformed markdown output"
219
+ end
220
+
221
+ output_range = normalize_output_range(raw_output_range)
222
+ renderer = cases.map do |resolution_case|
223
+ resolution_case.metadata[:delegated_apply_renderer]
224
+ end.compact.uniq.fetch(0)
225
+ prior_selections = cases.map do |resolution_case|
226
+ resolution_case.metadata[:delegated_applied_selections]
227
+ end.compact.reduce({}) do |memo, selections|
228
+ memo.merge(selections.transform_keys(&:to_s))
229
+ end
230
+ prior_root_selections = cases.map do |resolution_case|
231
+ resolution_case.metadata[:delegated_root_applied_selections]
232
+ end.compact.reduce({}) do |memo, selections|
233
+ memo.merge(selections.transform_keys(&:to_s))
234
+ end
235
+ prior_root_identities = cases.map do |resolution_case|
236
+ resolution_case.metadata[:delegated_root_case_identities]
237
+ end.compact.reduce({}) do |memo, identities|
238
+ memo.merge(identities.transform_keys(&:to_s))
239
+ end
240
+ delegated_selections = selected_cases.each_with_object(prior_selections) do |resolution_case, hash|
241
+ hash[resolution_case.metadata[:delegated_case_id]] = normalized_resolutions.fetch(resolution_case.case_id)
242
+ end
243
+ delegated_root_selections = selected_cases.each_with_object(prior_root_selections) do |resolution_case, hash|
244
+ hash[resolution_case.case_id] = normalized_resolutions.fetch(resolution_case.case_id)
245
+ end
246
+ delegated_root_identities = selected_cases.each_with_object(prior_root_identities) do |resolution_case, hash|
247
+ hash[resolution_case.case_id] = review_identity_for_case(resolution_case)
248
+ end
249
+ delegated_result = renderer.call(delegated_selections)
250
+ updated_output_range = replace_output_range!(output_range, delegated_result.fetch(:content))
251
+ previous_case_ids = cases.map(&:case_id)
252
+ @unresolved_cases.reject! { |resolution_case| previous_case_ids.include?(resolution_case.case_id) }
253
+ @conflicts.reject! { |conflict| previous_case_ids.include?(conflict[:case_id].to_s) }
254
+ remapped_cases = remap_delegated_cases_after_apply(
255
+ delegated_result[:unresolved_cases],
256
+ template_case: cases.first,
257
+ output_range: updated_output_range,
258
+ renderer: renderer,
259
+ applied_selections: delegated_selections,
260
+ root_applied_selections: delegated_root_selections,
261
+ root_case_identities: delegated_root_identities
262
+ )
263
+ remapped_cases.each { |resolution_case| add_unresolved_case(resolution_case) }
264
+ @conflicts.concat(remapped_cases.map { |resolution_case| conflict_for_resolution_case(resolution_case) })
265
+ end
266
+
267
+ def apply_output_range_resolutions_descending!(normalized_resolutions)
268
+ selected_cases = @unresolved_cases.select do |resolution_case|
269
+ normalized_resolutions.key?(resolution_case.case_id) && normalize_output_range(resolution_case.metadata[:output_range])
270
+ end
271
+ selected_cases
272
+ .sort_by { |resolution_case| -normalize_output_range(resolution_case.metadata[:output_range]).first }
273
+ .each do |resolution_case|
274
+ selection = normalized_resolutions.fetch(resolution_case.case_id)
275
+ apply_unresolved_resolution!(resolution_case, selection)
276
+ normalized_resolutions.delete(resolution_case.case_id)
277
+ @conflicts.reject! { |conflict| conflict[:case_id].to_s == resolution_case.case_id }
278
+ end
279
+ selected_case_ids = selected_cases.map(&:case_id)
280
+ @unresolved_cases.reject! { |resolution_case| selected_case_ids.include?(resolution_case.case_id) }
281
+ end
282
+
283
+ def normalize_output_range(value)
284
+ range = Array(value)
285
+ return unless range.length == 2
286
+
287
+ start_offset = range[0].to_i
288
+ end_offset = range[1].to_i
289
+ return unless start_offset >= 0 && end_offset >= start_offset && @raw_content
290
+ return unless end_offset <= @raw_content.bytesize
291
+
292
+ [start_offset, end_offset]
293
+ end
294
+
295
+ def replace_output_range!(output_range, replacement)
296
+ start_offset, end_offset = output_range
297
+ prefix = @content_raw.byteslice(0, start_offset).to_s
298
+ suffix = @content_raw.byteslice(end_offset..).to_s
299
+ @content_raw = "#{prefix}#{replacement}#{suffix}"
300
+ @raw_content = @content_raw
301
+ [start_offset, start_offset + replacement.to_s.bytesize]
302
+ end
303
+
304
+ def remap_delegated_cases_after_apply(unresolved_cases, template_case:, output_range:, renderer:,
305
+ applied_selections:, root_applied_selections:, root_case_identities:)
306
+ operation_id = template_case.metadata[:delegated_runtime_operation_id]
307
+ surface_prefix = template_case.metadata[:delegated_runtime_surface_path]
308
+ delegated_group = template_case.metadata[:delegated_apply_group]
309
+
310
+ Array(unresolved_cases).map do |resolution_case|
311
+ suffix = delegated_surface_suffix_for(resolution_case.surface_path)
312
+ Ast::Merge::Runtime::ResolutionCase.new(
313
+ case_id: "#{operation_id}-#{resolution_case.case_id}",
314
+ reason: resolution_case.reason,
315
+ candidates: resolution_case.candidates,
316
+ provisional_winner: resolution_case.provisional_winner,
317
+ surface_path: [surface_prefix, suffix].compact.join(' > '),
318
+ operation_id: operation_id,
319
+ metadata: resolution_case.metadata.merge(
320
+ delegated_case_id: resolution_case.case_id,
321
+ output_range: output_range,
322
+ delegated_apply_group: delegated_group,
323
+ delegated_apply_renderer: renderer,
324
+ delegated_applied_selections: applied_selections,
325
+ delegated_root_applied_selections: root_applied_selections,
326
+ delegated_root_case_identities: root_case_identities,
327
+ delegated_runtime_operation_id: operation_id,
328
+ delegated_runtime_surface_path: surface_prefix
329
+ )
330
+ )
331
+ end
332
+ end
333
+
334
+ def delegated_surface_suffix_for(surface_path)
335
+ path = surface_path.to_s
336
+ return if path.empty? || path == 'document[0]'
337
+
338
+ path.sub(/\Adocument\[0\]\s*>\s*/, '')
339
+ end
340
+
341
+ def conflict_for_resolution_case(resolution_case)
342
+ {
343
+ case_id: resolution_case.case_id,
344
+ reason: resolution_case.reason,
345
+ template: resolution_case.candidates[:template],
346
+ destination: resolution_case.candidates[:destination],
347
+ provisional_winner: resolution_case.provisional_winner,
348
+ surface_path: resolution_case.surface_path
349
+ }
350
+ end
351
+
352
+ def serializable_unresolved_cases
353
+ @unresolved_cases.map do |resolution_case|
354
+ Ast::Merge::Runtime::ResolutionCase.new(
355
+ case_id: resolution_case.case_id,
356
+ reason: resolution_case.reason,
357
+ candidates: resolution_case.candidates,
358
+ provisional_winner: resolution_case.provisional_winner,
359
+ surface_path: resolution_case.surface_path,
360
+ operation_id: resolution_case.operation_id,
361
+ metadata: sanitize_review_state_metadata(
362
+ resolution_case.metadata.merge(review_identity: review_identity_for_case(resolution_case))
363
+ )
364
+ )
365
+ end
366
+ end
367
+
368
+ def delegated_applied_selections
369
+ @unresolved_cases.each_with_object({}) do |resolution_case, selections|
370
+ selections.merge!(resolution_case.metadata[:delegated_root_applied_selections].to_h)
371
+ end
372
+ end
373
+
374
+ def review_state_metadata(metadata, normalized_selections)
375
+ metadata_hash = super(metadata, normalized_selections)
376
+ markdown_review_state = metadata_hash.fetch(:markdown_review_state,
377
+ metadata_hash.fetch('markdown_review_state', {})).to_h
378
+ metadata_hash.merge(
379
+ markdown_review_state: markdown_review_state.merge(
380
+ selection_identities: selection_review_identities(normalized_selections)
381
+ )
382
+ )
383
+ end
384
+
385
+ def selection_review_identities(normalized_selections)
386
+ delegated_identities = @unresolved_cases.each_with_object({}) do |resolution_case, identities|
387
+ identities.merge!(resolution_case.metadata[:delegated_root_case_identities].to_h)
388
+ end
389
+
390
+ normalized_selections.each_with_object(delegated_identities) do |(case_id, _selection), identities|
391
+ current_case = unresolved_case(case_id)
392
+ identities[case_id.to_s] = review_identity_for_case(current_case) if current_case
393
+ end
394
+ end
395
+
396
+ def persisted_selection_identities(metadata)
397
+ markdown_review_state = metadata.fetch(:markdown_review_state, metadata.fetch('markdown_review_state', {})).to_h
398
+ markdown_review_state.fetch(:selection_identities, markdown_review_state.fetch('selection_identities', {})).to_h
399
+ .transform_keys(&:to_s)
400
+ end
401
+
402
+ def review_identity_for_case(resolution_case)
403
+ persisted_review_identity = case_metadata_value(resolution_case, :review_identity)
404
+ return persisted_review_identity if persisted_review_identity
405
+
406
+ Digest::SHA256.hexdigest(
407
+ [
408
+ resolution_case.surface_path,
409
+ resolution_case.reason,
410
+ resolution_case.provisional_winner,
411
+ case_metadata_value(resolution_case, :match_kind),
412
+ resolution_case.candidates[:template],
413
+ resolution_case.candidates[:destination]
414
+ ].map(&:to_s).join("\u001f")
415
+ )
416
+ end
417
+
418
+ def sanitize_review_state_metadata(value)
419
+ case value
420
+ when Hash
421
+ value.each_with_object({}) do |(key, entry), hash|
422
+ sanitized = sanitize_review_state_metadata(entry)
423
+ next if sanitized.equal?(SERIALIZATION_OMIT)
424
+
425
+ hash[key] = sanitized
426
+ end
427
+ when Array
428
+ value.filter_map do |entry|
429
+ sanitized = sanitize_review_state_metadata(entry)
430
+ sanitized unless sanitized.equal?(SERIALIZATION_OMIT)
431
+ end
432
+ else
433
+ return SERIALIZATION_OMIT if value.respond_to?(:call)
434
+
435
+ value
436
+ end
437
+ end
438
+
439
+ def validate_review_state_compatibility!(state)
440
+ super
441
+
442
+ persisted_cases = state.cases.each_with_object({}) do |resolution_case, hash|
443
+ hash[resolution_case.case_id] = resolution_case
444
+ end
445
+
446
+ state.selections.each_key do |case_id|
447
+ current_case = unresolved_case(case_id)
448
+ persisted_case = persisted_cases[case_id]
449
+ if persisted_case && !case_metadata_value(persisted_case, :review_identity) &&
450
+ (persisted_case.surface_path != current_case.surface_path ||
451
+ case_metadata_value(persisted_case, :match_kind) != case_metadata_value(current_case, :match_kind))
452
+ raise ArgumentError,
453
+ "cannot apply markdown review state: case #{case_id} no longer matches the current unresolved markdown surface"
454
+ end
455
+
456
+ next unless current_case&.metadata&.[](:output_range)
457
+ next if @content_raw == @raw_content
458
+
459
+ raise ArgumentError,
460
+ "cannot apply markdown review state for case #{case_id} after post-processing transformed markdown output"
461
+ end
462
+ end
463
+
464
+ def case_metadata_value(resolution_case, key)
465
+ return unless resolution_case
466
+
467
+ resolution_case.metadata[key] || resolution_case.metadata[key.to_s]
468
+ end
469
+
470
+ # Default statistics structure
471
+ #
472
+ # @return [Hash] Default stats hash
473
+ def default_stats
474
+ {
475
+ nodes_added: 0,
476
+ nodes_removed: 0,
477
+ nodes_modified: 0,
478
+ merge_time_ms: 0
479
+ }
480
+ end
481
+ end
482
+ end
483
+ end
@@ -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