feat(Expand Variables): Custom variable expansion instead of Go's os.Expand

Copy over the tests from https://github.com/bkeepers/dotenv/blob/master/spec/dotenv/parser_spec.rb
related to expanding variables and implement the required changes. I also realized as part of this
that this implementation was not handling values in single quotes properly (e.g.: not the same was
as the ruby package mentionned) so that has been fixed as well along with the related tests.

Fixes: #52
This commit is contained in:
Lucas Tétreault
2018-09-11 00:55:10 -06:00
parent 1709ab122c
commit 2d8b3aab88
2 changed files with 106 additions and 19 deletions

View File

@ -270,12 +270,19 @@ func parseValue(value string, envMap map[string]string) string {
// check if we've got quoted values or possible escapes // check if we've got quoted values or possible escapes
if len(value) > 1 { if len(value) > 1 {
first := string(value[0:1]) rs := regexp.MustCompile(`\A'(.*)'\z`)
last := string(value[len(value)-1:]) singleQuotes := rs.FindStringSubmatch(value)
if first == last && strings.ContainsAny(first, `"'`) {
rd := regexp.MustCompile(`\A"(.*)"\z`)
doubleQuotes := rd.FindStringSubmatch(value)
if singleQuotes != nil || doubleQuotes != nil {
// pull the quotes off the edges // pull the quotes off the edges
value = value[1 : len(value)-1] value = value[1 : len(value)-1]
// handle escapes }
if doubleQuotes != nil {
// expand newlines
escapeRegex := regexp.MustCompile(`\\.`) escapeRegex := regexp.MustCompile(`\\.`)
value = escapeRegex.ReplaceAllStringFunc(value, func(match string) string { value = escapeRegex.ReplaceAllStringFunc(value, func(match string) string {
c := strings.TrimPrefix(match, `\`) c := strings.TrimPrefix(match, `\`)
@ -285,25 +292,40 @@ func parseValue(value string, envMap map[string]string) string {
case "r": case "r":
return "\r" return "\r"
default: default:
return c return match
} }
}) })
// unescape characters
e := regexp.MustCompile(`\\([^$])`)
value = e.ReplaceAllString(value, "$1")
}
if singleQuotes == nil {
value = expandVariables(value, envMap)
} }
} }
// expand variables
value = os.Expand(value, func(key string) string {
if val, ok := envMap[key]; ok {
return val
}
if val, ok := os.LookupEnv(key); ok {
return val
}
return ""
})
return value return value
} }
func expandVariables(v string, m map[string]string) string {
r := regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
return r.ReplaceAllStringFunc(v, func(s string) string {
submatch := r.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
})
}
func isIgnoredLine(line string) bool { func isIgnoredLine(line string) bool {
trimmedLine := strings.Trim(line, " \n\t") trimmedLine := strings.Trim(line, " \n\t")
return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#") return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#")

View File

@ -6,6 +6,7 @@ import (
"os" "os"
"reflect" "reflect"
"testing" "testing"
"strings"
) )
var noopPresets = make(map[string]string) var noopPresets = make(map[string]string)
@ -161,7 +162,7 @@ func TestLoadExportedEnv(t *testing.T) {
envFileName := "fixtures/exported.env" envFileName := "fixtures/exported.env"
expectedValues := map[string]string{ expectedValues := map[string]string{
"OPTION_A": "2", "OPTION_A": "2",
"OPTION_B": "\n", "OPTION_B": "\\n",
} }
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
@ -182,7 +183,7 @@ func TestLoadQuotedEnv(t *testing.T) {
"OPTION_A": "1", "OPTION_A": "1",
"OPTION_B": "2", "OPTION_B": "2",
"OPTION_C": "", "OPTION_C": "",
"OPTION_D": "\n", "OPTION_D": "\\n",
"OPTION_E": "1", "OPTION_E": "1",
"OPTION_F": "2", "OPTION_F": "2",
"OPTION_G": "", "OPTION_G": "",
@ -193,7 +194,7 @@ func TestLoadQuotedEnv(t *testing.T) {
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
} }
func TestSubstituitions(t *testing.T) { func TestSubstitutions(t *testing.T) {
envFileName := "fixtures/substitutions.env" envFileName := "fixtures/substitutions.env"
expectedValues := map[string]string{ expectedValues := map[string]string{
"OPTION_A": "1", "OPTION_A": "1",
@ -206,6 +207,70 @@ func TestSubstituitions(t *testing.T) {
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
} }
func TestExpanding(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]string
}{
{
"expands variables found in values",
"FOO=test\nBAR=$FOO",
map[string]string{"FOO": "test", "BAR": "test"},
},
{
"parses variables wrapped in brackets",
"FOO=test\nBAR=${FOO}bar",
map[string]string{"FOO": "test", "BAR": "testbar"},
},
{
"expands undefined variables to an empty string",
"BAR=$FOO",
map[string]string{"BAR": ""},
},
{
"expands variables in double quoted strings",
"FOO=test\nBAR=\"quote $FOO\"",
map[string]string{"FOO": "test", "BAR": "quote test"},
},
{
"does not expand variables in single quoted strings",
"BAR='quote $FOO'",
map[string]string{"BAR": "quote $FOO"},
},
{
"does not expand escaped variables",
`FOO="foo\$BAR"`,
map[string]string{"FOO": "foo$BAR"},
},
{
"does not expand escaped variables",
`FOO="foo\${BAR}"`,
map[string]string{"FOO": "foo${BAR}"},
},
{
"does not expand escaped variables",
"FOO=test\nBAR=\"foo\\${FOO} ${FOO}\"",
map[string]string{"FOO": "test", "BAR": "foo${FOO} test"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, err := Parse(strings.NewReader(tt.input))
if err != nil {
t.Errorf("Error: %s", err.Error())
}
for k, v := range tt.expected {
if strings.Compare(env[k], v) != 0 {
t.Errorf("Expected: %s, Actual: %s", v, env[k])
}
}
})
}
}
func TestActualEnvVarsAreLeftAlone(t *testing.T) { func TestActualEnvVarsAreLeftAlone(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("OPTION_A", "actualenv") os.Setenv("OPTION_A", "actualenv")
@ -247,7 +312,7 @@ func TestParsing(t *testing.T) {
// parses export keyword // parses export keyword
parseAndCompare(t, "export OPTION_A=2", "OPTION_A", "2") parseAndCompare(t, "export OPTION_A=2", "OPTION_A", "2")
parseAndCompare(t, `export OPTION_B='\n'`, "OPTION_B", "\n") parseAndCompare(t, `export OPTION_B='\n'`, "OPTION_B", "\\n")
// it 'expands newlines in quoted strings' do // it 'expands newlines in quoted strings' do
// expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz") // expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz")