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
data/REEK
ADDED
|
File without changes
|
data/RUBOCOP.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# RuboCop Usage Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A tale of two RuboCop plugin gems.
|
|
6
|
+
|
|
7
|
+
### RuboCop Gradual
|
|
8
|
+
|
|
9
|
+
This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
|
|
10
|
+
|
|
11
|
+
### RuboCop LTS
|
|
12
|
+
|
|
13
|
+
This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
|
|
14
|
+
RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
|
|
15
|
+
|
|
16
|
+
## Checking RuboCop Violations
|
|
17
|
+
|
|
18
|
+
To check for RuboCop violations in this project, always use:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle exec rake rubocop_gradual:check
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Do not use** the standard RuboCop commands like:
|
|
25
|
+
- `bundle exec rubocop`
|
|
26
|
+
- `rubocop`
|
|
27
|
+
|
|
28
|
+
## Understanding the Lock File
|
|
29
|
+
|
|
30
|
+
The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
|
|
31
|
+
|
|
32
|
+
1. Prevent new violations while gradually fixing existing ones
|
|
33
|
+
2. Track progress on code style improvements
|
|
34
|
+
3. Ensure CI builds don't fail due to pre-existing violations
|
|
35
|
+
|
|
36
|
+
## Common Commands
|
|
37
|
+
|
|
38
|
+
- **Check violations**
|
|
39
|
+
- `bundle exec rake rubocop_gradual`
|
|
40
|
+
- `bundle exec rake rubocop_gradual:check`
|
|
41
|
+
- **(Safe) Autocorrect violations, and update lockfile if no new violations**
|
|
42
|
+
- `bundle exec rake rubocop_gradual:autocorrect`
|
|
43
|
+
- **Force update the lock file (w/o autocorrect) to match violations present in code**
|
|
44
|
+
- `bundle exec rake rubocop_gradual:force_update`
|
|
45
|
+
|
|
46
|
+
## Workflow
|
|
47
|
+
|
|
48
|
+
1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
|
|
49
|
+
a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
|
|
50
|
+
2. If there are new violations, either:
|
|
51
|
+
- Fix them in your code
|
|
52
|
+
- Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
|
|
53
|
+
3. Commit the updated `.rubocop_gradual.lock` file along with your changes
|
|
54
|
+
|
|
55
|
+
## Never add inline RuboCop disables
|
|
56
|
+
|
|
57
|
+
Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
|
|
58
|
+
|
|
59
|
+
- Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
|
|
60
|
+
- Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
|
|
61
|
+
- `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
|
|
62
|
+
- If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
|
|
63
|
+
|
|
64
|
+
In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
|
|
65
|
+
|
|
66
|
+
## Benefits of rubocop_gradual
|
|
67
|
+
|
|
68
|
+
- Allows incremental adoption of code style rules
|
|
69
|
+
- Prevents CI failures due to pre-existing violations
|
|
70
|
+
- Provides a clear record of code style debt
|
|
71
|
+
- Enables focused efforts on improving code quality over time
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
|----------|-----------|
|
|
7
|
+
| 1.latest | ✅ |
|
|
8
|
+
|
|
9
|
+
## Security contact information
|
|
10
|
+
|
|
11
|
+
To report a security vulnerability, please use the
|
|
12
|
+
[Tidelift security contact](https://tidelift.com/security).
|
|
13
|
+
Tidelift will coordinate the fix and disclosure.
|
|
14
|
+
|
|
15
|
+
## Additional Support
|
|
16
|
+
|
|
17
|
+
If you are interested in support for versions older than the latest release,
|
|
18
|
+
please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
|
|
19
|
+
or find other sponsorship links in the [README].
|
|
20
|
+
|
|
21
|
+
[README]: README.md
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dotenv
|
|
4
|
+
module Merge
|
|
5
|
+
# Debug logging support for dotenv-merge.
|
|
6
|
+
# Extends the base Ast::Merge::DebugLogger with dotenv-specific configuration.
|
|
7
|
+
#
|
|
8
|
+
# @example Enable debug logging
|
|
9
|
+
# ENV["DOTENV_MERGE_DEBUG"] = "true"
|
|
10
|
+
#
|
|
11
|
+
# @example Direct usage
|
|
12
|
+
# Dotenv::Merge::DebugLogger.debug("message", { key: "value" })
|
|
13
|
+
#
|
|
14
|
+
# @see Ast::Merge::DebugLogger
|
|
15
|
+
module DebugLogger
|
|
16
|
+
extend Ast::Merge::DebugLogger
|
|
17
|
+
|
|
18
|
+
# Configure for dotenv-merge
|
|
19
|
+
self.env_var_name = "DOTENV_MERGE_DEBUG"
|
|
20
|
+
self.log_prefix = "[dotenv-merge]"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dotenv
|
|
4
|
+
module Merge
|
|
5
|
+
# Represents a single line in a dotenv file.
|
|
6
|
+
# Parses and categorizes lines as assignments, comments, blank lines, or invalid.
|
|
7
|
+
#
|
|
8
|
+
# Inherits from Ast::Merge::AstNode for a normalized API across all ast-merge
|
|
9
|
+
# content nodes. This provides #slice, #location, #unwrap, and other standard methods.
|
|
10
|
+
#
|
|
11
|
+
# Dotenv files follow a simple format where each line is one of:
|
|
12
|
+
# - `KEY=value` - Environment variable assignment
|
|
13
|
+
# - `export KEY=value` - Assignment with export prefix
|
|
14
|
+
# - `# comment` - Comment line
|
|
15
|
+
# - Empty/whitespace - Blank line
|
|
16
|
+
#
|
|
17
|
+
# @example Parse a simple assignment
|
|
18
|
+
# line = EnvLine.new("API_KEY=secret123", 1)
|
|
19
|
+
# line.assignment? # => true
|
|
20
|
+
# line.key # => "API_KEY"
|
|
21
|
+
# line.value # => "secret123"
|
|
22
|
+
#
|
|
23
|
+
# @example Parse an export statement
|
|
24
|
+
# line = EnvLine.new("export DATABASE_URL=postgres://localhost/db", 2)
|
|
25
|
+
# line.assignment? # => true
|
|
26
|
+
# line.export? # => true
|
|
27
|
+
# line.key # => "DATABASE_URL"
|
|
28
|
+
#
|
|
29
|
+
# @example Parse a comment
|
|
30
|
+
# line = EnvLine.new("# Database configuration", 3)
|
|
31
|
+
# line.comment? # => true
|
|
32
|
+
# line.comment # => "# Database configuration"
|
|
33
|
+
#
|
|
34
|
+
# @example Quoted values with escape sequences
|
|
35
|
+
# line = EnvLine.new('MESSAGE="Hello\nWorld"', 4)
|
|
36
|
+
# line.value # => "Hello\nWorld" (with actual newline)
|
|
37
|
+
class EnvLine < Ast::Merge::AstNode
|
|
38
|
+
# Prefix for exported environment variables
|
|
39
|
+
# @return [String]
|
|
40
|
+
EXPORT_PREFIX = "export "
|
|
41
|
+
|
|
42
|
+
# @return [String] The original raw line content
|
|
43
|
+
attr_reader :raw
|
|
44
|
+
|
|
45
|
+
# @return [Integer] The 1-indexed line number in the source file
|
|
46
|
+
attr_reader :line_number
|
|
47
|
+
|
|
48
|
+
# @return [Symbol, nil] The line type (:assignment, :comment, :blank, :invalid)
|
|
49
|
+
attr_reader :type
|
|
50
|
+
|
|
51
|
+
# @return [String, nil] The environment variable key (for assignments)
|
|
52
|
+
attr_reader :key
|
|
53
|
+
|
|
54
|
+
# @return [String, nil] The environment variable value (for assignments)
|
|
55
|
+
attr_reader :value
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] Whether the line has an export prefix
|
|
58
|
+
attr_reader :export
|
|
59
|
+
|
|
60
|
+
# Initialize a new EnvLine by parsing the raw content
|
|
61
|
+
#
|
|
62
|
+
# @param raw [String] The raw line content from the dotenv file
|
|
63
|
+
# @param line_number [Integer] The 1-indexed line number
|
|
64
|
+
def initialize(raw, line_number)
|
|
65
|
+
@raw = raw
|
|
66
|
+
@line_number = line_number
|
|
67
|
+
@type = nil
|
|
68
|
+
@key = nil
|
|
69
|
+
@value = nil
|
|
70
|
+
@export = false
|
|
71
|
+
parse!
|
|
72
|
+
|
|
73
|
+
location = Ast::Merge::AstNode::Location.new(
|
|
74
|
+
start_line: line_number,
|
|
75
|
+
end_line: line_number,
|
|
76
|
+
start_column: 0,
|
|
77
|
+
end_column: @raw.length,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
super(slice: @raw, location: location)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Generate a unique signature for this line (used for merge matching)
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Symbol, String>, nil] Signature array [:env, key] for assignments, nil otherwise
|
|
86
|
+
def signature
|
|
87
|
+
return unless @type == :assignment
|
|
88
|
+
|
|
89
|
+
[:env, @key]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if this line is an environment variable assignment
|
|
93
|
+
#
|
|
94
|
+
# @return [Boolean] true if the line is a valid KEY=value assignment
|
|
95
|
+
def assignment?
|
|
96
|
+
@type == :assignment
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Check if this line is a comment
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean] true if the line starts with #
|
|
102
|
+
def comment?
|
|
103
|
+
@type == :comment
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check if this line is blank (empty or whitespace only)
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean] true if the line is blank
|
|
109
|
+
def blank?
|
|
110
|
+
@type == :blank
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if this line is invalid (unparseable)
|
|
114
|
+
#
|
|
115
|
+
# @return [Boolean] true if the line could not be parsed
|
|
116
|
+
def invalid?
|
|
117
|
+
@type == :invalid
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check if this line has the export prefix
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean] true if the line starts with "export "
|
|
123
|
+
def export?
|
|
124
|
+
@export
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get the raw comment text (for comment lines only)
|
|
128
|
+
#
|
|
129
|
+
# @return [String, nil] The raw line content if this is a comment, nil otherwise
|
|
130
|
+
def comment
|
|
131
|
+
return @raw if comment?
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Convert to string representation (returns raw content)
|
|
137
|
+
#
|
|
138
|
+
# @return [String] The original raw line content
|
|
139
|
+
def to_s
|
|
140
|
+
@raw
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Inspect for debugging
|
|
144
|
+
#
|
|
145
|
+
# @return [String] A debug representation of this EnvLine
|
|
146
|
+
def inspect
|
|
147
|
+
"#<#{self.class.name} line=#{@line_number} type=#{@type} key=#{@key.inspect}>"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Parse the raw line content and set type, key, value, and export
|
|
153
|
+
#
|
|
154
|
+
# @return [void]
|
|
155
|
+
def parse!
|
|
156
|
+
stripped = @raw.strip
|
|
157
|
+
if stripped.empty?
|
|
158
|
+
@type = :blank
|
|
159
|
+
elsif stripped.start_with?("#")
|
|
160
|
+
@type = :comment
|
|
161
|
+
else
|
|
162
|
+
parse_assignment!(stripped)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Parse a potential assignment line
|
|
167
|
+
#
|
|
168
|
+
# @param stripped [String] The stripped line content
|
|
169
|
+
# @return [void]
|
|
170
|
+
def parse_assignment!(stripped)
|
|
171
|
+
line = stripped
|
|
172
|
+
if line.start_with?(EXPORT_PREFIX)
|
|
173
|
+
@export = true
|
|
174
|
+
line = line[EXPORT_PREFIX.length..]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if line.include?("=")
|
|
178
|
+
key_part, value_part = line.split("=", 2)
|
|
179
|
+
key_part = key_part.strip
|
|
180
|
+
if valid_key?(key_part)
|
|
181
|
+
@type = :assignment
|
|
182
|
+
@key = key_part
|
|
183
|
+
@value = unquote(value_part || "")
|
|
184
|
+
else
|
|
185
|
+
@type = :invalid
|
|
186
|
+
end
|
|
187
|
+
else
|
|
188
|
+
@type = :invalid
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Validate an environment variable key
|
|
193
|
+
#
|
|
194
|
+
# @param key [String, nil] The key to validate
|
|
195
|
+
# @return [Boolean] true if the key is valid (starts with letter/underscore, contains only alphanumerics/underscores)
|
|
196
|
+
def valid_key?(key)
|
|
197
|
+
return false if key.nil? || key.empty?
|
|
198
|
+
|
|
199
|
+
key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Remove quotes from a value and process escape sequences
|
|
203
|
+
#
|
|
204
|
+
# @param value [String] The raw value part after the =
|
|
205
|
+
# @return [String] The unquoted and processed value
|
|
206
|
+
def unquote(value)
|
|
207
|
+
value = value.strip
|
|
208
|
+
|
|
209
|
+
# Double-quoted: process escape sequences
|
|
210
|
+
if value.start_with?('"') && value.end_with?('"')
|
|
211
|
+
return process_escape_sequences(value[1..-2])
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Single-quoted: literal value, no escape processing
|
|
215
|
+
if value.start_with?("'") && value.end_with?("'")
|
|
216
|
+
return value[1..-2]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Unquoted: strip inline comments
|
|
220
|
+
strip_inline_comment(value)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Process escape sequences in double-quoted strings
|
|
224
|
+
#
|
|
225
|
+
# Handles: \n (newline), \t (tab), \r (carriage return), \" (quote), \\ (backslash)
|
|
226
|
+
#
|
|
227
|
+
# @param value [String] The value with escape sequences
|
|
228
|
+
# @return [String] The value with escape sequences converted
|
|
229
|
+
def process_escape_sequences(value)
|
|
230
|
+
value
|
|
231
|
+
.gsub('\n', "\n")
|
|
232
|
+
.gsub('\t', "\t")
|
|
233
|
+
.gsub('\r', "\r")
|
|
234
|
+
.gsub('\"', '"')
|
|
235
|
+
.gsub("\\\\", "\\")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Strip inline comments from unquoted values
|
|
239
|
+
#
|
|
240
|
+
# @param value [String] The unquoted value
|
|
241
|
+
# @return [String] The value with inline comments removed
|
|
242
|
+
def strip_inline_comment(value)
|
|
243
|
+
# Find # that's preceded by whitespace and strip from there
|
|
244
|
+
if (match = value.match(/\s+#/))
|
|
245
|
+
value[0, match.begin(0)].strip
|
|
246
|
+
else
|
|
247
|
+
value
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dotenv
|
|
4
|
+
module Merge
|
|
5
|
+
# File analysis for dotenv files.
|
|
6
|
+
# Parses dotenv source and extracts environment variable assignments,
|
|
7
|
+
# comments, and freeze blocks.
|
|
8
|
+
#
|
|
9
|
+
# Dotenv files follow a simple format:
|
|
10
|
+
# - `KEY=value` - Environment variable assignment
|
|
11
|
+
# - `export KEY=value` - Assignment with export prefix
|
|
12
|
+
# - `# comment` - Comment line
|
|
13
|
+
# - Blank lines are preserved
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# analysis = FileAnalysis.new(dotenv_source)
|
|
17
|
+
# analysis.statements.each do |stmt|
|
|
18
|
+
# puts stmt.class
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example With custom freeze token
|
|
22
|
+
# analysis = FileAnalysis.new(source, freeze_token: "my-merge")
|
|
23
|
+
# # Looks for: # my-merge:freeze / # my-merge:unfreeze
|
|
24
|
+
class FileAnalysis
|
|
25
|
+
include Ast::Merge::FileAnalyzable
|
|
26
|
+
|
|
27
|
+
# Default freeze token for identifying freeze blocks
|
|
28
|
+
# @return [String]
|
|
29
|
+
DEFAULT_FREEZE_TOKEN = "dotenv-merge"
|
|
30
|
+
|
|
31
|
+
# Initialize file analysis with dotenv parser
|
|
32
|
+
#
|
|
33
|
+
# @param source [String] Dotenv source code to analyze
|
|
34
|
+
# @param freeze_token [String] Token for freeze block markers (default: "dotenv-merge")
|
|
35
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
36
|
+
def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil)
|
|
37
|
+
@source = source
|
|
38
|
+
@freeze_token = freeze_token
|
|
39
|
+
@signature_generator = signature_generator
|
|
40
|
+
|
|
41
|
+
# Parse all lines
|
|
42
|
+
@lines = parse_lines(source)
|
|
43
|
+
|
|
44
|
+
# Extract and integrate freeze blocks
|
|
45
|
+
@statements = extract_and_integrate_statements
|
|
46
|
+
|
|
47
|
+
DebugLogger.debug("FileAnalysis initialized", {
|
|
48
|
+
signature_generator: signature_generator ? "custom" : "default",
|
|
49
|
+
lines_count: @lines.size,
|
|
50
|
+
statements_count: @statements.size,
|
|
51
|
+
freeze_blocks: freeze_blocks.size,
|
|
52
|
+
assignments: assignment_lines.size,
|
|
53
|
+
})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check if parse was successful (dotenv always succeeds, may have invalid lines)
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def valid?
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get assignment lines (not in freeze blocks)
|
|
63
|
+
# @return [Array<EnvLine>]
|
|
64
|
+
def assignment_lines
|
|
65
|
+
@statements.select { |stmt| stmt.is_a?(EnvLine) && stmt.assignment? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get all assignment lines including those in freeze blocks
|
|
69
|
+
# @return [Array<EnvLine>]
|
|
70
|
+
def all_assignments
|
|
71
|
+
@lines.select(&:assignment?)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get a specific line (1-indexed)
|
|
75
|
+
# Override base to return EnvLine objects instead of raw strings
|
|
76
|
+
# @param line_number [Integer] Line number (1-indexed)
|
|
77
|
+
# @return [EnvLine, nil] The line object
|
|
78
|
+
def line_at(line_number)
|
|
79
|
+
return if line_number < 1
|
|
80
|
+
|
|
81
|
+
@lines[line_number - 1]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Compute default signature for a node
|
|
85
|
+
# @param node [EnvLine, FreezeNode] The statement
|
|
86
|
+
# @return [Array, nil] Signature array
|
|
87
|
+
def compute_node_signature(node)
|
|
88
|
+
case node
|
|
89
|
+
when FreezeNode
|
|
90
|
+
node.signature
|
|
91
|
+
when EnvLine
|
|
92
|
+
node.signature
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Note: fallthrough_node? is inherited from FileAnalyzable.
|
|
97
|
+
# EnvLine inherits from AstNode and FreezeNode inherits from FreezeNodeBase,
|
|
98
|
+
# both of which are recognized by the base implementation.
|
|
99
|
+
|
|
100
|
+
# Get environment variable by key
|
|
101
|
+
# @param key [String] The environment variable key
|
|
102
|
+
# @return [EnvLine, nil] The assignment line or nil
|
|
103
|
+
def env_var(key)
|
|
104
|
+
@lines.find { |line| line.assignment? && line.key == key }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get all environment variable keys
|
|
108
|
+
# @return [Array<String>] List of keys
|
|
109
|
+
def keys
|
|
110
|
+
all_assignments.map(&:key)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Parse source into EnvLine objects
|
|
116
|
+
# @param source [String] Source content
|
|
117
|
+
# @return [Array<EnvLine>]
|
|
118
|
+
def parse_lines(source)
|
|
119
|
+
source.lines.each_with_index.map do |line, index|
|
|
120
|
+
EnvLine.new(line.chomp, index + 1)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Extract statements, integrating freeze blocks
|
|
125
|
+
# @return [Array<EnvLine, FreezeNode>]
|
|
126
|
+
def extract_and_integrate_statements
|
|
127
|
+
freeze_markers = find_freeze_markers
|
|
128
|
+
return @lines.dup if freeze_markers.empty?
|
|
129
|
+
|
|
130
|
+
# Build freeze blocks from markers
|
|
131
|
+
freeze_blocks = build_freeze_blocks(freeze_markers)
|
|
132
|
+
|
|
133
|
+
# Integrate: replace lines in freeze blocks with FreezeNode
|
|
134
|
+
integrate_freeze_blocks(freeze_blocks)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Find all freeze markers in the source
|
|
138
|
+
# @return [Array<Hash>] Array of marker info hashes
|
|
139
|
+
def find_freeze_markers
|
|
140
|
+
markers = []
|
|
141
|
+
pattern = Ast::Merge::FreezeNodeBase.pattern_for(:hash_comment, @freeze_token)
|
|
142
|
+
|
|
143
|
+
@lines.each do |line|
|
|
144
|
+
next unless line.comment?
|
|
145
|
+
|
|
146
|
+
if line.raw =~ pattern
|
|
147
|
+
marker_type = ::Regexp.last_match(1) # 'freeze' or 'unfreeze'
|
|
148
|
+
reason = ::Regexp.last_match(2)&.strip
|
|
149
|
+
reason = nil if reason&.empty?
|
|
150
|
+
|
|
151
|
+
markers << {
|
|
152
|
+
type: marker_type.to_sym,
|
|
153
|
+
line: line.line_number,
|
|
154
|
+
reason: reason,
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
markers
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Build FreezeNode objects from markers
|
|
163
|
+
# @param markers [Array<Hash>] Freeze markers
|
|
164
|
+
# @return [Array<FreezeNode>]
|
|
165
|
+
def build_freeze_blocks(markers)
|
|
166
|
+
blocks = []
|
|
167
|
+
open_marker = nil
|
|
168
|
+
|
|
169
|
+
markers.each do |marker|
|
|
170
|
+
case marker[:type]
|
|
171
|
+
when :freeze
|
|
172
|
+
if open_marker
|
|
173
|
+
DebugLogger.warning("Nested freeze block at line #{marker[:line]}, ignoring")
|
|
174
|
+
else
|
|
175
|
+
open_marker = marker
|
|
176
|
+
end
|
|
177
|
+
when :unfreeze
|
|
178
|
+
if open_marker
|
|
179
|
+
blocks << FreezeNode.new(
|
|
180
|
+
start_line: open_marker[:line],
|
|
181
|
+
end_line: marker[:line],
|
|
182
|
+
analysis: self,
|
|
183
|
+
reason: open_marker[:reason],
|
|
184
|
+
)
|
|
185
|
+
open_marker = nil
|
|
186
|
+
else
|
|
187
|
+
DebugLogger.warning("Unfreeze without freeze at line #{marker[:line]}, ignoring")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if open_marker
|
|
193
|
+
DebugLogger.warning("Unclosed freeze block starting at line #{open_marker[:line]}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
blocks
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Integrate freeze blocks into statement list
|
|
200
|
+
# @param freeze_blocks [Array<FreezeNode>]
|
|
201
|
+
# @return [Array<EnvLine, FreezeNode>]
|
|
202
|
+
def integrate_freeze_blocks(freeze_blocks)
|
|
203
|
+
return @lines.dup if freeze_blocks.empty?
|
|
204
|
+
|
|
205
|
+
# Build a set of line numbers covered by freeze blocks
|
|
206
|
+
frozen_lines = Set.new
|
|
207
|
+
freeze_blocks.each do |block|
|
|
208
|
+
(block.start_line..block.end_line).each { |ln| frozen_lines << ln }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
result = []
|
|
212
|
+
freeze_block_starts = freeze_blocks.map(&:start_line).to_set
|
|
213
|
+
|
|
214
|
+
@lines.each do |line|
|
|
215
|
+
if frozen_lines.include?(line.line_number)
|
|
216
|
+
# If this is the start of a freeze block, add the FreezeNode
|
|
217
|
+
if freeze_block_starts.include?(line.line_number)
|
|
218
|
+
block = freeze_blocks.find { |b| b.start_line == line.line_number }
|
|
219
|
+
result << block if block
|
|
220
|
+
end
|
|
221
|
+
# Skip individual lines in freeze blocks
|
|
222
|
+
else
|
|
223
|
+
result << line
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
result
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dotenv
|
|
4
|
+
module Merge
|
|
5
|
+
# Represents a freeze block in a dotenv file.
|
|
6
|
+
# Freeze blocks protect sections from being overwritten during merge.
|
|
7
|
+
#
|
|
8
|
+
# @example Freeze block in dotenv file
|
|
9
|
+
# # dotenv-merge:freeze Custom API settings
|
|
10
|
+
# API_KEY=my_custom_key
|
|
11
|
+
# API_SECRET=my_custom_secret
|
|
12
|
+
# # dotenv-merge:unfreeze
|
|
13
|
+
#
|
|
14
|
+
# @see Ast::Merge::FreezeNodeBase
|
|
15
|
+
class FreezeNode < Ast::Merge::FreezeNodeBase
|
|
16
|
+
# Make InvalidStructureError available as Dotenv::Merge::FreezeNode::InvalidStructureError
|
|
17
|
+
InvalidStructureError = Ast::Merge::FreezeNodeBase::InvalidStructureError
|
|
18
|
+
|
|
19
|
+
# Make Location available as Dotenv::Merge::FreezeNode::Location
|
|
20
|
+
Location = Ast::Merge::FreezeNodeBase::Location
|
|
21
|
+
|
|
22
|
+
# Initialize a new FreezeNode for dotenv
|
|
23
|
+
#
|
|
24
|
+
# @param start_line [Integer] Starting line number (1-indexed)
|
|
25
|
+
# @param end_line [Integer] Ending line number (1-indexed)
|
|
26
|
+
# @param analysis [FileAnalysis] The file analysis
|
|
27
|
+
# @param reason [String, nil] Optional reason from freeze marker
|
|
28
|
+
def initialize(start_line:, end_line:, analysis:, reason: nil)
|
|
29
|
+
super(
|
|
30
|
+
start_line: start_line,
|
|
31
|
+
end_line: end_line,
|
|
32
|
+
analysis: analysis,
|
|
33
|
+
reason: reason
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get the content of this freeze block
|
|
38
|
+
# @return [String] The content lines joined
|
|
39
|
+
def content
|
|
40
|
+
@lines&.map { |l| l.respond_to?(:raw) ? l.raw : l.to_s }&.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get a signature for this freeze block
|
|
44
|
+
# @return [Array] Signature based on normalized content
|
|
45
|
+
def signature
|
|
46
|
+
[:FreezeNode, content.gsub(/\s+/, " ").strip]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get environment variable lines within the freeze block
|
|
50
|
+
# @return [Array<EnvLine>] Assignment lines only
|
|
51
|
+
def env_lines
|
|
52
|
+
@lines&.select { |l| l.respond_to?(:assignment?) && l.assignment? } || []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# String representation for debugging
|
|
56
|
+
# @return [String]
|
|
57
|
+
def inspect
|
|
58
|
+
"#<#{self.class.name} lines=#{@start_line}..#{@end_line} env_vars=#{env_lines.size}>"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|