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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +98 -645
- data/lib/bash/merge/comment_tracker.rb +36 -100
- data/lib/bash/merge/debug_logger.rb +10 -10
- data/lib/bash/merge/emitter.rb +45 -33
- data/lib/bash/merge/file_analysis.rb +133 -33
- data/lib/bash/merge/freeze_node.rb +9 -9
- data/lib/bash/merge/merge_result.rb +11 -5
- data/lib/bash/merge/node_wrapper.rb +89 -36
- data/lib/bash/merge/provider.rb +983 -0
- data/lib/bash/merge/smart_merger.rb +641 -32
- data/lib/bash/merge/version.rb +5 -4
- data/lib/bash/merge.rb +120 -25
- data/lib/bash-merge.rb +7 -1
- data/sig/bash/merge.rbs +18 -248
- data.tar.gz.sig +3 -1
- metadata +84 -88
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -211
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- data/SECURITY.md +0 -21
- data/lib/bash/merge/conflict_resolver.rb +0 -199
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
183
|
+
'bash-merge'
|
|
142
184
|
end
|
|
143
185
|
|
|
144
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
@
|
|
169
|
-
@
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|