-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathparser.go
More file actions
292 lines (247 loc) · 6.91 KB
/
parser.go
File metadata and controls
292 lines (247 loc) · 6.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
package dotenv
import (
"bufio"
"fmt"
"io"
"os"
"regexp"
"strconv"
"strings"
)
var (
// Regular expressions for parsing
lineRegex = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_]*)\s*[=:]\s*(.*)$`)
exportRegex = regexp.MustCompile(`^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)\s*[=:]\s*(.*)$`)
expandVarRegex = regexp.MustCompile(`\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
)
// Parser handles the parsing of .env file content
type Parser struct {
// expandVars determines if variable expansion should be performed
expandVars bool
// env holds the currently parsed environment variables for expansion
env map[string]string
}
// NewParser creates a new parser with default settings
func NewParser() *Parser {
return &Parser{
expandVars: true,
env: make(map[string]string),
}
}
// NewParserWithOptions creates a parser with custom options
func NewParserWithOptions(expandVars bool) *Parser {
return &Parser{
expandVars: expandVars,
env: make(map[string]string),
}
}
// Parse reads from an io.Reader and parses the .env content
func (p *Parser) Parse(reader io.Reader) (map[string]string, error) {
result := make(map[string]string)
p.env = result // For variable expansion
scanner := bufio.NewScanner(reader)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := scanner.Text()
// Skip empty lines and comments
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, err := p.parseLine(line)
if err != nil {
return nil, fmt.Errorf("parse error on line %d: %w", lineNumber, err)
}
if key != "" {
if p.expandVars {
value = p.expandVariables(value, result)
}
result[key] = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading input: %w", err)
}
return result, nil
}
// parseLine parses a single line and returns key, value, and any error
func (p *Parser) parseLine(line string) (string, string, error) {
// Remove inline comments (but not those inside quotes)
line = p.removeInlineComment(line)
// Handle export prefix
if matches := exportRegex.FindStringSubmatch(line); matches != nil {
key := matches[1]
value := strings.TrimSpace(matches[2])
parsedValue, err := p.parseValue(value)
return key, parsedValue, err
}
// Handle regular key=value or key:value
if matches := lineRegex.FindStringSubmatch(line); matches != nil {
key := matches[1]
value := strings.TrimSpace(matches[2])
parsedValue, err := p.parseValue(value)
return key, parsedValue, err
}
// If line doesn't match any pattern and isn't empty, it's an error
if strings.TrimSpace(line) != "" {
return "", "", fmt.Errorf("invalid line format: %q", line)
}
return "", "", nil
}
// parseValue parses a value, handling quotes and escaping
func (p *Parser) parseValue(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
// Handle quoted values
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
quote := value[0]
inner := value[1 : len(value)-1]
if quote == '"' {
// Double quotes: process escape sequences
return p.unescapeDoubleQuoted(inner), nil
} else {
// Single quotes: literal value (no escape processing)
return inner, nil
}
}
}
// Unquoted value - trim trailing whitespace and remove trailing comments
return strings.TrimSpace(value), nil
}
// removeInlineComment removes inline comments while preserving those inside quotes
func (p *Parser) removeInlineComment(line string) string {
inQuotes := false
quoteChar := byte(0)
for i := 0; i < len(line); i++ {
char := line[i]
if !inQuotes {
if char == '"' || char == '\'' {
inQuotes = true
quoteChar = char
} else if char == '#' {
// Found unquoted comment, trim everything after
return strings.TrimSpace(line[:i])
}
} else {
if char == quoteChar {
// Check if it's escaped
if i > 0 && line[i-1] != '\\' {
inQuotes = false
quoteChar = 0
}
}
}
}
return line
}
// unescapeDoubleQuoted processes escape sequences in double-quoted strings
func (p *Parser) unescapeDoubleQuoted(value string) string {
result := strings.Builder{}
for i := 0; i < len(value); i++ {
if value[i] == '\\' && i+1 < len(value) {
next := value[i+1]
switch next {
case 'n':
result.WriteByte('\n')
case 'r':
result.WriteByte('\r')
case 't':
result.WriteByte('\t')
case '\\':
result.WriteByte('\\')
case '"':
result.WriteByte('"')
case '\'':
result.WriteByte('\'')
default:
// Unknown escape, keep the backslash
result.WriteByte('\\')
result.WriteByte(next)
}
i++ // Skip the next character
} else {
result.WriteByte(value[i])
}
}
return result.String()
}
// expandVariables expands variable references in the format $VAR or ${VAR}
func (p *Parser) expandVariables(value string, env map[string]string) string {
return expandVarRegex.ReplaceAllStringFunc(value, func(match string) string {
var varName string
if strings.HasPrefix(match, "${") && strings.HasSuffix(match, "}") {
// ${VAR} format
varName = match[2 : len(match)-1]
} else if strings.HasPrefix(match, "$") {
// $VAR format
varName = match[1:]
}
// Look up in parsed env first, then in OS env
if val, exists := env[varName]; exists {
return val
}
if val, exists := os.LookupEnv(varName); exists {
return val
}
// Variable not found, return empty string (bash behavior)
return ""
})
}
// ParseInt parses an environment variable as an integer
func ParseInt(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
if parsed, err := strconv.Atoi(value); err == nil {
return parsed
}
return defaultValue
}
// ParseBool parses an environment variable as a boolean
// Recognizes: true, false, 1, 0, yes, no, on, off (case insensitive)
func ParseBool(key string, defaultValue bool) bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if value == "" {
return defaultValue
}
switch value {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return defaultValue
}
}
// ParseFloat parses an environment variable as a float64
func ParseFloat(key string, defaultValue float64) float64 {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
if parsed, err := strconv.ParseFloat(value, 64); err == nil {
return parsed
}
return defaultValue
}
// GetRequired gets an environment variable and panics if it's not set
func GetRequired(key string) string {
value := os.Getenv(key)
if value == "" {
panic(fmt.Sprintf("required environment variable %s is not set", key))
}
return value
}
// GetWithDefault gets an environment variable with a default value
func GetWithDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}