bash-merge 2.0.6 → 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.
@@ -3,8 +3,8 @@
3
3
  module Bash
4
4
  module Merge
5
5
  # Main entry point for intelligent Bash script merging.
6
- # SmartMerger orchestrates the merge process using FileAnalysis,
7
- # ConflictResolver, and MergeResult to merge two Bash scripts intelligently.
6
+ # SmartMerger orchestrates the merge process using FileAnalysis
7
+ # and MergeResult to merge two Bash scripts intelligently.
8
8
  #
9
9
  # @example Basic merge (destination customizations preserved)
10
10
  # merger = SmartMerger.new(template_bash, dest_bash)
@@ -35,6 +35,11 @@ module Bash
35
35
  # node_typing: { "function_definition" => ->(n) { NodeTyping.with_merge_type(n, :func) } },
36
36
  # preference: { default: :destination, func: :template })
37
37
  class SmartMerger < ::Ast::Merge::SmartMergerBase
38
+ include ::Ast::Merge::TrailingGroups::DestIterate
39
+ include ::Ast::Merge::Runtime::RootSessionSupport
40
+ include ::Ast::Merge::StructuredEmitterProvenanceSupport
41
+ include ::Ast::Merge::CommentLayoutEmissionSupport
42
+
38
43
  # Creates a new SmartMerger for intelligent Bash script merging.
39
44
  #
40
45
  # @param template_content [String] Template Bash source code
@@ -60,6 +65,8 @@ module Bash
60
65
  signature_generator: nil,
61
66
  preference: :destination,
62
67
  add_template_only_nodes: false,
68
+ remove_template_missing_nodes: false,
69
+ corruption_handling: :heal,
63
70
  freeze_token: nil,
64
71
  match_refiner: nil,
65
72
  regions: nil,
@@ -67,6 +74,9 @@ module Bash
67
74
  node_typing: nil,
68
75
  **options
69
76
  )
77
+ @remove_template_missing_nodes = remove_template_missing_nodes
78
+ @corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
79
+
70
80
  super(
71
81
  template_content,
72
82
  dest_content,
@@ -82,6 +92,8 @@ module Bash
82
92
  )
83
93
  end
84
94
 
95
+ attr_reader :runtime_session, :corruption_handling, :remove_template_missing_nodes
96
+
85
97
  # Perform the merge and return the result as a Bash string.
86
98
  #
87
99
  # @return [String] Merged Bash content
@@ -89,26 +101,56 @@ module Bash
89
101
  merge_result.to_bash
90
102
  end
91
103
 
104
+ # Perform the merge operation and return the full MergeResult object.
105
+ #
106
+ # @return [MergeResult] The merge result containing merged Bash content and metadata
107
+ def merge_result
108
+ return @merge_result if @merge_result
109
+
110
+ root_operation = start_runtime_session!
111
+ @merge_result = super
112
+ complete_runtime_session!(root_operation, @merge_result)
113
+ @merge_result
114
+ rescue StandardError => e
115
+ fail_runtime_session!(root_operation, e)
116
+ raise
117
+ end
118
+
92
119
  # Perform the merge and return detailed results including debug info.
93
120
  #
94
121
  # @return [Hash] Hash containing :content, :statistics, :decisions
95
122
  def merge_with_debug
96
- content = merge
123
+ result_obj = merge_result
124
+ template_analysis_debug = {
125
+ valid: @template_analysis.valid?,
126
+ nodes: @template_analysis.nodes.size,
127
+ freeze_blocks: @template_analysis.freeze_blocks.size
128
+ }
129
+ dest_analysis_debug = {
130
+ valid: @dest_analysis.valid?,
131
+ nodes: @dest_analysis.nodes.size,
132
+ freeze_blocks: @dest_analysis.freeze_blocks.size
133
+ }
97
134
 
