json-merge 1.1.2 → 7.1.1

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.
@@ -8,7 +8,16 @@ module Json
8
8
  # @example Basic usage
9
9
  # resolver = ConflictResolver.new(template_analysis, dest_analysis)
10
10
  # resolver.resolve(result)
11
- class ConflictResolver < ::Ast::Merge::ConflictResolverBase
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
+
12
21
  # Creates a new ConflictResolver
13
22
  #
14
23
  # @param template_analysis [FileAnalysis] Analyzed template file
@@ -22,17 +31,23 @@ module Json
22
31
  # @param options [Hash] Additional options for forward compatibility
23
32
  # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
24
33
  # for per-node-type preferences
25
- def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false, match_refiner: nil, node_typing: nil, **options)
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)
26
36
  super(
27
37
  strategy: :batch,
28
38
  preference: preference,
29
39
  template_analysis: template_analysis,
30
40
  dest_analysis: dest_analysis,
31
41
  add_template_only_nodes: add_template_only_nodes,
42
+ remove_template_missing_nodes: remove_template_missing_nodes,
32
43
  match_refiner: match_refiner,
33
44
  **options
34
45
  )
46
+ @resolution_mode = resolution_mode
47
+ @corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
35
48
  @node_typing = node_typing
49
+ @merge_arrays = merge_arrays
50
+ @preserve_atomic_formatting = preserve_atomic_formatting
36
51
  @emitter = Emitter.new
37
52
  end
38
53
 
@@ -42,37 +57,47 @@ module Json
42
57
  #
43
58
  # @param result [MergeResult] Result object to populate
44
59
  def resolve_batch(result)
45
- DebugLogger.time("ConflictResolver#resolve") do
60
+ DebugLogger.time('ConflictResolver#resolve') do
61
+ @result = result
46
62
  template_statements = @template_analysis.statements
47
63
  dest_statements = @dest_analysis.statements
48
64
 
49
65
  # Clear emitter for fresh merge
50
66
  @emitter.clear
67
+ @emitted_leading_comment_texts = ::Set.new
68
+
69
+ emit_document_prelude(@dest_analysis, nodes: dest_statements)
51
70
 
52
71
  # Merge root-level statements via emitter
53
72
  merge_node_lists_to_emitter(
54
73
  template_statements,
55
74
  dest_statements,
56
75
  @template_analysis,
57
- @dest_analysis,
76
+ @dest_analysis
58
77
  )
59
78
 
79
+ emit_document_postlude(@dest_analysis, fallback_node: dest_statements.last)
80
+
60
81
  # Transfer emitter output to result
61
- emitted_content = @emitter.to_s
62
- unless emitted_content.empty?
63
- emitted_content.lines.each do |line|
64
- result.add_line(line.chomp, decision: MergeResult::DECISION_MERGED, source: :merged)
65
- end
66
- end
82
+ transfer_emitter_output(result)
67
83
 
68
- DebugLogger.debug("Conflict resolution complete", {
69
- template_statements: template_statements.size,
70
- dest_statements: dest_statements.size,
71
- result_lines: result.line_count,
72
- })
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
+ })
73
89
  end
74
90
  end
75
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
+
76
101
  private
77
102
 
78
103
  # Recursively merge two lists of nodes, emitting to emitter
@@ -80,7 +105,7 @@ module Json
80
105
  # @param dest_nodes [Array<NodeWrapper>] Destination nodes
81
106
  # @param template_analysis [FileAnalysis] Template analysis for line access
82
107
  # @param dest_analysis [FileAnalysis] Destination analysis for line access
83
- def merge_node_lists_to_emitter(template_nodes, dest_nodes, template_analysis, dest_analysis)
108
+ def merge_node_lists_to_emitter(template_nodes, dest_nodes, template_analysis, dest_analysis, dest_owners: nil)
84
109
  # Build signature maps for matching
85
110
  template_by_sig = build_signature_map(template_nodes, template_analysis)
86
111
  dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
@@ -89,63 +114,136 @@ module Json
89
114
  refined_matches = build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
90
115
  refined_dest_to_template = refined_matches.invert
91
116
 
92
- # Track which nodes have been processed
93
- processed_template_sigs = ::Set.new
94
- processed_dest_sigs = ::Set.new
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
95
145
 
96
146
  # First pass: Process destination nodes
97
147
  dest_nodes.each do |dest_node|
98
148
  dest_sig = dest_analysis.generate_signature(dest_node)
99
149
 
150
+ if freeze_node?(dest_node)
151
+ emit_freeze_block(dest_node)
152
+ next
153
+ end
154
+
100
155
  # Check for signature match
101
156
  if dest_sig && template_by_sig[dest_sig]
102
- template_info = template_by_sig[dest_sig].first
103
- template_node = template_info[:node]
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]
104
173
 
105
- # Both have this node - merge them (recursively if containers)
106
- merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
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)
107
177
 
