dotenv-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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # Result container for dotenv file merge operations.
6
+ # Inherits from Ast::Merge::MergeResultBase for shared functionality.
7
+ #
8
+ # Tracks merged content, decisions made during merge, and provides
9
+ # methods to reconstruct the final merged dotenv file.
10
+ #
11
+ # @example Basic usage
12
+ # result = MergeResult.new(template_analysis, dest_analysis)
13
+ # result.add_from_template(0)
14
+ # result.add_from_destination(1)
15
+ # merged_content = result.to_s
16
+ #
17
+ # @see Ast::Merge::MergeResultBase
18
+ class MergeResult < Ast::Merge::MergeResultBase
19
+ # Decision indicating content was preserved from a freeze block
20
+ # @return [Symbol]
21
+ DECISION_FREEZE_BLOCK = :freeze_block
22
+
23
+ # Decision indicating content came from the template
24
+ # @return [Symbol]
25
+ DECISION_TEMPLATE = :template
26
+
27
+ # Decision indicating content came from the destination (customization preserved)
28
+ # @return [Symbol]
29
+ DECISION_DESTINATION = :destination
30
+
31
+ # Decision indicating content was added from template (new in template)
32
+ # @return [Symbol]
33
+ DECISION_ADDED = :added
34
+
35
+ # Initialize a new merge result
36
+ # @param template_analysis [FileAnalysis] Analysis of the template file
37
+ # @param dest_analysis [FileAnalysis] Analysis of the destination file
38
+ def initialize(template_analysis, dest_analysis)
39
+ super(template_analysis: template_analysis, dest_analysis: dest_analysis)
40
+ end
41
+
42
+ # Add content from the template at the given statement index
43
+ # @param index [Integer] Statement index in template
44
+ # @param decision [Symbol] Decision type (default: DECISION_TEMPLATE)
45
+ # @return [void]
46
+ def add_from_template(index, decision: DECISION_TEMPLATE)
47
+ statement = @template_analysis.statements[index]
48
+ return unless statement
49
+
50
+ lines = extract_lines(statement)
51
+ @lines.concat(lines)
52
+ @decisions << {decision: decision, source: :template, index: index, lines: lines.length}
53
+ end
54
+
55
+ # Add content from the destination at the given statement index
56
+ # @param index [Integer] Statement index in destination
57
+ # @param decision [Symbol] Decision type (default: DECISION_DESTINATION)
58
+ # @return [void]
59
+ def add_from_destination(index, decision: DECISION_DESTINATION)
60
+ statement = @dest_analysis.statements[index]
61
+ return unless statement
62
+
63
+ lines = extract_lines(statement)
64
+ @lines.concat(lines)
65
+ @decisions << {decision: decision, source: :destination, index: index, lines: lines.length}
66
+ end
67
+
68
+ # Add content from a freeze block
69
+ # @param freeze_node [FreezeNode] The freeze block to add
70
+ # @return [void]
71
+ def add_freeze_block(freeze_node)
72
+ lines = freeze_node.lines.map(&:raw)
73
+ @lines.concat(lines)
74
+ @decisions << {
75
+ decision: DECISION_FREEZE_BLOCK,
76
+ source: :destination,
77
+ start_line: freeze_node.start_line,
78
+ end_line: freeze_node.end_line,
79
+ lines: lines.length,
80
+ }
81
+ end
82
+
83
+ # Add raw content lines
84
+ # @param lines [Array<String>] Lines to add
85
+ # @param decision [Symbol] Decision type
86
+ # @return [void]
87
+ def add_raw(lines, decision:)
88
+ @lines.concat(lines)
89
+ @decisions << {decision: decision, source: :raw, lines: lines.length}
90
+ end
91
+
92
+ # Convert the merged result to a string
93
+ # @return [String] The merged dotenv content
94
+ def to_s
95
+ return "" if @lines.empty?
96
+
97
+ # Join with newlines and ensure file ends with newline
98
+ result = @lines.join("\n")
99
+ result += "\n" unless result.end_with?("\n")
100
+ result
101
+ end
102
+
103
+ # Check if any content has been added
104
+ # @return [Boolean]
105
+ def empty?
106
+ @lines.empty?
107
+ end
108
+
109
+ # Get summary of merge decisions
110
+ # @return [Hash] Summary with counts by decision type
111
+ def summary
112
+ counts = @decisions.group_by { |d| d[:decision] }.transform_values(&:count)
113
+ {
114
+ total_decisions: @decisions.length,
115
+ total_lines: @lines.length,
116
+ by_decision: counts,
117
+ }
118
+ end
119
+
120
+ private
121
+
122
+ # Extract lines from a statement
123
+ # @param statement [EnvLine, FreezeNode] The statement
124
+ # @return [Array<String>]
125
+ def extract_lines(statement)
126
+ case statement
127
+ when FreezeNode
128
+ statement.lines.map(&:raw)
129
+ when EnvLine
130
+ [statement.raw]
131
+ else
132
+ []
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # Smart merger for dotenv files.
6
+ # Intelligently combines template and destination dotenv files by matching
7
+ # environment variable names and preserving customizations.
8
+ #
9
+ # @example Basic merge
10
+ # merger = SmartMerger.new(template_content, dest_content)
11
+ # result = merger.merge
12
+ # puts result.to_s
13
+ #
14
+ # @example With options
15
+ # merger = SmartMerger.new(
16
+ # template_content,
17
+ # dest_content,
18
+ # preference: :template,
19
+ # add_template_only_nodes: true,
20
+ # )
21
+ # result = merger.merge
22
+ class SmartMerger
23
+ # @return [FileAnalysis] Analysis of template file
24
+ attr_reader :template_analysis
25
+
26
+ # @return [FileAnalysis] Analysis of destination file
27
+ attr_reader :dest_analysis
28
+
29
+ # Initialize a new SmartMerger
30
+ #
31
+ # @param template_content [String] Content of the template dotenv file
32
+ # @param dest_content [String] Content of the destination dotenv file
33
+ # @param preference [Symbol] Which version to prefer on match
34
+ # (:template or :destination, default: :destination)
35
+ # @param add_template_only_nodes [Boolean] Whether to add template-only env vars
36
+ # (default: false)
37
+ # @param freeze_token [String] Token for freeze block markers
38
+ # (default: "dotenv-merge")
39
+ # @param signature_generator [Proc, nil] Custom signature generator
40
+ def initialize(
41
+ template_content,
42
+ dest_content,
43
+ preference: :destination,
44
+ add_template_only_nodes: false,
45
+ freeze_token: FileAnalysis::DEFAULT_FREEZE_TOKEN,
46
+ signature_generator: nil
47
+ )
48
+ @preference = preference
49
+ @add_template_only_nodes = add_template_only_nodes
50
+
51
+ # Parse template
52
+ @template_analysis = FileAnalysis.new(
53
+ template_content,
54
+ freeze_token: freeze_token,
55
+ signature_generator: signature_generator,
56
+ )
57
+
58
+ # Parse destination
59
+ @dest_analysis = FileAnalysis.new(
60
+ dest_content,
61
+ freeze_token: freeze_token,
62
+ signature_generator: signature_generator,
63
+ )
64
+
65
+ @result = MergeResult.new(@template_analysis, @dest_analysis)
66
+ end
67
+
68
+ # Perform the merge operation
69
+ #
70
+ # @return [String] The merged content as a string
71
+ def merge
72
+ merge_result.to_s
73
+ end
74
+
75
+ # Perform the merge operation and return the full result object
76
+ #
77
+ # @return [MergeResult] The merge result containing merged content
78
+ def merge_result
79
+ return @merge_result if @merge_result
80
+
81
+ @merge_result = DebugLogger.time("SmartMerger#merge") do
82
+ alignment = align_statements
83
+
84
+ DebugLogger.debug("Alignment complete", {
85
+ total_entries: alignment.size,
86
+ matches: alignment.count { |e| e[:type] == :match },
87
+ template_only: alignment.count { |e| e[:type] == :template_only },
88
+ dest_only: alignment.count { |e| e[:type] == :dest_only },
89
+ })
90
+
91
+ process_alignment(alignment)
92
+ @result
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ # Align statements between template and destination
99
+ # @return [Array<Hash>] Alignment entries
100
+ def align_statements
101
+ template_stmts = @template_analysis.statements
102
+ dest_stmts = @dest_analysis.statements
103
+
104
+ # Build signature maps
105
+ _template_sigs = build_signature_map(template_stmts, @template_analysis)
106
+ dest_sigs = build_signature_map(dest_stmts, @dest_analysis)
107
+
108
+ alignment = []
109
+ matched_dest_indices = Set.new
110
+
111
+ # First pass: find matches for template statements
112
+ template_stmts.each_with_index do |stmt, t_idx|
113
+ sig = @template_analysis.generate_signature(stmt)
114
+
115
+ if sig && dest_sigs.key?(sig)
116
+ d_idx = dest_sigs[sig]
117
+ alignment << {
118
+ type: :match,
119
+ template_stmt: stmt,
120
+ dest_stmt: dest_stmts[d_idx],
121
+ template_index: t_idx,
122
+ dest_index: d_idx,
123
+ signature: sig,
124
+ }
125
+ matched_dest_indices << d_idx
126
+ else
127
+ alignment << {
128
+ type: :template_only,
129
+ template_stmt: stmt,
130
+ template_index: t_idx,
131
+ signature: sig,
132
+ }
133
+ end
134
+ end
135
+
136
+ # Second pass: add destination-only statements
137
+ dest_stmts.each_with_index do |stmt, d_idx|
138
+ next if matched_dest_indices.include?(d_idx)
139
+
140
+ alignment << {
141
+ type: :dest_only,
142
+ dest_stmt: stmt,
143
+ dest_index: d_idx,
144
+ signature: @dest_analysis.generate_signature(stmt),
145
+ }
146
+ end
147
+
148
+ # Sort by destination order (preserve dest structure), then template order for additions
149
+ sort_alignment(alignment, dest_stmts.size)
150
+ end
151
+
152
+ # Build a map of signature => statement index
153
+ # @param statements [Array] Statements
154
+ # @param analysis [FileAnalysis] Analysis for signature generation
155
+ # @return [Hash]
156
+ def build_signature_map(statements, analysis)
157
+ map = {}
158
+ statements.each_with_index do |stmt, idx|
159
+ sig = analysis.generate_signature(stmt)
160
+ # First occurrence wins
161
+ map[sig] ||= idx if sig
162
+ end
163
+ map
164
+ end
165
+
166
+ # Sort alignment entries for output
167
+ # @param alignment [Array<Hash>] Alignment entries
168
+ # @param dest_size [Integer] Number of destination statements
169
+ # @return [Array<Hash>]
170
+ def sort_alignment(alignment, dest_size)
171
+ alignment.sort_by do |entry|
172
+ case entry[:type]
173
+ when :match
174
+ # Matches: use destination position
175
+ [entry[:dest_index], 0]
176
+ when :dest_only
177
+ # Destination-only: use destination position
178
+ [entry[:dest_index], 0]
179
+ when :template_only
180
+ # Template-only: add at end, in template order
181
+ [dest_size + entry[:template_index], 1]
182
+ end
183
+ end
184
+ end
185
+
186
+ # Process alignment entries and build result
187
+ # @param alignment [Array<Hash>] Alignment entries
188
+ # @return [void]
189
+ def process_alignment(alignment)
190
+ alignment.each do |entry|
191
+ case entry[:type]
192
+ when :match
193
+ process_match(entry)
194
+ when :template_only
195
+ process_template_only(entry)
196
+ when :dest_only
197
+ process_dest_only(entry)
198
+ end
199
+ end
200
+ end
201
+
202
+ # Process a matched entry
203
+ # @param entry [Hash] Alignment entry
204
+ # @return [void]
205
+ def process_match(entry)
206
+ dest_stmt = entry[:dest_stmt]
207
+
208
+ # Freeze blocks always win
209
+ if dest_stmt.is_a?(FreezeNode)
210
+ @result.add_freeze_block(dest_stmt)
211
+ return
212
+ end
213
+
214
+ # Apply preference
215
+ case @preference
216
+ when :template
217
+ @result.add_from_template(entry[:template_index], decision: MergeResult::DECISION_TEMPLATE)
218
+ when :destination
219
+ @result.add_from_destination(entry[:dest_index], decision: MergeResult::DECISION_DESTINATION)
220
+ else
221
+ @result.add_from_destination(entry[:dest_index], decision: MergeResult::DECISION_DESTINATION)
222
+ end
223
+ end
224
+
225
+ # Process a template-only entry
226
+ # @param entry [Hash] Alignment entry
227
+ # @return [void]
228
+ def process_template_only(entry)
229
+ return unless @add_template_only_nodes
230
+
231
+ # Skip comments and blank lines from template
232
+ stmt = entry[:template_stmt]
233
+ return if stmt.is_a?(EnvLine) && (stmt.comment? || stmt.blank?)
234
+
235
+ @result.add_from_template(entry[:template_index], decision: MergeResult::DECISION_ADDED)
236
+ end
237
+
238
+ # Process a destination-only entry
239
+ # @param entry [Hash] Alignment entry
240
+ # @return [void]
241
+ def process_dest_only(entry)
242
+ dest_stmt = entry[:dest_stmt]
243
+
244
+ if dest_stmt.is_a?(FreezeNode)
245
+ @result.add_freeze_block(dest_stmt)
246
+ else
247
+ @result.add_from_destination(entry[:dest_index], decision: MergeResult::DECISION_DESTINATION)
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # Version information for Dotenv::Merge
6
+ module Version
7
+ # Current version of the dotenv-merge gem
8
+ VERSION = "1.0.0"
9
+ end
10
+ VERSION = Version::VERSION # traditional location
11
+ end
12
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External gems
4
+ require "version_gem"
5
+ require "set"
6
+
7
+ # Shared merge infrastructure
8
+ require "ast/merge"
9
+
10
+ # This gem
11
+ require_relative "merge/version"
12
+
13
+ module Dotenv
14
+ module Merge
15
+ # Base error class for dotenv-merge
16
+ # Inherits from Ast::Merge::Error for consistency across merge gems.
17
+ class Error < Ast::Merge::Error; end
18
+
19
+ # Raised when a dotenv file has parsing errors.
20
+ # Inherits from Ast::Merge::ParseError for consistency across merge gems.
21
+ #
22
+ # @example Handling parse errors
23
+ # begin
24
+ # analysis = FileAnalysis.new(env_content)
25
+ # rescue ParseError => e
26
+ # puts "Dotenv syntax error: #{e.message}"
27
+ # e.errors.each { |error| puts " #{error}" }
28
+ # end
29
+ class ParseError < Ast::Merge::ParseError
30
+ # @param message [String, nil] Error message (auto-generated if nil)
31
+ # @param content [String, nil] The dotenv source that failed to parse
32
+ # @param errors [Array] Parse errors
33
+ def initialize(message = nil, content: nil, errors: [])
34
+ super(message, errors: errors, content: content)
35
+ end
36
+ end
37
+
38
+ # Raised when the template file cannot be parsed.
39
+ #
40
+ # @example Handling template parse errors
41
+ # begin
42
+ # merger = SmartMerger.new(template, destination)
43
+ # result = merger.merge
44
+ # rescue TemplateParseError => e
45
+ # puts "Template syntax error: #{e.message}"
46
+ # e.errors.each { |error| puts " #{error.message}" }
47
+ # end
48
+ class TemplateParseError < ParseError; end
49
+
50
+ # Raised when the destination file cannot be parsed.
51
+ #
52
+ # @example Handling destination parse errors
53
+ # begin
54
+ # merger = SmartMerger.new(template, destination)
55
+ # result = merger.merge
56
+ # rescue DestinationParseError => e
57
+ # puts "Destination syntax error: #{e.message}"
58
+ # e.errors.each { |error| puts " #{error.message}" }
59
+ # end
60
+ class DestinationParseError < ParseError; end
61
+
62
+ autoload :DebugLogger, "dotenv/merge/debug_logger"
63
+ autoload :EnvLine, "dotenv/merge/env_line"
64
+ autoload :FreezeNode, "dotenv/merge/freeze_node"
65
+ autoload :FileAnalysis, "dotenv/merge/file_analysis"
66
+ autoload :MergeResult, "dotenv/merge/merge_result"
67
+ autoload :SmartMerger, "dotenv/merge/smart_merger"
68
+ end
69
+ end
70
+
71
+ Dotenv::Merge::Version.class_eval do
72
+ extend VersionGem::Basic
73
+ end
@@ -0,0 +1,4 @@
1
+ # For technical reasons, if we move to Zeitwerk, this cannot be require_relative.
2
+ # See: https://github.com/fxn/zeitwerk#for_gem_extension
3
+ # Hook for other libraries to load this library (e.g. via bundler)
4
+ require "dotenv/merge"
@@ -0,0 +1,90 @@
1
+ module Dotenv
2
+ module Merge
3
+ # Represents a single line in a dotenv file.
4
+ # Parses and categorizes lines as assignments, comments, blank lines, or invalid.
5
+ class EnvLine
6
+ # Prefix for exported environment variables
7
+ EXPORT_PREFIX: String
8
+
9
+ # Location struct for compatibility with AST nodes
10
+ class Location < Struct[Integer]
11
+ attr_accessor start_line: Integer
12
+ attr_accessor end_line: Integer
13
+
14
+ # Check if a line number falls within this location
15
+ def cover?: (Integer line_number) -> bool
16
+ end
17
+
18
+ # The original raw line content
19
+ attr_reader raw: String
20
+
21
+ # The 1-indexed line number in the source file
22
+ attr_reader line_number: Integer
23
+
24
+ # The line type (:assignment, :comment, :blank, :invalid)
25
+ attr_reader type: Symbol?
26
+
27
+ # The environment variable key (for assignments)
28
+ attr_reader key: String?
29
+
30
+ # The environment variable value (for assignments)
31
+ attr_reader value: String?
32
+
33
+ # Whether the line has an export prefix
34
+ attr_reader export: bool
35
+
36
+ # Initialize a new EnvLine by parsing the raw content
37
+ def initialize: (String raw, Integer line_number) -> void
38
+
39
+ # Generate a unique signature for this line (used for merge matching)
40
+ def signature: () -> Array[Symbol | String]?
41
+
42
+ # Get a location object for this line
43
+ def location: () -> Location
44
+
45
+ # Check if this line is an environment variable assignment
46
+ def assignment?: () -> bool
47
+
48
+ # Check if this line is a comment
49
+ def comment?: () -> bool
50
+
51
+ # Check if this line is blank (empty or whitespace only)
52
+ def blank?: () -> bool
53
+
54
+ # Check if this line is invalid (unparseable)
55
+ def invalid?: () -> bool
56
+
57
+ # Check if this line has the export prefix
58
+ def export?: () -> bool
59
+
60
+ # Get the raw comment text (for comment lines only)
61
+ def comment: () -> String?
62
+
63
+ # Convert to string representation (returns raw content)
64
+ def to_s: () -> String
65
+
66
+ # Inspect for debugging
67
+ def inspect: () -> String
68
+
69
+ private
70
+
71
+ # Parse the raw line content and set type, key, value, and export
72
+ def parse!: () -> void
73
+
74
+ # Parse a potential assignment line
75
+ def parse_assignment!: (String stripped) -> void
76
+
77
+ # Validate an environment variable key
78
+ def valid_key?: (String? key) -> bool
79
+
80
+ # Remove quotes from a value and process escape sequences
81
+ def unquote: (String value) -> String
82
+
83
+ # Process escape sequences in double-quoted strings
84
+ def process_escape_sequences: (String value) -> String
85
+
86
+ # Strip inline comments from unquoted values
87
+ def strip_inline_comment: (String value) -> String
88
+ end
89
+ end
90
+ end