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.
@@ -75,11 +75,11 @@ module Bash
75
75
  # String representation for debugging
76
76
  # @return [String]
77
77
  def inspect
78
- # :nocov:
78
+ # simplecov:disable
79
79
  # Defensive: slice is always set via resolve_content in FreezeNodeBase#initialize
80
80
  # The || 0 branch is normally unreachable because validate_structure! ensures non-empty content
81
81
  content_length = slice&.length || 0
82
- # :nocov:
82
+ # simplecov:enable
83
83
  "#<#{self.class.name} lines=#{start_line}..#{end_line} content_length=#{content_length}>"
84
84
  end
85
85
 
@@ -88,13 +88,13 @@ module Bash
88
88
  def validate_structure!
89
89
  validate_line_order!
90
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
91
+ return unless @lines.empty? || @lines.all?(&:nil?)
92
+
93
+ raise InvalidStructureError.new(
94
+ 'Freeze block is empty',
95
+ start_line: @start_line,
96
+ end_line: @end_line
97
+ )
98
98
  end
99
99
  end
100
100
  end
@@ -12,6 +12,8 @@ module Bash
12
12
  # result.add_line("echo 'hello'", decision: :kept_template, source: :template)
13
13
  # result.to_bash # => "echo 'hello'\n"
14
14
  class MergeResult < Ast::Merge::MergeResultBase
15
+ include Ast::Merge::StructuredReviewApplySupport
16
+
15
17
  # Inherit decision constants from base class
16
18
  DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
17
19
  DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
@@ -31,7 +33,7 @@ module Bash
31
33
  dest_lines: 0,
32
34
  merged_lines: 0,
33
35
  freeze_preserved_lines: 0,
34
- total_decisions: 0,
36
+ total_decisions: 0
35
37
  }
36
38
  end
37
39
 
@@ -46,7 +48,7 @@ module Bash
46
48
  content: line,
47
49
  decision: decision,
48
50
  source: source,
49
- original_line: original_line,
51
+ original_line: original_line
50
52
  }
51
53
 
52
54
  track_statistics(decision, source)
@@ -71,7 +73,7 @@ module Bash
71
73
  # @param decision [Symbol] Decision for the blank line
72
74
  # @param source [Symbol] Source
73
75
  def add_blank_line(decision: DECISION_MERGED, source: :merged)
74
- add_line("", decision: decision, source: source)
76
+ add_line('', decision: decision, source: source)
75
77
  end
76
78
 
77
79
  # Add content from a freeze block
@@ -83,7 +85,7 @@ module Bash
83
85
  line.chomp,
84
86
  decision: DECISION_FREEZE_BLOCK,
85
87
  source: :destination,
86
- original_line: freeze_node.start_line + idx,
88
+ original_line: freeze_node.start_line + idx
87
89
  )
88
90
  end
89
91
  end
@@ -121,9 +123,13 @@ module Bash
121
123
  to_bash
122
124
  end
123
125
 
126
+ def to_s
127
+ to_bash
128
+ end
129
+
124
130
  private
125
131
 
126
- def track_statistics(decision, source)
132
+ def track_statistics(decision, _source)
127
133
  @statistics[:total_decisions] += 1
128
134
 
129
135
  case decision
@@ -19,22 +19,24 @@ module Bash
19
19
  #
20
20
  # @see Ast::Merge::NodeWrapperBase
21
21
  class NodeWrapper < Ast::Merge::NodeWrapperBase
22
+ attr_reader :node
23
+
22
24
  # Check if this is a function definition
23
25
  # @return [Boolean]
24
26
  def function_definition?
25
- @node.type.to_s == "function_definition"
27
+ @node.type.to_s == 'function_definition'
26
28
  end
27
29
 
28
30
  # Check if this is a variable assignment
29
31
  # @return [Boolean]
30
32
  def variable_assignment?
31
- @node.type.to_s == "variable_assignment"
33
+ @node.type.to_s == 'variable_assignment'
32
34
  end
33
35
 
34
36
  # Check if this is an if statement
35
37
  # @return [Boolean]
36
38
  def if_statement?
37
- @node.type.to_s == "if_statement"
39
+ @node.type.to_s == 'if_statement'
38
40
  end
39
41
 
40
42
  # Check if this is a for loop
@@ -46,31 +48,31 @@ module Bash
46
48
  # Check if this is a while loop
47
49
  # @return [Boolean]
48
50
  def while_statement?
49
- @node.type.to_s == "while_statement"
51
+ @node.type.to_s == 'while_statement'
50
52
  end
