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,742 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Merges fenced code blocks using language-specific *-merge gems.
6
+ #
7
+ # When two code blocks with the same signature are matched, this class
8
+ # delegates the merge to the appropriate language-specific merger:
9
+ # - Ruby code → prism-merge
10
+ # - YAML code → psych-merge
11
+ # - JSON code → json-merge
12
+ # - TOML code → toml-merge
13
+ #
14
+ # @example Basic usage
15
+ # merger = CodeBlockMerger.new
16
+ # result = merger.merge_code_blocks(template_node, dest_node, preference: :destination)
17
+ # if result[:merged]
18
+ # puts result[:content]
19
+ # else
20
+ # # Fall back to standard resolution
21
+ # end
22
+ #
23
+ # @example With custom mergers
24
+ # merger = CodeBlockMerger.new(
25
+ # mergers: {
26
+ # "ruby" => ->(template, dest, pref) { MyCustomRubyMerger.merge(template, dest, pref) },
27
+ # }
28
+ # )
29
+ #
30
+ # @see SmartMergerBase
31
+ # @api public
32
+ class CodeBlockMerger
33
+ # Default language-to-merger mapping
34
+ # Each merger is a lambda that takes (template_content, dest_content, preference)
35
+ # and returns { merged: true/false, content: String, stats: Hash }
36
+ # simplecov:disable integration - DEFAULT_MERGERS lambdas require external gems
37
+ DEFAULT_MERGERS = {
38
+ # Ruby code blocks
39
+ 'ruby' => lambda { |template, dest, preference, **opts|
40
+ require 'prism/merge'
41
+ CodeBlockMerger.merge_with_prism(template, dest, preference, **opts)
42
+ },
43
+ 'rb' => lambda { |template, dest, preference, **opts|
44
+ require 'prism/merge'
45
+ CodeBlockMerger.merge_with_prism(template, dest, preference, **opts)
46
+ },
47
+
48
+ # YAML code blocks
49
+ 'yaml' => lambda { |template, dest, preference, **opts|
50
+ require 'psych/merge'
51
+ CodeBlockMerger.merge_with_psych(template, dest, preference, **opts)
52
+ },
53
+ 'yml' => lambda { |template, dest, preference, **opts|
54
+ require 'psych/merge'
55
+ CodeBlockMerger.merge_with_psych(template, dest, preference, **opts)
56
+ },
57
+
58
+ # JSON code blocks
59
+ 'json' => lambda { |template, dest, preference, **opts|
60
+ require 'json/merge'
61
+ CodeBlockMerger.merge_with_json(template, dest, preference, **opts)
62
+ },
63
+
64
+ # Markdown code blocks
65
+ 'markdown' => lambda { |template, dest, preference, **opts|
66
+ require 'markdown/merge'
67
+ CodeBlockMerger.merge_with_markdown(template, dest, preference, **opts)
68
+ },
69
+ 'md' => lambda { |template, dest, preference, **opts|
70
+ require 'markdown/merge'
71
+ CodeBlockMerger.merge_with_markdown(template, dest, preference, **opts)
72
+ },
73
+
74
+ # TOML code blocks
75
+ 'toml' => lambda { |template, dest, preference, **opts|
76
+ require 'toml/merge'
77
+ CodeBlockMerger.merge_with_toml(template, dest, preference, **opts)
78
+ }
79
+ }.freeze
80
+ # simplecov:enable
81
+
82
+ # @return [Hash<String, Proc>] Language to merger mapping
83
+ attr_reader :mergers
84
+
85
+ # @return [Boolean] Whether inner-merge is enabled
86
+ attr_reader :enabled
87
+
88
+ # @return [Array<Ast::Merge::Runtime::Delegate>] Runtime delegates exposed by this merger
89
+ attr_reader :runtime_delegates
90
+
91
+ # Creates a new CodeBlockMerger.
92
+ #
93
+ # @param mergers [Hash<String, Proc>] Custom language-to-merger mapping.
94
+ # Mergers are merged with defaults, allowing selective overrides.
95
+ # @param enabled [Boolean] Whether to enable inner-merge (default: true)
96
+ def initialize(mergers: {}, enabled: true)
97
+ @mergers = DEFAULT_MERGERS.merge(mergers)
98
+ @enabled = enabled
99
+ @runtime_delegates = build_runtime_delegates.freeze
100
+ end
101
+
102
+ # Check if inner-merge is available for a language.
103
+ #
104
+ # @param language [String] The language identifier from fence_info
105
+ # @return [Boolean] true if a merger exists for this language
106
+ def supports_language?(language)
107
+ return false unless @enabled
108
+ return false if language.nil? || language.empty?
109
+
110
+ @mergers.key?(language.downcase)
111
+ end
112
+
113
+ # Merge two code blocks using the appropriate language-specific merger.
114
+ #
115
+ # @param template_node [Object] Template code block node
116
+ # @param dest_node [Object] Destination code block node
117
+ # @param preference [Symbol] :destination or :template
118
+ # @param opts [Hash] Additional options passed to the merger
119
+ # @return [Hash] { merged: Boolean, content: String, stats: Hash }
120
+ def merge_code_blocks(template_node, dest_node, preference:, runtime_session: nil, parent_operation: nil, **opts)
121
+ if runtime_session && parent_operation
122
+ return merge_code_blocks_with_runtime(
123
+ template_node,
124
+ dest_node,
125
+ preference: preference,
126
+ runtime_session: runtime_session,
127
+ parent_operation: parent_operation,
128
+ **opts
129
+ )
130
+ end
131
+
132
+ merge_code_blocks_without_runtime(template_node, dest_node, preference: preference, **opts)
133
+ end
134
+
135
+ def merge_code_blocks_without_runtime(template_node, dest_node, preference:, **opts)
136
+ return not_merged('inner-merge disabled') unless @enabled
137
+
138
+ language = extract_language(template_node) || extract_language(dest_node)
139
+ return not_merged('no language specified') unless language
140
+
141
+ template_content = extract_content(template_node)
142
+ dest_content = extract_content(dest_node)
143
+
144
+ perform_code_block_merge(
145
+ language: language,
146
+ template_content: template_content,
147
+ dest_content: dest_content,
148
+ preference: preference,
149
+ reference_node: dest_node,
150
+ **opts
151
+ )
152
+ end
153
+
154
+ private
155
+
156
+ def merge_code_blocks_with_runtime(template_node, dest_node, preference:, runtime_session:, parent_operation:,
157
+ **opts)
158
+ operation = build_runtime_operation(
159
+ template_node: template_node,
160
+ dest_node: dest_node,
161
+ preference: preference,
162
+ runtime_session: runtime_session,
163
+ parent_operation: parent_operation,
164
+ **opts
165
+ )
166
+
167
+ parent_operation.add_child(operation)
168
+ parent_frame = runtime_session.frame_for(parent_operation.operation_id)
169
+ delegate = runtime_session.resolve_delegate_for(operation.surface, capability: :merge)
170
+ runtime_session.register(
171
+ operation,
172
+ frame: Ast::Merge::Runtime::Frame.new(
173
+ parent_operation_id: parent_operation.operation_id,
174
+ operation_id: operation.operation_id,
175
+ depth: parent_frame ? parent_frame.depth + 1 : 1,
176
+ surface_path: operation.surface.address,
177
+ language_chain: [*(parent_frame&.language_chain || [:markdown]),
178
+ operation.surface.effective_language].compact
179
+ ),
180
+ delegate: delegate
181
+ )
182
+
183
+ unless delegate
184
+ reason = unsupported_runtime_reason_for(operation.surface)
185
+ operation.fail!(
186
+ diagnostic: Ast::Merge::Runtime::Diagnostic.new(
187
+ severity: :warn,
188
+ kind: :unsupported_capability,
189
+ operation_id: operation.operation_id,
190
+ surface_path: operation.surface.address,
191
+ message: reason,
192
+ metadata: {
193
+ capability: :merge,
194
+ language: operation.surface.effective_language
195
+ }
196
+ )
197
+ )
198
+ return not_merged(reason).merge(runtime_operation_id: operation.operation_id)
199
+ end
200
+
201
+ operation.running!
202
+ child_result = delegate.merge(operation: operation, session: runtime_session)
203
+ if child_result.unresolved?
204
+ operation.unresolved!(result: child_result)
205
+ else
206
+ operation.complete!(result: child_result)
207
+ end
208
+
209
+ if child_result.metadata[:merged]
210
+ {
211
+ merged: true,
212
+ content: child_result.replacement_text,
213
+ stats: child_result.metadata[:stats] || {},
214
+ runtime_operation_id: operation.operation_id,
215
+ runtime_surface_path: operation.surface.address,
216
+ unresolved_cases: child_result.unresolved_cases,
217
+ metadata: child_result.metadata
218
+ }
219
+ else
220
+ not_merged(child_result.metadata[:reason] || 'merger declined').merge(
221
+ stats: child_result.metadata[:stats] || {},
222
+ runtime_operation_id: operation.operation_id
223
+ )
224
+ end
225
+ end
226
+
227
+ def build_runtime_operation(template_node:, dest_node:, preference:, runtime_session:, parent_operation:, **opts)
228
+ language = extract_language(template_node) || extract_language(dest_node)
229
+ reference_node = dest_node || template_node
230
+ surface = Ast::Merge::Runtime::Surface.new(
231
+ surface_kind: :markdown_fenced_code_block,
232
+ declared_language: language,
233
+ effective_language: language,
234
+ address: runtime_surface_address(reference_node, runtime_session),
235
+ parent_address: parent_operation.surface.address,
236
+ span: runtime_surface_span(reference_node),
237
+ reconstruction_strategy: :portable_write,
238
+ metadata: {
239
+ fence_info: reference_node.respond_to?(:fence_info) ? reference_node.fence_info : nil,
240
+ language: language
241
+ }.compact
242
+ )
243
+
244
+ Ast::Merge::Runtime::Operation.new(
245
+ operation_id: "markdown-code-block-#{runtime_session.operations.count}",
246
+ surface: surface,
247
+ template_fragment: rebuild_code_block(language, extract_content(template_node), template_node),
248
+ destination_fragment: rebuild_code_block(language, extract_content(dest_node), dest_node),
249
+ requested_strategy: :delegate_child_surface,
250
+ options: {
251
+ preference: preference,
252
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
253
+ resolution_mode: opts.fetch(:resolution_mode, :eager),
254
+ unresolved_policy: Ast::Merge::UnresolvedPolicy.coerce(opts[:unresolved_policy]).to_h,
255
+ template_content: extract_content(template_node),
256
+ destination_content: extract_content(dest_node),
257
+ reference_node: reference_node
258
+ }
259
+ )
260
+ end
261
+
262
+ def runtime_surface_address(reference_node, runtime_session)
263
+ span = runtime_surface_span(reference_node)
264
+ suffix =
265
+ if span
266
+ "L#{span.begin}-L#{span.end}"
267
+ else
268
+ "operation-#{runtime_session.operations.count}"
269
+ end
270
+
271
+ "document[0] > fenced_code_block[#{suffix}]"
272
+ end
273
+
274
+ def runtime_surface_span(node)
275
+ position = node_source_position(node)
276
+ start_line = position&.dig(:start_line)
277
+ end_line = position&.dig(:end_line)
278
+ return unless start_line && end_line
279
+
280
+ start_line..end_line
281
+ end
282
+
283
+ def node_source_position(node)
284
+ raw_node = unwrap_code_block_node(node)
285
+ raw_node&.source_position
286
+ rescue NoMethodError
287
+ nil
288
+ end
289
+
290
+ def build_runtime_delegates
291
+ [
292
+ Ast::Merge::Runtime::Delegate.new(
293
+ name: 'markdown-code-block-inner-merge',
294
+ priority: 100,
295
+ surface_kinds: [:markdown_fenced_code_block],
296
+ languages: mergers.keys,
297
+ capabilities: { merge: [:markdown_fenced_code_block] },
298
+ merge: method(:merge_runtime_surface),
299
+ metadata: {
300
+ source: :markdown_merge,
301
+ languages: mergers.keys.sort
302
+ }
303
+ )
304
+ ]
305
+ end
306
+
307
+ def merge_runtime_surface(operation:, session:)
308
+ result = perform_code_block_merge(
309
+ language: operation.surface.effective_language,
310
+ template_content: operation.options[:template_content].to_s,
311
+ dest_content: operation.options[:destination_content].to_s,
312
+ preference: operation.options[:preference],
313
+ reference_node: operation.options[:reference_node],
314
+ add_template_only_nodes: operation.options.fetch(:add_template_only_nodes, false),
315
+ resolution_mode: operation.options[:resolution_mode],
316
+ unresolved_policy: operation.options[:unresolved_policy]
317
+ )
318
+
319
+ if result[:merged]
320
+ Ast::Merge::Runtime::ChildResult.new(
321
+ replacement_text: result[:content],
322
+ diagnostics: operation.diagnostics,
323
+ capabilities_used: %i[delegated_child_merge language_specific_merge],
324
+ capabilities_missing: [],
325
+ unresolved_cases: result[:unresolved_cases] || [],
326
+ metadata: {
327
+ merged: true,
328
+ stats: result[:stats] || {},
329
+ language: operation.surface.effective_language,
330
+ delegate_name: operation.delegate_name,
331
+ session_policy: session.policy_context
332
+ }.merge(result[:metadata] || {})
333
+ )
334
+ else
335
+ operation.add_diagnostic(
336
+ Ast::Merge::Runtime::Diagnostic.new(
337
+ severity: :warn,
338
+ kind: :delegated_merge_declined,
339
+ operation_id: operation.operation_id,
340
+ surface_path: operation.surface.address,
341
+ message: result[:reason].to_s,
342
+ metadata: {
343
+ language: operation.surface.effective_language
344
+ }
345
+ )
346
+ )
347
+
348
+ Ast::Merge::Runtime::ChildResult.new(
349
+ replacement_text: '',
350
+ diagnostics: operation.diagnostics,
351
+ capabilities_used: [:delegated_child_merge],
352
+ capabilities_missing: [:language_specific_merge],
353
+ metadata: {
354
+ merged: false,
355
+ reason: result[:reason],
356
+ stats: result[:stats] || {},
357
+ language: operation.surface.effective_language,
358
+ delegate_name: operation.delegate_name,
359
+ session_policy: session.policy_context
360
+ }.merge(result[:metadata] || {})
361
+ )
362
+ end
363
+ end
364
+
365
+ def perform_code_block_merge(language:, template_content:, dest_content:, preference:, reference_node:, **opts)
366
+ return not_merged('no language specified') unless language
367
+
368
+ merger = @mergers[language.to_s.downcase]
369
+ return not_merged("no merger for language: #{language}") unless merger
370
+
371
+ if template_content == dest_content
372
+ return {
373
+ merged: true,
374
+ content: rebuild_code_block(language, dest_content, reference_node),
375
+ stats: { decision: :identical }
376
+ }
377
+ end
378
+
379
+ begin
380
+ result = merger.call(template_content, dest_content, preference, nested_mergers: @mergers, **opts)
381
+ if result[:merged]
382
+ metadata = (result[:metadata] || {}).dup
383
+ metadata[:root_apply_candidates_by_case_id] = root_apply_candidates_by_case_id(
384
+ merger: merger,
385
+ unresolved_cases: result[:unresolved_cases],
386
+ language: language,
387
+ template_content: template_content,
388
+ dest_content: dest_content,
389
+ preference: preference,
390
+ reference_node: reference_node,
391
+ **opts
392
+ )
393
+ metadata[:delegated_apply_renderer] = delegated_apply_renderer(
394
+ merger: merger,
395
+ language: language,
396
+ template_content: template_content,
397
+ dest_content: dest_content,
398
+ preference: preference,
399
+ reference_node: reference_node,
400
+ **opts
401
+ )
402
+ {
403
+ merged: true,
404
+ content: rebuild_code_block(language, result[:content], reference_node),
405
+ stats: result[:stats] || {},
406
+ unresolved_cases: result[:unresolved_cases] || [],
407
+ metadata: metadata
408
+ }
409
+ else
410
+ not_merged(result[:reason] || 'merger declined').merge(
411
+ stats: result[:stats] || {},
412
+ metadata: result[:metadata] || {}
413
+ )
414
+ end
415
+ rescue LoadError => e
416
+ not_merged("merger gem not available: #{e.message}")
417
+ rescue TreeHaver::Error => e
418
+ not_merged("backend not available: #{e.message}")
419
+ rescue StandardError => e
420
+ if defined?(::Prism::Merge::ParseError) && e.is_a?(::Prism::Merge::ParseError)
421
+ not_merged("Ruby parse error: #{e.message}")
422
+ else
423
+ not_merged("merge failed: #{e.class}: #{e.message}")
424
+ end
425
+ end
426
+ end
427
+
428
+ def unsupported_runtime_reason_for(surface)
429
+ language = surface.effective_language || surface.declared_language
430
+ return 'no language specified' unless language
431
+
432
+ "no merger for language: #{language}"
433
+ end
434
+
435
+ def root_apply_candidates_by_case_id(merger:, unresolved_cases:, language:, template_content:, dest_content:,
436
+ preference:, reference_node:, **opts)
437
+ cases = Array(unresolved_cases)
438
+ return {} unless cases.one?
439
+
440
+ resolution_case = cases.first
441
+ {
442
+ resolution_case.case_id => {
443
+ template: rebuild_code_block(
444
+ language,
445
+ merged_delegate_content_for(
446
+ merger: merger,
447
+ template_content: template_content,
448
+ dest_content: dest_content,
449
+ preference: preference,
450
+ case_id: resolution_case.case_id,
451
+ selection: :template,
452
+ **opts
453
+ ),
454
+ reference_node
455
+ ),
456
+ destination: rebuild_code_block(
457
+ language,
458
+ merged_delegate_content_for(
459
+ merger: merger,
460
+ template_content: template_content,
461
+ dest_content: dest_content,
462
+ preference: preference,
463
+ case_id: resolution_case.case_id,
464
+ selection: :destination,
465
+ **opts
466
+ ),
467
+ reference_node
468
+ )
469
+ }
470
+ }
471
+ end
472
+
473
+ def delegated_apply_renderer(merger:, language:, template_content:, dest_content:, preference:, reference_node:,
474
+ **opts)
475
+ lambda do |selections|
476
+ delegated_result = delegated_merge_result_for(
477
+ merger: merger,
478
+ template_content: template_content,
479
+ dest_content: dest_content,
480
+ preference: preference,
481
+ apply_unresolved_resolutions: selections,
482
+ **opts
483
+ )
484
+ {
485
+ content: rebuild_code_block(language, delegated_result[:content].to_s, reference_node),
486
+ unresolved_cases: delegated_result[:unresolved_cases] || [],
487
+ metadata: delegated_result[:metadata] || {}
488
+ }
489
+ end
490
+ end
491
+
492
+ def merged_delegate_content_for(merger:, template_content:, dest_content:, preference:, case_id: nil, selection: nil,
493
+ apply_unresolved_resolutions: nil, **opts)
494
+ delegated_merge_result_for(
495
+ merger: merger,
496
+ template_content: template_content,
497
+ dest_content: dest_content,
498
+ preference: preference,
499
+ case_id: case_id,
500
+ selection: selection,
501
+ apply_unresolved_resolutions: apply_unresolved_resolutions,
502
+ **opts
503
+ )[:content].to_s
504
+ end
505
+
506
+ def delegated_merge_result_for(merger:, template_content:, dest_content:, preference:, case_id: nil, selection: nil,
507
+ apply_unresolved_resolutions: nil, **opts)
508
+ merger.call(
509
+ template_content,
510
+ dest_content,
511
+ preference,
512
+ **opts,
513
+ apply_unresolved_resolutions: apply_unresolved_resolutions || { case_id => selection }
514
+ )
515
+ end
516
+
517
+ # Extract language from a code block node.
518
+ #
519
+ # @param node [Object] The code block node
520
+ # @return [String, nil] The language identifier
521
+ def extract_language(node)
522
+ raw_node = unwrap_code_block_node(node)
523
+ info = safe_code_block_value(raw_node, :fence_info)
524
+ return if info.nil? || info.empty?
525
+
526
+ # fence_info may contain additional info after the language (e.g., "ruby linenos")
527
+ info.split(/\s+/).first
528
+ end
529
+
530
+ # Extract content from a code block node.
531
+ #
532
+ # @param node [Object] The code block node
533
+ # @return [String] The code content
534
+ def extract_content(node)
535
+ raw_node = unwrap_code_block_node(node)
536
+ safe_code_block_value(raw_node, :string_content, :text).to_s
537
+ end
538
+
539
+ # Rebuild a fenced code block with merged content.
540
+ #
541
+ # @param language [String] The language identifier
542
+ # @param content [String] The merged content
543
+ # @param reference_node [Object] Node to copy fence style from
544
+ # @return [String] The reconstructed code block
545
+ def rebuild_code_block(language, content, _reference_node)
546
+ # Ensure content ends with newline for proper fence closing
547
+ content = "#{content.chomp}\n" unless content.end_with?("\n")
548
+
549
+ # Use backticks as default fence
550
+ fence = '```'
551
+
552
+ "#{fence}#{language}\n#{content}#{fence}\n"
553
+ end
554
+
555
+ # Return a not-merged result.
556
+ #
557
+ # @param reason [String] Why merge was not performed
558
+ # @return [Hash] Not-merged result hash
559
+ def not_merged(reason)
560
+ { merged: false, reason: reason }
561
+ end
562
+
563
+ def unwrap_code_block_node(node)
564
+ return node unless defined?(Ast::Merge::NodeTyping::Wrapper) && node.is_a?(Ast::Merge::NodeTyping::Wrapper)
565
+
566
+ Ast::Merge::NodeTyping.unwrap(node)
567
+ end
568
+
569
+ def safe_code_block_value(node, *methods)
570
+ methods.each do |method_name|
571
+ return node.public_send(method_name) if node
572
+ rescue NoMethodError
573
+ next
574
+ rescue Exception => e
575
+ next if e.instance_of?(::RSpec::Mocks::MockExpectationError)
576
+
577
+ raise
578
+ end
579
+
580
+ nil
581
+ end
582
+
583
+ class << self
584
+ # Merge Ruby code using prism-merge.
585
+ #
586
+ # @param template [String] Template Ruby code
587
+ # @param dest [String] Destination Ruby code
588
+ # @param preference [Symbol] :destination or :template
589
+ # @return [Hash] Merge result
590
+ # @raise [Prism::Merge::ParseError] If template or dest has syntax errors
591
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
592
+ def merge_with_prism(template, dest, preference, **opts)
593
+ merger = ::Prism::Merge::SmartMerger.new(
594
+ template,
595
+ dest,
596
+ preference: preference,
597
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
598
+ resolution_mode: opts.fetch(:resolution_mode, :eager),
599
+ unresolved_policy: opts[:unresolved_policy]
600
+ )
601
+ merge_result = merger.merge_result
602
+ if opts[:apply_unresolved_resolutions]
603
+ merge_result.apply_unresolved_resolutions!(opts[:apply_unresolved_resolutions])
604
+ end
605
+
606
+ {
607
+ merged: true,
608
+ content: merge_result.to_s,
609
+ stats: merger.stats,
610
+ unresolved_cases: merge_result.unresolved_cases
611
+ }
612
+ end
613
+
614
+ # Merge YAML code using psych-merge.
615
+ #
616
+ # @param template [String] Template YAML code
617
+ # @param dest [String] Destination YAML code
618
+ # @param preference [Symbol] :destination or :template
619
+ # @return [Hash] Merge result
620
+ # @raise [Psych::Merge::ParseError] If template or dest has syntax errors
621
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
622
+ def merge_with_psych(template, dest, preference, **opts)
623
+ merger = ::Psych::Merge::SmartMerger.new(
624
+ template,
625
+ dest,
626
+ preference: preference,
627
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
628
+ resolution_mode: opts.fetch(:resolution_mode, :eager),
629
+ unresolved_policy: opts[:unresolved_policy]
630
+ )
631
+ merge_result = merger.merge_result
632
+ if opts[:apply_unresolved_resolutions]
633
+ merge_result.apply_unresolved_resolutions!(opts[:apply_unresolved_resolutions])
634
+ end
635
+
636
+ {
637
+ merged: true,
638
+ content: merge_result.to_yaml,
639
+ stats: merger.stats,
640
+ unresolved_cases: merge_result.unresolved_cases
641
+ }
642
+ end
643
+
644
+ # Merge JSON code using json-merge.
645
+ #
646
+ # @param template [String] Template JSON code
647
+ # @param dest [String] Destination JSON code
648
+ # @param preference [Symbol] :destination or :template
649
+ # @return [Hash] Merge result
650
+ # @raise [Json::Merge::ParseError] If template or dest has syntax errors
651
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
652
+ def merge_with_json(template, dest, preference, **opts)
653
+ merger = ::Json::Merge::SmartMerger.new(
654
+ template,
655
+ dest,
656
+ preference: preference,
657
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
658
+ resolution_mode: opts.fetch(:resolution_mode, :eager),
659
+ unresolved_policy: opts[:unresolved_policy]
660
+ )
661
+ merge_result = merger.merge_result
662
+ if opts[:apply_unresolved_resolutions]
663
+ merge_result.apply_unresolved_resolutions!(opts[:apply_unresolved_resolutions])
664
+ end
665
+
666
+ {
667
+ merged: true,
668
+ content: merge_result.to_json,
669
+ stats: merger.stats,
670
+ unresolved_cases: merge_result.unresolved_cases
671
+ }
672
+ end
673
+
674
+ # Merge Markdown code using markdown-merge.
675
+ #
676
+ # @param template [String] Template Markdown code
677
+ # @param dest [String] Destination Markdown code
678
+ # @param preference [Symbol] :destination or :template
679
+ # @return [Hash] Merge result
680
+ def merge_with_markdown(template, dest, preference, **opts)
681
+ nested_code_block_merger = CodeBlockMerger.new(
682
+ mergers: opts.fetch(:nested_mergers, {}),
683
+ enabled: opts.fetch(:inner_merge_code_blocks, true)
684
+ )
685
+ merger = ::Markdown::Merge::SmartMerger.new(
686
+ template,
687
+ dest,
688
+ preference: preference,
689
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
690
+ inner_merge_code_blocks: nested_code_block_merger,
691
+ resolution_mode: opts.fetch(:resolution_mode, :eager),
692
+ unresolved_policy: opts[:unresolved_policy]
693
+ )
694
+ merge_result = merger.merge_result
695
+ if opts[:apply_unresolved_resolutions]
696
+ merge_result.apply_unresolved_resolutions!(opts[:apply_unresolved_resolutions])
697
+ end
698
+
699
+ {
700
+ merged: true,
701
+ content: merge_result.to_s,
702
+ stats: merger.stats,
703
+ unresolved_cases: merge_result.unresolved_cases,
704
+ metadata: {
705
+ nested_runtime_session: merger.runtime_session&.to_h
706
+ }
707
+ }
708
+ end
709
+
710
+ # Merge TOML code using toml-merge.
711
+ #
712
+ # @param template [String] Template TOML code
713
+ # @param dest [String] Destination TOML code
714
+ # @param preference [Symbol] :destination or :template
715
+ # @return [Hash] Merge result
716
+ # @raise [Toml::Merge::ParseError] If template or dest has syntax errors
717
+ # @note Errors are handled by merge_code_blocks when called via DEFAULT_MERGERS
718
+ def merge_with_toml(template, dest, preference, **opts)
719
+ merger = ::Toml::Merge::SmartMerger.new(
720
+ template,
721
+ dest,
722
+ preference: preference,
723
+ add_template_only_nodes: opts.fetch(:add_template_only_nodes, false),
724
+ resolution_mode: opts.fetch(:resolution_mode, :eager),
725
+ unresolved_policy: opts[:unresolved_policy]
726
+ )
727
+ merge_result = merger.merge_result
728
+ if opts[:apply_unresolved_resolutions]
729
+ merge_result.apply_unresolved_resolutions!(opts[:apply_unresolved_resolutions])
730
+ end
731
+
732
+ {
733
+ merged: true,
734
+ content: merge_result.to_toml,
735
+ stats: merger.stats,
736
+ unresolved_cases: merge_result.unresolved_cases
737
+ }
738
+ end
739
+ end
740
+ end
741
+ end
742
+ end