Skip to content

Commit ceb32a4

Browse files
committed
feat: implement ParseDotEnv function to parse .env file content and add unit tests
1 parent 7cd9e68 commit ceb32a4

2 files changed

Lines changed: 181 additions & 12 deletions

File tree

core/env/envfile.go

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,24 @@ func LoadDotEnvSling() map[string]string {
142142
return dotEnvMap.Items() // file doesn't exist or can't be read
143143
}
144144

145-
for _, line := range strings.Split(string(bytes), "\n") {
146-
line = strings.TrimSpace(line)
145+
for key, val := range ParseDotEnv(string(bytes)) {
146+
// don't overwrite existing env vars
147+
if _, exists := os.LookupEnv(key); !exists {
148+
dotEnvMap.Set(key, val)
149+
os.Setenv(key, val)
150+
}
151+
}
152+
return dotEnvMap.Items()
153+
}
154+
155+
// ParseDotEnv parses a .env file content into key-value pairs.
156+
// It supports single-line and multi-line values enclosed in matching quotes (' or ").
157+
func ParseDotEnv(content string) map[string]string {
158+
result := map[string]string{}
159+
lines := strings.Split(content, "\n")
160+
161+
for i := 0; i < len(lines); i++ {
162+
line := strings.TrimSpace(lines[i])
147163
if line == "" || strings.HasPrefix(line, "#") {
148164
continue
149165
}
@@ -156,21 +172,36 @@ func LoadDotEnvSling() map[string]string {
156172
key = strings.TrimSpace(key)
157173
val = strings.TrimSpace(val)
158174

159-
// remove surrounding quotes
160-
if len(val) >= 2 {
161-
if (val[0] == '"' && val[len(val)-1] == '"') ||
162-
(val[0] == '\'' && val[len(val)-1] == '\'') {
175+
// check for quoted multi-line values
176+
if len(val) >= 1 && (val[0] == '\'' || val[0] == '"') {
177+
quote := val[0]
178+
179+
// check if closing quote is on the same line
180+
if len(val) >= 2 && val[len(val)-1] == quote {
181+
// single-line quoted value
163182
val = val[1 : len(val)-1]
183+
} else {
184+
// multi-line: accumulate lines until we find the closing quote
185+
var buf strings.Builder
186+
buf.WriteString(val[1:]) // content after opening quote
187+
for i++; i < len(lines); i++ {
188+
raw := lines[i]
189+
trimmed := strings.TrimRight(raw, " \t")
190+
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == quote {
191+
buf.WriteByte('\n')
192+
buf.WriteString(trimmed[:len(trimmed)-1])
193+
break
194+
}
195+
buf.WriteByte('\n')
196+
buf.WriteString(raw)
197+
}
198+
val = buf.String()
164199
}
165200
}
166201

167-
// don't overwrite existing env vars
168-
if _, exists := os.LookupEnv(key); !exists {
169-
dotEnvMap.Set(key, val)
170-
os.Setenv(key, val)
171-
}
202+
result[key] = val
172203
}
173-
return dotEnvMap.Items()
204+
return result
174205
}
175206

176207
func UnsetEnvKeys(keys []string) {

core/env/envfile_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package env
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestParseDotEnv(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
content string
13+
expected map[string]string
14+
}{
15+
{
16+
name: "simple key=value",
17+
content: "FOO=bar",
18+
expected: map[string]string{
19+
"FOO": "bar",
20+
},
21+
},
22+
{
23+
name: "single-line single-quoted JSON",
24+
content: `KEY='{"a": "b"}'`,
25+
expected: map[string]string{
26+
"KEY": `{"a": "b"}`,
27+
},
28+
},
29+
{
30+
name: "single-line double-quoted JSON",
31+
content: `KEY="{\"a\": \"b\"}"`,
32+
expected: map[string]string{
33+
"KEY": `{\"a\": \"b\"}`,
34+
},
35+
},
36+
{
37+
name: "multi-line single-quoted JSON",
38+
content: `KEY='{
39+
"a": "b"
40+
}'`,
41+
expected: map[string]string{
42+
"KEY": "{\n \"a\": \"b\"\n}",
43+
},
44+
},
45+
{
46+
name: "multi-line double-quoted value",
47+
content: "KEY=\"hello\nworld\"",
48+
expected: map[string]string{
49+
"KEY": "hello\nworld",
50+
},
51+
},
52+
{
53+
name: "multi-line with multiple keys",
54+
content: `BEFORE=hello
55+
JSON_VAL='{
56+
"key": "value",
57+
"num": 42
58+
}'
59+
AFTER=world`,
60+
expected: map[string]string{
61+
"BEFORE": "hello",
62+
"JSON_VAL": "{\n \"key\": \"value\",\n \"num\": 42\n}",
63+
"AFTER": "world",
64+
},
65+
},
66+
{
67+
name: "comments and blank lines are skipped",
68+
content: `# this is a comment
69+
FOO=bar
70+
71+
# another comment
72+
BAZ=qux`,
73+
expected: map[string]string{
74+
"FOO": "bar",
75+
"BAZ": "qux",
76+
},
77+
},
78+
{
79+
name: "value with equals sign",
80+
content: `CONN=postgres://user:pass@host/db?sslmode=require`,
81+
expected: map[string]string{
82+
"CONN": "postgres://user:pass@host/db?sslmode=require",
83+
},
84+
},
85+
{
86+
name: "multi-line with nested braces",
87+
content: `CONFIG='{
88+
"database": {
89+
"host": "localhost",
90+
"port": 5432
91+
}
92+
}'`,
93+
expected: map[string]string{
94+
"CONFIG": "{\n \"database\": {\n \"host\": \"localhost\",\n \"port\": 5432\n }\n}",
95+
},
96+
},
97+
{
98+
name: "unquoted value",
99+
content: `KEY=some value here`,
100+
expected: map[string]string{
101+
"KEY": "some value here",
102+
},
103+
},
104+
{
105+
name: "empty value",
106+
content: `KEY=`,
107+
expected: map[string]string{
108+
"KEY": "",
109+
},
110+
},
111+
{
112+
name: "double-quoted value with single quotes inside",
113+
content: `KEY="{'a': 'b'}"`,
114+
expected: map[string]string{
115+
"KEY": "{'a': 'b'}",
116+
},
117+
},
118+
{
119+
name: "multi-line double-quoted with single quotes inside",
120+
content: "KEY=\"{\n 'a': 'b'\n}\"",
121+
expected: map[string]string{
122+
"KEY": "{\n 'a': 'b'\n}",
123+
},
124+
},
125+
{
126+
name: "line without equals is skipped",
127+
content: "NOPE",
128+
expected: map[string]string{},
129+
},
130+
}
131+
132+
for _, tt := range tests {
133+
t.Run(tt.name, func(t *testing.T) {
134+
result := ParseDotEnv(tt.content)
135+
assert.Equal(t, tt.expected, result)
136+
})
137+
}
138+
}

0 commit comments

Comments
 (0)