Skip to content

Commit

Permalink
feat: add support for embedded command evaluation
Browse files Browse the repository at this point in the history
  • Loading branch information
the-wondersmith committed Apr 26, 2024
1 parent 7765d9d commit 30e44a2
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 15 deletions.
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
}

0 comments on commit 30e44a2

Please sign in to comment.