51
53
 
52
54
  # Check if this is a case statement
53
55
  # @return [Boolean]
54
56
  def case_statement?
55
- @node.type.to_s == "case_statement"
57
+ @node.type.to_s == 'case_statement'
56
58
  end
57
59
 
58
60
  # Check if this is a command
59
61
  # @return [Boolean]
60
62
  def command?
61
- @node.type.to_s == "command"
63
+ @node.type.to_s == 'command'
62
64
  end
63
65
 
64
66
  # Check if this is a pipeline
65
67
  # @return [Boolean]
66
68
  def pipeline?
67
- @node.type.to_s == "pipeline"
69
+ @node.type.to_s == 'pipeline'
68
70
  end
69
71
 
70
72
  # Check if this is a comment
71
73
  # @return [Boolean]
72
74
  def comment?
73
- @node.type.to_s == "comment"
75
+ @node.type.to_s == 'comment'
74
76
  end
75
77
 
76
78
  # Get the function name if this is a function definition
@@ -79,7 +81,7 @@ module Bash
79
81
  return unless function_definition?
80
82
 
81
83
  # In bash tree-sitter, function name is in a 'name' or 'word' child
82
- name_node = find_child_by_type("word") || find_child_by_field("name")
84
+ name_node = find_child_by_type('word') || find_child_by_field('name')
83
85
  node_text(name_node) if name_node
84
86
  end
85
87
 
@@ -89,7 +91,7 @@ module Bash
89
91
  return unless variable_assignment?
90
92
 
91
93
  # In bash tree-sitter, variable name is a child of type 'variable_name'
92
- name_node = find_child_by_type("variable_name")
94
+ name_node = find_child_by_type('variable_name')
93
95
  node_text(name_node) if name_node
94
96
  end
95
97
 
@@ -107,6 +109,18 @@ module Bash
107
109
  nil
108
110
  end
109
111
 
112
+ def start_byte = node.start_byte
113
+ def end_byte = node.end_byte
114
+ def source_text = @source.byteslice(start_byte...end_byte).to_s
115
+
116
+ def semantic_tree
117
+ semantic_node(node)
118
+ end
119
+
120
+ def heredoc?
121
+ descendant_type?(node, %w[heredoc_redirect heredoc_body])
122
+ end
123
+
110
124
  # Find a child by field name
111
125
  # @param field_name [String] Field name to look for
112
126
  # @return [TreeSitter::Node, nil]
@@ -139,44 +153,49 @@ module Bash
139
153
  node_type = node.type.to_s
140
154
 
141
155
  case node_type
142
- when "program"
156
+ when 'program'
143
157
  # Root node - signature based on direct children structure
144
158
  child_types = []
145
- node.each { |child| child_types << child.type.to_s unless child.type.to_s == "comment" }
159
+ node.each { |child| child_types << child.type.to_s unless child.type.to_s == 'comment' }
146
160
  [:program, child_types.length]
147
- when "function_definition"
161
+ when 'function_definition'
148
162
  # Functions are identified by their name
149
163
  name = function_name
150
164
  [:function_definition, name]
151
- when "variable_assignment"
165
+ when 'variable_assignment'
152
166
  # Variable assignments are identified by variable name
153
167
  name = variable_name
154
168
  [:variable_assignment, name]
155
- when "command"
156
- # Commands identified by their command name
169
+ when 'command'
170
+ # Commands identified by their command name and arguments.
171
+ # Arguments are included so that `PATH_add exe` and `PATH_add bin`
172
+ # get distinct signatures, while `echo "hello"` repeated twice gets
173
+ # the same signature — the resolver handles positional matching for
174
+ # nodes with identical signatures.
157
175
  name = command_name
158
- [:command, name, extract_command_signature_context(node)]
159
- when "if_statement"
176
+ args = extract_command_arguments(node)
177
+ [:command, name, args, extract_command_signature_context(node)]
178
+ when 'if_statement'
160
179
  # If statements identified by their condition pattern
161
180
  condition = extract_condition_pattern(node)
162
181
  [:if_statement, condition]
163
- when "for_statement", "c_style_for_statement"
182
+ when 'for_statement', 'c_style_for_statement'
164
183
  # For loops identified by their loop variable
165
184
  var = extract_loop_variable(node)
166
185
  [:for_statement, var]
167
- when "while_statement"
186
+ when 'while_statement'
168
187
  # While loops identified by condition
169
188
  condition = extract_condition_pattern(node)
