Skip to content

Commit cc9e9b7

Browse files
johox1unixaustinsasko
authored
Multiline string support (#156)
* refactor dotenv parser in order to support multi-line variable values declaration Signed-off-by: x1unix <[email protected]> * Add multi-line var values test case and update comment test Signed-off-by: x1unix <[email protected]> * Expand fixture tests to include multiline strings * Update go versions to test against * Switch to GOINSECURE for power8 CI task * When tests fail, show source version of string (inc special chars) * Update parser.go Co-authored-by: Austin Sasko <[email protected]> * Fix up bad merge * Add a full fixture for comments for extra piece of mind * Fix up some lint/staticcheck recommendations * Test against go 1.19 too Signed-off-by: x1unix <[email protected]> Co-authored-by: x1unix <[email protected]> Co-authored-by: Austin Sasko <[email protected]>
1 parent 0f21d20 commit cc9e9b7

File tree

6 files changed

+325
-70
lines changed

6 files changed

+325
-70
lines changed

Diff for: .github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
strategy:
99
fail-fast: false
1010
matrix:
11-
go: [ '1.18', '1.17', '1.16', '1.15' ]
11+
go: [ '1.19', '1.18', '1.17', '1.16', '1.15' ]
1212
os: [ ubuntu-latest, macOS-latest, windows-latest ]
1313
name: ${{ matrix.os }} Go ${{ matrix.go }} Tests
1414
steps:

Diff for: fixtures/comments.env

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Full line comment
2+
foo=bar # baz
3+
bar=foo#baz
4+
baz="foo"#bar

Diff for: fixtures/quoted.env

+10
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,13 @@ OPTION_F="2"
77
OPTION_G=""
88
OPTION_H="\n"
99
OPTION_I = "echo 'asd'"
10+
OPTION_J='line 1
11+
line 2'
12+
OPTION_K='line one
13+
this is \'quoted\'
14+
one more line'
15+
OPTION_L="line 1
16+
line 2"
17+
OPTION_M="line one
18+
this is \"quoted\"
19+
one more line"

Diff for: godotenv.go

+29-43
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
package godotenv
1515

1616
import (
17-
"bufio"
1817
"errors"
1918
"fmt"
2019
"io"
20+
"io/ioutil"
2121
"os"
2222
"os/exec"
2323
"regexp"
@@ -28,6 +28,16 @@ import (
2828

2929
const doubleQuoteSpecialChars = "\\\n\r\"!$`"
3030

31+
// Parse reads an env file from io.Reader, returning a map of keys and values.
32+
func Parse(r io.Reader) (map[string]string, error) {
33+
data, err := ioutil.ReadAll(r)
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
return UnmarshalBytes(data)
39+
}
40+
3141
// Load will read your env file(s) and load them into ENV for this process.
3242
//
3343
// Call this function as close as possible to the start of your program (ideally in main).
@@ -96,37 +106,17 @@ func Read(filenames ...string) (envMap map[string]string, err error) {
96106
return
97107
}
98108

99-
// Parse reads an env file from io.Reader, returning a map of keys and values.
100-
func Parse(r io.Reader) (envMap map[string]string, err error) {
101-
envMap = make(map[string]string)
102-
103-
var lines []string
104-
scanner := bufio.NewScanner(r)
105-
for scanner.Scan() {
106-
lines = append(lines, scanner.Text())
107-
}
108-
109-
if err = scanner.Err(); err != nil {
110-
return
111-
}
112-
113-
for _, fullLine := range lines {
114-
if !isIgnoredLine(fullLine) {
115-
var key, value string
116-
key, value, err = parseLine(fullLine, envMap)
117-
118-
if err != nil {
119-
return
120-
}
121-
envMap[key] = value
122-
}
123-
}
124-
return
125-
}
126-
127109
// Unmarshal reads an env file from a string, returning a map of keys and values.
128110
func Unmarshal(str string) (envMap map[string]string, err error) {
129-
return Parse(strings.NewReader(str))
111+
return UnmarshalBytes([]byte(str))
112+
}
113+
114+
// UnmarshalBytes parses env file from byte slice of chars, returning a map of keys and values.
115+
func UnmarshalBytes(src []byte) (map[string]string, error) {
116+
out := make(map[string]string)
117+
err := parseBytes(src, out)
118+
119+
return out, err
130120
}
131121

132122
// Exec loads env vars from the specified filenames (empty map falls back to default)
@@ -137,7 +127,9 @@ func Unmarshal(str string) (envMap map[string]string, err error) {
137127
// If you want more fine grained control over your command it's recommended
138128
// that you use `Load()` or `Read()` and the `os/exec` package yourself.
139129
func Exec(filenames []string, cmd string, cmdArgs []string) error {
140-
Load(filenames...)
130+
if err := Load(filenames...); err != nil {
131+
return err
132+
}
141133

142134
command := exec.Command(cmd, cmdArgs...)
143135
command.Stdin = os.Stdin
@@ -161,8 +153,7 @@ func Write(envMap map[string]string, filename string) error {
161153
if err != nil {
162154
return err
163155
}
164-
file.Sync()
165-
return err
156+
return file.Sync()
166157
}
167158

168159
// Marshal outputs the given environment as a dotenv-formatted environment file.
@@ -202,7 +193,7 @@ func loadFile(filename string, overload bool) error {
202193

203194
for key, value := range envMap {
204195
if !currentEnv[key] || overload {
205-
os.Setenv(key, value)
196+
_ = os.Setenv(key, value)
206197
}
207198
}
208199

@@ -259,15 +250,15 @@ func parseLine(line string, envMap map[string]string) (key string, value string,
259250
}
260251

261252
if len(splitString) != 2 {
262-
err = errors.New("Can't separate key from value")
253+
err = errors.New("can't separate key from value")
263254
return
264255
}
265256

266257
// Parse the key
267258
key = splitString[0]
268-
if strings.HasPrefix(key, "export") {
269-
key = strings.TrimPrefix(key, "export")
270-
}
259+
260+
key = strings.TrimPrefix(key, "export")
261+
271262
key = strings.TrimSpace(key)
272263

273264
key = exportRegex.ReplaceAllString(splitString[0], "$1")
@@ -343,11 +334,6 @@ func expandVariables(v string, m map[string]string) string {
343334
})
344335
}
345336

346-
func isIgnoredLine(line string) bool {
347-
trimmedLine := strings.TrimSpace(line)
348-
return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#")
349-
}
350-
351337
func doubleQuoteEscape(line string) string {
352338
for _, c := range doubleQuoteSpecialChars {
353339
toReplace := "\\" + string(c)

Diff for: godotenv_test.go

+74-26
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func loadEnvAndCompareValues(t *testing.T, loader func(files ...string) error, e
3535
envValue := os.Getenv(k)
3636
v := expectedValues[k]
3737
if envValue != v {
38-
t.Errorf("Mismatch for key '%v': expected '%v' got '%v'", k, v, envValue)
38+
t.Errorf("Mismatch for key '%v': expected '%#v' got '%#v'", k, v, envValue)
3939
}
4040
}
4141
}
@@ -189,6 +189,10 @@ func TestLoadQuotedEnv(t *testing.T) {
189189
"OPTION_G": "",
190190
"OPTION_H": "\n",
191191
"OPTION_I": "echo 'asd'",
192+
"OPTION_J": "line 1\nline 2",
193+
"OPTION_K": "line one\nthis is \\'quoted\\'\none more line",
194+
"OPTION_L": "line 1\nline 2",
195+
"OPTION_M": "line one\nthis is \"quoted\"\none more line",
192196
}
193197

194198
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
@@ -271,6 +275,34 @@ func TestExpanding(t *testing.T) {
271275

272276
}
273277

278+
func TestVariableStringValueSeparator(t *testing.T) {
279+
input := "TEST_URLS=\"stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443\""
280+
want := map[string]string{
281+
"TEST_URLS": "stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443",
282+
}
283+
got, err := Parse(strings.NewReader(input))
284+
if err != nil {
285+
t.Error(err)
286+
}
287+
288+
if len(got) != len(want) {
289+
t.Fatalf(
290+
"unexpected value:\nwant:\n\t%#v\n\ngot:\n\t%#v", want, got)
291+
}
292+
293+
for k, wantVal := range want {
294+
gotVal, ok := got[k]
295+
if !ok {
296+
t.Fatalf("key %q doesn't present in result", k)
297+
}
298+
if wantVal != gotVal {
299+
t.Fatalf(
300+
"mismatch in %q value:\nwant:\n\t%s\n\ngot:\n\t%s", k,
301+
wantVal, gotVal)
302+
}
303+
}
304+
}
305+
274306
func TestActualEnvVarsAreLeftAlone(t *testing.T) {
275307
os.Clearenv()
276308
os.Setenv("OPTION_A", "actualenv")
@@ -377,33 +409,38 @@ func TestParsing(t *testing.T) {
377409
}
378410

379411
func TestLinesToIgnore(t *testing.T) {
380-
// it 'ignores empty lines' do
381-
// expect(env("\n \t \nfoo=bar\n \nfizz=buzz")).to eql('foo' => 'bar', 'fizz' => 'buzz')
382-
if !isIgnoredLine("\n") {
383-
t.Error("Line with nothing but line break wasn't ignored")
384-
}
385-
386-
if !isIgnoredLine("\r\n") {
387-
t.Error("Line with nothing but windows-style line break wasn't ignored")
388-
}
389-
390-
if !isIgnoredLine("\t\t ") {
391-
t.Error("Line full of whitespace wasn't ignored")
392-
}
393-
394-
// it 'ignores comment lines' do
395-
// expect(env("\n\n\n # HERE GOES FOO \nfoo=bar")).to eql('foo' => 'bar')
396-
if !isIgnoredLine("# comment") {
397-
t.Error("Comment wasn't ignored")
398-
}
399-
400-
if !isIgnoredLine("\t#comment") {
401-
t.Error("Indented comment wasn't ignored")
412+
cases := map[string]struct {
413+
input string
414+
want string
415+
}{
416+
"Line with nothing but line break": {
417+
input: "\n",
418+
},
419+
"Line with nothing but windows-style line break": {
420+
input: "\r\n",
421+
},
422+
"Line full of whitespace": {
423+
input: "\t\t ",
424+
},
425+
"Comment": {
426+
input: "# Comment",
427+
},
428+
"Indented comment": {
429+
input: "\t # comment",
430+
},
431+
"non-ignored value": {
432+
input: `export OPTION_B='\n'`,
433+
want: `export OPTION_B='\n'`,
434+
},
402435
}
403436

404-
// make sure we're not getting false positives
405-
if isIgnoredLine(`export OPTION_B='\n'`) {
406-
t.Error("ignoring a perfectly valid line to parse")
437+
for n, c := range cases {
438+
t.Run(n, func(t *testing.T) {
439+
got := string(getStatementStart([]byte(c.input)))
440+
if got != c.want {
441+
t.Errorf("Expected:\t %q\nGot:\t %q", c.want, got)
442+
}
443+
})
407444
}
408445
}
409446

@@ -424,6 +461,17 @@ func TestErrorParsing(t *testing.T) {
424461
}
425462
}
426463

464+
func TestComments(t *testing.T) {
465+
envFileName := "fixtures/comments.env"
466+
expectedValues := map[string]string{
467+
"foo": "bar",
468+
"bar": "foo#baz",
469+
"baz": "foo",
470+
}
471+
472+
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
473+
}
474+
427475
func TestWrite(t *testing.T) {
428476
writeAndCompare := func(env string, expected string) {
429477
envMap, _ := Unmarshal(env)

0 commit comments

Comments
 (0)