diff --git a/ffenv/env.go b/ffenv/env.go new file mode 100644 index 0000000..6dabb99 --- /dev/null +++ b/ffenv/env.go @@ -0,0 +1,89 @@ +package ffenv + +import ( + "bufio" + "flag" + "fmt" + "io" + "strings" +) + +// Parser is a parser for .env file format: flag=value. Each +// line is tokenized as a single key/value pair. +func Parser(fs *flag.FlagSet) func(io.Reader, func(string, string) error) error { + return func(r io.Reader, set func(name, value string) error) error { + return parse(fs)("", r, set) + } +} + +// Parser is a parser for config files in an extremely simple format. Each +// line is tokenized as a single key/value pair. The first =-delimited +// token in the line is interpreted as the flag name, and all remaining tokens +// are interpreted as the value. Any leading hyphens on the flag name are +// ignored. +func parse(fs *flag.FlagSet) func(string, io.Reader, func(string, string) error) error { + return func(prefix string, r io.Reader, set func(name, value string) error) error { + + var flags []string + fs.VisitAll(func(f *flag.Flag) { + flags = append(flags, f.Name) + }) + + s := bufio.NewScanner(r) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" { + continue // skip empties + } + + if line[0] == '#' { + continue // skip comments + } + + line = strings.TrimPrefix(line, prefix+"_") + + var ( + name string + value string + index = strings.IndexRune(line, '=') + ) + if index < 0 { + return fmt.Errorf("wrong format in env file, must be: name=value") + } + + name, value = strings.ToLower(line[:index]), line[index+1:] + + if i := strings.Index(value, " #"); i >= 0 { + value = strings.TrimSpace(value[:i]) + } + + var errs []error + for _, sep := range []string{"_", "-", ".", "/"} { + for _, f := range flags { + replaced := strings.ReplaceAll(name, "_", sep) + if f == replaced { + if err := set(replaced, value); err != nil { + errs = append(errs, err) + continue + } + goto done + } + } + } + if len(errs) > 0 { + return errs[0] + } + done: + } + return s.Err() + } +} + +// ParserWithPrefix returns a Parser that will remove any prefix on keys in an +// .env file. For example, given prefix "MY_APP", the line `MY_APP_FOO=bar` +// in an .env file will be evaluated as name=foo, value=bar. +func ParserWithPrefix(fs *flag.FlagSet, prefix string) func(io.Reader, func(string, string) error) error { + return func(r io.Reader, set func(name, value string) error) error { + return parse(fs)(prefix, r, set) + } +} diff --git a/ffenv/env_test.go b/ffenv/env_test.go new file mode 100644 index 0000000..a4c18c7 --- /dev/null +++ b/ffenv/env_test.go @@ -0,0 +1,285 @@ +package ffenv_test + +import ( + "flag" + "fmt" + "os" + "testing" + "time" + + "github.com/peterbourgon/ff/v3" + "github.com/peterbourgon/ff/v3/ffenv" + "github.com/peterbourgon/ff/v3/fftest" +) + +type Vars struct { + *fftest.Vars + S_S string + SDotS string + SDashS string + SSlashS string +} + +func Pair() (*flag.FlagSet, *Vars) { + fs, v := fftest.Pair() + + var vv Vars + vv.Vars = v + + fs.StringVar(&vv.S_S, "s_s", "", "string") + fs.StringVar(&vv.SDashS, "s-s", "", "string") + fs.StringVar(&vv.SDotS, "s.s", "", "string") + fs.StringVar(&vv.SSlashS, "s/s", "", "string") + return fs, &vv +} + +func Compare(want, have *Vars) error { + if want.SDashS != have.SDashS { + return fmt.Errorf("var SDashS: want %q, have %q", want.SDashS, have.SDashS) + } + if want.SDotS != have.SDotS { + return fmt.Errorf("var SDotS: want %q, have %q", want.SDotS, have.SDotS) + } + if want.SSlashS != have.SSlashS { + return fmt.Errorf("var SSlashS: want %q, have %q", want.SSlashS, have.SSlashS) + } + if want.S_S != have.S_S { + return fmt.Errorf("var S_S: want %q, have %q", want.S_S, have.S_S) + } + return fftest.Compare(want.Vars, have.Vars) +} + +func TestParseBasics(t *testing.T) { + t.Parallel() + + for _, testcase := range []struct { + name string + env map[string]string + file string + args []string + opts []ff.Option + want Vars + }{ + { + name: "empty", + args: []string{}, + want: Vars{Vars: &fftest.Vars{}}, + }, + { + name: "args only", + args: []string{"-s", "foo", "-i", "123", "-b", "-d", "24m"}, + want: Vars{Vars: &fftest.Vars{S: "foo", I: 123, B: true, D: 24 * time.Minute}}, + }, + { + name: "file only", + file: "testdata/1.env", + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{Vars: &fftest.Vars{S: "bar", I: 99, B: true, D: time.Hour}}, + }, + { + name: "env only", + env: map[string]string{"TEST_PARSE_S": "baz", "TEST_PARSE_F": "0.99", "TEST_PARSE_D": "100s"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{Vars: &fftest.Vars{S: "baz", F: 0.99, D: 100 * time.Second}}, + }, + { + name: "file args", + file: "testdata/2.env", + args: []string{"-s", "foo", "-i", "1234"}, + want: Vars{Vars: &fftest.Vars{S: "foo", I: 1234, D: 3 * time.Second}}, + }, + { + name: "env args", + env: map[string]string{"TEST_PARSE_S": "should be overridden", "TEST_PARSE_B": "true"}, + args: []string{"-s", "explicit wins", "-i", "7"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{Vars: &fftest.Vars{S: "explicit wins", I: 7, B: true}}, + }, + { + name: "file env", + env: map[string]string{"TEST_PARSE_S": "env takes priority", "TEST_PARSE_B": "true"}, + file: "testdata/3.env", + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{Vars: &fftest.Vars{S: "env takes priority", I: 99, B: true, D: 34 * time.Second}}, + }, + { + name: "file env args", + file: "testdata/4.env", + env: map[string]string{"TEST_PARSE_S": "from env", "TEST_PARSE_I": "300", "TEST_PARSE_F": "0.15", "TEST_PARSE_B": "true"}, + args: []string{"-s", "from arg", "-i", "100"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{Vars: &fftest.Vars{S: "from arg", I: 100, F: 0.15, B: true, D: time.Minute}}, + }, + { + name: "repeated args", + args: []string{"-s", "foo", "-s", "bar", "-d", "1m", "-d", "1h", "-x", "1", "-x", "2", "-x", "3"}, + want: Vars{Vars: &fftest.Vars{S: "bar", D: time.Hour, X: []string{"1", "2", "3"}}}, + }, + { + name: "long args", + args: []string{"-s_s", "f_oo", "-s-s", "f-oo", "-s.s", "f.oo", "-s/s", "f/oo"}, + want: Vars{S_S: "f_oo", SDashS: "f-oo", SDotS: "f.oo", SSlashS: "f/oo", Vars: &fftest.Vars{}}, + }, + { + name: "priority repeats", + env: map[string]string{"TEST_PARSE_S": "s.env", "TEST_PARSE_X": "x.env.1"}, + file: "testdata/5.env", + args: []string{"-s", "s.arg.1", "-s", "s.arg.2", "-x", "x.arg.1", "-x", "x.arg.2"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{Vars: &fftest.Vars{S: "s.arg.2", X: []string{"x.arg.1", "x.arg.2"}}}, // highest prio wins and no others are called + }, + { + name: "PlainParser string with spaces", + file: "testdata/equals.env", + want: Vars{Vars: &fftest.Vars{S: "i=am=the=very=model=of=a=modern=major=general"}}, + }, + { + name: "default comma behavior", + env: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{Vars: &fftest.Vars{S: "one,two,three", X: []string{"one,two,three"}}}, + }, + { + name: "WithEnvVarSplit", + env: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, + want: Vars{Vars: &fftest.Vars{S: "three", X: []string{"one", "two", "three"}}}, + }, + { + name: "WithEnvVarNoPrefix", + env: map[string]string{"TEST_PARSE_S": "foo", "S": "bar"}, + opts: []ff.Option{ff.WithEnvVarNoPrefix()}, + want: Vars{Vars: &fftest.Vars{S: "bar"}}, + }, + { + name: "env var split comma whitespace", + env: map[string]string{"TEST_PARSE_S": "one, two, three ", "TEST_PARSE_X": "one, two, three "}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, + want: Vars{Vars: &fftest.Vars{S: " three ", X: []string{"one", " two", " three "}}}, + }, + { + name: "flags with . from env", + env: map[string]string{"TEST_PARSE_S_S": "one"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: Vars{S_S: "one", SDashS: "one", SDotS: "one", SSlashS: "one", Vars: &fftest.Vars{}}, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + fs, vars := Pair() + if testcase.file != "" { + testcase.opts = append(testcase.opts, ff.WithConfigFile(testcase.file), ff.WithConfigFileParser(ffenv.ParserWithPrefix(fs, "TEST_PARSE"))) + } + + if len(testcase.env) > 0 { + for k, v := range testcase.env { + defer os.Setenv(k, os.Getenv(k)) + os.Setenv(k, v) + } + } + + vars.ParseError = ff.Parse(fs, testcase.args, testcase.opts...) + if err := Compare(&testcase.want, vars); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestParseIssue16(t *testing.T) { + t.Parallel() + + for _, testcase := range []struct { + name string + data string + want string + }{ + { + name: "hash in value", + data: "s=bar#baz", + want: "bar#baz", + }, + { + name: "EOL comment with space", + data: "s=bar # baz", + want: "bar", + }, + { + name: "EOL comment no space", + data: "s=bar #baz", + want: "bar", + }, + { + name: "only comment with space", + data: "#=foo=bar\n", + want: "", + }, + { + name: "only comment no space", + data: "#foo=bar\n", + want: "", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + filename, cleanup := fftest.TempFile(t, testcase.data) + defer cleanup() + + fs, vars := fftest.Pair() + vars.ParseError = ff.Parse(fs, []string{}, + ff.WithConfigFile(filename), + ff.WithConfigFileParser(ffenv.Parser(fs)), + ) + + want := fftest.Vars{S: testcase.want} + if err := fftest.Compare(&want, vars); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestParseConfigFile(t *testing.T) { + t.Parallel() + + for _, testcase := range []struct { + name string + missing bool + allowMissing bool + parseError error + }{ + { + name: "has config file", + }, + { + name: "config file missing", + missing: true, + parseError: os.ErrNotExist, + }, + { + name: "config file missing + allow missing", + missing: true, + allowMissing: true, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + filename := "dummy" + if !testcase.missing { + var cleanup func() + filename, cleanup = fftest.TempFile(t, "") + defer cleanup() + } + + fs, vars := fftest.Pair() + options := []ff.Option{ff.WithConfigFile(filename), ff.WithConfigFileParser(ffenv.Parser(fs))} + if testcase.allowMissing { + options = append(options, ff.WithAllowMissingConfigFile(true)) + } + + vars.ParseError = ff.Parse(fs, []string{}, options...) + + want := fftest.Vars{WantParseErrorIs: testcase.parseError} + if err := fftest.Compare(&want, vars); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/ffenv/testdata/1.env b/ffenv/testdata/1.env new file mode 100644 index 0000000..b0c224a --- /dev/null +++ b/ffenv/testdata/1.env @@ -0,0 +1,4 @@ +TEST_PARSE_S=bar +TEST_PARSE_I=99 +TEST_PARSE_B=true +TEST_PARSE_D=1h diff --git a/ffenv/testdata/2.env b/ffenv/testdata/2.env new file mode 100644 index 0000000..9d07afc --- /dev/null +++ b/ffenv/testdata/2.env @@ -0,0 +1,4 @@ + +s=should be overridden + +d=3s diff --git a/ffenv/testdata/3.env b/ffenv/testdata/3.env new file mode 100644 index 0000000..1b1ecf9 --- /dev/null +++ b/ffenv/testdata/3.env @@ -0,0 +1,4 @@ +s=bar +i=99 +d=34s +# comment line diff --git a/ffenv/testdata/4.env b/ffenv/testdata/4.env new file mode 100644 index 0000000..0b2a9af --- /dev/null +++ b/ffenv/testdata/4.env @@ -0,0 +1,4 @@ +s=from file +i=200 # comment +d=1m +f=2.3 diff --git a/ffenv/testdata/5.env b/ffenv/testdata/5.env new file mode 100644 index 0000000..bda3679 --- /dev/null +++ b/ffenv/testdata/5.env @@ -0,0 +1,5 @@ +s=s.file.1 +s=s.file.2 + +x=x.file.1 +x=x.file.2 diff --git a/ffenv/testdata/equals.env b/ffenv/testdata/equals.env new file mode 100644 index 0000000..3a4539d --- /dev/null +++ b/ffenv/testdata/equals.env @@ -0,0 +1 @@ +s=i=am=the=very=model=of=a=modern=major=general diff --git a/ffenv/testdata/prefix.env b/ffenv/testdata/prefix.env new file mode 100644 index 0000000..accd25f --- /dev/null +++ b/ffenv/testdata/prefix.env @@ -0,0 +1,5 @@ +TEST_PARSE_S=this +TEST_PARSE_I=1 +TEST_PARSE_F=1.3 +TEST_PARSE_D=10s +TEST_PARSE_B=true diff --git a/ffenv/testdata/solo_bool.env b/ffenv/testdata/solo_bool.env new file mode 100644 index 0000000..8381d83 --- /dev/null +++ b/ffenv/testdata/solo_bool.env @@ -0,0 +1 @@ +s=x diff --git a/ffenv/testdata/undefined.env b/ffenv/testdata/undefined.env new file mode 100644 index 0000000..b60db8b --- /dev/null +++ b/ffenv/testdata/undefined.env @@ -0,0 +1,2 @@ +undef=undefined variable +s=one diff --git a/testdata/1.conf b/testdata/1.conf index 48ff474..867b455 100644 --- a/testdata/1.conf +++ b/testdata/1.conf @@ -1,4 +1,4 @@ s bar i 99 b true -d 1h \ No newline at end of file +d 1h