parser.go

  1package godotenv
  2
  3import (
  4	"bytes"
  5	"errors"
  6	"fmt"
  7	"regexp"
  8	"strings"
  9	"unicode"
 10)
 11
 12const (
 13	charComment       = '#'
 14	prefixSingleQuote = '\''
 15	prefixDoubleQuote = '"'
 16
 17	exportPrefix = "export"
 18)
 19
 20func parseBytes(src []byte, out map[string]string) error {
 21	src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1)
 22	cutset := src
 23	for {
 24		cutset = getStatementStart(cutset)
 25		if cutset == nil {
 26			// reached end of file
 27			break
 28		}
 29
 30		key, left, err := locateKeyName(cutset)
 31		if err != nil {
 32			return err
 33		}
 34
 35		value, left, err := extractVarValue(left, out)
 36		if err != nil {
 37			return err
 38		}
 39
 40		out[key] = value
 41		cutset = left
 42	}
 43
 44	return nil
 45}
 46
 47// getStatementPosition returns position of statement begin.
 48//
 49// It skips any comment line or non-whitespace character.
 50func getStatementStart(src []byte) []byte {
 51	pos := indexOfNonSpaceChar(src)
 52	if pos == -1 {
 53		return nil
 54	}
 55
 56	src = src[pos:]
 57	if src[0] != charComment {
 58		return src
 59	}
 60
 61	// skip comment section
 62	pos = bytes.IndexFunc(src, isCharFunc('\n'))
 63	if pos == -1 {
 64		return nil
 65	}
 66
 67	return getStatementStart(src[pos:])
 68}
 69
 70// locateKeyName locates and parses key name and returns rest of slice
 71func locateKeyName(src []byte) (key string, cutset []byte, err error) {
 72	// trim "export" and space at beginning
 73	src = bytes.TrimLeftFunc(src, isSpace)
 74	if bytes.HasPrefix(src, []byte(exportPrefix)) {
 75		trimmed := bytes.TrimPrefix(src, []byte(exportPrefix))
 76		if bytes.IndexFunc(trimmed, isSpace) == 0 {
 77			src = bytes.TrimLeftFunc(trimmed, isSpace)
 78		}
 79	}
 80
 81	// locate key name end and validate it in single loop
 82	offset := 0
 83loop:
 84	for i, char := range src {
 85		rchar := rune(char)
 86		if isSpace(rchar) {
 87			continue
 88		}
 89
 90		switch char {
 91		case '=', ':':
 92			// library also supports yaml-style value declaration
 93			key = string(src[0:i])
 94			offset = i + 1
 95			break loop
 96		case '_':
 97		default:
 98			// variable name should match [A-Za-z0-9_.]
 99			if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' {
100				continue
101			}
102
103			return "", nil, fmt.Errorf(
104				`unexpected character %q in variable name near %q`,
105				string(char), string(src))
106		}
107	}
108
109	if len(src) == 0 {
110		return "", nil, errors.New("zero length string")
111	}
112
113	// trim whitespace
114	key = strings.TrimRightFunc(key, unicode.IsSpace)
115	cutset = bytes.TrimLeftFunc(src[offset:], isSpace)
116	return key, cutset, nil
117}
118
119// extractVarValue extracts variable value and returns rest of slice
120func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) {
121	quote, hasPrefix := hasQuotePrefix(src)
122	if !hasPrefix {
123		// unquoted value - read until end of line
124		endOfLine := bytes.IndexFunc(src, isLineEnd)
125
126		// Hit EOF without a trailing newline
127		if endOfLine == -1 {
128			endOfLine = len(src)
129
130			if endOfLine == 0 {
131				return "", nil, nil
132			}
133		}
134
135		// Convert line to rune away to do accurate countback of runes
136		line := []rune(string(src[0:endOfLine]))
137
138		// Assume end of line is end of var
139		endOfVar := len(line)
140		if endOfVar == 0 {
141			return "", src[endOfLine:], nil
142		}
143
144		// Work backwards to check if the line ends in whitespace then
145		// a comment (ie asdasd # some comment)
146		for i := endOfVar - 1; i >= 0; i-- {
147			if line[i] == charComment && i > 0 {
148				if isSpace(line[i-1]) {
149					endOfVar = i
150					break
151				}
152			}
153		}
154
155		trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
156
157		return expandVariables(trimmed, vars), src[endOfLine:], nil
158	}
159
160	// lookup quoted string terminator
161	for i := 1; i < len(src); i++ {
162		if char := src[i]; char != quote {
163			continue
164		}
165
166		// skip escaped quote symbol (\" or \', depends on quote)
167		if prevChar := src[i-1]; prevChar == '\\' {
168			continue
169		}
170
171		// trim quotes
172		trimFunc := isCharFunc(rune(quote))
173		value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc))
174		if quote == prefixDoubleQuote {
175			// unescape newlines for double quote (this is compat feature)
176			// and expand environment variables
177			value = expandVariables(expandEscapes(value), vars)
178		}
179
180		return value, src[i+1:], nil
181	}
182
183	// return formatted error if quoted string is not terminated
184	valEndIndex := bytes.IndexFunc(src, isCharFunc('\n'))
185	if valEndIndex == -1 {
186		valEndIndex = len(src)
187	}
188
189	return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
190}
191
192func expandEscapes(str string) string {
193	out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string {
194		c := strings.TrimPrefix(match, `\`)
195		switch c {
196		case "n":
197			return "\n"
198		case "r":
199			return "\r"
200		default:
201			return match
202		}
203	})
204	return unescapeCharsRegex.ReplaceAllString(out, "$1")
205}
206
207func indexOfNonSpaceChar(src []byte) int {
208	return bytes.IndexFunc(src, func(r rune) bool {
209		return !unicode.IsSpace(r)
210	})
211}
212
213// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
214func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) {
215	if len(src) == 0 {
216		return 0, false
217	}
218
219	switch prefix := src[0]; prefix {
220	case prefixDoubleQuote, prefixSingleQuote:
221		return prefix, true
222	default:
223		return 0, false
224	}
225}
226
227func isCharFunc(char rune) func(rune) bool {
228	return func(v rune) bool {
229		return v == char
230	}
231}
232
233// isSpace reports whether the rune is a space character but not line break character
234//
235// this differs from unicode.IsSpace, which also applies line break as space
236func isSpace(r rune) bool {
237	switch r {
238	case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
239		return true
240	}
241	return false
242}
243
244func isLineEnd(r rune) bool {
245	if r == '\n' || r == '\r' {
246		return true
247	}
248	return false
249}
250
251var (
252	escapeRegex        = regexp.MustCompile(`\\.`)
253	expandVarRegex     = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
254	unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
255)
256
257func expandVariables(v string, m map[string]string) string {
258	return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
259		submatch := expandVarRegex.FindStringSubmatch(s)
260
261		if submatch == nil {
262			return s
263		}
264		if submatch[1] == "\\" || submatch[2] == "(" {
265			return submatch[0][1:]
266		} else if submatch[4] != "" {
267			return m[submatch[4]]
268		}
269		return s
270	})
271}