108
- processed_dest_sigs << dest_sig
109
- processed_template_sigs << dest_sig
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
110
184
  elsif refined_dest_to_template.key?(dest_node)
111
185
  # Found refined match
112
186
  template_node = refined_dest_to_template[dest_node]
113
187
  template_sig = template_analysis.generate_signature(template_node)
114
188
 
115
- # Merge matched nodes
116
- merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
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
117
198
 
118
- processed_dest_sigs << dest_sig if dest_sig
119
- processed_template_sigs << template_sig if template_sig
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)
120
204
  else
121
205
  # Destination-only node - always keep
122
- emit_node(dest_node, dest_analysis)
123
- processed_dest_sigs << dest_sig if dest_sig
206
+ emit_atomic_node(dest_node, dest_analysis)
124
207
  end
125
- end
126
208
 
127
- # Second pass: Add template-only nodes if configured
128
- return unless @add_template_only_nodes
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])
129
216
 
130
- template_nodes.each do |template_node|
131
- template_sig = template_analysis.generate_signature(template_node)
217
+ emit_atomic_node(info[:node], template_analysis)
218
+ end
219
+ end
132
220
 
133
- # Skip if already processed
134
- next if template_sig && processed_template_sigs.include?(template_sig)
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])
135
227
 
136
- # Add template-only node
137
- emit_node(template_node, template_analysis)
138
- processed_template_sigs << template_sig if template_sig
228
+ emit_atomic_node(info[:node], template_analysis)
139
229
  end
140
230
  end
141
231
 
232
+ def trailing_group_node_matched?(node, _signature)
233
+ freeze_node?(node)
234
+ end
235
+
142
236
  # Merge two matched nodes - for containers, recursively merge children
143
237
  # Emits to emitter instead of result
144
238
  # @param template_node [NodeWrapper] Template node
145
239
  # @param dest_node [NodeWrapper] Destination node
146
240
  # @param template_analysis [FileAnalysis] Template analysis
147
241
  # @param dest_analysis [FileAnalysis] Destination analysis
148
- def merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_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
+
149
247
  if dest_node.container? && template_node.container?
150
248
  # Both are containers - recursively merge their children
151
249
  merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
@@ -154,63 +252,153 @@ module Json
154
252
  template_value = template_node.value_node
155
253
  dest_value = dest_node.value_node
156
254
 
157
- # Only recursively merge if BOTH values are objects (not arrays)
158
- # Arrays are replaced atomically based on preference
159
- if template_value&.type == :object && dest_value&.type == :object &&
160
- template_value.container? && dest_value.container?
161
- # Both values are objects - recursively merge
162
- @emitter.emit_nested_object_start(dest_node.key_name)
163
-
164
- # Recursively merge the value objects
165
- merge_node_lists_to_emitter(
166
- template_value.mergeable_children,
167
- dest_value.mergeable_children,
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,
168
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,
169
278
  dest_analysis,
279
+ fallback_node: template_value,
280
+ fallback_analysis: template_analysis
170
281
  )
282
+ compact_source_node = trailing_source_node || dest_value || template_value
171
283
 
172
- # Emit closing brace
173
- @emitter.emit_nested_object_end
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
174
321
  elsif preference_for_pair(template_node, dest_node) == :destination
175
322
  # Values are not both objects, or one/both are arrays - use preference and emit
176
323
  # Arrays are always replaced, not merged
177
- emit_node(dest_node, dest_analysis)
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)
178
330
  else
179
- emit_node(template_node, template_analysis)
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
+ )
180
342
  end
181
343
  elsif preference_for_pair(template_node, dest_node) == :destination
182
344
  # Leaf nodes or mismatched types - use preference
183
- emit_node(dest_node, dest_analysis)
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)
184
351
  else
185
- emit_node(template_node, template_analysis)
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
+ )
186
363
  end
187
364
  end
188
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
+
189
375
  # Merge container nodes by emitting via emitter
190
376
  # @param template_node [NodeWrapper] Template container node
191
377
  # @param dest_node [NodeWrapper] Destination container node
192
378
  # @param template_analysis [FileAnalysis] Template analysis
193
379
  # @param dest_analysis [FileAnalysis] Destination analysis
194
380
  def merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
195
- # Emit opening bracket
196
381
  if dest_node.object?
197
382
  @emitter.emit_object_start
198
383
  elsif dest_node.array?
199
384
  @emitter.emit_array_start
200
385
  end
201
386
 
202
- # Recursively merge the children
203
- template_children = template_node.mergeable_children
204
- dest_children = dest_node.mergeable_children
205
-
206
387
  merge_node_lists_to_emitter(
207
- template_children,
208
- dest_children,
388
+ template_node.mergeable_children,
389
+ dest_node.mergeable_children,
209
390
  template_analysis,
391
+ dest_analysis
392
+ )
393
+
394
+ trailing_source_node, trailing_source_analysis = preferred_container_comment_source(
395
+ dest_node,
210
396
  dest_analysis,
397
+ fallback_node: template_node,
398
+ fallback_analysis: template_analysis
211
399
  )
