Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for embedded command evaluation #230

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions fixtures/embed_cmd.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPTION_A=$(echo 123)
16 changes: 16 additions & 0 deletions godotenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ import (

const doubleQuoteSpecialChars = "\\\n\r\"!$`"

var shouldEvaluateEmbeddedCommands bool = false

// EnableEmbeddedCommandEvaluation must be called before calling functions
// like Load or Parse on a target if you want values containing embedded
// commands to be evaluated
func EnableEmbeddedCommandEvaluation() {
shouldEvaluateEmbeddedCommands = true
}

// DisableEmbeddedCommandEvaluation can be called before calling functions
// like Load or Parse on a target to ensure that values containing embedded
// commands are not evaluated but instead passed straight through as strings
func DisableEmbeddedCommandEvaluation() {
shouldEvaluateEmbeddedCommands = false
}

// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (map[string]string, error) {
var buf bytes.Buffer
Expand Down
26 changes: 18 additions & 8 deletions godotenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,16 @@ func TestSubstitutions(t *testing.T) {
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}

func TestEmbeddedCommandEvaluation(t *testing.T) {
envFileName := "fixtures/embed_cmd.env"
expectedValues := map[string]string{
"OPTION_A": "123",
}
EnableEmbeddedCommandEvaluation()
defer DisableEmbeddedCommandEvaluation()
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}

func TestExpanding(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -582,42 +592,42 @@ func TestWhitespace(t *testing.T) {
}{
"Leading whitespace": {
input: " A=a\n",
key: "A",
key: "A",
value: "a",
},
"Leading tab": {
input: "\tA=a\n",
key: "A",
key: "A",
value: "a",
},
"Leading mixed whitespace": {
input: " \t \t\n\t \t A=a\n",
key: "A",
key: "A",
value: "a",
},
"Leading whitespace before export": {
input: " \t\t export A=a\n",
key: "A",
key: "A",
value: "a",
},
"Trailing whitespace": {
input: "A=a \t \t\n",
key: "A",
key: "A",
value: "a",
},
"Trailing whitespace with export": {
input: "export A=a\t \t \n",
key: "A",
key: "A",
value: "a",
},
"No EOL": {
input: "A=a",
key: "A",
key: "A",
value: "a",
},
"Trailing whitespace with no EOL": {
input: "A=a ",
key: "A",
key: "A",
value: "a",
},
}
Expand Down
68 changes: 61 additions & 7 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"runtime"
"strings"
"unicode"
)
Expand Down Expand Up @@ -255,17 +258,68 @@ var (
)

func expandVariables(v string, m map[string]string) string {
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
submatch := expandVarRegex.FindStringSubmatch(s)
expanded := expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
matched := expandVarRegex.FindStringSubmatch(s)

if submatch == nil {
if matched == nil {
return s
}
if submatch[1] == "\\" || submatch[2] == "(" {
return submatch[0][1:]
} else if submatch[4] != "" {
return m[submatch[4]]
if matched[1] == "\\" || matched[2] == "(" {
return matched[0][1:]
} else if matched[4] != "" {
return m[matched[4]]
}
return s
})

if shouldEvaluateEmbeddedCommands {
expanded = evaluateEmbeddedCommand(expanded)
}

return expanded
}

const (
cmdSuffix = ")"
cmdPrefix = "$("
)

func evaluateEmbeddedCommand(value string) string {
if !(strings.HasPrefix(value, cmdPrefix) && strings.HasSuffix(value, cmdSuffix)) {
return value
}

value = strings.TrimSuffix(strings.TrimPrefix(value, cmdPrefix), cmdSuffix)

shell, args := assembleEmbeddedCommand(value)
command := exec.Command(shell, args...)

if output, err := command.Output(); err == nil {
value = strings.Trim(string(output), " \n\r\t")
} else {
panic(err)
}

return value
}

func assembleEmbeddedCommand(value string) (string, []string) {
var shell string = "/bin/sh"
var flags []string = []string{"-c", value}

if envShell, ok := os.LookupEnv("GOENV_SHELL"); ok && 0 < len(envShell) {
parts := strings.Split(envShell, " ")
shell = parts[0]
flags = append(parts[1:], value)
} else if runtime.GOOS == "windows" {
shell = "cmd"
}

if resolved, err := exec.LookPath(shell); err == nil {
shell = resolved
} else {
panic(err)
}

return shell, flags
}