bash-merge 1.0.0
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +48 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +900 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/bash/merge/comment_tracker.rb +153 -0
- data/lib/bash/merge/conflict_resolver.rb +161 -0
- data/lib/bash/merge/debug_logger.rb +43 -0
- data/lib/bash/merge/emitter.rb +218 -0
- data/lib/bash/merge/file_analysis.rb +247 -0
- data/lib/bash/merge/freeze_node.rb +101 -0
- data/lib/bash/merge/merge_result.rb +142 -0
- data/lib/bash/merge/node_wrapper.rb +342 -0
- data/lib/bash/merge/smart_merger.rb +188 -0
- data/lib/bash/merge/version.rb +12 -0
- data/lib/bash/merge.rb +129 -0
- data/lib/bash-merge.rb +6 -0
- data/sig/bash/merge.rbs +260 -0
- data.tar.gz.sig +0 -0
- metadata +353 -0
- metadata.gz.sig +3 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bash
|
|
4
|
+
module Merge
|
|
5
|
+
# Analyzes Bash script structure, extracting nodes, comments, and freeze blocks.
|
|
6
|
+
# This is the main analysis class that prepares Bash content for merging.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# analysis = FileAnalysis.new(bash_source)
|
|
10
|
+
# analysis.valid? # => true
|
|
11
|
+
# analysis.nodes # => [NodeWrapper, FreezeNodeBase, ...]
|
|
12
|
+
# analysis.freeze_blocks # => [FreezeNodeBase, ...]
|
|
13
|
+
class FileAnalysis
|
|
14
|
+
include Ast::Merge::FileAnalyzable
|
|
15
|
+
|
|
16
|
+
# Default freeze token for identifying freeze blocks
|
|
17
|
+
DEFAULT_FREEZE_TOKEN = "bash-merge"
|
|
18
|
+
|
|
19
|
+
# @return [CommentTracker] Comment tracker for this file
|
|
20
|
+
attr_reader :comment_tracker
|
|
21
|
+
|
|
22
|
+
# @return [TreeHaver::Tree, nil] Parsed AST
|
|
23
|
+
attr_reader :ast
|
|
24
|
+
|
|
25
|
+
# @return [Array] Parse errors if any
|
|
26
|
+
attr_reader :errors
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Find the parser library path using TreeHaver::GrammarFinder
|
|
30
|
+
#
|
|
31
|
+
# @return [String, nil] Path to the parser library or nil if not found
|
|
32
|
+
# @raise [TreeHaver::NotAvailable] if ENV is set to invalid path
|
|
33
|
+
def find_parser_path
|
|
34
|
+
TreeHaver::GrammarFinder.new(:bash).find_library_path
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Initialize file analysis
|
|
39
|
+
#
|
|
40
|
+
# @param source [String] Bash source code to analyze
|
|
41
|
+
# @param freeze_token [String] Token for freeze block markers
|
|
42
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
43
|
+
# @param parser_path [String, nil] Path to tree-sitter-bash parser library
|
|
44
|
+
# @param options [Hash] Additional options (forward compatibility - ignored by FileAnalysis)
|
|
45
|
+
def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, parser_path: nil, **options)
|
|
46
|
+
@source = source
|
|
47
|
+
@lines = source.lines.map(&:chomp)
|
|
48
|
+
@freeze_token = freeze_token
|
|
49
|
+
@signature_generator = signature_generator
|
|
50
|
+
@parser_path = parser_path || self.class.find_parser_path
|
|
51
|
+
@errors = []
|
|
52
|
+
# **options captures any additional parameters (e.g., node_typing) for forward compatibility
|
|
53
|
+
|
|
54
|
+
# Initialize comment tracking
|
|
55
|
+
@comment_tracker = CommentTracker.new(source)
|
|
56
|
+
|
|
57
|
+
# Parse the Bash script
|
|
58
|
+
DebugLogger.time("FileAnalysis#parse_bash") { parse_bash }
|
|
59
|
+
|
|
60
|
+
# Extract freeze blocks and integrate with nodes
|
|
61
|
+
@freeze_blocks = extract_freeze_blocks
|
|
62
|
+
@nodes = integrate_nodes_and_freeze_blocks
|
|
63
|
+
|
|
64
|
+
DebugLogger.debug("FileAnalysis initialized", {
|
|
65
|
+
signature_generator: signature_generator ? "custom" : "default",
|
|
66
|
+
nodes_count: @nodes.size,
|
|
67
|
+
freeze_blocks: @freeze_blocks.size,
|
|
68
|
+
valid: valid?,
|
|
69
|
+
})
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if parse was successful
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def valid?
|
|
75
|
+
@errors.empty? && !@ast.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The base module uses 'statements' - provide both names for compatibility
|
|
79
|
+
# @return [Array<NodeWrapper, FreezeNodeBase>]
|
|
80
|
+
def statements
|
|
81
|
+
@nodes ||= []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Alias for convenience - bash-merge prefers "nodes" terminology
|
|
85
|
+
alias_method :nodes, :statements
|
|
86
|
+
|
|
87
|
+
# Check if a line is within a freeze block.
|
|
88
|
+
#
|
|
89
|
+
# @param line_num [Integer] 1-based line number
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def in_freeze_block?(line_num)
|
|
92
|
+
@freeze_blocks.any? { |fb| fb.location.cover?(line_num) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get the freeze block containing the given line.
|
|
96
|
+
#
|
|
97
|
+
# @param line_num [Integer] 1-based line number
|
|
98
|
+
# @return [FreezeNode, nil]
|
|
99
|
+
def freeze_block_at(line_num)
|
|
100
|
+
@freeze_blocks.find { |fb| fb.location.cover?(line_num) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Override to detect tree-sitter nodes for signature generator fallthrough
|
|
104
|
+
# @param value [Object] The value to check
|
|
105
|
+
# @return [Boolean] true if this is a fallthrough node
|
|
106
|
+
def fallthrough_node?(value)
|
|
107
|
+
value.is_a?(NodeWrapper) || value.is_a?(FreezeNode) || super
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get the root node of the parse tree
|
|
111
|
+
# @return [NodeWrapper, nil]
|
|
112
|
+
def root_node
|
|
113
|
+
return unless valid?
|
|
114
|
+
|
|
115
|
+
NodeWrapper.new(@ast.root_node, lines: @lines, source: @source)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get top-level statements from the script
|
|
119
|
+
# @return [Array<NodeWrapper>]
|
|
120
|
+
def top_level_statements
|
|
121
|
+
return [] unless valid?
|
|
122
|
+
|
|
123
|
+
root = @ast.root_node
|
|
124
|
+
return [] unless root
|
|
125
|
+
|
|
126
|
+
statements = []
|
|
127
|
+
root.each do |child|
|
|
128
|
+
next if child.type.to_s == "comment" # Comments handled separately
|
|
129
|
+
|
|
130
|
+
statements << NodeWrapper.new(child, lines: @lines, source: @source)
|
|
131
|
+
end
|
|
132
|
+
statements
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def parse_bash
|
|
138
|
+
# TreeHaver handles grammar discovery and backend selection
|
|
139
|
+
# Set TREE_HAVER_BACKEND=ffi for bash (MRI/Rust have compatibility issues)
|
|
140
|
+
parser = TreeHaver.parser_for(:bash, library_path: @parser_path)
|
|
141
|
+
@ast = parser.parse(@source)
|
|
142
|
+
|
|
143
|
+
# Check for parse errors in the tree
|
|
144
|
+
if @ast&.root_node&.has_error?
|
|
145
|
+
collect_parse_errors(@ast.root_node)
|
|
146
|
+
end
|
|
147
|
+
rescue TreeHaver::NotAvailable => e
|
|
148
|
+
@errors << e.message
|
|
149
|
+
@ast = nil
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
@errors << e.message
|
|
152
|
+
@ast = nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def collect_parse_errors(node)
|
|
156
|
+
# Collect ERROR and MISSING nodes from the tree
|
|
157
|
+
if node.type.to_s == "ERROR" || node.missing?
|
|
158
|
+
@errors << {
|
|
159
|
+
type: node.type.to_s,
|
|
160
|
+
start_point: node.start_point,
|
|
161
|
+
end_point: node.end_point,
|
|
162
|
+
text: node.to_s,
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
node.each { |child| collect_parse_errors(child) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def extract_freeze_blocks
|
|
170
|
+
# Use shared pattern from Ast::Merge::FreezeNodeBase with our specific token
|
|
171
|
+
freeze_pattern = Ast::Merge::FreezeNodeBase.pattern_for(:hash_comment, @freeze_token)
|
|
172
|
+
|
|
173
|
+
freeze_starts = []
|
|
174
|
+
freeze_ends = []
|
|
175
|
+
|
|
176
|
+
@lines.each_with_index do |line, idx|
|
|
177
|
+
line_num = idx + 1
|
|
178
|
+
next unless (match = line.match(freeze_pattern))
|
|
179
|
+
|
|
180
|
+
marker_type = match[1]&.downcase # 'freeze' or 'unfreeze'
|
|
181
|
+
if marker_type == "freeze"
|
|
182
|
+
freeze_starts << {line: line_num, marker: line}
|
|
183
|
+
elsif marker_type == "unfreeze"
|
|
184
|
+
freeze_ends << {line: line_num, marker: line}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Match freeze starts with ends
|
|
189
|
+
blocks = []
|
|
190
|
+
freeze_starts.each do |start_info|
|
|
191
|
+
# Find the next unfreeze after this freeze
|
|
192
|
+
matching_end = freeze_ends.find { |e| e[:line] > start_info[:line] }
|
|
193
|
+
next unless matching_end
|
|
194
|
+
|
|
195
|
+
# Remove used end marker
|
|
196
|
+
freeze_ends.delete(matching_end)
|
|
197
|
+
|
|
198
|
+
blocks << FreezeNode.new(
|
|
199
|
+
start_line: start_info[:line],
|
|
200
|
+
end_line: matching_end[:line],
|
|
201
|
+
lines: @lines,
|
|
202
|
+
start_marker: start_info[:marker],
|
|
203
|
+
end_marker: matching_end[:marker],
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
blocks
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def integrate_nodes_and_freeze_blocks
|
|
211
|
+
return @freeze_blocks.dup unless valid?
|
|
212
|
+
|
|
213
|
+
result = []
|
|
214
|
+
processed_lines = ::Set.new
|
|
215
|
+
|
|
216
|
+
# Mark freeze block lines as processed
|
|
217
|
+
@freeze_blocks.each do |fb|
|
|
218
|
+
(fb.start_line..fb.end_line).each { |ln| processed_lines << ln }
|
|
219
|
+
result << fb
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Add top-level statements that aren't in freeze blocks
|
|
223
|
+
top_level_statements.each do |stmt|
|
|
224
|
+
next unless stmt.start_line && stmt.end_line
|
|
225
|
+
|
|
226
|
+
# Skip if any part of this statement is in a freeze block
|
|
227
|
+
stmt_lines = (stmt.start_line..stmt.end_line).to_a
|
|
228
|
+
next if stmt_lines.any? { |ln| processed_lines.include?(ln) }
|
|
229
|
+
|
|
230
|
+
result << stmt
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Sort by start line
|
|
234
|
+
result.sort_by { |node| node.start_line || 0 }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def compute_node_signature(node)
|
|
238
|
+
case node
|
|
239
|
+
when FreezeNode
|
|
240
|
+
node.signature
|
|
241
|
+
when NodeWrapper
|
|
242
|
+
node.signature
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bash
|
|
4
|
+
module Merge
|
|
5
|
+
# Wrapper to represent freeze blocks as first-class nodes in Bash scripts.
|
|
6
|
+
# A freeze block is a section marked with freeze/unfreeze comment markers that
|
|
7
|
+
# should be preserved from the destination during merges.
|
|
8
|
+
#
|
|
9
|
+
# Inherits from Ast::Merge::FreezeNodeBase for shared functionality including
|
|
10
|
+
# the Location struct, InvalidStructureError, and configurable marker patterns.
|
|
11
|
+
#
|
|
12
|
+
# Uses the `:hash_comment` pattern type by default for Bash files (# comments).
|
|
13
|
+
#
|
|
14
|
+
# @example Freeze block in Bash
|
|
15
|
+
# # bash-merge:freeze
|
|
16
|
+
# SECRET_KEY="my-secret-value"
|
|
17
|
+
# API_ENDPOINT="https://custom.example.com"
|
|
18
|
+
# # bash-merge:unfreeze
|
|
19
|
+
class FreezeNode < Ast::Merge::FreezeNodeBase
|
|
20
|
+
# Inherit InvalidStructureError from base class
|
|
21
|
+
InvalidStructureError = Ast::Merge::FreezeNodeBase::InvalidStructureError
|
|
22
|
+
|
|
23
|
+
# Inherit Location from base class
|
|
24
|
+
Location = Ast::Merge::FreezeNodeBase::Location
|
|
25
|
+
|
|
26
|
+
# @param start_line [Integer] Line number of freeze marker
|
|
27
|
+
# @param end_line [Integer] Line number of unfreeze marker
|
|
28
|
+
# @param lines [Array<String>] All source lines
|
|
29
|
+
# @param start_marker [String, nil] The freeze start marker text
|
|
30
|
+
# @param end_marker [String, nil] The freeze end marker text
|
|
31
|
+
# @param pattern_type [Symbol] Pattern type for marker matching (defaults to :hash_comment)
|
|
32
|
+
def initialize(start_line:, end_line:, lines:, start_marker: nil, end_marker: nil, pattern_type: Ast::Merge::FreezeNodeBase::DEFAULT_PATTERN)
|
|
33
|
+
# Extract lines for the entire block (lines param is all source lines)
|
|
34
|
+
block_lines = (start_line..end_line).map { |ln| lines[ln - 1] }
|
|
35
|
+
|
|
36
|
+
super(
|
|
37
|
+
start_line: start_line,
|
|
38
|
+
end_line: end_line,
|
|
39
|
+
lines: block_lines,
|
|
40
|
+
start_marker: start_marker,
|
|
41
|
+
end_marker: end_marker,
|
|
42
|
+
pattern_type: pattern_type
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
validate_structure!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns a stable signature for this freeze block.
|
|
49
|
+
# Signature includes the normalized content to detect changes.
|
|
50
|
+
# @return [Array] Signature array
|
|
51
|
+
def signature
|
|
52
|
+
# Normalize by stripping each line and joining
|
|
53
|
+
normalized = @lines.map { |l| l&.strip }.compact.reject(&:empty?).join("\n")
|
|
54
|
+
[:FreezeNode, normalized]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if this is a function definition (always false for FreezeNode)
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def function_definition?
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check if this is a variable assignment (always false for FreezeNode)
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def variable_assignment?
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check if this is a command (always false for FreezeNode)
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def command?
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# String representation for debugging
|
|
76
|
+
# @return [String]
|
|
77
|
+
def inspect
|
|
78
|
+
# :nocov:
|
|
79
|
+
# Defensive: slice is always set via resolve_content in FreezeNodeBase#initialize
|
|
80
|
+
# The || 0 branch is normally unreachable because validate_structure! ensures non-empty content
|
|
81
|
+
content_length = slice&.length || 0
|
|
82
|
+
# :nocov:
|
|
83
|
+
"#<#{self.class.name} lines=#{start_line}..#{end_line} content_length=#{content_length}>"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def validate_structure!
|
|
89
|
+
validate_line_order!
|
|
90
|
+
|
|
91
|
+
if @lines.empty? || @lines.all?(&:nil?)
|
|
92
|
+
raise InvalidStructureError.new(
|
|
93
|
+
"Freeze block is empty",
|
|
94
|
+
start_line: @start_line,
|
|
95
|
+
end_line: @end_line,
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bash
|
|
4
|
+
module Merge
|
|
5
|
+
# Tracks the result of a merge operation, including the merged content,
|
|
6
|
+
# decisions made, and statistics.
|
|
7
|
+
#
|
|
8
|
+
# Inherits decision constants and base functionality from Ast::Merge::MergeResultBase.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# result = MergeResult.new
|
|
12
|
+
# result.add_line("echo 'hello'", decision: :kept_template, source: :template)
|
|
13
|
+
# result.to_bash # => "echo 'hello'\n"
|
|
14
|
+
class MergeResult < Ast::Merge::MergeResultBase
|
|
15
|
+
# Inherit decision constants from base class
|
|
16
|
+
DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
|
|
17
|
+
DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
|
|
18
|
+
DECISION_MERGED = Ast::Merge::MergeResultBase::DECISION_MERGED
|
|
19
|
+
DECISION_ADDED = Ast::Merge::MergeResultBase::DECISION_ADDED
|
|
20
|
+
DECISION_FREEZE_BLOCK = Ast::Merge::MergeResultBase::DECISION_FREEZE_BLOCK
|
|
21
|
+
|
|
22
|
+
# @return [Hash] Statistics about the merge
|
|
23
|
+
attr_reader :statistics
|
|
24
|
+
|
|
25
|
+
# Initialize a new merge result
|
|
26
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
27
|
+
def initialize(**options)
|
|
28
|
+
super(**options)
|
|
29
|
+
@statistics = {
|
|
30
|
+
template_lines: 0,
|
|
31
|
+
dest_lines: 0,
|
|
32
|
+
merged_lines: 0,
|
|
33
|
+
freeze_preserved_lines: 0,
|
|
34
|
+
total_decisions: 0,
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Add a single line to the result
|
|
39
|
+
#
|
|
40
|
+
# @param line [String] Line content
|
|
41
|
+
# @param decision [Symbol] Decision that led to this line
|
|
42
|
+
# @param source [Symbol] Source of the line (:template, :destination, :merged)
|
|
43
|
+
# @param original_line [Integer, nil] Original line number
|
|
44
|
+
def add_line(line, decision:, source:, original_line: nil)
|
|
45
|
+
@lines << {
|
|
46
|
+
content: line,
|
|
47
|
+
decision: decision,
|
|
48
|
+
source: source,
|
|
49
|
+
original_line: original_line,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
track_statistics(decision, source)
|
|
53
|
+
track_decision(decision, source, line: original_line)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Add multiple lines to the result
|
|
57
|
+
#
|
|
58
|
+
# @param lines [Array<String>] Lines to add
|
|
59
|
+
# @param decision [Symbol] Decision for all lines
|
|
60
|
+
# @param source [Symbol] Source of the lines
|
|
61
|
+
# @param start_line [Integer, nil] Starting original line number
|
|
62
|
+
def add_lines(lines, decision:, source:, start_line: nil)
|
|
63
|
+
lines.each_with_index do |line, idx|
|
|
64
|
+
original_line = start_line ? start_line + idx : nil
|
|
65
|
+
add_line(line, decision: decision, source: source, original_line: original_line)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Add a blank line
|
|
70
|
+
#
|
|
71
|
+
# @param decision [Symbol] Decision for the blank line
|
|
72
|
+
# @param source [Symbol] Source
|
|
73
|
+
def add_blank_line(decision: DECISION_MERGED, source: :merged)
|
|
74
|
+
add_line("", decision: decision, source: source)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Add content from a freeze block
|
|
78
|
+
#
|
|
79
|
+
# @param freeze_node [FreezeNode] Freeze block to add
|
|
80
|
+
def add_freeze_block(freeze_node)
|
|
81
|
+
freeze_node.lines.each_with_index do |line, idx|
|
|
82
|
+
add_line(
|
|
83
|
+
line.chomp,
|
|
84
|
+
decision: DECISION_FREEZE_BLOCK,
|
|
85
|
+
source: :destination,
|
|
86
|
+
original_line: freeze_node.start_line + idx,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Add content from a node wrapper
|
|
92
|
+
#
|
|
93
|
+
# @param node [NodeWrapper] Node to add
|
|
94
|
+
# @param decision [Symbol] Decision that led to keeping this node
|
|
95
|
+
# @param source [Symbol] Source of the node
|
|
96
|
+
# @param analysis [FileAnalysis] Analysis for accessing source lines
|
|
97
|
+
def add_node(node, decision:, source:, analysis:)
|
|
98
|
+
return unless node.start_line && node.end_line
|
|
99
|
+
|
|
100
|
+
(node.start_line..node.end_line).each do |line_num|
|
|
101
|
+
line = analysis.line_at(line_num)
|
|
102
|
+
next unless line
|
|
103
|
+
|
|
104
|
+
add_line(line.chomp, decision: decision, source: source, original_line: line_num)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the merged content as a Bash string
|
|
109
|
+
#
|
|
110
|
+
# @return [String]
|
|
111
|
+
def to_bash
|
|
112
|
+
content = @lines.map { |l| l[:content] }.join("\n")
|
|
113
|
+
# Ensure trailing newline
|
|
114
|
+
content += "\n" unless content.end_with?("\n") || content.empty?
|
|
115
|
+
content
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Alias for to_bash
|
|
119
|
+
# @return [String]
|
|
120
|
+
def content
|
|
121
|
+
to_bash
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def track_statistics(decision, source)
|
|
127
|
+
@statistics[:total_decisions] += 1
|
|
128
|
+
|
|
129
|
+
case decision
|
|
130
|
+
when DECISION_KEPT_TEMPLATE
|
|
131
|
+
@statistics[:template_lines] += 1
|
|
132
|
+
when DECISION_KEPT_DEST
|
|
133
|
+
@statistics[:dest_lines] += 1
|
|
134
|
+
when DECISION_FREEZE_BLOCK
|
|
135
|
+
@statistics[:freeze_preserved_lines] += 1
|
|
136
|
+
else
|
|
137
|
+
@statistics[:merged_lines] += 1
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|