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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -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 +820 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/dotenv/merge/debug_logger.rb +23 -0
- data/lib/dotenv/merge/env_line.rb +252 -0
- data/lib/dotenv/merge/file_analysis.rb +231 -0
- data/lib/dotenv/merge/freeze_node.rb +62 -0
- data/lib/dotenv/merge/merge_result.rb +137 -0
- data/lib/dotenv/merge/smart_merger.rb +252 -0
- data/lib/dotenv/merge/version.rb +12 -0
- data/lib/dotenv/merge.rb +73 -0
- data/lib/dotenv-merge.rb +4 -0
- data/sig/dotenv/merge/env_line.rbs +90 -0
- data/sig/dotenv/merge.rbs +212 -0
- data.tar.gz.sig +0 -0
- metadata +304 -0
- metadata.gz.sig +4 -0
|
@@ -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
|
data/lib/dotenv/merge.rb
ADDED
|
@@ -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
|
data/lib/dotenv-merge.rb
ADDED
|
@@ -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
|