json-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.
@@ -0,0 +1,1379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Resolves conflicts between template and destination JSON content
6
+ # using structural signatures and configurable preferences.
7
+ #
8
+ # @example Basic usage
9
+ # resolver = ConflictResolver.new(template_analysis, dest_analysis)
10
+ # resolver.resolve(result)
11
+ class ConflictResolver < Ast::Merge::ConflictResolverBase
12
+ include Ast::Merge::CommentLayoutEmissionSupport
13
+ include Ast::Merge::StructuredEmitterProvenanceSupport
14
+
15
+ class MissingSharedInlineRegionError < Json::Merge::Error; end
16
+
17
+ include ::Ast::Merge::TrailingGroups::DestIterate
18
+
19
+ attr_reader :corruption_handling
20
+
21
+ # Creates a new ConflictResolver
22
+ #
23
+ # @param template_analysis [FileAnalysis] Analyzed template file
24
+ # @param dest_analysis [FileAnalysis] Analyzed destination file
25
+ # @param preference [Symbol, Hash] Which version to prefer when
26
+ # nodes have matching signatures:
27
+ # - :destination (default) - Keep destination version (customizations)
28
+ # - :template - Use template version (updates)
29
+ # @param add_template_only_nodes [Boolean] Whether to add nodes only in template
30
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
31
+ # @param options [Hash] Additional options for forward compatibility
32
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
33
+ # for per-node-type preferences
34
+ def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false,
35
+ remove_template_missing_nodes: false, resolution_mode: :eager, corruption_handling: :heal, match_refiner: nil, node_typing: nil, merge_arrays: true, preserve_atomic_formatting: false, **options)
36
+ super(
37
+ strategy: :batch,
38
+ preference: preference,
39
+ template_analysis: template_analysis,
40
+ dest_analysis: dest_analysis,
41
+ add_template_only_nodes: add_template_only_nodes,
42
+ remove_template_missing_nodes: remove_template_missing_nodes,
43
+ match_refiner: match_refiner,
44
+ **options
45
+ )
46
+ @resolution_mode = resolution_mode
47
+ @corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
48
+ @node_typing = node_typing
49
+ @merge_arrays = merge_arrays
50
+ @preserve_atomic_formatting = preserve_atomic_formatting
51
+ @emitter = Emitter.new
52
+ end
53
+
54
+ protected
55
+
56
+ # Resolve conflicts and populate the result using tree-based merging
57
+ #
58
+ # @param result [MergeResult] Result object to populate
59
+ def resolve_batch(result)
60
+ DebugLogger.time('ConflictResolver#resolve') do
61
+ @result = result
62
+ template_statements = @template_analysis.statements
63
+ dest_statements = @dest_analysis.statements
64
+
65
+ # Clear emitter for fresh merge
66
+ @emitter.clear
67
+ @emitted_leading_comment_texts = ::Set.new
68
+
69
+ emit_document_prelude(@dest_analysis, nodes: dest_statements)
70
+
71
+ # Merge root-level statements via emitter
72
+ merge_node_lists_to_emitter(
73
+ template_statements,
74
+ dest_statements,
75
+ @template_analysis,
76
+ @dest_analysis
77
+ )
78
+
79
+ emit_document_postlude(@dest_analysis, fallback_node: dest_statements.last)
80
+
81
+ # Transfer emitter output to result
82
+ transfer_emitter_output(result)
83
+
84
+ DebugLogger.debug('Conflict resolution complete', {
85
+ template_statements: template_statements.size,
86
+ dest_statements: dest_statements.size,
87
+ result_lines: result.line_count
88
+ })
89
+ end
90
+ end
91
+
92
+ public
93
+
94
+ def freeze_node?(node)
95
+ return false unless node
96
+ return node.freeze_node? if node.respond_to?(:freeze_node?)
97
+
98
+ node.is_a?(FreezeNode)
99
+ end
100
+
101
+ private
102
+
103
+ # Recursively merge two lists of nodes, emitting to emitter
104
+ # @param template_nodes [Array<NodeWrapper>] Template nodes
105
+ # @param dest_nodes [Array<NodeWrapper>] Destination nodes
106
+ # @param template_analysis [FileAnalysis] Template analysis for line access
107
+ # @param dest_analysis [FileAnalysis] Destination analysis for line access
108
+ def merge_node_lists_to_emitter(template_nodes, dest_nodes, template_analysis, dest_analysis, dest_owners: nil)
109
+ # Build signature maps for matching
110
+ template_by_sig = build_signature_map(template_nodes, template_analysis)
111
+ dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
112
+
113
+ # Build refined matches for nodes that don't match by signature
114
+ refined_matches = build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
115
+ refined_dest_to_template = refined_matches.invert
116
+
117
+ # Track consumed individual node indices (not just signatures) so that
118
+ # multiple nodes sharing the same signature are matched 1:1 in order
119
+ # rather than collapsed into a single match.
120
+ consumed_template_indices = ::Set.new
121
+ sig_cursor = Hash.new(0)
122
+
123
+ # Pre-compute position-aware trailing groups for template-only nodes.
124
+ dest_sigs = ::Set.new
125
+ dest_nodes.each do |n|
126
+ sig = dest_analysis.generate_signature(n)
127
+ dest_sigs << sig if sig
128
+ end
129
+ refined_template_ids = ::Set.new(refined_matches.keys.map(&:object_id))
130
+
131
+ trailing_groups, all_matched_indices = build_dest_iterate_trailing_groups(
132
+ template_nodes: template_nodes,
133
+ dest_sigs: dest_sigs,
134
+ signature_for: ->(node) { template_analysis.generate_signature(node) },
135
+ refined_template_ids: refined_template_ids,
136
+ add_template_only_nodes: @add_template_only_nodes
137
+ )
138
+
139
+ # Emit template-only nodes that precede the first matched template node.
140
+ emit_prefix_trailing_group(trailing_groups, consumed_template_indices) do |info|
141
+ next if freeze_node?(info[:node])
142
+
143
+ emit_atomic_node(info[:node], template_analysis)
144
+ end
145
+
146
+ # First pass: Process destination nodes
147
+ dest_nodes.each do |dest_node|
148
+ dest_sig = dest_analysis.generate_signature(dest_node)
149
+
150
+ if freeze_node?(dest_node)
151
+ emit_freeze_block(dest_node)
152
+ next
153
+ end
154
+
155
+ # Check for signature match
156
+ if dest_sig && template_by_sig[dest_sig]
157
+ # Find the next unconsumed template node with this signature
158
+ candidates = template_by_sig[dest_sig]
159
+ cursor = sig_cursor[dest_sig]
160
+ template_info = nil
161
+
162
+ while cursor < candidates.size
163
+ candidate = candidates[cursor]
164
+ unless consumed_template_indices.include?(candidate[:index])
165
+ template_info = candidate
166
+ break
167
+ end
168
+ cursor += 1
169
+ end
170
+
171
+ if template_info
172
+ template_node = template_info[:node]
173
+
174
+ # Both have this node - merge them (recursively if containers)
175
+ merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis,
176
+ dest_owners: dest_nodes)
177
+
178
+ consumed_template_indices << template_info[:index]
179
+ sig_cursor[dest_sig] = cursor + 1
180
+ else
181
+ # All template copies consumed — keep dest copy
182
+ emit_atomic_node(dest_node, dest_analysis)
183
+ end
184
+ elsif refined_dest_to_template.key?(dest_node)
185
+ # Found refined match
186
+ template_node = refined_dest_to_template[dest_node]
187
+ template_sig = template_analysis.generate_signature(template_node)
188
+
189
+ # Find and consume the matching template index
190
+ if template_sig && template_by_sig[template_sig]
191
+ template_by_sig[template_sig].each do |info|
192
+ unless consumed_template_indices.include?(info[:index])
193
+ consumed_template_indices << info[:index]
194
+ break
195
+ end
196
+ end
197
+ end
198
+
199
+ # Merge matched nodes
200
+ merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis,
201
+ dest_owners: dest_nodes)
202
+ elsif @remove_template_missing_nodes
203
+ emit_removed_destination_node_comments(dest_node, dest_analysis)
204
+ else
205
+ # Destination-only node - always keep
206
+ emit_atomic_node(dest_node, dest_analysis)
207
+ end
208
+
209
+ # Flush interior trailing groups (between two matches) that are ready
210
+ flush_ready_trailing_groups(
211
+ trailing_groups: trailing_groups,
212
+ matched_indices: all_matched_indices,
213
+ consumed_indices: consumed_template_indices
214
+ ) do |info|
215
+ next if freeze_node?(info[:node])
216
+
217
+ emit_atomic_node(info[:node], template_analysis)
218
+ end
219
+ end
220
+
221
+ # Emit remaining trailing groups (tail groups after last match + safety net)
222
+ emit_remaining_trailing_groups(
223
+ trailing_groups: trailing_groups,
224
+ consumed_indices: consumed_template_indices
225
+ ) do |info|
226
+ next if freeze_node?(info[:node])
227
+
228
+ emit_atomic_node(info[:node], template_analysis)
229
+ end
230
+ end
231
+
232
+ def trailing_group_node_matched?(node, _signature)
233
+ freeze_node?(node)
234
+ end
235
+
236
+ # Merge two matched nodes - for containers, recursively merge children
237
+ # Emits to emitter instead of result
238
+ # @param template_node [NodeWrapper] Template node
239
+ # @param dest_node [NodeWrapper] Destination node
240
+ # @param template_analysis [FileAnalysis] Template analysis
241
+ # @param dest_analysis [FileAnalysis] Destination analysis
242
+ def merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis, dest_owners: nil)
243
+ if preference_for_pair(template_node, dest_node) == :template
244
+ emit_retained_gap_before_matched_template_node(dest_node, dest_analysis, owners: dest_owners)
245
+ end
246
+
247
+ if dest_node.container? && template_node.container?
248
+ # Both are containers - recursively merge their children
249
+ merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
250
+ elsif dest_node.pair? && template_node.pair?
251
+ # Both are pairs - check if their values are OBJECTS (not arrays) that need recursive merge
252
+ template_value = template_node.value_node
253
+ dest_value = dest_node.value_node
254
+
255
+ # Only recursively merge if BOTH values are objects.
256
+ # Arrays are file-owned atoms unless a caller explicitly changes the
257
+ # preference; their element order and formatting must survive.
258
+ if recursively_merge_pair_values?(template_value, dest_value)
259
+ key_name = dest_node.key_name || template_node.key_name
260
+ comment_source_node, comment_source_analysis = preferred_comment_source(
261
+ dest_node,
262
+ dest_analysis,
263
+ fallback_node: template_node,
264
+ fallback_analysis: template_analysis
265
+ )
266
+ comment_attachment = shared_line_comment_attachment_for(comment_source_node, comment_source_analysis)
267
+ inline_source_node, inline_source_analysis, inline_attachment = preferred_available_inline_attachment(
268
+ template_node,
269
+ template_analysis,
270
+ dest_node,
271
+ dest_analysis
272
+ )
273
+
274
+ emit_preferred_leading_comments_for(comment_source_node, comment_source_analysis,
275
+ shared_attachment: comment_attachment)
276
+ trailing_source_node, trailing_source_analysis = preferred_container_comment_source(
277
+ dest_value,
278
+ dest_analysis,
279
+ fallback_node: template_value,
280
+ fallback_analysis: template_analysis
281
+ )
282
+ compact_source_node = trailing_source_node || dest_value || template_value
283
+
284
+ with_resolution_path_segment(dest_node, template_node) do
285
+ if compact_empty_container?(template_value, compact_source_node, trailing_source_analysis)
286
+ emit_with_preferred_inline_comment(inline_source_node, inline_source_analysis,
287
+ shared_attachment: inline_attachment) do |inline_text|
288
+ @emitter.emit_pair(key_name, compact_container_literal_for(template_value),
289
+ inline_comment: inline_text)
290
+ end
291
+ elsif template_value.object?
292
+ emit_with_preferred_inline_comment(inline_source_node, inline_source_analysis,
293
+ shared_attachment: inline_attachment) do |inline_text|
294
+ @emitter.emit_nested_object_start(key_name, inline_comment: inline_text)
295
+ end
296
+ elsif template_value.array?
297
+ emit_with_preferred_inline_comment(inline_source_node, inline_source_analysis,
298
+ shared_attachment: inline_attachment) do |inline_text|
299
+ @emitter.emit_array_start(key_name, inline_comment: inline_text)
300
+ end
301
+ end
302
+
303
+ unless compact_empty_container?(template_value, compact_source_node, trailing_source_analysis)
304
+ merge_node_lists_to_emitter(
305
+ template_value.mergeable_children,
306
+ dest_value.mergeable_children,
307
+ template_analysis,
308
+ dest_analysis,
309
+ dest_owners: dest_value.mergeable_children
310
+ )
311
+
312
+ emit_container_trailing_lines(trailing_source_node, trailing_source_analysis)
313
+
314
+ if template_value.object?
315
+ @emitter.emit_nested_object_end
316
+ elsif template_value.array?
317
+ @emitter.emit_array_end
318
+ end
319
+ end
320
+ end
321
+ elsif preference_for_pair(template_node, dest_node) == :destination
322
+ # Values are not both objects, or one/both are arrays - use preference and emit
323
+ # Arrays are always replaced, not merged
324
+ record_unresolved_choice(
325
+ template_node: template_node,
326
+ dest_node: dest_node,
327
+ match_kind: :pair_value
328
+ )
329
+ emit_atomic_node(dest_node, dest_analysis)
330
+ else
331
+ record_unresolved_choice(
332
+ template_node: template_node,
333
+ dest_node: dest_node,
334
+ match_kind: :pair_value
335
+ )
336
+ emit_atomic_node(
337
+ template_node,
338
+ template_analysis,
339
+ comment_source_node: dest_node,
340
+ comment_analysis: dest_analysis
341
+ )
342
+ end
343
+ elsif preference_for_pair(template_node, dest_node) == :destination
344
+ # Leaf nodes or mismatched types - use preference
345
+ record_unresolved_choice(
346
+ template_node: template_node,
347
+ dest_node: dest_node,
348
+ match_kind: :node_value
349
+ )
350
+ emit_atomic_node(dest_node, dest_analysis)
351
+ else
352
+ record_unresolved_choice(
353
+ template_node: template_node,
354
+ dest_node: dest_node,
355
+ match_kind: :node_value
356
+ )
357
+ emit_atomic_node(
358
+ template_node,
359
+ template_analysis,
360
+ comment_source_node: dest_node,
361
+ comment_analysis: dest_analysis
362
+ )
363
+ end
364
+ end
365
+
366
+ def emit_retained_gap_before_matched_template_node(dest_node, dest_analysis, owners: nil)
367
+ gap_lines = retained_owner_leading_gap_lines_for(dest_node, dest_analysis, owners: owners)
368
+ return if gap_lines.empty?
369
+ return if @emitter.blank_lines?(gap_lines) && @emitter.ends_with_blank_line?
370
+
371
+ gap = retained_owner_leading_gap_for(dest_node, dest_analysis, owners: owners)
372
+ @emitter.emit_raw_lines(gap_lines, metadata: emitter_block_metadata(dest_analysis, gap.start_line))
373
+ end
374
+
375
+ # Merge container nodes by emitting via emitter
376
+ # @param template_node [NodeWrapper] Template container node
377
+ # @param dest_node [NodeWrapper] Destination container node
378
+ # @param template_analysis [FileAnalysis] Template analysis
379
+ # @param dest_analysis [FileAnalysis] Destination analysis
380
+ def merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
381
+ if dest_node.object?
382
+ @emitter.emit_object_start
383
+ elsif dest_node.array?
384
+ @emitter.emit_array_start
385
+ end
386
+
387
+ merge_node_lists_to_emitter(
388
+ template_node.mergeable_children,
389
+ dest_node.mergeable_children,
390
+ template_analysis,
391
+ dest_analysis
392
+ )
393
+
394
+ trailing_source_node, trailing_source_analysis = preferred_container_comment_source(
395
+ dest_node,
396
+ dest_analysis,
397
+ fallback_node: template_node,
398
+ fallback_analysis: template_analysis
399
+ )
400
+ emit_container_trailing_lines(trailing_source_node, trailing_source_analysis)
401
+
402
+ if dest_node.object?
403
+ @emitter.emit_object_end
404
+ elsif dest_node.array?
405
+ @emitter.emit_array_end
406
+ end
407
+ end
408
+
409
+ def preference_for_pair(template_node, dest_node)
410
+ return @preference unless @preference.is_a?(Hash)
411
+
412
+ typed_template = apply_node_typing(template_node)
413
+ typed_dest = apply_node_typing(dest_node)
414
+
415
+ if Ast::Merge::NodeTyping.typed_node?(typed_template)
416
+ merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_template)
417
+ return @preference.fetch(merge_type) { default_preference } if merge_type
418
+ end
419
+
420
+ if Ast::Merge::NodeTyping.typed_node?(typed_dest)
421
+ merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_dest)
422
+ return @preference.fetch(merge_type) { default_preference } if merge_type
423
+ end
424
+
425
+ default_preference
426
+ end
427
+
428
+ def apply_node_typing(node)
429
+ return node unless @node_typing
430
+ return node unless node
431
+
432
+ Ast::Merge::NodeTyping.process(node, @node_typing)
433
+ end
434
+
435
+ def record_unresolved_choice(template_node:, dest_node:, match_kind:)
436
+ return unless unresolved_mode?
437
+ return unless template_node && dest_node
438
+
439
+ template_text = node_resolution_text(template_node)
440
+ dest_text = node_resolution_text(dest_node)
441
+ return if template_text == dest_text
442
+
443
+ provisional_winner = preference_for_pair(template_node, dest_node) == :template ? :template : :destination
444
+ key_name = resolution_key_name(template_node, dest_node)
445
+ surface_path = resolution_surface_path(template_node, dest_node)
446
+ metadata = {
447
+ match_kind: match_kind,
448
+ node_type: dest_node.respond_to?(:type) ? dest_node.type : nil,
449
+ key_name: key_name,
450
+ review_identity: review_identity_for_unresolved_choice(
451
+ template_text: template_text,
452
+ destination_text: dest_text,
453
+ provisional_winner: provisional_winner,
454
+ surface_path: surface_path,
455
+ match_kind: match_kind,
456
+ key_name: key_name
457
+ )
458
+ }.compact
459
+
460
+ record_unresolved_node_choice(
461
+ result: @result,
462
+ template_node: template_node,
463
+ destination_node: dest_node,
464
+ template_text: template_text,
465
+ destination_text: dest_text,
466
+ provisional_winner: provisional_winner,
467
+ case_prefix: 'json',
468
+ case_parts: [match_kind, metadata[:key_name]],
469
+ surface_path: surface_path,
470
+ metadata: metadata,
471
+ conflict_fields: {
472
+ match_kind: match_kind,
473
+ key_name: metadata[:key_name]
474
+ }
475
+ )
476
+ end
477
+
478
+ def node_resolution_text(node)
479
+ return unless node.respond_to?(:text)
480
+
481
+ node.text
482
+ end
483
+
484
+ def resolution_key_name(template_node, dest_node)
485
+ unresolved_identifier_for_nodes(dest_node, template_node, methods: [:key_name])
486
+ end
487
+
488
+ def resolution_surface_path(template_node, dest_node)
489
+ segment = resolution_path_segment_for(template_node, dest_node)
490
+ line = dest_node.respond_to?(:start_line) ? dest_node.start_line : nil
491
+ unresolved_surface_path_for(segment, fallback_segment: (line ? "line[#{line}]" : nil))
492
+ end
493
+
494
+ def resolution_path_segment_for(template_node, dest_node)
495
+ key_name = resolution_key_name(template_node, dest_node)
496
+ return "pair[#{key_name.inspect}]" if key_name
497
+
498
+ nil
499
+ end
500
+
501
+ def with_resolution_path_segment(*nodes, &block)
502
+ with_first_unresolved_path_segment(*nodes, segment_builder: lambda { |node|
503
+ resolution_path_segment_for(node, node)
504
+ }, &block)
505
+ end
506
+
507
+ # Emit a single node to the emitter
508
+ # @param node [NodeWrapper] Node to emit
509
+ # @param analysis [FileAnalysis] Analysis for accessing source
510
+ def emit_node(node, analysis, comment_source_node: nil, comment_analysis: analysis)
511
+ return if freeze_node?(node)
512
+
513
+ source_node = comment_source_node || node
514
+ source_analysis = comment_source_node ? comment_analysis : analysis
515
+ source_attachment = shared_line_comment_attachment_for(source_node, source_analysis)
516
+ _inline_source_node, _inline_source_analysis, inline_attachment =
517
+ preferred_available_inline_attachment(
518
+ node,
519
+ analysis,
520
+ comment_source_node,
521
+ source_analysis,
522
+ preferred_node: node,
523
+ preferred_analysis: analysis
524
+ )
525
+
526
+ emit_preferred_leading_comments_for(source_node, source_analysis, shared_attachment: source_attachment)
527
+
528
+ if node.pair?
529
+ # Emit as pair
530
+ key = node.key_name
531
+ value_node = node.value_node
532
+ source_value_node = source_node.respond_to?(:value_node) ? source_node.value_node : nil
533
+
534
+ if value_node
535
+ # Check if value is an object (not array) and needs recursive emission
536
+ if value_node.container?
537
+ container_comment_source = source_value_node || value_node
538
+
539
+ if compact_empty_container?(value_node, container_comment_source, source_analysis)
540
+ emit_with_preferred_inline_comment(node, analysis,
541
+ shared_attachment: inline_attachment) do |inline_text|
542
+ if key
543
+ @emitter.emit_pair(
544
+ key,
545
+ compact_container_literal_for(value_node),
546
+ inline_comment: inline_text,
547
+ metadata: emitter_line_metadata(analysis, line_number: node.start_line)
548
+ )
549
+ end
550
+ end
551
+ elsif value_node.object?
552
+ emit_with_preferred_inline_comment(node, analysis,
553
+ shared_attachment: inline_attachment) do |inline_text|
554
+ @emitter.emit_nested_object_start(
555
+ key,
556
+ inline_comment: inline_text,
557
+ metadata: emitter_line_metadata(analysis, line_number: node.start_line)
558
+ )
559
+ end
560
+ elsif value_node.array?
561
+ emit_with_preferred_inline_comment(node, analysis,
562
+ shared_attachment: inline_attachment) do |inline_text|
563
+ @emitter.emit_array_start(
564
+ key,
565
+ inline_comment: inline_text,
566
+ metadata: emitter_line_metadata(analysis, line_number: node.start_line)
567
+ )
568
+ end
569
+ end
570
+
571
+ unless compact_empty_container?(value_node, container_comment_source, source_analysis)
572
+ value_node.mergeable_children.each do |child|
573
+ emit_node(child, analysis)
574
+ end
575
+
576
+ emit_container_trailing_lines(container_comment_source, source_analysis)
577
+
578
+ if value_node.object?
579
+ @emitter.emit_nested_object_end(metadata: emitter_line_metadata(analysis, line_number: node.end_line))
580
+ elsif value_node.array?
581
+ @emitter.emit_array_end(metadata: emitter_line_metadata(analysis, line_number: node.end_line))
582
+ end
583
+ end
584
+ else
585
+ emit_with_preferred_inline_comment(node, analysis, shared_attachment: inline_attachment) do |inline_text|
586
+ if key
587
+ @emitter.emit_pair(
588
+ key,
589
+ value_node.text,
590
+ inline_comment: inline_text,
591
+ metadata: emitter_line_metadata(analysis, line_number: node.start_line)
592
+ )
593
+ end
594
+ end
595
+ end
596
+ end
597
+ elsif node.container?
598
+ if node.object?
599
+ @emitter.emit_object_start(metadata: emitter_line_metadata(analysis, line_number: node.start_line))
600
+ elsif node.array?
601
+ @emitter.emit_array_start(metadata: emitter_line_metadata(analysis, line_number: node.start_line))
602
+ end
603
+
604
+ node.mergeable_children.each do |child|
605
+ emit_node(child, analysis)
606
+ end
607
+
608
+ emit_container_trailing_lines(source_node, source_analysis)
609
+
610
+ if node.object?
611
+ @emitter.emit_object_end(metadata: emitter_line_metadata(analysis, line_number: node.end_line))
612
+ elsif node.array?
613
+ @emitter.emit_array_end(metadata: emitter_line_metadata(analysis, line_number: node.end_line))
614
+ end
615
+ elsif node.start_line && node.end_line
616
+ if node.start_line == node.end_line
617
+ emit_with_preferred_inline_comment(node, analysis, shared_attachment: inline_attachment) do |inline_text|
618
+ @emitter.emit_array_element(
619
+ node.text,
620
+ inline_comment: inline_text,
621
+ metadata: emitter_line_metadata(analysis, line_number: node.start_line)
622
+ )
623
+ end
624
+ else
625
+ lines = []
626
+ (node.start_line..node.end_line).each do |ln|
627
+ line = analysis.line_at(ln)
628
+ lines << line if line
629
+ end
630
+ @emitter.emit_raw_lines(lines, metadata: emitter_block_metadata(analysis, node.start_line))
631
+ end
632
+ end
633
+ end
634
+
635
+ def emit_atomic_node(node, analysis, comment_source_node: nil, comment_analysis: analysis)
636
+ return if freeze_node?(node)
637
+ unless @preserve_atomic_formatting
638
+ return emit_node(node, analysis, comment_source_node: comment_source_node,
639
+ comment_analysis: comment_analysis)
640
+ end
641
+ unless node.respond_to?(:text)
642
+ return emit_node(node, analysis, comment_source_node: comment_source_node,
643
+ comment_analysis: comment_analysis)
644
+ end
645
+
646
+ source_node = comment_source_node || node
647
+ source_analysis = comment_source_node ? comment_analysis : analysis
648
+ source_attachment = shared_line_comment_attachment_for(source_node, source_analysis)
649
+ inline_attachment = shared_inline_comment_attachment_for(source_node, source_analysis)
650
+
651
+ emit_preferred_leading_comments_for(source_node, source_analysis, shared_attachment: source_attachment)
652
+ emit_with_preferred_inline_comment(node, analysis, shared_attachment: inline_attachment) do |inline_text|
653
+ fragment = reindented_source_fragment(node, analysis)
654
+ fragment = "#{fragment} // #{inline_text}" if inline_text && !inline_text.empty?
655
+ @emitter.emit_raw_fragment(
656
+ fragment,
657
+ metadata: emitter_block_metadata(analysis, node.start_line)
658
+ )
659
+ end
660
+ end
661
+
662
+ def reindented_source_fragment(node, analysis)
663
+ fragment = node.text.to_s
664
+ source_line = node.respond_to?(:start_line) && node.start_line ? analysis.line_at(node.start_line).to_s : ''
665
+ source_indent = leading_indent(source_line)
666
+ return fragment if source_indent.empty?
667
+
668
+ fragment.lines(chomp: true).map do |line|
669
+ line.start_with?(source_indent) ? line.delete_prefix(source_indent) : line
670
+ end.join("\n")
671
+ end
672
+
673
+ def leading_indent(line)
674
+ indent = +''
675
+ line.each_char do |char|
676
+ break unless [' ', "\t"].include?(char)
677
+
678
+ indent << char
679
+ end
680
+ indent
681
+ end
682
+
683
+ def recursively_merge_pair_values?(template_value, dest_value)
684
+ return true if template_value&.object? && dest_value&.object?
685
+ return true if @merge_arrays && template_value&.array? && dest_value&.array?
686
+
687
+ false
688
+ end
689
+
690
+ def preferred_comment_source(node, analysis, fallback_node: nil, fallback_analysis: nil)
691
+ return [node, analysis] if node_has_emittable_leading_comments?(node, analysis)
692
+ return [fallback_node, fallback_analysis] if fallback_node && node_has_emittable_leading_comments?(
693
+ fallback_node, fallback_analysis
694
+ )
695
+
696
+ [node, analysis]
697
+ end
698
+
699
+ def preferred_available_inline_attachment(template_node, template_analysis, dest_node, dest_analysis,
700
+ preferred_node: nil, preferred_analysis: nil)
701
+ if preferred_node && preferred_analysis
702
+ primary_node = preferred_node
703
+ primary_analysis = preferred_analysis
704
+ fallback_node = preferred_node.equal?(template_node) && preferred_analysis.equal?(template_analysis) ? dest_node : template_node
705
+ fallback_analysis = fallback_node.equal?(dest_node) ? dest_analysis : template_analysis
706
+ elsif preference_for_pair(template_node, dest_node) == :destination
707
+ primary_node = dest_node
708
+ primary_analysis = dest_analysis
709
+ fallback_node = template_node
710
+ fallback_analysis = template_analysis
711
+ else
712
+ primary_node = template_node
713
+ primary_analysis = template_analysis
714
+ fallback_node = dest_node
715
+ fallback_analysis = dest_analysis
716
+ end
717
+
718
+ primary_attachment = shared_inline_comment_attachment_for(primary_node, primary_analysis)
719
+ if primary_attachment&.inline_region && !primary_attachment.inline_region.empty?
720
+ return [primary_node, primary_analysis,
721
+ primary_attachment]
722
+ end
723
+
724
+ fallback_attachment = shared_inline_comment_attachment_for(fallback_node, fallback_analysis)
725
+ if fallback_attachment&.inline_region && !fallback_attachment.inline_region.empty?
726
+ return [fallback_node, fallback_analysis,
727
+ fallback_attachment]
728
+ end
729
+
730
+ [primary_node, primary_analysis, nil]
731
+ end
732
+
733
+ def preferred_container_comment_source(node, analysis, fallback_node: nil, fallback_analysis: nil)
734
+ return [node, analysis] if container_has_trailing_comments?(node, analysis)
735
+ return [fallback_node, fallback_analysis] if fallback_node && container_has_trailing_comments?(fallback_node,
736
+ fallback_analysis)
737
+
738
+ [node, analysis]
739
+ end
740
+
741
+ def node_has_emittable_leading_comments?(node, analysis)
742
+ return false unless node.respond_to?(:start_line) && node.start_line
743
+
744
+ analysis.comment_tracker.leading_comments_before(node.start_line).any?
745
+ end
746
+
747
+ def emit_preferred_leading_comments_for(node, analysis, shared_attachment: nil)
748
+ attachment = shared_attachment || shared_line_comment_attachment_for(node, analysis)
749
+ region = canonical_leading_comment_region(attachment&.leading_region, analysis: analysis, node: node)
750
+
751
+ unless region && !region.empty?
752
+ emit_leading_comments_for(node, analysis)
753
+ return
754
+ end
755
+
756
+ # Bidirectional dedup: skip this region if an identical comment block
757
+ # was already emitted by a preceding node (from either source).
758
+ normalized = region.normalized_content
759
+ if normalized && !normalized.empty? && @emitted_leading_comment_texts&.include?(normalized)
760
+ should_heal = handle_suspected_corruption(
761
+ kind: :comment_ownership_overlap,
762
+ message: 'leading comment region overlaps previously emitted JSON comment ownership',
763
+ context: dedup_warning_context(region: region, analysis: analysis, node: node)
764
+ )
765
+ if should_heal
766
+ emit_blank_lines_in_range((region.end_line || node.start_line).to_i + 1, node.start_line.to_i - 1, analysis)
767
+ return
768
+ end
769
+ end
770
+ @emitted_leading_comment_texts&.add(normalized) if normalized && !normalized.empty?
771
+
772
+ emit_blank_lines_before_leading_comments(region.start_line, analysis)
773
+ if attachment&.leading_region.equal?(region)
774
+ @emitter.emit_comment_attachment(attachment, leading: true, inline: false, source_lines: analysis.lines)
775
+ else
776
+ @emitter.emit_comment_region(region, source_lines: analysis.lines)
777
+ end
778
+ emit_blank_lines_in_range((region.end_line || node.start_line).to_i + 1, node.start_line.to_i - 1, analysis)
779
+ end
780
+
781
+ def emit_leading_comments_for(node, analysis)
782
+ return unless node.respond_to?(:start_line) && node.start_line
783
+
784
+ leading = analysis.comment_tracker.leading_comments_before(node.start_line)
785
+ leading = canonical_tracked_leading_comments(leading, analysis: analysis, node: node)
786
+ return if leading.empty?
787
+
788
+ # Bidirectional dedup: build normalized text from tracked comments
789
+ # and skip if already emitted by a preceding node.
790
+ normalized = leading.map { |c| c[:text].to_s.strip }.join("\n")
791
+ if @emitted_leading_comment_texts&.include?(normalized)
792
+ should_heal = handle_suspected_corruption(
793
+ kind: :comment_ownership_overlap,
794
+ message: 'tracked leading comments overlap previously emitted JSON comment ownership',
795
+ context: dedup_warning_context(
796
+ region: nil,
797
+ analysis: analysis,
798
+ node: node,
799
+ normalized_content: normalized,
800
+ region_lines: [leading.first[:line], comment_end_line(leading.last)]
801
+ )
802
+ )
803
+ if should_heal
804
+ emit_blank_lines_in_range(comment_end_line(leading.last) + 1, node.start_line - 1, analysis)
805
+ return
806
+ end
807
+ end
808
+ @emitted_leading_comment_texts&.add(normalized)
809
+
810
+ emit_blank_lines_before_leading_comments(leading.first[:line], analysis)
811
+ emit_tracked_comments_with_internal_blank_lines(leading, analysis)
812
+
813
+ emit_blank_lines_in_range(comment_end_line(leading.last) + 1, node.start_line - 1, analysis) if leading.any?
814
+ end
815
+
816
+ def canonical_leading_comment_region(region, analysis:, node:)
817
+ return region unless region && !region.empty?
818
+ return region unless analysis.equal?(@dest_analysis)
819
+ return region unless first_statement?(node, analysis)
820
+
821
+ template_region = first_leading_comment_region(@template_analysis)
822
+ return region unless template_region && !template_region.empty?
823
+
824
+ template_nodes = Array(template_region.nodes)
825
+ region_nodes = Array(region.nodes)
826
+ return region if template_nodes.empty? || region_nodes.length < template_nodes.length
827
+
828
+ repeat_count = leading_repeat_count(region_nodes, template_nodes) do |left, right|
829
+ normalized_comment_unit(left) == normalized_comment_unit(right)
830
+ end
831
+ return region if repeat_count < 2
832
+
833
+ remaining_nodes = region_nodes.drop(repeat_count * template_nodes.length)
834
+ return region if remaining_nodes.empty?
835
+
836
+ should_heal = handle_suspected_corruption(
837
+ kind: :duplicate_template_preamble_prefix,
838
+ message: 'leading JSON comment region begins with duplicated template-owned preamble comments',
839
+ context: {
840
+ template_comment_lines: template_nodes.length,
841
+ merged_comment_lines: region_nodes.length,
842
+ destination_specific_comment_lines: remaining_nodes.length
843
+ }
844
+ )
845
+ return region unless should_heal
846
+
847
+ ::Ast::Merge::Comment::Region.new(
848
+ kind: region.kind,
849
+ nodes: remaining_nodes,
850
+ metadata: region.metadata
851
+ )
852
+ end
853
+
854
+ def canonical_tracked_leading_comments(leading, analysis:, node:)
855
+ return leading unless analysis.equal?(@dest_analysis)
856
+ return leading unless first_statement?(node, analysis)
857
+
858
+ template_region = first_leading_comment_region(@template_analysis)
859
+ template_units = Array(template_region&.nodes).map { |comment| normalized_comment_unit(comment) }
860
+ return leading if template_units.empty? || leading.length < template_units.length
861
+
862
+ leading_units = leading.map { |comment| normalized_comment_unit(comment) }
863
+ repeat_count = leading_repeat_count(leading_units, template_units)
864
+ return leading if repeat_count < 2
865
+
866
+ remaining_comments = leading.drop(repeat_count * template_units.length)
867
+ return leading if remaining_comments.empty?
868
+
869
+ should_heal = handle_suspected_corruption(
870
+ kind: :duplicate_template_preamble_prefix,
871
+ message: 'tracked JSON leading comments begin with duplicated template-owned preamble comments',
872
+ context: {
873
+ template_comment_lines: template_units.length,
874
+ merged_comment_lines: leading.length,
875
+ destination_specific_comment_lines: remaining_comments.length
876
+ }
877
+ )
878
+ return leading unless should_heal
879
+
880
+ remaining_comments
881
+ end
882
+
883
+ def tracked_inline_comment_for(node, analysis)
884
+ return unless node.respond_to?(:start_line) && node.start_line
885
+
886
+ analysis.comment_tracker.inline_comment_at(inline_comment_line_for(node))
887
+ end
888
+
889
+ def dedup_warning_context(region:, analysis:, node:, normalized_content: nil, region_lines: nil)
890
+ {
891
+ file: analysis.respond_to?(:path) ? analysis.path : nil,
892
+ owner_type: node.respond_to?(:type) ? node.type : node.class.name.split('::').last,
893
+ region_lines: region_lines || [region.respond_to?(:start_line) ? region.start_line : nil,
894
+ region.respond_to?(:end_line) ? region.end_line : nil],
895
+ normalized_content: normalized_content || region&.normalized_content
896
+ }.compact
897
+ end
898
+
899
+ def handle_suspected_corruption(kind:, message:, context:)
900
+ ::Ast::Merge::Healer.handle(
901
+ mode: corruption_handling,
902
+ kind: kind,
903
+ message: message,
904
+ prefix: '[json-merge]',
905
+ error_class: Json::Merge::CorruptionDetectedError,
906
+ warner: ->(formatted) { DebugLogger.debug_warning(formatted, context) }
907
+ )
908
+ end
909
+
910
+ def first_leading_comment_region(analysis)
911
+ first_statement = first_owned_node(analysis)
912
+ return unless first_statement
913
+
914
+ shared_line_comment_attachment_for(first_statement, analysis)&.leading_region
915
+ end
916
+
917
+ def first_statement?(node, analysis)
918
+ equivalent_owner?(first_owned_node(analysis), node)
919
+ end
920
+
921
+ def first_owned_node(analysis)
922
+ first_statement = Array(analysis&.statements).first
923
+ return unless first_statement
924
+
925
+ if first_statement.respond_to?(:container?) && first_statement.container? &&
926
+ first_statement.respond_to?(:mergeable_children)
927
+ first_child = Array(first_statement.mergeable_children).first
928
+ return first_child if first_child
929
+ end
930
+
931
+ first_statement
932
+ end
933
+
934
+ def equivalent_owner?(left, right)
935
+ return false unless left && right
936
+ return true if left.equal?(right)
937
+ return false unless left.respond_to?(:type) && right.respond_to?(:type) && left.type == right.type
938
+ unless left.respond_to?(:start_line) && right.respond_to?(:start_line) && left.start_line == right.start_line
939
+ return false
940
+ end
941
+ unless left.respond_to?(:end_line) && right.respond_to?(:end_line) && left.end_line == right.end_line
942
+ return false
943
+ end
944
+
945
+ if left.respond_to?(:key_name) && right.respond_to?(:key_name)
946
+ left.key_name == right.key_name
947
+ else
948
+ true
949
+ end
950
+ end
951
+
952
+ def leading_repeat_count(lines, prefix, &comparator)
953
+ return 0 if prefix.empty? || lines.length < prefix.length
954
+
955
+ comparator ||= ->(left, right) { left == right }
956
+ count = 0
957
+ count += 1 while prefix_match?(lines.drop(count * prefix.length).first(prefix.length), prefix, comparator)
958
+ count
959
+ end
960
+
961
+ def prefix_match?(candidate, prefix, comparator)
962
+ return false unless candidate && candidate.length == prefix.length
963
+
964
+ candidate.zip(prefix).all? { |left, right| comparator.call(left, right) }
965
+ end
966
+
967
+ def normalized_comment_unit(comment)
968
+ return comment.normalized_content if comment.respond_to?(:normalized_content)
969
+ return comment[:text].to_s.strip if comment.is_a?(Hash)
970
+
971
+ comment.to_s.strip
972
+ end
973
+
974
+ def emit_with_preferred_inline_comment(node, analysis, shared_attachment: nil)
975
+ tracked_inline_comment = tracked_inline_comment_for(node, analysis)
976
+ attachment = shared_attachment || shared_line_comment_attachment_for(node, analysis)
977
+ inline_region = attachment&.inline_region
978
+
979
+ unless inline_region && !inline_region.empty?
980
+ if tracked_inline_comment
981
+ raise MissingSharedInlineRegionError,
982
+ "Expected shared inline region for tracked inline comment at line #{tracked_inline_comment[:line]}"
983
+ end
984
+
985
+ yield nil
986
+ return
987
+ end
988
+
989
+ yield nil
990
+ @emitter.emit_comment_attachment(attachment, leading: false, inline: true, source_lines: analysis.lines)
991
+ end
992
+
993
+ def shared_line_comment_attachment_for(node, analysis)
994
+ return unless node && analysis
995
+ return unless node.respond_to?(:start_line) && node.start_line
996
+
997
+ tracker = analysis.comment_tracker
998
+ leading_comments = tracker.leading_comments_before(node.start_line)
999
+ return if leading_comments.any? { |comment| comment[:block] }
1000
+
1001
+ inline_comment = tracker.inline_comment_at(inline_comment_line_for(node))
1002
+ return unless leading_comments.any? || inline_comment
1003
+
1004
+ analysis.comment_attachment_for(
1005
+ node,
1006
+ line_num: node.start_line,
1007
+ leading_comments: leading_comments,
1008
+ inline_comment: inline_comment
1009
+ )
1010
+ end
1011
+
1012
+ def shared_inline_comment_attachment_for(node, analysis)
1013
+ return unless node && analysis
1014
+ return unless node.respond_to?(:start_line) && node.start_line
1015
+
1016
+ inline_comment = analysis.comment_tracker.inline_comment_at(inline_comment_line_for(node))
1017
+ return unless inline_comment
1018
+
1019
+ analysis.comment_attachment_for(
1020
+ node,
1021
+ line_num: node.start_line,
1022
+ leading_comments: [],
1023
+ inline_comment: inline_comment
1024
+ )
1025
+ end
1026
+
1027
+ def inline_comment_line_for(node)
1028
+ return unless node
1029
+ return node.start_line if node.respond_to?(:pair?) && node.pair?
1030
+ return node.start_line if node.respond_to?(:container?) && node.container?
1031
+
1032
+ node.end_line || node.start_line
1033
+ end
1034
+
1035
+ def emit_container_trailing_lines(container_node, analysis)
1036
+ range = trailing_container_line_range(container_node)
1037
+ return unless range
1038
+
1039
+ region = shared_trailing_line_comment_region_for(range, analysis)
1040
+ unless region && !region.empty?
1041
+ emit_comment_and_blank_lines_in_range(range.begin, range.end, analysis)
1042
+ return
1043
+ end
1044
+
1045
+ emit_blank_lines_in_range(range.begin, region.start_line - 1, analysis)
1046
+ @emitter.emit_comment_region(region, source_lines: analysis.lines)
1047
+ emit_blank_lines_in_range(region.end_line + 1, range.end, analysis)
1048
+ end
1049
+
1050
+ def container_has_trailing_comments?(container_node, analysis)
1051
+ range = trailing_container_line_range(container_node)
1052
+ return false unless range
1053
+
1054
+ range.any? do |line_num|
1055
+ stripped = analysis.line_at(line_num).to_s.strip
1056
+ comment_like_line?(stripped)
1057
+ end
1058
+ end
1059
+
1060
+ def trailing_container_line_range(container_node)
1061
+ return unless container_node&.container?
1062
+ return unless container_node.respond_to?(:start_line) && container_node.respond_to?(:end_line)
1063
+ return unless container_node.start_line && container_node.end_line
1064
+
1065
+ children = container_node.mergeable_children
1066
+ start_line = if children.any?
1067
+ last_child = children.last
1068
+ (last_child.end_line || last_child.start_line) + 1
1069
+ else
1070
+ container_node.start_line + 1
1071
+ end
1072
+ end_line = container_node.end_line - 1
1073
+ return if end_line < start_line
1074
+
1075
+ start_line..end_line
1076
+ end
1077
+
1078
+ def emit_comment_and_blank_lines_in_range(start_line, end_line, analysis)
1079
+ return unless start_line && end_line
1080
+ return if end_line < start_line
1081
+
1082
+ lines = []
1083
+ (start_line..end_line).each do |line_num|
1084
+ line = analysis.line_at(line_num)
1085
+ next unless line
1086
+
1087
+ stripped = line.strip
1088
+ next unless stripped.empty? || comment_like_line?(stripped)
1089
+
1090
+ lines << line
1091
+ end
1092
+
1093
+ @emitter.emit_raw_lines(lines) if lines.any?
1094
+ end
1095
+
1096
+ def shared_trailing_line_comment_region_for(range, analysis)
1097
+ return unless range && analysis
1098
+ return unless trailing_range_supports_shared_line_region?(range, analysis)
1099
+
1100
+ region = analysis.comment_region_for_range(range, kind: :trailing, full_line_only: true)
1101
+ return unless region && !region.empty?
1102
+
1103
+ region
1104
+ end
1105
+
1106
+ def trailing_range_supports_shared_line_region?(range, analysis)
1107
+ range.each do |line_num|
1108
+ stripped = analysis.line_at(line_num).to_s.strip
1109
+ next if stripped.empty?
1110
+ return false unless stripped.start_with?('//')
1111
+ return false unless analysis.comment_tracker.full_line_comment?(line_num)
1112
+ end
1113
+
1114
+ true
1115
+ end
1116
+
1117
+ def comment_like_line?(stripped_line)
1118
+ stripped_line.start_with?('//', '/*', '*', '*/')
1119
+ end
1120
+
1121
+ def emit_freeze_block(freeze_node)
1122
+ @emitter.emit_raw_lines(freeze_node.lines)
1123
+ end
1124
+
1125
+ def emit_removed_destination_node_comments(node, analysis)
1126
+ return unless node.respond_to?(:start_line) && node.start_line
1127
+
1128
+ leading_comments = analysis.comment_tracker.leading_comments_before(node.start_line)
1129
+ emit_preferred_leading_comments_for(node, analysis)
1130
+
1131
+ inline_comment = removed_inline_comment_for(node, analysis)
1132
+ if inline_comment
1133
+ @emitter.emit_tracked_comment(normalize_comment_indent(
1134
+ inline_comment.merge(
1135
+ indent: current_emitter_indent,
1136
+ full_line: true,
1137
+ block: inline_comment[:block] || false
1138
+ )
1139
+ ))
1140
+ end
1141
+
1142
+ emit_following_removed_node_blank_lines(node, analysis) if leading_comments.any? || inline_comment
1143
+ end
1144
+
1145
+ def emit_following_removed_node_blank_lines(node, analysis)
1146
+ line_num = (node.end_line || node.start_line) + 1
1147
+ first_nonblank_line = line_num
1148
+
1149
+ while first_nonblank_line <= analysis.lines.length && analysis.comment_tracker.blank_line?(first_nonblank_line)
1150
+ first_nonblank_line += 1
1151
+ end
1152
+
1153
+ return if analysis.comment_tracker.full_line_comment?(first_nonblank_line)
1154
+
1155
+ while line_num <= analysis.lines.length && analysis.comment_tracker.blank_line?(line_num)
1156
+ @emitter.emit_blank_line
1157
+ line_num += 1
1158
+ end
1159
+ end
1160
+
1161
+ def removed_inline_comment_for(node, analysis)
1162
+ line_num = inline_comment_line_for(node)
1163
+ return unless line_num
1164
+
1165
+ region = analysis.comment_tracker.inline_comment_region_at(line_num)
1166
+ tracked = Array(region&.metadata&.dig(:tracked_hashes)).first
1167
+ return tracked if tracked
1168
+
1169
+ analysis.comment_tracker.inline_comment_at(line_num) || removed_inline_block_comment_at(line_num, analysis)
1170
+ end
1171
+
1172
+ def removed_inline_block_comment_at(line_num, analysis)
1173
+ line = analysis.line_at(line_num).to_s
1174
+ return if line.empty?
1175
+
1176
+ start_idx = line.index('/*')
1177
+ end_idx = start_idx && line.index('*/', start_idx + 2)
1178
+ return unless start_idx && end_idx
1179
+
1180
+ before_comment = line[0...start_idx].to_s
1181
+ after_comment = line[(end_idx + 2)..].to_s
1182
+ return if before_comment.strip.empty?
1183
+ return unless after_comment.strip.empty?
1184
+
1185
+ quote_count = before_comment.count('"') - before_comment.scan('\\"').count
1186
+ return unless quote_count.even?
1187
+
1188
+ {
1189
+ line: line_num,
1190
+ indent: 0,
1191
+ text: line[(start_idx + 2)...end_idx].to_s.strip,
1192
+ full_line: false,
1193
+ block: true,
1194
+ raw: line[start_idx..(end_idx + 1)]
1195
+ }
1196
+ end
1197
+
1198
+ def emit_tracked_comments_with_internal_blank_lines(comments, analysis)
1199
+ Array(comments).each_with_index do |comment, index|
1200
+ emit_tracked_comment_preserving_raw_layout(comment, analysis)
1201
+
1202
+ next_comment = comments[index + 1]
1203
+ next unless next_comment
1204
+
1205
+ emit_blank_lines_in_range(comment_end_line(comment) + 1, next_comment[:line] - 1, analysis)
1206
+ end
1207
+ end
1208
+
1209
+ def emit_tracked_comment_preserving_raw_layout(comment, analysis)
1210
+ if multiline_block_comment?(comment)
1211
+ lines = (comment[:line]..comment_end_line(comment)).map { |line_num| analysis.line_at(line_num) }.compact
1212
+ @emitter.emit_raw_lines(lines) if lines.any?
1213
+ return
1214
+ end
1215
+
1216
+ @emitter.emit_tracked_comment(normalize_comment_indent(comment))
1217
+ end
1218
+
1219
+ def multiline_block_comment?(comment)
1220
+ comment && comment[:block] && comment_end_line(comment) > comment[:line]
1221
+ end
1222
+
1223
+ def comment_end_line(comment)
1224
+ return unless comment
1225
+
1226
+ comment[:end_line] || comment[:line]
1227
+ end
1228
+
1229
+ def emit_document_prelude(analysis, nodes: [])
1230
+ augmenter = document_comment_augmenter_for(analysis)
1231
+ return unless augmenter
1232
+
1233
+ normalized_nodes = Array(nodes)
1234
+ regions = []
1235
+ preamble = augmenter.preamble_region
1236
+ regions << preamble if preamble && !preamble.empty?
1237
+
1238
+ if normalized_nodes.any?
1239
+ first_attachment = augmenter.attachment_for(normalized_nodes.first)
1240
+ first_leading = first_attachment&.leading_region
1241
+ if first_leading && !first_leading.empty?
1242
+ duplicate = regions.any? do |region|
1243
+ region.start_line == first_leading.start_line && region.end_line == first_leading.end_line
1244
+ end
1245
+ regions << first_leading unless duplicate
1246
+ end
1247
+ end
1248
+
1249
+ if normalized_nodes.empty?
1250
+ augmenter.orphan_regions.each do |region|
1251
+ regions << region if region && !region.empty?
1252
+ end
1253
+ end
1254
+
1255
+ regions.each do |region|
1256
+ @emitter.emit_comment_region(region, source_lines: analysis.lines)
1257
+ end
1258
+
1259
+ return if regions.empty?
1260
+
1261
+ last_region_end = regions.last.end_line
1262
+ if normalized_nodes.any?
1263
+ first_node_start = normalized_nodes.first.start_line
1264
+ if last_region_end && first_node_start
1265
+ emit_blank_lines_in_range(last_region_end + 1, first_node_start - 1,
1266
+ analysis)
1267
+ end
1268
+ elsif last_region_end
1269
+ emit_blank_lines_in_range(last_region_end + 1, analysis.lines.length, analysis)
1270
+ end
1271
+ end
1272
+
1273
+ def emit_document_postlude(analysis, fallback_node: nil)
1274
+ augmenter = document_comment_augmenter_for(analysis)
1275
+ regions = []
1276
+ postlude = augmenter&.postlude_region
1277
+ regions << postlude if postlude && !postlude.empty?
1278
+
1279
+ if fallback_node
1280
+ last_attachment = augmenter&.attachment_for(fallback_node)
1281
+ last_trailing = last_attachment&.trailing_region
1282
+ if last_trailing && !last_trailing.empty?
1283
+ duplicate = regions.any? do |region|
1284
+ region.start_line == last_trailing.start_line && region.end_line == last_trailing.end_line
1285
+ end
1286
+ regions << last_trailing unless duplicate
1287
+ end
1288
+ end
1289
+
1290
+ return if regions.empty?
1291
+
1292
+ first_region = regions.first
1293
+ if fallback_node && first_region.respond_to?(:start_line) && first_region.start_line && fallback_node.respond_to?(:end_line) && fallback_node.end_line && fallback_node.respond_to?(:end_line) && fallback_node.end_line
1294
+ emit_blank_lines_in_range(fallback_node.end_line + 1, first_region.start_line - 1,
1295
+ analysis)
1296
+ end
1297
+
1298
+ regions.each do |region|
1299
+ @emitter.emit_comment_region(region, source_lines: analysis.lines)
1300
+ end
1301
+ end
1302
+
1303
+ def document_comment_augmenter_for(analysis)
1304
+ @document_comment_augmenters ||= {}
1305
+ @document_comment_augmenters[analysis.object_id] ||= analysis.comment_augmenter
1306
+ end
1307
+
1308
+ def emit_blank_lines_in_range(start_line, end_line, analysis)
1309
+ return unless start_line && end_line
1310
+ return if end_line < start_line
1311
+
1312
+ (start_line..end_line).each do |line_num|
1313
+ @emitter.emit_blank_line if analysis.comment_tracker.blank_line?(line_num)
1314
+ end
1315
+ end
1316
+
1317
+ def emit_blank_lines_before_leading_comments(first_comment_line, analysis)
1318
+ return unless first_comment_line
1319
+
1320
+ blank_lines = []
1321
+ line_num = first_comment_line - 1
1322
+ while line_num >= 1 && analysis.comment_tracker.blank_line?(line_num)
1323
+ blank_lines << line_num
1324
+ line_num -= 1
1325
+ end
1326
+
1327
+ blank_lines.reverse_each { @emitter.emit_blank_line }
1328
+ end
1329
+
1330
+ def normalize_comment_indent(comment)
1331
+ return comment unless comment
1332
+
1333
+ comment.merge(indent: current_emitter_indent)
1334
+ end
1335
+
1336
+ def current_emitter_indent
1337
+ @emitter.indent_level * @emitter.indent_size
1338
+ end
1339
+
1340
+ def compact_empty_container?(container_node, source_node, source_analysis)
1341
+ return false unless container_node&.container?
1342
+ return false unless container_node.mergeable_children.empty?
1343
+
1344
+ !container_has_trailing_comments?(source_node || container_node, source_analysis)
1345
+ end
1346
+
1347
+ def compact_container_literal_for(container_node)
1348
+ container_node.object? ? '{}' : '[]'
1349
+ end
1350
+
1351
+ def build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
1352
+ return {} unless @match_refiner
1353
+
1354
+ matched_sigs = template_by_sig.keys & dest_by_sig.keys
1355
+
1356
+ unmatched_template = template_nodes.reject do |node|
1357
+ sig = @template_analysis.generate_signature(node)
1358
+ sig && matched_sigs.include?(sig)
1359
+ end
1360
+
1361
+ unmatched_dest = dest_nodes.reject do |node|
1362
+ sig = @dest_analysis.generate_signature(node)
1363
+ sig && matched_sigs.include?(sig)
1364
+ end
1365
+
1366
+ return {} if unmatched_template.empty? || unmatched_dest.empty?
1367
+
1368
+ matches = @match_refiner.call(unmatched_template, unmatched_dest, {
1369
+ template_analysis: @template_analysis,
1370
+ dest_analysis: @dest_analysis
1371
+ })
1372
+
1373
+ matches.each_with_object({}) do |match, hash|
1374
+ hash[match.template_node] = match.dest_node
1375
+ end
1376
+ end
1377
+ end
1378
+ end
1379
+ end