Skip to content

Commit

Permalink
Inherit variables
Browse files Browse the repository at this point in the history
This adds mirror functions to resolve inherited variables
with a lookup function.

Signed-off-by: Ulysses Souza <[email protected]>
  • Loading branch information
ulyssessouza committed Jul 20, 2021
1 parent ddf83eb commit d3051d5
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 31 deletions.
1 change: 1 addition & 0 deletions fixtures/inherited-not-found.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VARIABLE_NOT_FOUND
1 change: 1 addition & 0 deletions fixtures/inherited.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PATH
121 changes: 95 additions & 26 deletions godotenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import (

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

// Load will read your env file(s) and load them into ENV for this process.
// LoadWithLookupFn will read your env file(s), lookup the inherited variables with `lookupFn`
//
// and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main)
//
Expand All @@ -39,18 +41,35 @@ const doubleQuoteSpecialChars = "\\\n\r\"!$`"
// godotenv.Load("fileone", "filetwo")
//
// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults
func Load(filenames ...string) (err error) {
func LoadWithLookupFn(lookupFn func(string) (string, bool), filenames ...string) (err error) {
filenames = filenamesOrDefault(filenames)

for _, filename := range filenames {
err = loadFile(filename, false)
err = loadFile(filename, false, lookupFn)
if err != nil {
return // return early on a spazout
}
}
return
}

// Load will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main)
//
// If you call Load without any args it will default to loading .env in the current path
//
// You can otherwise tell it which files to load (there can be more than one) like
//
// godotenv.Load("fileone", "filetwo")
//
// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults
func Load(filenames ...string) (err error) {
return LoadWithLookupFn(func(string) (string, bool){
return "", true
}, filenames...)
}

// Overload will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main)
Expand All @@ -66,7 +85,9 @@ func Overload(filenames ...string) (err error) {
filenames = filenamesOrDefault(filenames)

for _, filename := range filenames {
err = loadFile(filename, true)
err = loadFile(filename, true, func(string) (string, bool) {
return "", true
})
if err != nil {
return // return early on a spazout
}
Expand All @@ -81,7 +102,7 @@ func Read(filenames ...string) (envMap map[string]string, err error) {
envMap = make(map[string]string)

for _, filename := range filenames {
individualEnvMap, individualErr := readFile(filename)
individualEnvMap, individualErr := readFile(filename, noLookupFn)

if individualErr != nil {
err = individualErr
Expand All @@ -96,32 +117,61 @@ func Read(filenames ...string) (envMap map[string]string, err error) {
return
}

// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (envMap map[string]string, err error) {
// ReadWithLookup all env (with same file loading semantics as Load) but return values as
// a map rather than automatically writing values into env
func ReadWithLookup(lookupFn func(string)(string, bool), filenames ...string) (envMap map[string]string, err error) {
filenames = filenamesOrDefault(filenames)
envMap = make(map[string]string)

for _, filename := range filenames {
individualEnvMap, individualErr := readFile(filename, lookupFn)

if individualErr != nil {
err = individualErr
return // return early on a spazout
}

for key, value := range individualEnvMap {
envMap[key] = value
}
}

return
}

// ParseWithLookup reads an env file from io.Reader resolving variables with lookupFn, returning a map of keys and values.
func ParseWithLookup(r io.Reader, lookupFn func(string)(string, bool)) (map[string]string, error) {
envMap := make(map[string]string)

var lines []string
scanner := bufio.NewScanner(r)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}

if err = scanner.Err(); err != nil {
return
if err := scanner.Err(); err != nil {
return envMap, err
}

for _, fullLine := range lines {
if !isIgnoredLine(fullLine) {
var key, value string
key, value, err = parseLine(fullLine, envMap)

key, value, err := parseLine(fullLine, envMap, lookupFn)
if err != nil {
return
return envMap, err
}
if key == "" {
continue
}
envMap[key] = value
}
}
return
return envMap, nil
}

// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (map[string]string, error) {
return ParseWithLookup(r, noLookupFn)
}

//Unmarshal reads an env file from a string, returning a map of keys and values.
Expand Down Expand Up @@ -187,8 +237,8 @@ func filenamesOrDefault(filenames []string) []string {
return filenames
}

func loadFile(filename string, overload bool) error {
envMap, err := readFile(filename)
func loadFile(filename string, overload bool, lookupFn func(string)(string, bool)) error {
envMap, err := readFile(filename, lookupFn)
if err != nil {
return err
}
Expand All @@ -209,19 +259,19 @@ func loadFile(filename string, overload bool) error {
return nil
}

func readFile(filename string) (envMap map[string]string, err error) {
func readFile(filename string, lookupFn func(string)(string, bool)) (envMap map[string]string, err error) {
file, err := os.Open(filename)
if err != nil {
return
}
defer file.Close()

return Parse(file)
return ParseWithLookup(file, lookupFn)
}

var exportRegex = regexp.MustCompile(`^\s*(?:export\s+)?(.*?)\s*$`)

func parseLine(line string, envMap map[string]string) (key string, value string, err error) {
func parseLine(line string, envMap map[string]string, lookupFn func(string)(string, bool)) (key string, value string, err error) {
if len(line) == 0 {
err = errors.New("zero length string")
return
Expand Down Expand Up @@ -258,30 +308,49 @@ func parseLine(line string, envMap map[string]string) (key string, value string,
splitString = strings.SplitN(line, ":", 2)
}

if len(splitString) != 2 {
err = errors.New("Can't separate key from value")
// Parse the key
key = strings.TrimSpace(strings.TrimPrefix(splitString[0], "export "))
key = exportRegex.ReplaceAllString(key, "$1")
if err = validateVariableName(key); err != nil {
return
}

// Parse the key
key = splitString[0]
if strings.HasPrefix(key, "export") {
key = strings.TrimPrefix(key, "export")
// Environment inherited variable
if firstEquals < 0 && firstColon < 0 {
value = ""
v, ok := lookupFn(strings.TrimSpace(key))
if ok {
value = v
}
return
}
key = strings.TrimSpace(key)

key = exportRegex.ReplaceAllString(splitString[0], "$1")
if len(splitString) != 2 {
err = errors.New("Can't separate key from value")
return
}

// Parse the value
value = parseValue(splitString[1], envMap)
return
}

func validateVariableName(key string) error {
key = strings.TrimSpace(strings.TrimPrefix(key, "export "))
if !variableNameRegex.MatchString(key) {
return fmt.Errorf("invalid variable name %q", key)
}
return nil
}

var (
singleQuotesRegex = regexp.MustCompile(`\A'(.*)'\z`)
doubleQuotesRegex = regexp.MustCompile(`\A"(.*)"\z`)
escapeRegex = regexp.MustCompile(`\\.`)
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
variableNameRegex = regexp.MustCompile(`^[_\\.a-zA-Z0-9]+$`)

noLookupFn = func(string)(string, bool) {return "", true}
)

func parseValue(value string, envMap map[string]string) string {
Expand Down
61 changes: 56 additions & 5 deletions godotenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
var noopPresets = make(map[string]string)

func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) {
key, value, _ := parseLine(rawEnvLine, noopPresets)
key, value, _ := parseLine(rawEnvLine, noopPresets, noLookupFn)
if key != expectedKey || value != expectedValue {
t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, expectedKey, expectedValue, key, value)
}
Expand Down Expand Up @@ -83,6 +83,33 @@ func TestReadPlainEnv(t *testing.T) {
}

envMap, err := Read(envFileName)
if err != nil {
t.Errorf("Error reading file: %q", err)
}

if len(envMap) != len(expectedValues) {
t.Error("Didn't get the right size map back")
}

for key, value := range expectedValues {
if envMap[key] != value {
t.Error("Read got one of the keys wrong")
}
}
}

func TestInheritedEnvVariable(t *testing.T) {
pathVariable, ok := os.LookupEnv("PATH")
if !ok {
t.Error("Error getting path variable")
}

envFileName := "fixtures/inherited.env"
expectedValues := map[string]string{
"PATH": pathVariable,
}

envMap, err := ReadWithLookup(os.LookupEnv, envFileName)
if err != nil {
t.Error("Error reading file")
}
Expand All @@ -98,6 +125,31 @@ func TestReadPlainEnv(t *testing.T) {
}
}

func TestInheritedEnvVariableNotFound(t *testing.T) {
envMap, err := Read("fixtures/inherited-not-found.env")
if envMap["VARIABLE_NOT_FOUND"] != "" || err != nil {
t.Errorf("Expected 'VARIABLE_NOT_FOUND' to be '' with no errors")
}
}

func TestInheritedEnvVariableNotFoundWithLookup(t *testing.T) {
notFoundMap := make(map[string]interface{})
envMap, err := ReadWithLookup(func(v string)(string, bool){
envVar, ok := os.LookupEnv(v)
if !ok {
notFoundMap[v] = nil
}
return envVar, ok
}, "fixtures/inherited-not-found.env")
if envMap["VARIABLE_NOT_FOUND"] != "" || err != nil {
t.Errorf("Expected 'VARIABLE_NOT_FOUND' to be '' with no errors")
}
_, ok := notFoundMap["VARIABLE_NOT_FOUND"]
if !ok {
t.Errorf("Expected 'VARIABLE_NOT_FOUND' to be in the set of not found variables")
}
}

func TestParse(t *testing.T) {
envMap, err := Parse(bytes.NewReader([]byte("ONE=1\nTWO='2'\nTHREE = \"3\"")))
expectedValues := map[string]string{
Expand Down Expand Up @@ -358,7 +410,6 @@ func TestParsing(t *testing.T) {
parseAndCompare(t, `FOO="bar\\\n\ b\az"`, "FOO", "bar\\\n baz")
parseAndCompare(t, `FOO="bar\\r\ b\az"`, "FOO", "bar\\r baz")

parseAndCompare(t, `="value"`, "", "value")
parseAndCompare(t, `KEY="`, "KEY", "\"")
parseAndCompare(t, `KEY="value`, "KEY", "\"value")

Expand All @@ -370,9 +421,9 @@ func TestParsing(t *testing.T) {
// it 'throws an error if line format is incorrect' do
// expect{env('lol$wut')}.to raise_error(Dotenv::FormatError)
badlyFormattedLine := "lol$wut"
_, _, err := parseLine(badlyFormattedLine, noopPresets)
k, v, err := parseLine(badlyFormattedLine, noopPresets, noLookupFn)
if err == nil {
t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine)
t.Errorf("Expected \"%v\" to return error, but it didn't. key=%q, value=%q", badlyFormattedLine, k, v)
}
}

Expand Down Expand Up @@ -454,7 +505,7 @@ func TestRoundtrip(t *testing.T) {
fixtures := []string{"equals.env", "exported.env", "plain.env", "quoted.env"}
for _, fixture := range fixtures {
fixtureFilename := fmt.Sprintf("fixtures/%s", fixture)
env, err := readFile(fixtureFilename)
env, err := readFile(fixtureFilename, noLookupFn)
if err != nil {
t.Errorf("Expected '%s' to read without error (%v)", fixtureFilename, err)
}
Expand Down

0 comments on commit d3051d5

Please sign in to comment.