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,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bash
|
|
4
|
+
module Merge
|
|
5
|
+
# Wraps TreeHaver nodes with comment associations, line information, and signatures.
|
|
6
|
+
# This provides a unified interface for working with Bash AST nodes during merging.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# parser = TreeHaver::Parser.new
|
|
10
|
+
# parser.language = TreeHaver::Language.bash
|
|
11
|
+
# tree = parser.parse(source)
|
|
12
|
+
# wrapper = NodeWrapper.new(tree.root_node, lines: source.lines, source: source)
|
|
13
|
+
# wrapper.signature # => [:program, ...]
|
|
14
|
+
class NodeWrapper
|
|
15
|
+
# @return [TreeHaver::Node] The wrapped tree-haver node
|
|
16
|
+
attr_reader :node
|
|
17
|
+
|
|
18
|
+
# @return [Array<Hash>] Leading comments associated with this node
|
|
19
|
+
attr_reader :leading_comments
|
|
20
|
+
|
|
21
|
+
# @return [Hash, nil] Inline/trailing comment on the same line
|
|
22
|
+
attr_reader :inline_comment
|
|
23
|
+
|
|
24
|
+
# @return [Integer] Start line (1-based)
|
|
25
|
+
attr_reader :start_line
|
|
26
|
+
|
|
27
|
+
# @return [Integer] End line (1-based)
|
|
28
|
+
attr_reader :end_line
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>] Source lines
|
|
31
|
+
attr_reader :lines
|
|
32
|
+
|
|
33
|
+
# @return [String] The original source string
|
|
34
|
+
attr_reader :source
|
|
35
|
+
|
|
36
|
+
# @param node [TreeHaver::Node] tree-haver node to wrap
|
|
37
|
+
# @param lines [Array<String>] Source lines for content extraction
|
|
38
|
+
# @param source [String] Original source string for byte-based text extraction
|
|
39
|
+
# @param leading_comments [Array<Hash>] Comments before this node
|
|
40
|
+
# @param inline_comment [Hash, nil] Inline comment on the node's line
|
|
41
|
+
def initialize(node, lines:, source: nil, leading_comments: [], inline_comment: nil)
|
|
42
|
+
@node = node
|
|
43
|
+
@lines = lines
|
|
44
|
+
@source = source || lines.join("\n")
|
|
45
|
+
@leading_comments = leading_comments
|
|
46
|
+
@inline_comment = inline_comment
|
|
47
|
+
|
|
48
|
+
# Extract line information from the tree-haver node (0-indexed to 1-indexed)
|
|
49
|
+
if node.respond_to?(:start_point)
|
|
50
|
+
point = node.start_point
|
|
51
|
+
@start_line = (point.respond_to?(:row) ? point.row : point[:row]) + 1
|
|
52
|
+
end
|
|
53
|
+
if node.respond_to?(:end_point)
|
|
54
|
+
point = node.end_point
|
|
55
|
+
@end_line = (point.respond_to?(:row) ? point.row : point[:row]) + 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Handle edge case where end_line might be before start_line
|
|
59
|
+
@end_line = @start_line if @start_line && @end_line && @end_line < @start_line
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Generate a signature for this node for matching purposes.
|
|
63
|
+
# Signatures are used to identify corresponding nodes between template and destination.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array, nil] Signature array or nil if not signaturable
|
|
66
|
+
def signature
|
|
67
|
+
compute_signature(@node)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if this is a freeze node
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def freeze_node?
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get the node type as a symbol
|
|
77
|
+
# @return [Symbol]
|
|
78
|
+
def type
|
|
79
|
+
@node.type.to_sym
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if this node has a specific type
|
|
83
|
+
# @param type_name [Symbol, String] Type to check
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def type?(type_name)
|
|
86
|
+
@node.type.to_s == type_name.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check if this is a function definition
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def function_definition?
|
|
92
|
+
@node.type.to_s == "function_definition"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if this is a variable assignment
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def variable_assignment?
|
|
98
|
+
@node.type.to_s == "variable_assignment"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check if this is an if statement
|
|
102
|
+
# @return [Boolean]
|
|
103
|
+
def if_statement?
|
|
104
|
+
@node.type.to_s == "if_statement"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check if this is a for loop
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def for_statement?
|
|
110
|
+
%w[for_statement c_style_for_statement].include?(@node.type.to_s)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if this is a while loop
|
|
114
|
+
# @return [Boolean]
|
|
115
|
+
def while_statement?
|
|
116
|
+
@node.type.to_s == "while_statement"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Check if this is a case statement
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def case_statement?
|
|
122
|
+
@node.type.to_s == "case_statement"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Check if this is a command
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def command?
|
|
128
|
+
@node.type.to_s == "command"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check if this is a pipeline
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
def pipeline?
|
|
134
|
+
@node.type.to_s == "pipeline"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Check if this is a comment
|
|
138
|
+
# @return [Boolean]
|
|
139
|
+
def comment?
|
|
140
|
+
@node.type.to_s == "comment"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get the function name if this is a function definition
|
|
144
|
+
# @return [String, nil]
|
|
145
|
+
def function_name
|
|
146
|
+
return unless function_definition?
|
|
147
|
+
|
|
148
|
+
# In bash tree-sitter, function name is in a 'name' or 'word' child
|
|
149
|
+
name_node = find_child_by_type("word") || find_child_by_field("name")
|
|
150
|
+
node_text(name_node) if name_node
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Get the variable name if this is a variable assignment
|
|
154
|
+
# @return [String, nil]
|
|
155
|
+
def variable_name
|
|
156
|
+
return unless variable_assignment?
|
|
157
|
+
|
|
158
|
+
# In bash tree-sitter, variable name is a child of type 'variable_name'
|
|
159
|
+
name_node = find_child_by_type("variable_name")
|
|
160
|
+
node_text(name_node) if name_node
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get the command name if this is a command
|
|
164
|
+
# @return [String, nil]
|
|
165
|
+
def command_name
|
|
166
|
+
return unless command?
|
|
167
|
+
|
|
168
|
+
# First child that is a word or simple_expansion
|
|
169
|
+
@node.each do |child|
|
|
170
|
+
next if %w[comment file_redirect heredoc_redirect].include?(child.type.to_s)
|
|
171
|
+
|
|
172
|
+
return node_text(child) if %w[word command_name].include?(child.type.to_s)
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get children wrapped as NodeWrappers
|
|
178
|
+
# @return [Array<NodeWrapper>]
|
|
179
|
+
def children
|
|
180
|
+
return [] unless @node.respond_to?(:each)
|
|
181
|
+
|
|
182
|
+
result = []
|
|
183
|
+
@node.each do |child|
|
|
184
|
+
result << NodeWrapper.new(child, lines: @lines, source: @source)
|
|
185
|
+
end
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Find a child by field name
|
|
190
|
+
# @param field_name [String] Field name to look for
|
|
191
|
+
# @return [TreeSitter::Node, nil]
|
|
192
|
+
def find_child_by_field(field_name)
|
|
193
|
+
return unless @node.respond_to?(:child_by_field_name)
|
|
194
|
+
|
|
195
|
+
@node.child_by_field_name(field_name)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Find a child by type
|
|
199
|
+
# @param type_name [String] Type name to look for
|
|
200
|
+
# @return [TreeSitter::Node, nil]
|
|
201
|
+
def find_child_by_type(type_name)
|
|
202
|
+
return unless @node.respond_to?(:each)
|
|
203
|
+
|
|
204
|
+
@node.each do |child|
|
|
205
|
+
return child if child.type.to_s == type_name
|
|
206
|
+
end
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Get the text content for this node by extracting from source using byte positions
|
|
211
|
+
# @return [String]
|
|
212
|
+
def text
|
|
213
|
+
node_text(@node)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Extract text from a tree-sitter node using byte positions
|
|
217
|
+
# @param ts_node [TreeSitter::Node] The tree-sitter node
|
|
218
|
+
# @return [String]
|
|
219
|
+
def node_text(ts_node)
|
|
220
|
+
return "" unless ts_node.respond_to?(:start_byte) && ts_node.respond_to?(:end_byte)
|
|
221
|
+
|
|
222
|
+
@source[ts_node.start_byte...ts_node.end_byte] || ""
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Get the content for this node from source lines
|
|
226
|
+
# @return [String]
|
|
227
|
+
def content
|
|
228
|
+
return "" unless @start_line && @end_line
|
|
229
|
+
|
|
230
|
+
(@start_line..@end_line).map { |ln| @lines[ln - 1] }.compact.join("\n")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# String representation for debugging
|
|
234
|
+
# @return [String]
|
|
235
|
+
def inspect
|
|
236
|
+
"#<#{self.class.name} type=#{@node.type} lines=#{@start_line}..#{@end_line}>"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def compute_signature(node)
|
|
242
|
+
node_type = node.type.to_s
|
|
243
|
+
|
|
244
|
+
case node_type
|
|
245
|
+
when "program"
|
|
246
|
+
# Root node - signature based on direct children structure
|
|
247
|
+
child_types = []
|
|
248
|
+
node.each { |child| child_types << child.type.to_s unless child.type.to_s == "comment" }
|
|
249
|
+
[:program, child_types.length]
|
|
250
|
+
when "function_definition"
|
|
251
|
+
# Functions are identified by their name
|
|
252
|
+
name = function_name
|
|
253
|
+
[:function_definition, name]
|
|
254
|
+
when "variable_assignment"
|
|
255
|
+
# Variable assignments are identified by variable name
|
|
256
|
+
name = variable_name
|
|
257
|
+
[:variable_assignment, name]
|
|
258
|
+
when "command"
|
|
259
|
+
# Commands identified by their command name
|
|
260
|
+
name = command_name
|
|
261
|
+
[:command, name, extract_command_signature_context(node)]
|
|
262
|
+
when "if_statement"
|
|
263
|
+
# If statements identified by their condition pattern
|
|
264
|
+
condition = extract_condition_pattern(node)
|
|
265
|
+
[:if_statement, condition]
|
|
266
|
+
when "for_statement", "c_style_for_statement"
|
|
267
|
+
# For loops identified by their loop variable
|
|
268
|
+
var = extract_loop_variable(node)
|
|
269
|
+
[:for_statement, var]
|
|
270
|
+
when "while_statement"
|
|
271
|
+
# While loops identified by condition
|
|
272
|
+
condition = extract_condition_pattern(node)
|
|
273
|
+
[:while_statement, condition]
|
|
274
|
+
when "case_statement"
|
|
275
|
+
# Case statements identified by the expression being matched
|
|
276
|
+
expr = extract_case_expression(node)
|
|
277
|
+
[:case_statement, expr]
|
|
278
|
+
when "pipeline"
|
|
279
|
+
# Pipelines identified by command names in order
|
|
280
|
+
commands = extract_pipeline_commands(node)
|
|
281
|
+
[:pipeline, commands]
|
|
282
|
+
when "comment"
|
|
283
|
+
# Comments identified by their content
|
|
284
|
+
[:comment, node_text(node).strip]
|
|
285
|
+
else
|
|
286
|
+
# Generic fallback - type and first few chars of content
|
|
287
|
+
content_preview = node_text(node).slice(0, 50).strip
|
|
288
|
+
[node_type.to_sym, content_preview]
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def extract_command_signature_context(node)
|
|
293
|
+
# Extract additional context like redirections
|
|
294
|
+
redirections = []
|
|
295
|
+
node.each do |child|
|
|
296
|
+
if child.type.to_s.include?("redirect")
|
|
297
|
+
redirections << child.type.to_s
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
redirections.empty? ? nil : redirections.sort
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def extract_condition_pattern(node)
|
|
304
|
+
# Try to extract the test/condition from if/while statements
|
|
305
|
+
# Look for test_command, compound_statement, etc.
|
|
306
|
+
node.each do |child|
|
|
307
|
+
if %w[test_command bracket_command].include?(child.type.to_s)
|
|
308
|
+
return node_text(child).slice(0, 100).strip
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def extract_loop_variable(node)
|
|
315
|
+
# Extract the loop variable from for statements
|
|
316
|
+
var_node = node.each.find { |child| child.type.to_s == "variable_name" }
|
|
317
|
+
node_text(var_node) if var_node
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def extract_case_expression(node)
|
|
321
|
+
# Extract the expression being matched in a case statement
|
|
322
|
+
node.each do |child|
|
|
323
|
+
return node_text(child).slice(0, 50).strip if child.type.to_s == "word" || child.type.to_s == "variable_name"
|
|
324
|
+
end
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def extract_pipeline_commands(node)
|
|
329
|
+
# Extract command names from a pipeline
|
|
330
|
+
commands = []
|
|
331
|
+
node.each do |child|
|
|
332
|
+
if child.type.to_s == "command"
|
|
333
|
+
wrapper = NodeWrapper.new(child, lines: @lines, source: @source)
|
|
334
|
+
cmd_name = wrapper.command_name
|
|
335
|
+
commands << cmd_name if cmd_name
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
commands
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bash
|
|
4
|
+
module Merge
|
|
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.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic merge (destination customizations preserved)
|
|
10
|
+
# merger = SmartMerger.new(template_bash, dest_bash)
|
|
11
|
+
# result = merger.merge
|
|
12
|
+
# File.write("output.sh", result)
|
|
13
|
+
#
|
|
14
|
+
# @example Template updates win
|
|
15
|
+
# merger = SmartMerger.new(
|
|
16
|
+
# template_bash,
|
|
17
|
+
# dest_bash,
|
|
18
|
+
# preference: :template,
|
|
19
|
+
# add_template_only_nodes: true
|
|
20
|
+
# )
|
|
21
|
+
# result = merger.merge
|
|
22
|
+
#
|
|
23
|
+
# @example With custom signature generator
|
|
24
|
+
# sig_gen = ->(node) {
|
|
25
|
+
# if node.is_a?(NodeWrapper) && node.function_definition? && node.function_name == "main"
|
|
26
|
+
# [:special_main]
|
|
27
|
+
# else
|
|
28
|
+
# node # Fall through to default
|
|
29
|
+
# end
|
|
30
|
+
# }
|
|
31
|
+
# merger = SmartMerger.new(template, dest, signature_generator: sig_gen)
|
|
32
|
+
#
|
|
33
|
+
# @example With node_typing for per-node-type preferences
|
|
34
|
+
# merger = SmartMerger.new(template, dest,
|
|
35
|
+
# node_typing: { "function_definition" => ->(n) { NodeTyping.with_merge_type(n, :func) } },
|
|
36
|
+
# preference: { default: :destination, func: :template })
|
|
37
|
+
class SmartMerger < ::Ast::Merge::SmartMergerBase
|
|
38
|
+
# Creates a new SmartMerger for intelligent Bash script merging.
|
|
39
|
+
#
|
|
40
|
+
# @param template_content [String] Template Bash source code
|
|
41
|
+
# @param dest_content [String] Destination Bash source code
|
|
42
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
43
|
+
# @param preference [Symbol, Hash] :destination, :template, or per-type Hash
|
|
44
|
+
# @param add_template_only_nodes [Boolean] Whether to add nodes only in template
|
|
45
|
+
# @param freeze_token [String] Token for freeze block markers
|
|
46
|
+
# @param match_refiner [#call, nil] Match refiner for fuzzy matching
|
|
47
|
+
# @param regions [Array<Hash>, nil] Region configurations for nested merging
|
|
48
|
+
# @param region_placeholder [String, nil] Custom placeholder for regions
|
|
49
|
+
# @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
50
|
+
#
|
|
51
|
+
# @note To specify a custom parser path, use the TREE_SITTER_BASH_PATH environment
|
|
52
|
+
# variable. This is handled by tree_haver's GrammarFinder.
|
|
53
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
54
|
+
#
|
|
55
|
+
# @raise [TemplateParseError] If template has syntax errors
|
|
56
|
+
# @raise [DestinationParseError] If destination has syntax errors
|
|
57
|
+
def initialize(
|
|
58
|
+
template_content,
|
|
59
|
+
dest_content,
|
|
60
|
+
signature_generator: nil,
|
|
61
|
+
preference: :destination,
|
|
62
|
+
add_template_only_nodes: false,
|
|
63
|
+
freeze_token: nil,
|
|
64
|
+
match_refiner: nil,
|
|
65
|
+
regions: nil,
|
|
66
|
+
region_placeholder: nil,
|
|
67
|
+
node_typing: nil,
|
|
68
|
+
**options
|
|
69
|
+
)
|
|
70
|
+
super(
|
|
71
|
+
template_content,
|
|
72
|
+
dest_content,
|
|
73
|
+
signature_generator: signature_generator,
|
|
74
|
+
preference: preference,
|
|
75
|
+
add_template_only_nodes: add_template_only_nodes,
|
|
76
|
+
freeze_token: freeze_token,
|
|
77
|
+
match_refiner: match_refiner,
|
|
78
|
+
regions: regions,
|
|
79
|
+
region_placeholder: region_placeholder,
|
|
80
|
+
node_typing: node_typing,
|
|
81
|
+
**options
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Perform the merge and return the result as a Bash string.
|
|
86
|
+
#
|
|
87
|
+
# @return [String] Merged Bash content
|
|
88
|
+
def merge
|
|
89
|
+
merge_result.to_bash
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Perform the merge and return detailed results including debug info.
|
|
93
|
+
#
|
|
94
|
+
# @return [Hash] Hash containing :content, :statistics, :decisions
|
|
95
|
+
def merge_with_debug
|
|
96
|
+
content = merge
|
|
97
|
+
|
|
98
|
+
{
|
|
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,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check if both files were parsed successfully.
|
|
116
|
+
#
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def valid?
|
|
119
|
+
@template_analysis.valid? && @dest_analysis.valid?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get any parse errors from template or destination.
|
|
123
|
+
#
|
|
124
|
+
# @return [Array] Array of errors
|
|
125
|
+
def errors
|
|
126
|
+
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} })
|
|
129
|
+
errors
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
protected
|
|
133
|
+
|
|
134
|
+
# @return [Class] The analysis class for Bash files
|
|
135
|
+
def analysis_class
|
|
136
|
+
FileAnalysis
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [String] The default freeze token
|
|
140
|
+
def default_freeze_token
|
|
141
|
+
"bash-merge"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [Class] The resolver class for Bash files
|
|
145
|
+
def resolver_class
|
|
146
|
+
ConflictResolver
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Class] The result class for Bash files
|
|
150
|
+
def result_class
|
|
151
|
+
MergeResult
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [Class] The template parse error class for Bash
|
|
155
|
+
def template_parse_error_class
|
|
156
|
+
TemplateParseError
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Class] The destination parse error class for Bash
|
|
160
|
+
def destination_parse_error_class
|
|
161
|
+
DestinationParseError
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Perform the Bash-specific merge
|
|
165
|
+
#
|
|
166
|
+
# @return [MergeResult] The merge result
|
|
167
|
+
def perform_merge
|
|
168
|
+
@resolver.resolve(@result)
|
|
169
|
+
@result
|
|
170
|
+
end
|
|
171
|
+
|
|
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
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Build the result (no-arg constructor for Bash)
|
|
183
|
+
def build_result
|
|
184
|
+
MergeResult.new
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/bash/merge.rb
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# External gems
|
|
4
|
+
# TreeHaver provides a unified cross-Ruby interface to tree-sitter
|
|
5
|
+
require "tree_haver"
|
|
6
|
+
|
|
7
|
+
# BACKEND COMPATIBILITY for Bash:
|
|
8
|
+
# - FFI: Most portable and reliable with bash grammar (recommended)
|
|
9
|
+
# - MRI: Has ABI incompatibility with bash grammar
|
|
10
|
+
# - Rust: Has version mismatch with bash grammar
|
|
11
|
+
#
|
|
12
|
+
# Set TREE_HAVER_BACKEND=ffi (or mri/rust) to control backend selection.
|
|
13
|
+
# When MRI loads a grammar first, FFI gets incompatible pointers (symbol conflict).
|
|
14
|
+
# MRI statically links tree-sitter, FFI dynamically links libtree-sitter.so.
|
|
15
|
+
|
|
16
|
+
# Register tree-sitter bash grammar
|
|
17
|
+
bash_finder = TreeHaver::GrammarFinder.new(:bash)
|
|
18
|
+
bash_available = bash_finder.available?
|
|
19
|
+
bash_finder.register! if bash_available
|
|
20
|
+
|
|
21
|
+
# Only warn if the grammar file is actually missing (not just runtime unavailable)
|
|
22
|
+
# When the runtime isn't available, tree-sitter backends just won't be used,
|
|
23
|
+
# which is expected behavior - no need to warn the user.
|
|
24
|
+
unless bash_available
|
|
25
|
+
grammar_path = bash_finder.find_library_path
|
|
26
|
+
unless grammar_path
|
|
27
|
+
warn "WARNING: Bash grammar not available. #{bash_finder.not_found_message}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
require "version_gem"
|
|
32
|
+
require "set"
|
|
33
|
+
|
|
34
|
+
# Shared merge infrastructure
|
|
35
|
+
require "ast/merge"
|
|
36
|
+
|
|
37
|
+
# This gem
|
|
38
|
+
require_relative "merge/version"
|
|
39
|
+
|
|
40
|
+
# Bash::Merge provides a generic Bash script smart merge system using tree-sitter AST analysis.
|
|
41
|
+
# It intelligently merges template and destination Bash scripts by identifying matching
|
|
42
|
+
# statements and resolving differences using structural signatures.
|
|
43
|
+
#
|
|
44
|
+
# @example Basic usage
|
|
45
|
+
# template = File.read("template.sh")
|
|
46
|
+
# destination = File.read("destination.sh")
|
|
47
|
+
# merger = Bash::Merge::SmartMerger.new(template, destination)
|
|
48
|
+
# result = merger.merge
|
|
49
|
+
#
|
|
50
|
+
# @example With debug information
|
|
51
|
+
# merger = Bash::Merge::SmartMerger.new(template, destination)
|
|
52
|
+
# debug_result = merger.merge_with_debug
|
|
53
|
+
# puts debug_result[:content]
|
|
54
|
+
# puts debug_result[:statistics]
|
|
55
|
+
module Bash
|
|
56
|
+
# Smart merge system for Bash scripts using tree-sitter AST analysis.
|
|
57
|
+
# Provides intelligent merging by understanding Bash structure
|
|
58
|
+
# rather than treating files as plain text.
|
|
59
|
+
#
|
|
60
|
+
# @see SmartMerger Main entry point for merge operations
|
|
61
|
+
# @see FileAnalysis Analyzes Bash structure
|
|
62
|
+
# @see ConflictResolver Resolves content conflicts
|
|
63
|
+
module Merge
|
|
64
|
+
# Base error class for Bash::Merge
|
|
65
|
+
# Inherits from Ast::Merge::Error for consistency across merge gems.
|
|
66
|
+
class Error < Ast::Merge::Error; end
|
|
67
|
+
|
|
68
|
+
# Raised when a Bash script has parsing errors.
|
|
69
|
+
# Inherits from Ast::Merge::ParseError for consistency across merge gems.
|
|
70
|
+
#
|
|
71
|
+
# @example Handling parse errors
|
|
72
|
+
# begin
|
|
73
|
+
# analysis = FileAnalysis.new(bash_content)
|
|
74
|
+
# rescue ParseError => e
|
|
75
|
+
# puts "Bash syntax error: #{e.message}"
|
|
76
|
+
# e.errors.each { |error| puts " #{error}" }
|
|
77
|
+
# end
|
|
78
|
+
class ParseError < Ast::Merge::ParseError
|
|
79
|
+
# @param message [String, nil] Error message (auto-generated if nil)
|
|
80
|
+
# @param content [String, nil] The Bash source that failed to parse
|
|
81
|
+
# @param errors [Array] Parse errors from tree-sitter
|
|
82
|
+
def initialize(message = nil, content: nil, errors: [])
|
|
83
|
+
super(message, errors: errors, content: content)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raised when the template file has syntax errors.
|
|
88
|
+
#
|
|
89
|
+
# @example Handling template parse errors
|
|
90
|
+
# begin
|
|
91
|
+
# merger = SmartMerger.new(template, destination)
|
|
92
|
+
# result = merger.merge
|
|
93
|
+
# rescue TemplateParseError => e
|
|
94
|
+
# puts "Template syntax error: #{e.message}"
|
|
95
|
+
# e.errors.each do |error|
|
|
96
|
+
# puts " #{error.message}"
|
|
97
|
+
# end
|
|
98
|
+
# end
|
|
99
|
+
class TemplateParseError < ParseError; end
|
|
100
|
+
|
|
101
|
+
# Raised when the destination file has syntax errors.
|
|
102
|
+
#
|
|
103
|
+
# @example Handling destination parse errors
|
|
104
|
+
# begin
|
|
105
|
+
# merger = SmartMerger.new(template, destination)
|
|
106
|
+
# result = merger.merge
|
|
107
|
+
# rescue DestinationParseError => e
|
|
108
|
+
# puts "Destination syntax error: #{e.message}"
|
|
109
|
+
# e.errors.each do |error|
|
|
110
|
+
# puts " #{error.message}"
|
|
111
|
+
# end
|
|
112
|
+
# end
|
|
113
|
+
class DestinationParseError < ParseError; end
|
|
114
|
+
|
|
115
|
+
autoload :CommentTracker, "bash/merge/comment_tracker"
|
|
116
|
+
autoload :DebugLogger, "bash/merge/debug_logger"
|
|
117
|
+
autoload :Emitter, "bash/merge/emitter"
|
|
118
|
+
autoload :FreezeNode, "bash/merge/freeze_node"
|
|
119
|
+
autoload :FileAnalysis, "bash/merge/file_analysis"
|
|
120
|
+
autoload :MergeResult, "bash/merge/merge_result"
|
|
121
|
+
autoload :NodeWrapper, "bash/merge/node_wrapper"
|
|
122
|
+
autoload :ConflictResolver, "bash/merge/conflict_resolver"
|
|
123
|
+
autoload :SmartMerger, "bash/merge/smart_merger"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Bash::Merge::Version.class_eval do
|
|
128
|
+
extend VersionGem::Basic
|
|
129
|
+
end
|
data/lib/bash-merge.rb
ADDED