98
135
  {
99
- content: content,
100
- statistics: @result.statistics,
101
- decisions: @result.decision_summary,
102
- template_analysis: {
103
- valid: @template_analysis.valid?,
104
- nodes: @template_analysis.nodes.size,
105
- freeze_blocks: @template_analysis.freeze_blocks.size,
106
- },
107
- dest_analysis: {
108
- valid: @dest_analysis.valid?,
109
- nodes: @dest_analysis.nodes.size,
110
- freeze_blocks: @dest_analysis.freeze_blocks.size,
136
+ content: result_obj.to_bash,
137
+ debug: {
138
+ template_nodes: template_analysis_debug[:nodes],
139
+ dest_nodes: dest_analysis_debug[:nodes],
140
+ preference: @preference,
141
+ add_template_only_nodes: @add_template_only_nodes,
142
+ remove_template_missing_nodes: @remove_template_missing_nodes,
143
+ resolution_mode: @resolution_mode,
144
+ corruption_handling: @corruption_handling,
145
+ freeze_token: @freeze_token,
146
+ runtime_operation_count: runtime_session&.operations&.size || 0,
147
+ runtime_diagnostic_count: runtime_session&.diagnostics&.size || 0
111
148
  },
149
+ runtime: runtime_session&.to_h,
150
+ statistics: result_obj.statistics,
151
+ decisions: result_obj.decision_summary,
152
+ template_analysis: template_analysis_debug,
153
+ dest_analysis: dest_analysis_debug
112
154
  }
113
155
  end
114
156
 
@@ -124,8 +166,8 @@ module Bash
124
166
  # @return [Array] Array of errors
125
167
  def errors
126
168
  errors = []
127
- errors.concat(@template_analysis.errors.map { |e| {source: :template, error: e} })
128
- errors.concat(@dest_analysis.errors.map { |e| {source: :destination, error: e} })
169
+ errors.concat(@template_analysis.errors.map { |e| { source: :template, error: e } })
170
+ errors.concat(@dest_analysis.errors.map { |e| { source: :destination, error: e } })
129
171
  errors
130
172
  end
131
173
 
@@ -138,12 +180,14 @@ module Bash
138
180
 
139
181
  # @return [String] The default freeze token
140
182
  def default_freeze_token
141
- "bash-merge"
183
+ 'bash-merge'
142
184
  end
143
185
 
144
- # @return [Class] The resolver class for Bash files
186
+ # No separate resolver SmartMerger handles merge logic directly
187
+ # (following prism-merge's paradigm of section-based inline merging)
188
+ # @return [Class, nil]
145
189
  def resolver_class
146
- ConflictResolver
190
+ nil
147
191
  end
148
192
 
149
193
  # @return [Class] The result class for Bash files
@@ -161,29 +205,594 @@ module Bash
161
205
  DestinationParseError
162
206
  end
163
207
 
164
- # Perform the Bash-specific merge
208
+ # Perform section-based merge directly (no ConflictResolver delegation).
209
+ #
210
+ # This follows prism-merge's paradigm: the SmartMerger itself owns the
211
+ # merge algorithm. Signature maps store ALL occurrences of each signature,
212
+ # and nodes are matched positionally (1:1 in order) when duplicates exist.
213
+ #
214
+ # Algorithm:
215
+ # 1. Build signature → [node_info, ...] maps for both files
216
+ # 2. Walk dest nodes in order; for each, find the next unconsumed
217
+ # template node with the same signature (sequential matching)
218
+ # 3. Emit the preferred version (or dest-only if no match)
219
+ # 4. Walk remaining unconsumed template nodes; emit as template-only
220
+ # if add_template_only_nodes is set
165
221
  #
166
222
  # @return [MergeResult] The merge result
167
223
  def perform_merge
168
- @resolver.resolve(@result)
169
- @result
170
- end
224
+ template_nodes = @template_analysis.nodes
225
+ dest_nodes = @dest_analysis.nodes
226
+
227
+ emitter = Emitter.new
228
+ @emitted_preserved_line_numbers = Hash.new { |hash, key| hash[key] = [] }
229
+
230
+ emit_root_boundary_to(emitter, :preamble)
171
231
 
172
- # Build the resolver with Bash-specific options
173
- def build_resolver
174
- ConflictResolver.new(
175
- @template_analysis,
176
- @dest_analysis,
177
- preference: @preference,
178
- add_template_only_nodes: @add_template_only_nodes,
179
- node_typing: @node_typing,
232
+ # Build signature maps: sig [{node:, index:}, ...]
233
+ template_by_sig = build_indexed_signature_map(template_nodes, @template_analysis)
234
+
235
+ # Track which individual template node indices have been consumed.
236
+ consumed_template_indices = ::Set.new
237
+
238
+ # Per-signature cursor so duplicate signatures match 1:1 in order.
239
+ sig_cursor = Hash.new(0)
240
+
241
+ # Pre-compute position-aware trailing groups for template-only nodes.
242
+ dest_sigs = ::Set.new
243
+ dest_nodes.each do |n|
244
+ sig = @dest_analysis.generate_signature(n)
245
+ dest_sigs << sig if sig
246
+ end
247
+
248
+ trailing_groups, all_matched_indices = build_dest_iterate_trailing_groups(
249
+ template_nodes: template_nodes,
250
+ dest_sigs: dest_sigs,
251
+ signature_for: ->(node) { @template_analysis.generate_signature(node) },
252
+ add_template_only_nodes: @add_template_only_nodes
180
253
  )
254
+
255
+ # Emit prefix template-only nodes (before first matched template node)
256
+ emit_prefix_trailing_group(trailing_groups, consumed_template_indices) do |info|
257
+ emit_node_to(emitter, info[:node], @template_analysis)
258
+ end
259
+
260
+ # Phase 1 — Walk destination nodes in order, preserving their positions.
261
+ dest_nodes.each do |dest_node|
262
+ dest_sig = @dest_analysis.generate_signature(dest_node)
263
+
264
+ # Freeze blocks from destination are always preserved verbatim.
265
+ if dest_node.is_a?(FreezeNode) || (dest_node.respond_to?(:is_a?) && dest_node.is_a?(Ast::Merge::Freezable))
266
+ emitter.emit_raw_lines(dest_node.respond_to?(:lines) ? dest_node.lines : [])
267
+ next
268
+ end
269
+
270
+ if dest_sig && template_by_sig.key?(dest_sig)
271
+ # Find the next unconsumed template node with this signature.
272
+ candidates = template_by_sig[dest_sig]
273
+ cursor = sig_cursor[dest_sig]
274
+ template_info = nil
275
+
276
+ while cursor < candidates.size
277
+ candidate = candidates[cursor]
278
+ unless consumed_template_indices.include?(candidate[:index])
279
+ template_info = candidate
280
+ break
281
+ end
282
+ cursor += 1
283
+ end
284
+
285
+ if template_info
286
+ template_node = template_info[:node]
287
+ consumed_template_indices << template_info[:index]
288
+ sig_cursor[dest_sig] = cursor + 1
289
+
290
+ # Emit based on preference
291
+ emit_preferred(emitter, template_node, dest_node)
292
+ else
293
+ # All template copies of this signature consumed — keep dest copy
294
+ handle_destination_only_node(emitter, dest_node)
295
+ end
296
+ else
297
+ # Destination-only node — always keep
298
+ handle_destination_only_node(emitter, dest_node)
299
+ end
300
+
301
+ # Flush interior trailing groups that are ready
302
+ flush_ready_trailing_groups(
303
+ trailing_groups: trailing_groups,
304
+ matched_indices: all_matched_indices,
305
+ consumed_indices: consumed_template_indices
306
+ ) { |info| emit_node_to(emitter, info[:node], @template_analysis) }
307
+ end
308
+
309
+ # Emit remaining trailing groups (tail + safety net)
310
+ emit_remaining_trailing_groups(
311
+ trailing_groups: trailing_groups,
312
+ consumed_indices: consumed_template_indices
313
+ ) { |info| emit_node_to(emitter, info[:node], @template_analysis) }
314
+
315
+ emit_root_boundary_to(emitter, :postlude)
316
+
317
+ # Transfer emitter output to result
318
+ @emitter = emitter
319
+ collapse_cross_source_preamble_prefixes!(emitter)
320
+ transfer_emitter_output(@result)
321
+
322
+ @result
181
323
  end
182
324
 
183
325
  # Build the result (no-arg constructor for Bash)
184
326
  def build_result
185
327
  MergeResult.new
186
328
  end
329
+
330
+ private
331
+
332
+ def start_runtime_session!
333
+ start_runtime_root_session!(
334
+ surface_kind: :bash_document,
335
+ declared_language: :bash,
336
+ effective_language: :bash,
337
+ operation_id: 'bash-document-root',
338
+ delegate_name: 'bash-shell',
339
+ policy_context: {
340
+ preference: @preference,
341
+ add_template_only_nodes: @add_template_only_nodes,
342
+ remove_template_missing_nodes: @remove_template_missing_nodes,
343
+ resolution_mode: @resolution_mode,
344
+ unresolved_policy: @unresolved_policy.to_h
345
+ },
346
+ metadata: { merger: self.class.name },
347
+ options: {
348
+ preference: @preference,
349
+ add_template_only_nodes: @add_template_only_nodes,
350
+ remove_template_missing_nodes: @remove_template_missing_nodes,
351
+ resolution_mode: @resolution_mode,
352
+ unresolved_policy: @unresolved_policy.to_h
353
+ },
354
+ language_chain: [:bash],
355
+ delegate_metadata: { merger: self.class.name }
356
+ )
357
+ end
358
+
359
+ def complete_runtime_session!(root_operation, merge_result)
360
+ complete_runtime_root_session!(
361
+ root_operation: root_operation,
362
+ replacement_text: merge_result.to_bash,
363
+ unresolved_cases: merge_result.unresolved_cases,
364
+ metadata: {
365
+ stats: merge_result.statistics,
366
+ decisions: merge_result.decision_summary
367
+ }
368
+ )
369
+ end
370
+
371
+ def fail_runtime_session!(root_operation, error)
372
+ fail_runtime_root_session!(
373
+ root_operation: root_operation,
374
+ error: error,
375
+ kind: :merge_failed
376
+ )
377
+ end
378
+
379
+ # Build a signature map that preserves ALL occurrences per signature,
380
+ # keyed by index for sequential consumption.
381
+ #
382
+ # @param nodes [Array<NodeWrapper>] Parsed nodes
383
+ # @param analysis [FileAnalysis] Analysis for signature generation
384
+ # @return [Hash{Array => Array<Hash>}] sig → [{node:, index:}, ...]
385
+ def build_indexed_signature_map(nodes, analysis)
386
+ map = Hash.new { |h, k| h[k] = [] }
387
+ nodes.each_with_index do |node, idx|
388
+ sig = analysis.generate_signature(node)
389
+ map[sig] << { node: node, index: idx } if sig
390
+ end
391
+ map
392
+ end
393
+
394
+ # Override hook: freeze nodes are treated as matched for trailing group purposes.
395
+ def trailing_group_node_matched?(node, _signature)
396
+ node.is_a?(FreezeNode) || (node.respond_to?(:is_a?) && node.is_a?(Ast::Merge::Freezable))
397
+ end
398
+
399
+ def emit_root_boundary_to(emitter, kind)
400
+ root_boundary_analysis_candidates.find do |analysis|
401
+ lines = root_boundary_lines_for(kind, analysis)
402
+ next if lines.empty?
403
+ next if skip_root_boundary_lines?(kind, analysis, lines)
404
+
405
+ start_line = kind == :preamble ? 1 : (analysis.lines.length - lines.length + 1)
406
+ emitter.emit_raw_lines(lines, metadata: emitter_block_metadata(analysis, start_line))
407
+ true
408
+ end
409
+ end
410
+
411
+ STANDALONE_BASH_COMMENT_LINE_RE = /\A\s*#(?!!).*\z/
412
+ private_constant :STANDALONE_BASH_COMMENT_LINE_RE
413
+
414
+ def collapse_cross_source_preamble_prefixes!(emitter)
415
+ template_comments, = leading_standalone_comment_run(@template_analysis.source.to_s)
416
+ return emitter if template_comments.empty?
417
+
418
+ leading_entries, remainder_entries = leading_standalone_comment_entries(emitter)
419
+ merged_comments = leading_entries.map { |entry| entry[:line] }
420
+ return emitter if merged_comments.empty?
421
+
422
+ destination_specific_comments = merged_comments.reject { |line| template_comments.include?(line) }
423
+ return emitter if destination_specific_comments.empty?
424
+
425
+ should_heal = ::Ast::Merge::Healer.handle(
426
+ mode: @corruption_handling,
427
+ kind: :duplicate_template_preamble_prefix,
428
+ message: 'merged Bash preamble begins with duplicated template-owned comment lines',
429
+ prefix: '[bash-merge]',
430
+ error_class: Bash::Merge::CorruptionDetectedError,
431
+ warner: lambda { |formatted|
432
+ DebugLogger.debug_warning(formatted, {
433
+ template_comment_lines: template_comments.length,
434
+ merged_comment_lines: merged_comments.length,
435
+ destination_specific_comment_lines: destination_specific_comments.length
436
+ })
437
+ }
438
+ )
439
+ return emitter unless should_heal
440
+
441
+ destination_specific_entries = leading_entries.reject { |entry| template_comments.include?(entry[:line]) }
442
+ trimmed_remainder_entries = remainder_entries.drop_while { |entry| entry[:line].strip.empty? }
443
+ rebuilt_entries = destination_specific_entries.dup
444
+ rebuilt_entries << { line: '', metadata: {} } if rebuilt_entries.any? && trimmed_remainder_entries.any?
445
+ rebuilt_entries.concat(trimmed_remainder_entries)
446
+
447
+ emitter.lines.replace(rebuilt_entries.map { |entry| entry[:line] })
448
+ emitter.line_metadata.replace(rebuilt_entries.map { |entry| entry[:metadata] })
449
+ emitter
450
+ end
451
+
452
+ def leading_standalone_comment_run(text)
453
+ lines = text.to_s.split("\n", -1)
454
+ comment_lines = []
455
+ index = 0
456
+
457
+ while index < lines.length
458
+ line = lines[index]
459
+ if line.strip.empty?
460
+ comment_lines << line if comment_lines.any?
461
+ index += 1
462
+ next
463
+ end
464
+
465
+ break unless STANDALONE_BASH_COMMENT_LINE_RE.match?(line)
466
+
467
+ comment_lines << line
468
+ index += 1
469
+ end
470
+
471
+ [comment_lines, lines.drop(index).join("\n")]
472
+ end
473
+
474
+ def leading_standalone_comment_entries(emitter)
475
+ entries = emitter.lines.each_with_index.map do |line, idx|
476
+ { line: line.to_s, metadata: emitter.line_metadata[idx].to_h }
477
+ end
478
+
479
+ leading_entries = []
480
+ index = 0
481
+ while index < entries.length
482
+ line = entries[index][:line]
483
+ if line.strip.empty?
484
+ leading_entries << entries[index] if leading_entries.any?
485
+ index += 1
486
+ next
487
+ end
488
+
489
+ break unless STANDALONE_BASH_COMMENT_LINE_RE.match?(line)
490
+
491
+ leading_entries << entries[index]
492
+ index += 1
493
+ end
494
+
495
+ [leading_entries, entries.drop(index)]
496
+ end
497
+
498
+ def handle_destination_only_node(emitter, dest_node)
499
+ if remove_template_missing_nodes
500
+ lines = removed_destination_node_lines_for(dest_node, @dest_analysis)
501
+ if lines.any?
502
+ start_line = emission_start_line_for(dest_node, @dest_analysis)
503
+ emitter.emit_raw_lines(lines, metadata: emitter_block_metadata(@dest_analysis, start_line))
504
+ mark_removed_destination_preserved_lines(dest_node, @dest_analysis)
505
+ end
506
+ else
507
+ emit_node_to(emitter, dest_node, @dest_analysis)
508
+ end
509
+ end
510
+
511
+ def preferred_root_boundary_analysis
512
+ pref = @preference.is_a?(Hash) ? (@preference[:default] || :destination) : @preference
513
+ pref == :template ? @template_analysis : @dest_analysis
514
+ end
515
+
516
+ def root_boundary_analysis_candidates
517
+ preferred = preferred_root_boundary_analysis
518
+ fallback = preferred.equal?(@template_analysis) ? @dest_analysis : @template_analysis
519
+
520
+ analyses = [preferred]
521
+ analyses << fallback if @add_template_only_nodes && !first_statement_has_leading_comments?(preferred)
522
+ analyses.compact.uniq
523
+ end
524
+
525
+ def root_boundary_lines_for(kind, analysis)
526
+ super(kind, analysis, owners: analysis.nodes, fallback_to_owner_bounds: true)
527
+ end
528
+
529
+ def emission_start_line_for(node, analysis)
530
+ region_start = region_start_line(leading_region_for(node, analysis))
531
+ comment_start = analysis.comment_tracker.leading_comments_before(node.start_line).first&.fetch(:line, nil)
532
+ preceding_blank_line_start(region_start || comment_start || node.start_line, analysis)
533
+ end
534
+
535
+ def root_boundary_owner_start_line_for(node, analysis)
536
+ return node.start_line unless region_present?(leading_region_for(node, analysis))
537
+
538
+ emission_start_line_for(node, analysis)
539
+ end
540
+
541
+ # Emit the preferred version of a matched node pair.
542
+ def emit_preferred(emitter, template_node, dest_node)
543
+ pref = preference_for_pair(template_node, dest_node)
544
+ record_unresolved_choice(template_node: template_node, dest_node: dest_node, provisional_winner: pref)
545
+ if pref == :destination
546
+ emit_retained_leading_gap_to(emitter, dest_node, @dest_analysis)
547
+ emit_node_to(emitter, dest_node, @dest_analysis)
548
+ else
549
+ comment_source_node, comment_source_analysis = preferred_comment_source_for(template_node, dest_node)
550
+ inline_comment = preferred_inline_comment_for(template_node, dest_node)
551
+ emit_retained_leading_gap_to(emitter, dest_node, @dest_analysis)
552
+ emit_node_to(
553
+ emitter,
554
+ template_node,
555
+ @template_analysis,
556
+ comment_source_node: comment_source_node,
557
+ comment_source_analysis: comment_source_analysis,
558
+ inline_comment: inline_comment
559
+ )
560
+ end
561
+ end
562
+
563
+ def preferred_comment_source_for(template_node, dest_node)
564
+ return [template_node, @template_analysis] if node_has_leading_comments?(template_node, @template_analysis)
565
+ return [dest_node, @dest_analysis] if node_has_leading_comments?(dest_node, @dest_analysis)
566
+
567
+ [template_node, @template_analysis]
568
+ end
569
+
570
+ def first_statement_has_leading_comments?(analysis)
571
+ first_statement = Array(analysis&.nodes).first
572
+ return false unless first_statement
573
+
574
+ node_has_leading_comments?(first_statement, analysis)
575
+ end
576
+
577
+ def node_has_leading_comments?(node, analysis)
578
+ attachment = analysis.comment_attachment_for(node)
579
+ leading_region = attachment&.leading_region
580
+ return false unless leading_region.respond_to?(:nodes)
581
+
582
+ leading_region.nodes.any? do |comment_node|
583
+ comment_node.respond_to?(:comment?) ? comment_node.comment? : true
584
+ end
585
+ end
586
+
587
+ def leading_comment_lines_for(node, analysis)
588
+ return [] unless node.respond_to?(:start_line) && node.start_line
589
+
590
+ start_line = emission_start_line_for(node, analysis)
591
+ return [] unless start_line && start_line < node.start_line
592
+
593
+ lines = (start_line...node.start_line).filter_map { |line_number| analysis.line_at(line_number) }
594
+ comments, = leading_standalone_comment_run(lines.join)
595
+ comments
596
+ end
597
+
598
+ def skip_root_boundary_lines?(kind, analysis, lines)
599
+ return false unless kind == :preamble
600
+ return false unless analysis.equal?(@template_analysis)
601
+ return false unless preferred_root_boundary_analysis.equal?(@template_analysis)
602
+
603
+ template_comments, = leading_standalone_comment_run(lines.join)
604
+ return false if template_comments.empty?
605
+
606
+ destination_first_statement = Array(@dest_analysis&.nodes).first
607
+ return false unless destination_first_statement
608
+
609
+ template_comments == leading_comment_lines_for(destination_first_statement, @dest_analysis)
610
+ end
611
+
612
+ def preferred_inline_comment_for(template_node, dest_node)
613
+ return unless safe_inline_comment_transfer?(template_node, dest_node)
614
+
615
+ template_inline_comment = inline_comment_for(template_node, @template_analysis)
616
+ return if template_inline_comment
617
+
618
+ inline_comment_for(dest_node, @dest_analysis)
619
+ end
620
+
621
+ def inline_comment_for(node, analysis)
622
+ return unless node.respond_to?(:end_line) && node.end_line
623
+
624
+ analysis.comment_tracker.inline_comment_at(node.end_line)
625
+ end
626
+
627
+ def safe_inline_comment_transfer?(template_node, dest_node)
628
+ single_line_node?(template_node) &&
629
+ single_line_node?(dest_node) &&
630
+ safe_inline_comment_node?(template_node) &&
631
+ safe_inline_comment_node?(dest_node)
632
+ end
633
+
634
+ def single_line_node?(node)
635
+ node.respond_to?(:start_line) &&
636
+ node.respond_to?(:end_line) &&
637
+ node.start_line &&
638
+ node.end_line &&
639
+ node.start_line == node.end_line
640
+ end
641
+
642
+ def safe_inline_comment_node?(node)
643
+ (node.respond_to?(:command?) && node.command?) ||
644
+ (node.respond_to?(:variable_assignment?) && node.variable_assignment?)
645
+ end
646
+
647
+ def promoted_inline_comment_lines_for(node, analysis)
648
+ inline_comment = inline_comment_for(node, analysis)
649
+ return [] unless inline_comment && single_line_node?(node) && safe_inline_comment_node?(node)
650
+
651
+ line = promoted_inline_comment_line_for(node, analysis, inline_comment)
652
+ line ? [line] : []
653
+ end
654
+
655
+ def promoted_inline_comment_line_for(node, analysis, inline_comment)
656
+ raw_line = analysis.line_at(node.start_line)
657
+ return unless raw_line
658
+
659
+ "#{raw_line[/\A\s*/]}#{inline_comment[:raw].sub(/\A\s+/, '')}"
660
+ end
661
+
662
+ def removed_destination_node_lines_for(node, analysis)
663
+ removed_owner_preserved_lines_for(
664
+ node,
665
+ analysis,
666
+ owners: analysis.nodes,
667
+ inline_lines: promoted_inline_comment_lines_for(node, analysis)
668
+ )
669
+ end
670
+
671
+ def mark_removed_destination_preserved_lines(node, analysis)
672
+ @emitted_preserved_line_numbers[analysis.object_id].concat(
673
+ removed_owner_preserved_line_numbers_for(node, analysis, owners: analysis.nodes)
674
+ )
675
+ end
676
+
677
+ # Determine preference for a matched pair, respecting per-type overrides.
678
+ def preference_for_pair(template_node, dest_node)
679
+ return @preference unless @preference.is_a?(Hash)
680
+
681
+ typed_template = @node_typing ? ::Ast::Merge::NodeTyping.process(template_node, @node_typing) : template_node
682
+ typed_dest = @node_typing ? ::Ast::Merge::NodeTyping.process(dest_node, @node_typing) : dest_node
683
+
684
+ if ::Ast::Merge::NodeTyping.typed_node?(typed_template)
685
+ merge_type = ::Ast::Merge::NodeTyping.merge_type_for(typed_template)
686
+ return @preference.fetch(merge_type) { @preference.fetch(:default, :destination) } if merge_type
687
+ end
688
+
689
+ if ::Ast::Merge::NodeTyping.typed_node?(typed_dest)
690
+ merge_type = ::Ast::Merge::NodeTyping.merge_type_for(typed_dest)
691
+ return @preference.fetch(merge_type) { @preference.fetch(:default, :destination) } if merge_type
692
+ end
693
+
694
+ @preference.fetch(:default, :destination)
695
+ end
696
+
697
+ def record_unresolved_choice(template_node:, dest_node:, provisional_winner:)
698
+ return unless unresolved_mode?
699
+ return unless template_node && dest_node
700
+
701
+ template_text = template_node.text
702
+ dest_text = dest_node.text
703
+
704
+ identifier = resolution_identifier(template_node, dest_node)
705
+ surface_path = resolution_surface_path(template_node, dest_node)
706
+ record_unresolved_node_choice(
707
+ result: @result,
708
+ template_node: template_node,
709
+ destination_node: dest_node,
710
+ template_text: template_text,
711
+ destination_text: dest_text,
712
+ provisional_winner: provisional_winner,
713
+ case_prefix: 'bash',
714
+ case_parts: [dest_node.type, identifier],
715
+ surface_path: surface_path,
716
+ metadata: {
717
+ node_type: dest_node.type,
718
+ identifier: identifier,
719
+ review_identity: review_identity_for_unresolved_choice(
720
+ template_text: template_text,
721
+ destination_text: dest_text,
722
+ provisional_winner: provisional_winner,
723
+ surface_path: surface_path,
724
+ node_type: dest_node.type,
725
+ identifier: identifier
726
+ )
727
+ },
728
+ conflict_fields: {
729
+ node_type: dest_node.type,
730
+ identifier: identifier
731
+ }
732
+ )
733
+ end
734
+
735
+ def resolution_identifier(template_node, dest_node)
736
+ unresolved_identifier_for_nodes(
737
+ dest_node,
738
+ template_node,
739
+ methods: %i[function_name variable_name command_name]
740
+ )
741
+ end
742
+
743
+ def resolution_surface_path(template_node, dest_node)
744
+ identifier = resolution_identifier(template_node, dest_node)
745
+ unresolved_surface_path_for(
746
+ unresolved_typed_path_segment(dest_node.type, identifier: identifier, node: dest_node, fallback: nil)
747
+ )
748
+ end
749
+
750
+ # Emit a single node (with its leading comments) to an emitter.
751
+ def emit_node_to(emitter, node, analysis, comment_source_node: node, comment_source_analysis: analysis,
752
+ inline_comment: nil)
753
+ # Emit the node content
754
+ return unless node.start_line && node.end_line
755
+
756
+ emit_leading_segment_to(emitter, comment_source_node, comment_source_analysis)
757
+ lines = (node.start_line..node.end_line).filter_map { |ln| analysis.line_at(ln) }
758
+ emitter.emit_raw_lines(
759
+ apply_inline_comment(lines, inline_comment),
760
+ metadata: emitter_block_metadata(analysis, node.start_line)
761
+ )
762
+ end
763
+
764
+ def apply_inline_comment(lines, inline_comment)
765
+ return lines if inline_comment.nil? || lines.empty?
766
+
767
+ updated_lines = lines.dup
768
+ updated_lines[-1] = "#{updated_lines[-1].rstrip} #{inline_comment[:raw].sub(/\A\s+/, '')}"
769
+ updated_lines
770
+ end
771
+
772
+ def emit_leading_segment_to(emitter, node, analysis)
773
+ line_numbers = leading_segment_line_numbers_for(node, analysis, owners: analysis.nodes)
774
+ emitted_line_numbers = @emitted_preserved_line_numbers[analysis.object_id]
775
+ line_numbers = line_numbers.reject { |line_number| emitted_line_numbers.include?(line_number) }
776
+ return if line_numbers.empty?
777
+
778
+ lines = line_numbers.filter_map { |line_number| analysis.line_at(line_number) }
779
+ start_line = line_numbers.first
780
+ emitter.emit_raw_lines(lines, metadata: emitter_block_metadata(analysis, start_line))
781
+ end
782
+
783
+ def emit_retained_leading_gap_to(emitter, node, analysis)
784
+ gap = retained_owner_leading_gap_for(node, analysis)
785
+ return unless gap
786
+
787
+ emitted_line_numbers = @emitted_preserved_line_numbers[analysis.object_id]
788
+ line_numbers = (gap.start_line..gap.end_line).reject do |line_number|
789
+ emitted_line_numbers.include?(line_number)
790
+ end
791
+ return if line_numbers.empty?
792
+
793
+ lines = line_numbers.filter_map { |line_number| analysis.line_at(line_number) }
794
+ emitter.emit_raw_lines(lines, metadata: emitter_block_metadata(analysis, line_numbers.first))
795
+ end
187
796
  end
188
797
  end
189
798
  end