Skip to content

Commit ff73039

Browse files
author
Chris Stockton
committed
feat: fork github.com/joho/godotenv into internal/conf/envparse
This initial commit adds the unmodified version of the godotenv parser to make it easier to identify changes I've implemented in the next commits.
1 parent be317c1 commit ff73039

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

internal/conf/envparse/envparse.go

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Package envparse is a fork of the github.com/joho/godotenv parser.
2+
//
3+
// -------
4+
//
5+
// # Copyright (c) 2013 John Barton
6+
//
7+
// # MIT License
8+
//
9+
// Permission is hereby granted, free of charge, to any person obtaining
10+
// a copy of this software and associated documentation files (the
11+
// "Software"), to deal in the Software without restriction, including
12+
// without limitation the rights to use, copy, modify, merge, publish,
13+
// distribute, sublicense, and/or sell copies of the Software, and to
14+
// permit persons to whom the Software is furnished to do so, subject to
15+
// the following conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22+
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
24+
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
25+
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
26+
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27+
package envparse
28+
29+
import (
30+
"bytes"
31+
"errors"
32+
"fmt"
33+
"os"
34+
"regexp"
35+
"strings"
36+
"unicode"
37+
)
38+
39+
const (
40+
charComment = '#'
41+
prefixSingleQuote = '\''
42+
prefixDoubleQuote = '"'
43+
44+
exportPrefix = "export"
45+
)
46+
47+
func parseBytes(src []byte, out map[string]string) error {
48+
src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1)
49+
cutset := src
50+
for {
51+
cutset = getStatementStart(cutset)
52+
if cutset == nil {
53+
// reached end of file
54+
break
55+
}
56+
57+
key, left, err := locateKeyName(cutset)
58+
if err != nil {
59+
return err
60+
}
61+
62+
value, left, err := extractVarValue(left, out)
63+
if err != nil {
64+
return err
65+
}
66+
67+
out[key] = value
68+
cutset = left
69+
}
70+
71+
return nil
72+
}
73+
74+
// getStatementPosition returns position of statement begin.
75+
//
76+
// It skips any comment line or non-whitespace character.
77+
func getStatementStart(src []byte) []byte {
78+
pos := indexOfNonSpaceChar(src)
79+
if pos == -1 {
80+
return nil
81+
}
82+
83+
src = src[pos:]
84+
if src[0] != charComment {
85+
return src
86+
}
87+
88+
// skip comment section
89+
pos = bytes.IndexFunc(src, isCharFunc('\n'))
90+
if pos == -1 {
91+
return nil
92+
}
93+
94+
return getStatementStart(src[pos:])
95+
}
96+
97+
// locateKeyName locates and parses key name and returns rest of slice
98+
func locateKeyName(src []byte) (key string, cutset []byte, err error) {
99+
// trim "export" and space at beginning
100+
src = bytes.TrimLeftFunc(src, isSpace)
101+
if bytes.HasPrefix(src, []byte(exportPrefix)) {
102+
trimmed := bytes.TrimPrefix(src, []byte(exportPrefix))
103+
if bytes.IndexFunc(trimmed, isSpace) == 0 {
104+
src = bytes.TrimLeftFunc(trimmed, isSpace)
105+
}
106+
}
107+
108+
// locate key name end and validate it in single loop
109+
offset := 0
110+
loop:
111+
for i, char := range src {
112+
rchar := rune(char)
113+
if isSpace(rchar) {
114+
continue
115+
}
116+
117+
switch char {
118+
case '=', ':':
119+
// library also supports yaml-style value declaration
120+
key = string(src[0:i])
121+
offset = i + 1
122+
break loop
123+
case '_':
124+
default:
125+
// variable name should match [A-Za-z0-9_.]
126+
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' {
127+
continue
128+
}
129+
130+
return "", nil, fmt.Errorf(
131+
`unexpected character %q in variable name near %q`,
132+
string(char), string(src))
133+
}
134+
}
135+
136+
if len(src) == 0 {
137+
return "", nil, errors.New("zero length string")
138+
}
139+
140+
// trim whitespace
141+
key = strings.TrimRightFunc(key, unicode.IsSpace)
142+
cutset = bytes.TrimLeftFunc(src[offset:], isSpace)
143+
return key, cutset, nil
144+
}
145+
146+
// extractVarValue extracts variable value and returns rest of slice
147+
func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) {
148+
quote, hasPrefix := hasQuotePrefix(src)
149+
if !hasPrefix {
150+
// unquoted value - read until end of line
151+
endOfLine := bytes.IndexFunc(src, isLineEnd)
152+
153+
// Hit EOF without a trailing newline
154+
if endOfLine == -1 {
155+
endOfLine = len(src)
156+
157+
if endOfLine == 0 {
158+
return "", nil, nil
159+
}
160+
}
161+
162+
// Convert line to rune away to do accurate countback of runes
163+
line := []rune(string(src[0:endOfLine]))
164+
165+
// Assume end of line is end of var
166+
endOfVar := len(line)
167+
if endOfVar == 0 {
168+
return "", src[endOfLine:], nil
169+
}
170+
171+
// Work backwards to check if the line ends in whitespace then
172+
// a comment, ie: foo=bar # baz # other
173+
for i := 0; i < endOfVar; i++ {
174+
if line[i] == charComment && i < endOfVar {
175+
if isSpace(line[i-1]) {
176+
endOfVar = i
177+
break
178+
}
179+
}
180+
}
181+
182+
trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
183+
184+
return expandVariables(trimmed, vars), src[endOfLine:], nil
185+
}
186+
187+
// lookup quoted string terminator
188+
for i := 1; i < len(src); i++ {
189+
if char := src[i]; char != quote {
190+
continue
191+
}
192+
193+
// skip escaped quote symbol (\" or \', depends on quote)
194+
if prevChar := src[i-1]; prevChar == '\\' {
195+
continue
196+
}
197+
198+
// trim quotes
199+
trimFunc := isCharFunc(rune(quote))
200+
value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc))
201+
if quote == prefixDoubleQuote {
202+
// unescape newlines for double quote (this is compat feature)
203+
// and expand environment variables
204+
value = expandVariables(expandEscapes(value), vars)
205+
}
206+
207+
return value, src[i+1:], nil
208+
}
209+
210+
// return formatted error if quoted string is not terminated
211+
valEndIndex := bytes.IndexFunc(src, isCharFunc('\n'))
212+
if valEndIndex == -1 {
213+
valEndIndex = len(src)
214+
}
215+
216+
return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
217+
}
218+
219+
func expandEscapes(str string) string {
220+
out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string {
221+
c := strings.TrimPrefix(match, `\`)
222+
switch c {
223+
case "n":
224+
return "\n"
225+
case "r":
226+
return "\r"
227+
default:
228+
return match
229+
}
230+
})
231+
return unescapeCharsRegex.ReplaceAllString(out, "$1")
232+
}
233+
234+
func indexOfNonSpaceChar(src []byte) int {
235+
return bytes.IndexFunc(src, func(r rune) bool {
236+
return !unicode.IsSpace(r)
237+
})
238+
}
239+
240+
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
241+
func hasQuotePrefix(src []byte) (prefix byte, isQuoted bool) {
242+
if len(src) == 0 {
243+
return 0, false
244+
}
245+
246+
switch prefix := src[0]; prefix {
247+
case prefixDoubleQuote, prefixSingleQuote:
248+
return prefix, true
249+
default:
250+
return 0, false
251+
}
252+
}
253+
254+
func isCharFunc(char rune) func(rune) bool {
255+
return func(v rune) bool {
256+
return v == char
257+
}
258+
}
259+
260+
// isSpace reports whether the rune is a space character but not line break character
261+
//
262+
// this differs from unicode.IsSpace, which also applies line break as space
263+
func isSpace(r rune) bool {
264+
switch r {
265+
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
266+
return true
267+
}
268+
return false
269+
}
270+
271+
func isLineEnd(r rune) bool {
272+
if r == '\n' || r == '\r' {
273+
return true
274+
}
275+
return false
276+
}
277+
278+
var (
279+
escapeRegex = regexp.MustCompile(`\\.`)
280+
expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
281+
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
282+
)
283+
284+
func expandVariables(v string, m map[string]string) string {
285+
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
286+
submatch := expandVarRegex.FindStringSubmatch(s)
287+
288+
if submatch == nil {
289+
return s
290+
}
291+
if submatch[1] == "\\" || submatch[2] == "(" {
292+
return submatch[0][1:]
293+
} else if submatch[4] != "" {
294+
if val, ok := m[submatch[4]]; ok {
295+
return val
296+
}
297+
if val, ok := os.LookupEnv(submatch[4]); ok {
298+
return val
299+
}
300+
return m[submatch[4]]
301+
}
302+
return s
303+
})
304+
}

0 commit comments

Comments
 (0)