Skip to content
This repository was archived by the owner on Jan 12, 2022. It is now read-only.

Commit d9069cd

Browse files
committed
refactor dotenv parser in order to support multi-line variable values declaration
Signed-off-by: x1unix <[email protected]>
1 parent d6ee687 commit d9069cd

File tree

2 files changed

+230
-39
lines changed

2 files changed

+230
-39
lines changed

godotenv.go

Lines changed: 24 additions & 39 deletions
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"
@@ -27,6 +27,16 @@ import (
2727

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

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

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

126-
//Unmarshal reads an env file from a string, returning a map of keys and values.
127-
func Unmarshal(str string) (envMap map[string]string, err error) {
128-
return Parse(strings.NewReader(str))
113+
// UnmarshalBytes parses env file from byte slice of chars, returning a map of keys and values.
114+
func UnmarshalBytes(src []byte) (map[string]string, error) {
115+
out := make(map[string]string)
116+
err := parseBytes(src, out)
117+
return out, err
129118
}
130119

131120
// Exec loads env vars from the specified filenames (empty map falls back to default)
@@ -136,7 +125,9 @@ func Unmarshal(str string) (envMap map[string]string, err error) {
136125
// If you want more fine grained control over your command it's recommended
137126
// that you use `Load()` or `Read()` and the `os/exec` package yourself.
138127
func Exec(filenames []string, cmd string, cmdArgs []string) error {
139-
Load(filenames...)
128+
if err := Load(filenames...); err != nil {
129+
return err
130+
}
140131

141132
command := exec.Command(cmd, cmdArgs...)
142133
command.Stdin = os.Stdin
@@ -160,8 +151,7 @@ func Write(envMap map[string]string, filename string) error {
160151
if err != nil {
161152
return err
162153
}
163-
file.Sync()
164-
return err
154+
return file.Sync()
165155
}
166156

167157
// Marshal outputs the given environment as a dotenv-formatted environment file.
@@ -197,7 +187,7 @@ func loadFile(filename string, overload bool) error {
197187

198188
for key, value := range envMap {
199189
if !currentEnv[key] || overload {
200-
os.Setenv(key, value)
190+
_ = os.Setenv(key, value)
201191
}
202192
}
203193

@@ -338,11 +328,6 @@ func expandVariables(v string, m map[string]string) string {
338328
})
339329
}
340330

341-
func isIgnoredLine(line string) bool {
342-
trimmedLine := strings.TrimSpace(line)
343-
return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#")
344-
}
345-
346331
func doubleQuoteEscape(line string) string {
347332
for _, c := range doubleQuoteSpecialChars {
348333
toReplace := "\\" + string(c)

parser.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package godotenv
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"unicode"
9+
)
10+
11+
const (
12+
charComment = '#'
13+
prefixSingleQuote = '\''
14+
prefixDoubleQuote = '"'
15+
16+
exportPrefix = "export"
17+
)
18+
19+
func parseBytes(src []byte, out map[string]string) error {
20+
cutset := src
21+
for {
22+
cutset = getStatementStart(cutset)
23+
if cutset == nil {
24+
// reached end of file
25+
break
26+
}
27+
28+
key, left, err := locateKeyName(cutset)
29+
if err != nil {
30+
return err
31+
}
32+
33+
value, left, err := extractVarValue(left, out)
34+
if err != nil {
35+
return err
36+
}
37+
38+
out[key] = value
39+
cutset = left
40+
}
41+
42+
return nil
43+
}
44+
45+
// getStatementPosition returns position of statement begin.
46+
//
47+
// It skips any comment line or non-whitespace character.
48+
func getStatementStart(src []byte) []byte {
49+
pos := indexOfNonSpaceChar(src)
50+
if pos == -1 {
51+
return nil
52+
}
53+
54+
src = src[pos:]
55+
if src[0] != charComment {
56+
return src
57+
}
58+
59+
// skip comment section
60+
pos = bytes.IndexFunc(src, isCharFunc('\n'))
61+
if pos == -1 {
62+
return nil
63+
}
64+
65+
return getStatementStart(src[pos:])
66+
}
67+
68+
// locateKeyName locates and parses key name and returns rest of slice
69+
func locateKeyName(src []byte) (key string, cutset []byte, err error) {
70+
// trim "export" and space at beginning
71+
src = bytes.TrimLeftFunc(bytes.TrimPrefix(src, []byte(exportPrefix)), isSpace)
72+
73+
// locate key name end and validate it in single loop
74+
offset := 0
75+
loop:
76+
for i, char := range src {
77+
rchar := rune(char)
78+
if isSpace(rchar) {
79+
continue
80+
}
81+
82+
switch char {
83+
case '=', ':':
84+
// library also supports yaml-style value declaration
85+
key = string(src[0:i])
86+
offset = i + 1
87+
break loop
88+
case '_':
89+
default:
90+
// variable name should match [A-Za-z0-9_]
91+
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) {
92+
continue
93+
}
94+
95+
return "", nil, fmt.Errorf(
96+
`unexpected character %q in variable name near %q`,
97+
string(char), string(src))
98+
}
99+
}
100+
101+
if len(src) == 0 {
102+
return "", nil, errors.New("zero length string")
103+
}
104+
105+
// trim whitespace
106+
key = strings.TrimRightFunc(key, unicode.IsSpace)
107+
cutset = bytes.TrimLeftFunc(src[offset:], isSpace)
108+
return key, cutset, nil
109+
}
110+
111+
// extractVarValue extracts variable value and returns rest of slice
112+
func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) {
113+
quote, hasPrefix := hasQuotePrefix(src)
114+
if !hasPrefix {
115+
// unquoted value - read until whitespace
116+
end := bytes.IndexFunc(src, unicode.IsSpace)
117+
if end == -1 {
118+
return expandVariables(string(src), vars), nil, nil
119+
}
120+
121+
return expandVariables(string(src[0:end]), vars), src[end:], nil
122+
}
123+
124+
// lookup quoted string terminator
125+
for i := 1; i < len(src); i++ {
126+
if char := src[i]; char != quote {
127+
continue
128+
}
129+
130+
// skip escaped quote symbol (\" or \', depends on quote)
131+
if prevChar := src[i-1]; prevChar == '\\' {
132+
continue
133+
}
134+
135+
// trim quotes
136+
trimFunc := isCharFunc(rune(quote))
137+
value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc))
138+
if quote == prefixDoubleQuote {
139+
// unescape newlines for double quote (this is compat feature)
140+
// and expand environment variables
141+
value = expandVariables(expandEscapes(value), vars)
142+
}
143+
144+
return value, src[i+1:], nil
145+
}
146+
147+
// return formatted error if quoted string is not terminated
148+
valEndIndex := bytes.IndexFunc(src, isCharFunc('\n'))
149+
if valEndIndex == -1 {
150+
valEndIndex = len(src)
151+
}
152+
153+
return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
154+
}
155+
156+
func expandEscapes(str string) string {
157+
out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string {
158+
c := strings.TrimPrefix(match, `\`)
159+
switch c {
160+
case "n":
161+
return "\n"
162+
case "r":
163+
return "\r"
164+
default:
165+
return match
166+
}
167+
})
168+
return unescapeCharsRegex.ReplaceAllString(out, "$1")
169+
}
170+
171+
func indexOfNonSpaceChar(src []byte) int {
172+
return bytes.IndexFunc(src, func(r rune) bool {
173+
return !unicode.IsSpace(r)
174+
})
175+
}
176+
177+
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
178+
func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) {
179+
if len(src) == 0 {
180+
return 0, false
181+
}
182+
183+
switch prefix := src[0]; prefix {
184+
case prefixDoubleQuote, prefixSingleQuote:
185+
return prefix, true
186+
default:
187+
return 0, false
188+
}
189+
}
190+
191+
func isCharFunc(char rune) func(rune) bool {
192+
return func(v rune) bool {
193+
return v == char
194+
}
195+
}
196+
197+
// isSpace reports whether the rune is a space character but not line break character
198+
//
199+
// this differs from unicode.IsSpace, which also applies line break as space
200+
func isSpace(r rune) bool {
201+
switch r {
202+
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
203+
return true
204+
}
205+
return false
206+
}

0 commit comments

Comments
 (0)