-
Notifications
You must be signed in to change notification settings - Fork 405
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor dotenv parser in order to support multi-line variable values…
… declaration
- Loading branch information
Showing
2 changed files
with
230 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
package godotenv | ||
|
||
import ( | ||
"bytes" | ||
"errors" | ||
"fmt" | ||
"strings" | ||
"unicode" | ||
) | ||
|
||
const ( | ||
charComment = '#' | ||
prefixSingleQuote = '\'' | ||
prefixDoubleQuote = '"' | ||
|
||
exportPrefix = "export" | ||
) | ||
|
||
func parseBytes(src []byte, out map[string]string) error { | ||
cutset := src | ||
for { | ||
cutset = getStatementStart(cutset) | ||
if cutset == nil { | ||
// reached end of file | ||
break | ||
} | ||
|
||
key, left, err := locateKeyName(cutset) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
value, left, err := extractVarValue(left, out) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
out[key] = value | ||
cutset = left | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// getStatementPosition returns position of statement begin. | ||
// | ||
// It skips any comment line or non-whitespace character. | ||
func getStatementStart(src []byte) []byte { | ||
pos := indexOfNonSpaceChar(src) | ||
if pos == -1 { | ||
return nil | ||
} | ||
|
||
src = src[pos:] | ||
if src[0] != charComment { | ||
return src | ||
} | ||
|
||
// skip comment section | ||
pos = bytes.IndexFunc(src, isCharFunc('\n')) | ||
if pos == -1 { | ||
return nil | ||
} | ||
|
||
return getStatementStart(src[pos:]) | ||
} | ||
|
||
// locateKeyName locates and parses key name and returns rest of slice | ||
func locateKeyName(src []byte) (key string, cutset []byte, err error) { | ||
// trim "export" and space at beginning | ||
src = bytes.TrimLeftFunc(bytes.TrimPrefix(src, []byte(exportPrefix)), isSpace) | ||
|
||
// locate key name end and validate it in single loop | ||
offset := 0 | ||
loop: | ||
for i, char := range src { | ||
rchar := rune(char) | ||
if isSpace(rchar) { | ||
continue | ||
} | ||
|
||
switch char { | ||
case '=', ':': | ||
// library also supports yaml-style value declaration | ||
key = string(src[0:i]) | ||
offset = i + 1 | ||
break loop | ||
case '_': | ||
default: | ||
// variable name should match [A-Za-z0-9_] | ||
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) { | ||
continue | ||
} | ||
|
||
return "", nil, fmt.Errorf( | ||
`unexpected character %q in variable name near %q`, | ||
string(char), string(src)) | ||
} | ||
} | ||
|
||
if len(src) == 0 { | ||
return "", nil, errors.New("zero length string") | ||
} | ||
|
||
// trim whitespace | ||
key = strings.TrimRightFunc(key, unicode.IsSpace) | ||
cutset = bytes.TrimLeftFunc(src[offset:], isSpace) | ||
return key, cutset, nil | ||
} | ||
|
||
// extractVarValue extracts variable value and returns rest of slice | ||
func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) { | ||
quote, hasPrefix := hasQuotePrefix(src) | ||
if !hasPrefix { | ||
// unquoted value - read until whitespace | ||
end := bytes.IndexFunc(src, unicode.IsSpace) | ||
if end == -1 { | ||
return expandVariables(string(src), vars), nil, nil | ||
} | ||
|
||
return expandVariables(string(src[0:end]), vars), src[end:], nil | ||
} | ||
|
||
// lookup quoted string terminator | ||
for i := 1; i < len(src); i++ { | ||
if char := src[i]; char != quote { | ||
continue | ||
} | ||
|
||
// skip escaped quote symbol (\" or \', depends on quote) | ||
if prevChar := src[i-1]; prevChar == '\\' { | ||
continue | ||
} | ||
|
||
// trim quotes | ||
trimFunc := isCharFunc(rune(quote)) | ||
value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc)) | ||
if quote == prefixDoubleQuote { | ||
// unescape newlines for double quote (this is compat feature) | ||
// and expand environment variables | ||
value = expandVariables(expandEscapes(value), vars) | ||
} | ||
|
||
return value, src[i+1:], nil | ||
} | ||
|
||
// return formatted error if quoted string is not terminated | ||
valEndIndex := bytes.IndexFunc(src, isCharFunc('\n')) | ||
if valEndIndex == -1 { | ||
valEndIndex = len(src) | ||
} | ||
|
||
return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex]) | ||
} | ||
|
||
func expandEscapes(str string) string { | ||
out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string { | ||
c := strings.TrimPrefix(match, `\`) | ||
switch c { | ||
case "n": | ||
return "\n" | ||
case "r": | ||
return "\r" | ||
default: | ||
return match | ||
} | ||
}) | ||
return unescapeCharsRegex.ReplaceAllString(out, "$1") | ||
} | ||
|
||
func indexOfNonSpaceChar(src []byte) int { | ||
return bytes.IndexFunc(src, func(r rune) bool { | ||
return !unicode.IsSpace(r) | ||
}) | ||
} | ||
|
||
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character | ||
func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) { | ||
if len(src) == 0 { | ||
return 0, false | ||
} | ||
|
||
switch prefix := src[0]; prefix { | ||
case prefixDoubleQuote, prefixSingleQuote: | ||
return prefix, true | ||
default: | ||
return 0, false | ||
} | ||
} | ||
|
||
func isCharFunc(char rune) func(rune) bool { | ||
return func(v rune) bool { | ||
return v == char | ||
} | ||
} | ||
|
||
// isSpace reports whether the rune is a space character but not line break character | ||
// | ||
// this differs from unicode.IsSpace, which also applies line break as space | ||
func isSpace(r rune) bool { | ||
switch r { | ||
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0: | ||
return true | ||
} | ||
return false | ||
} |