400
+ emit_container_trailing_lines(trailing_source_node, trailing_source_analysis)
212
401
 
213
- # Emit closing bracket
214
402
  if dest_node.object?
215
403
  @emitter.emit_object_end
216
404
  elsif dest_node.array?
@@ -244,91 +432,946 @@ module Json
244
432
  Ast::Merge::NodeTyping.process(node, @node_typing)
245
433
  end
246
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
+
247
507
  # Emit a single node to the emitter
248
508
  # @param node [NodeWrapper] Node to emit
249
509
  # @param analysis [FileAnalysis] Analysis for accessing source
250
- def emit_node(node, analysis)
251
- # Emit the node content
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
+
252
528
  if node.pair?
253
529
  # Emit as pair
254
530
  key = node.key_name
255
531
  value_node = node.value_node
532
+ source_value_node = source_node.respond_to?(:value_node) ? source_node.value_node : nil
256
533
 
257
534
  if value_node
258
535
  # Check if value is an object (not array) and needs recursive emission
259
- if value_node.type == :object && value_node.container?
260
- # Object value - emit structure recursively
261
- @emitter.emit_nested_object_start(key)
262
- # Recursively emit object children
263
- value_node.mergeable_children.each do |child|
264
- emit_node(child, analysis)
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
265
583
  end
266
- @emitter.emit_nested_object_end
267
584
  else
268
- # Leaf value or array - get its text and emit as simple pair
269
- # Arrays are emitted as raw text (not recursively)
270
- value_text = if value_node.start_line == value_node.end_line
271
- value_node.text
272
- else
273
- # Multi-line value - get all lines
274
- lines = []
275
- (value_node.start_line..value_node.end_line).each do |ln|
276
- lines << analysis.line_at(ln)
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
+ )
277
593
  end
278
- lines.join("\n")
279
594
  end
280
-
281
- @emitter.emit_pair(key, value_text) if key && value_text
282
595
  end
283
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
284
615
  elsif node.start_line && node.end_line
285
- # Emit raw content for non-pair nodes
286
616
  if node.start_line == node.end_line
287
- # Single line - add directly
288
- @emitter.lines << node.text
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
289
624
  else
290
- # Multi-line - collect and emit
291
625
  lines = []
292
626
  (node.start_line..node.end_line).each do |ln|
293
627
  line = analysis.line_at(ln)
294
628
  lines << line if line
295
629
  end
296
- @emitter.emit_raw_lines(lines)
630
+ @emitter.emit_raw_lines(lines, metadata: emitter_block_metadata(analysis, node.start_line))
297
631
  end
298
632
  end
299
633
  end
300
634
 
301
- # Build a map of refined matches from template node to destination node
302
- # @param template_nodes [Array<NodeWrapper>] Template nodes
303
- # @param dest_nodes [Array<NodeWrapper>] Destination nodes
304
- # @param template_by_sig [Hash] Template signature map
305
- # @param dest_by_sig [Hash] Destination signature map
306
- # @return [Hash] Map of template_node => dest_node
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
+
307
1351
  def build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
308
1352
  return {} unless @match_refiner
309
1353
 
310
- # Find unmatched nodes
311
1354
  matched_sigs = template_by_sig.keys & dest_by_sig.keys
312
- unmatched_t_nodes = template_nodes.reject do |n|
313
- sig = @template_analysis.generate_signature(n)
1355
+
1356
+ unmatched_template = template_nodes.reject do |node|
1357
+ sig = @template_analysis.generate_signature(node)
314
1358
  sig && matched_sigs.include?(sig)
315
1359
  end
316
- unmatched_d_nodes = dest_nodes.reject do |n|
317
- sig = @dest_analysis.generate_signature(n)
1360
+
1361
+ unmatched_dest = dest_nodes.reject do |node|
1362
+ sig = @dest_analysis.generate_signature(node)
318
1363
  sig && matched_sigs.include?(sig)
319
1364
  end
320
1365
 
321
- return {} if unmatched_t_nodes.empty? || unmatched_d_nodes.empty?
1366
+ return {} if unmatched_template.empty? || unmatched_dest.empty?
322
1367
 
323
- # Call the refiner
324
- matches = @match_refiner.call(unmatched_t_nodes, unmatched_d_nodes, {
325
- template_analysis: @template_analysis,
326
- dest_analysis: @dest_analysis,
327
- })
1368
+ matches = @match_refiner.call(unmatched_template, unmatched_dest, {
1369
+ template_analysis: @template_analysis,
1370
+ dest_analysis: @dest_analysis
1371
+ })
328
1372
 
329
- # Build result map: template node -> dest node
330
- matches.each_with_object({}) do |match, h|
331
- h[match.template_node] = match.dest_node
1373
+ matches.each_with_object({}) do |match, hash|
1374
+ hash[match.template_node] = match.dest_node
332
1375
  end
333
1376
  end
334
1377
  end