forked from toolshed/abra
cli
cmd
pkg
scripts
tests
vendor
coopcloud.tech
dario.cat
git.coopcloud.tech
toolshed
godotenv
.gitignore
LICENCE
README.md
godotenv.go
parser.go
github.com
go.opentelemetry.io
golang.org
google.golang.org
gopkg.in
gotest.tools
modules.txt
.dockerignore
.drone.yml
.envrc.sample
.gitignore
.goreleaser.yml
AUTHORS.md
Dockerfile
LICENSE
Makefile
README.md
go.mod
go.sum
renovate.json
294 lines
6.6 KiB
Go
294 lines
6.6 KiB
Go
package godotenv
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
const (
|
|
charComment = '#'
|
|
prefixSingleQuote = '\''
|
|
prefixDoubleQuote = '"'
|
|
|
|
exportPrefix = "export"
|
|
)
|
|
|
|
func parseBytes(src []byte, vars map[string]string, modifiers map[string]map[string]string) error {
|
|
src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1)
|
|
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, mods, left, err := extractVarValue(left, vars)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vars[key] = value
|
|
modifiers[key] = mods
|
|
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(src, isSpace)
|
|
if bytes.HasPrefix(src, []byte(exportPrefix)) {
|
|
trimmed := bytes.TrimPrefix(src, []byte(exportPrefix))
|
|
if bytes.IndexFunc(trimmed, isSpace) == 0 {
|
|
src = bytes.TrimLeftFunc(trimmed, 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) || 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, modifiers map[string]string, rest []byte, err error) {
|
|
quote, hasPrefix := hasQuotePrefix(src)
|
|
// unquoted value - read until end of line
|
|
endOfLine := bytes.IndexFunc(src, isLineEnd)
|
|
// Hit EOF without a trailing newline
|
|
if endOfLine == -1 {
|
|
endOfLine = len(src)
|
|
|
|
if endOfLine == 0 {
|
|
return "", nil, nil, nil
|
|
}
|
|
}
|
|
if !hasPrefix {
|
|
// Convert line to rune away to do accurate countback of runes
|
|
line := []rune(string(src[0:endOfLine]))
|
|
|
|
// Assume end of line is end of var
|
|
endOfVar := len(line)
|
|
if endOfVar == 0 {
|
|
return "", nil, src[endOfLine:], nil
|
|
}
|
|
|
|
comment := ""
|
|
// Work backwards to check if the line ends in whitespace then
|
|
// a comment (ie asdasd # some comment)
|
|
for i := endOfVar - 1; i >= 0; i-- {
|
|
if line[i] == charComment && i > 0 {
|
|
comment = string(line[i+1:])
|
|
if isSpace(line[i-1]) {
|
|
endOfVar = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
|
|
|
|
return expandVariables(trimmed, vars), extractModifiers(comment), src[endOfLine:], 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)
|
|
}
|
|
|
|
var mods map[string]string
|
|
if endOfLine > i {
|
|
mods = extractModifiers(string(src[i+1 : endOfLine]))
|
|
}
|
|
return value, mods, 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, nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
|
|
}
|
|
|
|
func extractModifiers(comment string) map[string]string {
|
|
if comment == "" {
|
|
return nil
|
|
}
|
|
comment = strings.TrimSpace(comment)
|
|
kvpairs := strings.Split(comment, " ")
|
|
mods := make(map[string]string)
|
|
for _, kv := range kvpairs {
|
|
kvsplit := strings.Split(kv, "=")
|
|
if len(kvsplit) != 2 {
|
|
continue
|
|
}
|
|
mods[kvsplit[0]] = kvsplit[1]
|
|
}
|
|
return mods
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func isLineEnd(r rune) bool {
|
|
if r == '\n' || r == '\r' {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var (
|
|
escapeRegex = regexp.MustCompile(`\\.`)
|
|
expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
|
|
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
|
|
)
|
|
|
|
func expandVariables(v string, m map[string]string) string {
|
|
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
|
|
submatch := expandVarRegex.FindStringSubmatch(s)
|
|
|
|
if submatch == nil {
|
|
return s
|
|
}
|
|
if submatch[1] == "\\" || submatch[2] == "(" {
|
|
return submatch[0][1:]
|
|
} else if submatch[4] != "" {
|
|
return m[submatch[4]]
|
|
}
|
|
return s
|
|
})
|
|
}
|