Skip to content

Commit

Permalink
Marshal (#17)
Browse files Browse the repository at this point in the history
* refactor: small cleanup

* feat: add Marshal and Unmarshal methods

Signed-off-by: Luis Davim <luis.davim@sendoso.com>

* fix: dont leave the file open on errors

* feat: add function to read directly from a file
  • Loading branch information
luisdavim committed May 30, 2022
1 parent 44bec41 commit f7680c0
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 42 deletions.
144 changes: 102 additions & 42 deletions gotenv.go
Expand Up @@ -6,7 +6,10 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)

Expand All @@ -24,41 +27,31 @@ const (
// Env holds key/value pair of valid environment variable
type Env map[string]string

/*
Load is a function to load a file or multiple files and then export the valid variables into environment variables if they do not exist.
When it's called with no argument, it will load `.env` file on the current path and set the environment variables.
Otherwise, it will loop over the filenames parameter and set the proper environment variables.
*/
// Load is a function to load a file or multiple files and then export the valid variables into environment variables if they do not exist.
// When it's called with no argument, it will load `.env` file on the current path and set the environment variables.
// Otherwise, it will loop over the filenames parameter and set the proper environment variables.
func Load(filenames ...string) error {
return loadenv(false, filenames...)
}

/*
OverLoad is a function to load a file or multiple files and then export and override the valid variables into environment variables.
*/
// OverLoad is a function to load a file or multiple files and then export and override the valid variables into environment variables.
func OverLoad(filenames ...string) error {
return loadenv(true, filenames...)
}

/*
Must is wrapper function that will panic when supplied function returns an error.
*/
// Must is wrapper function that will panic when supplied function returns an error.
func Must(fn func(filenames ...string) error, filenames ...string) {
if err := fn(filenames...); err != nil {
panic(err.Error())
}
}

/*
Apply is a function to load an io Reader then export the valid variables into environment variables if they do not exist.
*/
// Apply is a function to load an io Reader then export the valid variables into environment variables if they do not exist.
func Apply(r io.Reader) error {
return parset(r, false)
}

/*
OverApply is a function to load an io Reader then export and override the valid variables into environment variables.
*/
// OverApply is a function to load an io Reader then export and override the valid variables into environment variables.
func OverApply(r io.Reader) error {
return parset(r, true)
}
Expand All @@ -75,11 +68,10 @@ func loadenv(override bool, filenames ...string) error {
}

err = parset(f, override)
f.Close()
if err != nil {
return err
}

f.Close()
}

return nil
Expand Down Expand Up @@ -124,6 +116,63 @@ func StrictParse(r io.Reader) (Env, error) {
return strictParse(r, false)
}

// Read is a function to parse a file line by line and returns the valid Env key/value pair of valid variables.
// It expands the value of a variable from the environment variable but does not set the value to the environment itself.
// This function is skipping any invalid lines and only processing the valid one.
func Read(filename string) (Env, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
return strictParse(f, false)
}

//Unmarshal reads a string line by line and returns the valid Env key/value pair of valid variables.
// It expands the value of a variable from the environment variable but does not set the value to the environment itself.
// This function is returning an error if there are any invalid lines.
func Unmarshal(str string) (Env, error) {
return strictParse(strings.NewReader(str), false)
}

// Marshal outputs the given environment as a env file.
// Variables will be sorted by name.
func Marshal(env Env) (string, error) {
lines := make([]string, 0, len(env))
for k, v := range env {
if d, err := strconv.Atoi(v); err == nil {
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
} else {
lines = append(lines, fmt.Sprintf(`%s=%q`, k, v))
}
}
sort.Strings(lines)
return strings.Join(lines, "\n"), nil
}

// Write serializes the given environment and writes it to a file
func Write(env Env, filename string) error {
content, err := Marshal(env)
if err != nil {
return err
}
// ensure the path exists
if err := os.MkdirAll(filepath.Dir(filename), 0o775); err != nil {
return err
}
// create or truncate the file
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(content + "\n")
if err != nil {
return err
}

return file.Sync()
}

