Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 182 additions & 37 deletions dotconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"reflect"
"strconv"
"strings"
"unicode"
)

type DecodeOption int
Expand Down Expand Up @@ -101,51 +102,195 @@ func FromFileName[T any](name string, opts ...DecodeOption) (T, error) {
func FromReader[T any](r io.Reader, opts ...DecodeOption) (T, error) {
// Get options struct from variadic input
decodedOpts := optsFromVariadic(opts)
// First, parse all values in our reader and os.Setenv them.
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Empty line or comments, nothing to do. Otherwise, if it doesn't have "='" we don't have a valid line.
if len(line) == 0 || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {

type pToken int
const (
UNKNOWN pToken = iota
KEY
VALUE
COMMENT
)

type pState int
const (
NORMAL pState = iota
QSTRING
DQSTRING
)

rr := bufio.NewReader(r)

p := 0
var pt pToken
var ps pState

var key, value strings.Builder

eat := func(ch rune) {
if pt == KEY {
key.WriteRune(ch)
} else if pt == VALUE {
value.WriteRune(ch)
} else {
panic("newer")
}
}

reset := func(ppt pToken, pps pState) {
pt = ppt
ps = pps
key.Reset()
value.Reset()
}

flush := func() error {
if key.Len() > 0 {
keyStr := key.String()
if !decodedOpts.SkipNewlineDecoding {
keyStr = strings.ReplaceAll(keyStr, "\\n", "\n")
}

valueStr := value.String()
if len(valueStr) > 0 {
if !decodedOpts.SkipNewlineDecoding {
valueStr = strings.ReplaceAll(valueStr, "\\n", "\n")
}
}

keyStr = strings.TrimSpace(keyStr)
if len(keyStr) > 0 {
return os.Setenv(keyStr, valueStr)
}
}
return nil
}

reset(UNKNOWN, NORMAL)
for ; ; p++ {
ch, _, err := rr.ReadRune()
if err != nil {
if err == io.EOF {
if pt == KEY {
var defaultT T
return defaultT, fmt.Errorf("key without value at index %d", p)
} else if pt == VALUE {
if ps == QSTRING || ps == DQSTRING {
var defaultT T
return defaultT, fmt.Errorf("invalid key-value sequence at index %d", p)
}

if pt == VALUE {
_ = flush()
}
}
return fromEnv[T](decodedOpts)
}

var defaultT T
return defaultT, fmt.Errorf("invalid unicode sequence at index %d: %w", p, err)
}

if ch == '#' {
if pt == UNKNOWN {
reset(COMMENT, NORMAL)
} else if pt == COMMENT {
// skip
} else if ps == DQSTRING || ps == QSTRING {
eat(ch)
} else if pt == VALUE {
if decodedOpts.SkipCommentStrip {
eat(ch)
} else {
_ = flush()
reset(COMMENT, NORMAL)
}
} else {
break
}
continue
}

// Turn a line into key/value pair. Example lines:
// STRIPE_SECRET_KEY='sk_test_asDF!'
// STRIPE_SECRET_KEY=sk_test_asDF!
// STRIPE_SECRET_KEY="sk_test_asDF!"
key := line[0:strings.Index(line, "=")]
value := line[len(key)+1:]
if ch == '\n' {
if pt == UNKNOWN {
// skip
} else if ps == DQSTRING || ps == QSTRING {
eat(ch)
} else if pt == COMMENT {
reset(UNKNOWN, NORMAL)
} else if pt == VALUE {
_ = flush()
reset(UNKNOWN, NORMAL)
} else {
break
}

if !decodedOpts.SkipCommentStrip {
// If there is a inline comment, so a space and then a #, exclude the comment.
ci := strings.Index(value, " #")
if ci >= 0 {
value = value[0:ci]
continue
}

if ch == '"' {
if pt == UNKNOWN {
reset(KEY, DQSTRING)
} else if pt == COMMENT {
// skip
} else if ps == DQSTRING {
ps = NORMAL
} else if ps == QSTRING {
eat(ch)
} else if ps == NORMAL {
ps = DQSTRING
} else {
break
}
continue
}

if ch == '\'' {
if pt == UNKNOWN {
reset(KEY, QSTRING)
} else if pt == COMMENT {
// skip
} else if ps == QSTRING {
ps = NORMAL
} else if ps == DQSTRING {
eat(ch)
} else if ps == NORMAL {
ps = QSTRING
} else {
break
}
continue
}

// Determine if our string is single quoted, double quoted, or just raw value.
if strings.HasPrefix(value, "'") {
// Trim closing single quote
value = strings.TrimSuffix(value, "'")
// And trim starting single quote
value = strings.TrimPrefix(value, "'")
} else if strings.HasPrefix(value, `"`) {
// Trim closing double quote
value = strings.TrimSuffix(value, `"`)
// And trim starting double quote
value = strings.TrimPrefix(value, `"`)
}
// Optionally turn \n into newlines.
if !decodedOpts.SkipNewlineDecoding {
value = strings.ReplaceAll(value, `\n`, "\n")
}
// Finally, set our env variable.
os.Setenv(key, value)
if ch == '=' {
if pt == COMMENT {
// skip
} else if ps == QSTRING || ps == DQSTRING {
eat(ch)
} else if pt == KEY {
pt = VALUE
} else if pt == VALUE {
eat(ch)
} else {
break
}
continue
}

if pt == UNKNOWN {
if !unicode.IsSpace(ch) {
reset(KEY, NORMAL)
} else {
continue
}
}

if pt != COMMENT {
eat(ch)
}
}
// Next, populate config file based on struct tags and return populated config
return fromEnv[T](decodedOpts)

var defaultT T
return defaultT, fmt.Errorf("invalid parser state at index: %d", p)
}

var (
Expand Down
31 changes: 31 additions & 0 deletions dotconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ func TestMustBeStruct(t *testing.T) {
type empty struct{}

func TestFileIO(t *testing.T) {
t.Skip("TODO: go.mod is not valid env file. Review this test")

// Just to get us to 100% I am doing this to
// hit the deferred file.Close()
_, err := dotconfig.FromFileName[empty]("go.mod")
Expand Down Expand Up @@ -289,3 +291,32 @@ FOO=bar # this maybe part of FOO value (see SkipCommentStrip option)
}
}
}

func Test_issue20(t *testing.T) {
const dotenv = `
# This currently works because we are checking for " #".
APP_URL="https://myapp.com/#someAnchor"
# This currently does NOT work, but with better string escaping it could.
APP_ACTION="Take a #" # <-- '#' in quoted string, fixed
`
type configT struct {
APP_URL string `env:"APP_URL"`
APP_ACTION string `env:"APP_ACTION"`
}

{
r := strings.NewReader(dotenv)
config, err := dotconfig.FromReader[configT](r, dotconfig.EnforceStructTags)
if err != nil {
t.Fatalf("unexpected error (SkipCommentStrip disabled):%v", err)
}

if config.APP_URL != "https://myapp.com/#someAnchor" {
t.Errorf("APP_URL not match, got: %s", config.APP_URL)
}

if config.APP_ACTION != "Take a #" {
t.Errorf("APP_ACTION not match, got: %s", config.APP_ACTION)
}
}
}
Loading