170
189
  [:while_statement, condition]
171
- when "case_statement"
190
+ when 'case_statement'
172
191
  # Case statements identified by the expression being matched
173
192
  expr = extract_case_expression(node)
174
193
  [:case_statement, expr]
175
- when "pipeline"
194
+ when 'pipeline'
176
195
  # Pipelines identified by command names in order
177
196
  commands = extract_pipeline_commands(node)
178
197
  [:pipeline, commands]
179
- when "comment"
198
+ when 'comment'
180
199
  # Comments identified by their content
181
200
  [:comment, node_text(node).strip]
182
201
  else
@@ -188,38 +207,72 @@ module Bash
188
207
 
189
208
  private
190
209
 
210
+ def semantic_node(value)
211
+ children = value.children
212
+ return [value.type.to_s, @source.byteslice(value.start_byte...value.end_byte).to_s] if children.empty?
213
+
214
+ [value.type.to_s, children.select(&:named?).map { |child| semantic_node(child) }]
215
+ end
216
+
217
+ def descendant_type?(value, types)
218
+ return true if types.include?(value.type.to_s)
219
+
220
+ value.children.any? { |child| descendant_type?(child, types) }
221
+ end
222
+
191
223
  def extract_command_signature_context(node)
192
224
  # Extract additional context like redirections
193
225
  redirections = []
194
226
  node.each do |child|
195
- if child.type.to_s.include?("redirect")
196
- redirections << child.type.to_s
197
- end
227
+ redirections << child.type.to_s if child.type.to_s.include?('redirect')
198
228
  end
199
229
  redirections.empty? ? nil : redirections.sort
200
230
  end
201
231
 
232
+ # Extract argument words from a command node.
233
+ # Returns the argument text values (everything after the command name).
234
+ #
235
+ # @param node [Object] A tree-sitter command node
236
+ # @return [Array<String>, nil] Argument values, or nil if none
237
+ def extract_command_arguments(node)
238
+ args = []
239
+ found_command_name = false
240
+ node.each do |child|
241
+ type_s = child.type.to_s
242
+ # Skip comments and redirections
243
+ next if %w[comment file_redirect heredoc_redirect].include?(type_s)
244
+
245
+ if !found_command_name && %w[word command_name].include?(type_s)
246
+ # First word/command_name is the command itself, skip it
247
+ found_command_name = true
248
+ next
249
+ end
250
+
251
+ # Everything after the command name is an argument
252
+ args << node_text(child) if found_command_name
253
+ end
254
+ args.empty? ? nil : args
255
+ end
256
+
202
257
  def extract_condition_pattern(node)
203
258
  # Try to extract the test/condition from if/while statements
204
259
  # Look for test_command, compound_statement, etc.
205
260
  node.each do |child|
206
- if %w[test_command bracket_command].include?(child.type.to_s)
207
- return node_text(child).slice(0, 100).strip
208
- end
261
+ return node_text(child).slice(0, 100).strip if %w[test_command bracket_command].include?(child.type.to_s)
209
262
  end
210
263
  nil
211
264
  end
212
265
 
213
266
  def extract_loop_variable(node)
214
267
  # Extract the loop variable from for statements
215
- var_node = node.each.find { |child| child.type.to_s == "variable_name" }
268
+ var_node = node.each.find { |child| child.type.to_s == 'variable_name' }
216
269
  node_text(var_node) if var_node
217
270
  end
218
271
 
219
272
  def extract_case_expression(node)
220
273
  # Extract the expression being matched in a case statement
221
274
  node.each do |child|
222
- return node_text(child).slice(0, 50).strip if child.type.to_s == "word" || child.type.to_s == "variable_name"
275
+ return node_text(child).slice(0, 50).strip if %w[word variable_name].include?(child.type.to_s)
223
276
  end
224
277
  nil
225
278
  end
@@ -228,11 +281,11 @@ module Bash
228
281
  # Extract command names from a pipeline
229
282
  commands = []
230
283
  node.each do |child|
231
- if child.type.to_s == "command"
232
- wrapper = NodeWrapper.new(child, lines: @lines, source: @source)
233
- cmd_name = wrapper.command_name
234
- commands << cmd_name if cmd_name
235
- end
284
+ next unless child.type.to_s == 'command'
285
+
286
+ wrapper = NodeWrapper.new(child, lines: @lines, source: @source)
287
+ cmd_name = wrapper.command_name
288
+ commands << cmd_name if cmd_name
236
289
  end
237
290
  commands
238
291
  end