func strictParse(r io.Reader, override bool) (Env, error) {
env := make(Env)
scanner := bufio.NewScanner(r)
Expand All @@ -143,28 +192,34 @@ func strictParse(r io.Reader, override bool) (Env, error) {
}

quote := ""
// look for the delimiter character
idx := strings.Index(line, "=")
if idx == -1 {
idx = strings.Index(line, ":")
}
// look for a quote character
if idx > 0 && idx < len(line)-1 {
val := strings.TrimSpace(line[idx+1:])
if val[0] == '"' || val[0] == '\'' {
quote = val[:1]
// look for the closing quote character within the same line
idx = strings.LastIndex(strings.TrimSpace(val[1:]), quote)
if idx >= 0 && val[idx] != '\\' {
quote = ""
}
}
}
// look for the closing quote character
for quote != "" && scanner.Scan() {
l := scanner.Text()
line += "\n" + l
idx := strings.LastIndex(l, quote)
if idx > 0 && l[idx-1] == '\\' {
// foud a matching quote character but it's escaped
continue
}
if idx >= 0 {
// foud a matching quote
quote = ""
}
}
Expand Down Expand Up @@ -195,21 +250,23 @@ func parseLine(s string, env Env, override bool) error {
return checkFormat(s, env)
}

key := rm[1]
val := rm[2]

// trim whitespace
val = strings.TrimSpace(val)
key := strings.TrimSpace(rm[1])
val := strings.TrimSpace(rm[2])

// determine if string has quote prefix
hdq := strings.HasPrefix(val, `"`)
var hsq, hdq bool

// determine if string has single quote prefix
hsq := strings.HasPrefix(val, `'`)
// check if the value is quoted
if l := len(val); l >= 2 {
l -= 1
// has double quotes
hdq = val[0] == '"' && val[l] == '"'
// has single quotes
hsq = val[0] == '\'' && val[l] == '\''

// remove quotes '' or ""
if l := len(val); (hsq || hdq) && l >= 2 {
val = val[1 : l-1]
// remove quotes '' or ""
if hsq || hdq {
val = val[1:l]
}
}

if hdq {
Expand All @@ -220,11 +277,10 @@ func parseLine(s string, env Env, override bool) error {
val = unescapeRgx.ReplaceAllString(val, "$1")
}

fv := func(s string) string {
return varReplacement(s, hsq, env, override)
}

if !hsq {
fv := func(s string) string {
return varReplacement(s, hsq, env, override)
}
val = varRgx.ReplaceAllStringFunc(val, fv)
val = parseVal(val, env, hdq, override)
}
Expand All @@ -250,8 +306,13 @@ func parseExport(st string, env Env) error {
var varNameRgx = regexp.MustCompile(`(\$)(\{?([A-Z0-9_]+)\}?)`)

func varReplacement(s string, hsq bool, env Env, override bool) string {
if strings.HasPrefix(s, "\\") {
return strings.TrimPrefix(s, "\\")
if s == "" {
return s
}

if s[0] == '\\' {
// the dollar sign is escaped
return s[1:]
}

if hsq {
Expand All @@ -270,18 +331,17 @@ func varReplacement(s string, hsq bool, env Env, override bool) string {
return replace
}

replace, ok := env[v]
if !ok {
replace = os.Getenv(v)
if replace, ok := env[v]; ok {
return replace
}

return replace
return os.Getenv(v)
}

func checkFormat(s string, env Env) error {
st := strings.TrimSpace(s)

if (st == "") || strings.HasPrefix(st, "#") {
if st == "" || st[0] == '#' {
return nil
}

Expand Down
21 changes: 21 additions & 0 deletions gotenv_test.go
Expand Up @@ -405,3 +405,24 @@ func TestOverApply(t *testing.T) {
assert.Equal(t, "universe", os.Getenv("HELLO"))
os.Clearenv()
}

func TestMarshal(t *testing.T) {
env := gotenv.Env{
"FOO": "BAR",
"ONE": "1",
"QUOTED": `some "quoted" text`,
"EMPTY": "",
}
expected := `EMPTY=""
FOO="BAR"
ONE=1
QUOTED="some \"quoted\" text"`

actual, err := gotenv.Marshal(env)
assert.Nil(t, err)
assert.Equal(t, expected, actual)

out, err := gotenv.Unmarshal(expected)
assert.Nil(t, err)
assert.Equal(t, env, out)
}

0 comments on commit f7680c0

Please sign in to comment.