From 2d8b3aab8888d1d7f5f8301d6a93584c673b9b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A9treault?= Date: Tue, 11 Sep 2018 00:55:10 -0600 Subject: [PATCH] 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 --- godotenv.go | 48 ++++++++++++++++++++++--------- godotenv_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/godotenv.go b/godotenv.go index e606481..29b436c 100644 --- a/godotenv.go +++ b/godotenv.go @@ -270,12 +270,19 @@ func parseValue(value string, envMap map[string]string) string { // check if we've got quoted values or possible escapes if len(value) > 1 { - first := string(value[0:1]) - last := string(value[len(value)-1:]) - if first == last && strings.ContainsAny(first, `"'`) { + rs := regexp.MustCompile(`\A'(.*)'\z`) + singleQuotes := rs.FindStringSubmatch(value) + + rd := regexp.MustCompile(`\A"(.*)"\z`) + doubleQuotes := rd.FindStringSubmatch(value) + + if singleQuotes != nil || doubleQuotes != nil { // pull the quotes off the edges value = value[1 : len(value)-1] - // handle escapes + } + + if doubleQuotes != nil { + // expand newlines escapeRegex := regexp.MustCompile(`\\.`) value = escapeRegex.ReplaceAllStringFunc(value, func(match string) string { c := strings.TrimPrefix(match, `\`) @@ -285,23 +292,38 @@ func parseValue(value string, envMap map[string]string) string { case "r": return "\r" 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 + 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 val, ok := os.LookupEnv(key); ok { - return val + if submatch[1] == "\\" || submatch[2] == "(" { + return submatch[0][1:] + } else if submatch[4] != "" { + return m[submatch[4]] } - return "" + return s }) - return value } func isIgnoredLine(line string) bool { diff --git a/godotenv_test.go b/godotenv_test.go index bbbd658..acc2131 100644 --- a/godotenv_test.go +++ b/godotenv_test.go @@ -6,6 +6,7 @@ import ( "os" "reflect" "testing" + "strings" ) var noopPresets = make(map[string]string) @@ -161,7 +162,7 @@ func TestLoadExportedEnv(t *testing.T) { envFileName := "fixtures/exported.env" expectedValues := map[string]string{ "OPTION_A": "2", - "OPTION_B": "\n", + "OPTION_B": "\\n", } loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) @@ -182,7 +183,7 @@ func TestLoadQuotedEnv(t *testing.T) { "OPTION_A": "1", "OPTION_B": "2", "OPTION_C": "", - "OPTION_D": "\n", + "OPTION_D": "\\n", "OPTION_E": "1", "OPTION_F": "2", "OPTION_G": "", @@ -193,7 +194,7 @@ func TestLoadQuotedEnv(t *testing.T) { loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) } -func TestSubstituitions(t *testing.T) { +func TestSubstitutions(t *testing.T) { envFileName := "fixtures/substitutions.env" expectedValues := map[string]string{ "OPTION_A": "1", @@ -206,6 +207,70 @@ func TestSubstituitions(t *testing.T) { 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) { os.Clearenv() os.Setenv("OPTION_A", "actualenv") @@ -247,7 +312,7 @@ func TestParsing(t *testing.T) { // parses export keyword 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 // expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz")