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.
@@ -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