diff --git a/README.md b/README.md index 939c34c..8c392ad 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,94 @@ -# conf +Package conf provides support for using environmental variables and command +line arguments for configuration. -Simple, self-documenting, struct-driven configuration with flag generation and zero dependencies. +It is compatible with the GNU extensions to the POSIX recommendations +for command-line options. See +http://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html -## Overview -`conf` provides a simple method to drive structured configuration from types and fields, with automatic flag and usage generation. +There are no hard bindings for this package. This package takes a struct +value and parses it for both the environment and flags. It supports several tags +to customize the flag options. -## Usage -```go -package main + default - Provides the default value for the help + env - Allows for overriding the default variable name. + flag - Allows for overriding the default flag name. + short - Denotes a shorthand option for the flag. + noprint - Denotes to not include the field in any display string. + required - Denotes a value must be provided. + help - Provides a description for the help. -import ( - "log" - "time" +The field name and any parent struct name will be used for the long form of +the command name unless the name is overridden. - "github.com/flowchartsman/conf" -) +As an example, this config struct: +``` + type ip struct { + Name string `conf:"default:localhost,env:IP_NAME_VAR"` + IP string `conf:"default:127.0.0.0"` + } + type Embed struct { + Name string `conf:"default:bill"` + Duration time.Duration `conf:"default:1s,flag:e-dur,short:d"` + } + type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip string `conf:"-"` + IP ip + Embed + } +``` +Would produce the following usage output: +``` +Usage: conf.test [options] [arguments] -type myConfig struct { - Sub subConfig - TimeToWait time.Duration `conf:"help:how long to wait,short:c,required"` - Password string `conf:"help:the database password to use,noprint"` - DNSServer *string `conf:"help:the address of the dns server to use,default:127.0.0.1"` - Debug bool `conf:"help:enable debug mode"` - DBServers []string `conf:"help:a list of mirror 'host's to contact"` -} +OPTIONS + --an-int/$CRUD_AN_INT (default: 9) + --a-string/-s/$CRUD_A_STRING (default: B) + --bool/$CRUD_BOOL + --ip-name/$CRUD_IP_NAME_VAR (default: localhost) + --ip-ip/$CRUD_IP_IP (default: 127.0.0.0) + --name/$CRUD_NAME (default: bill) + --e-dur/-d/$CRUD_DURATION (default: 1s) + --help/-h + display this help message +``` -type subConfig struct { - Value int `conf:"help: I am a subvalue"` -} +The API is a single call to `Parse` +``` + // Parse(args []string, namespace string, cfgStruct interface{}, sources ...Sourcer) error -func main() { - log.SetFlags(0) - var c myConfig - err := conf.Parse(&c, - conf.WithConfigFile("/etc/test.conf"), - conf.WithConfigFileFlag("conf")) - if err != nil { - log.Fatal(err) + if err := conf.Parse(os.Args, "CRUD", &cfg); err != nil { + log.Fatalf("main : Parsing Config : %v", err) } - log.Println(conf.String(&c)) -} ``` -``` -$ ./conftest -h -Usage: ./conftest [options] [arguments] +Additionally, if the config struct has a field of the slice type `conf.Args` +then it will be populated with any remaining arguments from the command line +after flags have been processed. -OPTIONS - --db-servers ,[host...] DB_SERVERS - a list of mirror hosts to contact - --debug enable debug mode DEBUG - --dns-server DNS_SERVER - the address of the dns server to use - (default: 127.0.0.1) - --password PASSWORD - the database password to use - (noprint) - --sub-value SUB_VALUE - I am a subvalue - --time-to-wait, -c TIME_TO_WAIT - how long to wait - (required) - --conf filename - the filename to load configuration from - (default: /etc/test.conf) - --help, -h display this help message +For example a program with a config struct like this: + +``` +var cfg struct { + Port int + Args conf.Args +} +``` -FILES - /etc/test.conf - The system-wide configuration file (overridden by --conf) +If that program is executed from the command line like this: -$ ./conftest -required field TimeToWait is missing value -$ ./conftest --time-to-wait 5s --sub-value 1 --password I4mInvisbl3! --db-servers 127.0.0.1,127.0.0.2 --dns-server 1.1.1.1 -SUB_VALUE=1 TIME_TO_WAIT=5s DNS_SERVER=1.1.1.1 DEBUG=false DB_SERVERS=[127.0.0.1 127.0.0.2] +``` +$ my-program --port=9000 serve http ``` -## note -This library is still in **alpha**. It needs docs, full coverage testing, and poking to find edgecases. +Then the `cfg.Args` field will contain the string values `["serve", "http"]`. +The `Args` type has a method `Num` for convenient access to these arguments +such as this: -## shoulders -This library takes inspiration (and some code) from some great work by some great engineers. These are credited in the license, but more detail soon. -- [kelseyhightower/envconfig](https://github.com/kelseyhightower/envconfig) -- [peterbourgon/ff](https://github.com/peterbourgon/ff) +``` +arg0 := cfg.Args.Num(0) // "serve" +arg1 := cfg.Args.Num(1) // "http" +arg2 := cfg.Args.Num(2) // "" empty string: not enough arguments +``` diff --git a/conf.go b/conf.go index 28b2dea..ba248b9 100644 --- a/conf.go +++ b/conf.go @@ -3,111 +3,95 @@ package conf import ( "errors" "fmt" - "os" + "reflect" + "strings" ) -var ( - // ErrInvalidStruct indicates that a configuration struct is not the correct type. - ErrInvalidStruct = errors.New("configuration must be a struct pointer") -) +// ErrInvalidStruct indicates that a configuration struct is not the correct type. +var ErrInvalidStruct = errors.New("configuration must be a struct pointer") -type context struct { - confFlag string - confFile string - sources []Source +// A FieldError occurs when an error occurs updating an individual field +// in the provided struct value. +type FieldError struct { + fieldName string + typeName string + value string + err error } -// Parse parses configuration into the provided struct -func Parse(confStruct interface{}, options ...Option) error { - _, err := ParseWithArgs(confStruct, options...) - return err +func (err *FieldError) Error() string { + return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", err.fieldName, err.value, err.typeName, err.err) } -// ParseWithArgs parses configuration into the provided struct, returning the -// remaining args after flag parsing -func ParseWithArgs(confStruct interface{}, options ...Option) ([]string, error) { - var c context - for _, option := range options { - option(&c) - } +// Sourcer provides the ability to source data from a configuration source. +// Consider the use of lazy-loading for sourcing large datasets or systems. +type Sourcer interface { - fields, err := extractFields(nil, confStruct) + // Source takes the field key and attempts to locate that key in its + // configuration data. Returns true if found with the value. + Source(fld field) (string, bool) +} + +// Parse parses configuration into the provided struct. +func Parse(args []string, namespace string, cfgStruct interface{}, sources ...Sourcer) error { + + // Create the flag source. + flag, err := newSourceFlag(args) if err != nil { - return nil, err + return err } - if len(fields) == 0 { - return nil, errors.New("no settable flags found in struct") - } + // Append default sources to any provided list. + sources = append(sources, newSourceEnv(namespace)) + sources = append(sources, flag) - sources := make([]Source, 0, 3) - - // Process flags and create flag source. If help is requested, print useage - // and exit. - fs, args, err := newFlagSource(fields, []string{c.confFlag}) - switch err { - case nil: - case errHelpWanted: - printUsage(fields, c) - os.Exit(1) - default: - return nil, err + // Get the list of fields from the configuration struct to process. + fields, err := extractFields(nil, cfgStruct) + if err != nil { + return err + } + if len(fields) == 0 { + return errors.New("no fields identified in config struct") } - sources = append(sources, fs) + // Process all fields found in the config struct provided. + for _, field := range fields { - // create config file source, if specified - if c.confFile != "" || c.confFlag != "" { - configFile := c.confFile - fromFlag := false - // if there's a config file flag, and it's set, use that filename instead - if configFileFromFlags, ok := fs.Get([]string{c.confFlag}); ok { - configFile = configFileFromFlags - fromFlag = true + // If the field is supposed to hold the leftover args then copy them in + // from the flags source. + if field.field.Type() == argsT { + args := reflect.ValueOf(Args(flag.args)) + field.field.Set(args) + continue } - cs, err := newConfSource(configFile) - if err != nil { - if os.IsNotExist(err) { - // The file doesn't exist. If it was specified by a flag, treat this - // as an error, since presumably the user either made a mistake, or - // the file they deliberately specified isn't there - if fromFlag { - return nil, err + + // Set any default value into the struct for this field. + if field.options.defaultVal != "" { + if err := processField(field.options.defaultVal, field.field); err != nil { + return &FieldError{ + fieldName: field.name, + typeName: field.field.Type().String(), + value: field.options.defaultVal, + err: err, } - } else { - return nil, err } - } else { - sources = append(sources, cs) } - } - - // create env souce - es := new(envSource) - sources = append(sources, es) - // append any additional sources - sources = append(sources, c.sources...) - // process all fields - - for _, field := range fields { - var value string - var found bool - for _, source := range sources { - value, found = source.Get(field.key) - if found { - break + // Process each field against all sources. + var provided bool + for _, sourcer := range sources { + if sourcer == nil { + continue } - } - if !found { - if field.options.required { - return nil, fmt.Errorf("required field %s is missing value", field.name) + + var value string + if value, provided = sourcer.Source(field); !provided { + continue } - value = field.options.defaultStr - } - if value != "" { + + // A value was found so update the struct value with it. if err := processField(value, field.field); err != nil { - return nil, &processError{ + return &FieldError{ fieldName: field.name, typeName: field.field.Type().String(), value: value, @@ -115,30 +99,61 @@ func ParseWithArgs(confStruct interface{}, options ...Option) ([]string, error) } } } + + // If this key is not provided by any source, check if it was + // required to be provided. + if !provided && field.options.required { + return fmt.Errorf("required field %s is missing value", field.name) + } } - return args, nil + return nil } -// A processError occurs when an environment variable cannot be converted to -// the type required by a struct field during assignment. -type processError struct { - fieldName string - typeName string - value string - err error +// Usage provides output to display the config usage on the command line. +func Usage(namespace string, v interface{}) (string, error) { + fields, err := extractFields(nil, v) + if err != nil { + return "", err + } + + return fmtUsage(namespace, fields), nil } -func (e *processError) Error() string { - return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", e.fieldName, e.value, e.typeName, e.err) +// String returns a stringified version of the provided conf-tagged +// struct, minus any fields tagged with `noprint`. +func String(v interface{}) (string, error) { + fields, err := extractFields(nil, v) + if err != nil { + return "", err + } + + var s strings.Builder + for i, fld := range fields { + if !fld.options.noprint { + s.WriteString(flagUsage(fld)) + s.WriteString("=") + s.WriteString(fmt.Sprintf("%v", fld.field.Interface())) + if i < len(fields)-1 { + s.WriteString("\n") + } + } + } + + return s.String(), nil } -// Source represents a source of configuration data. Sources requiring -// the pre-fetching and processing of several values should ideally be lazily- -// loaded so that sources further down the chain are not queried if they're -// not going to be needed. -type Source interface { - // Get takes a location specified by a key and returns a string and whether - // or not the value was set in the source - Get(key []string) (value string, found bool) +// Args holds command line arguments after flags have been parsed. +type Args []string + +// argsT is used by Parse and Usage to detect struct fields of the Args type. +var argsT = reflect.TypeOf(Args{}) + +// Num returns the i'th argument in the Args slice. It returns an empty string +// the request element is not present. +func (a Args) Num(i int) string { + if i < 0 || i >= len(a) { + return "" + } + return a[i] } diff --git a/conf_test.go b/conf_test.go index 090840c..e2912ea 100644 --- a/conf_test.go +++ b/conf_test.go @@ -1,230 +1,346 @@ -package conf +package conf_test import ( - "io/ioutil" + "fmt" "os" + "strings" "testing" + "time" + + "github.com/flowchartsman/conf" + "github.com/google/go-cmp/cmp" ) -type simpleConf struct { - TestInt int - TestString string - TestBool bool -} +const ( + success = "\u2713" + failed = "\u2717" +) -func TestSimpleParseFlags(t *testing.T) { - prepArgs( - "--test-int", "1", - "--test-string", "s", - "--test-bool") - prepEnv() - var c simpleConf - err := Parse(&c) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestString == "s") - assert(t, c.TestBool) +type ip struct { + Name string `conf:"default:localhost,env:IP_NAME_VAR"` + IP string `conf:"default:127.0.0.0"` } - -func TestSimpleParseEnv(t *testing.T) { - prepArgs() - prepEnv( - "TEST_INT", "1", - "TEST_STRING", "s", - "TEST_BOOL", "TRUE", - ) - var c simpleConf - err := Parse(&c) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestString == "s") - assert(t, c.TestBool) +type Embed struct { + Name string `conf:"default:bill"` + Duration time.Duration `conf:"default:1s,flag:e-dur,short:d"` +} +type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip string `conf:"-"` + IP ip + Embed } -func TestSimpleFile(t *testing.T) { - prepArgs() - prepEnv() - testFile, err := ioutil.TempFile("", "conf-test") - if err != nil { - panic("error creating temp file for test: " + err.Error()) +// ============================================================================= + +func TestParse(t *testing.T) { + tests := []struct { + name string + envs map[string]string + args []string + want config + }{ + { + "default", + nil, + nil, + config{9, "B", false, "", ip{"localhost", "127.0.0.0"}, Embed{"bill", time.Second}}, + }, + { + "env", + map[string]string{"TEST_AN_INT": "1", "TEST_A_STRING": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME_VAR": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + nil, + config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, + }, + { + "flag", + nil, + []string{"--an-int", "1", "-s", "s", "--bool", "--skip", "skip", "--ip-name", "local", "--name", "andy", "--e-dur", "1m"}, + config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, + }, + { + "multi", + map[string]string{"TEST_A_STRING": "s", "TEST_BOOL": "TRUE", "TEST_IP_NAME_VAR": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + []string{"--an-int", "2", "--bool", "--skip", "skip", "--name", "jack", "-d", "1ms"}, + config{2, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"jack", time.Millisecond}}, + }, } - defer os.Remove(testFile.Name()) - testFile.Write([]byte(`TEST_INT 1 -TEST_STRING s -TEST_BOOL TRUE -`)) - err = testFile.Close() - if err != nil { - panic("error closing temp file for test: " + err.Error()) + + t.Log("Given the need to parse basic configuration.") + { + for i, tt := range tests { + t.Logf("\tTest: %d\tWhen checking with arguments %v", i, tt.args) + { + os.Clearenv() + for k, v := range tt.envs { + os.Setenv(k, v) + } + + f := func(t *testing.T) { + var cfg config + if err := conf.Parse(tt.args, "TEST", &cfg); err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) + + if diff := cmp.Diff(tt.want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) + } + + t.Run(tt.name, f) + } + } } - var c simpleConf - err = Parse(&c, - WithConfigFile(testFile.Name()), - ) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestString == "s") - assert(t, c.TestBool) } -func TestSimpleSourcePriority(t *testing.T) { - type simpleConfPriority struct { - TestInt int - TestIntTwo int - TestIntThree int - } - prepEnv( - "TEST_INT", "1", - "TEST_INT_TWO", "1", - "TEST_INT_THREE", "1", - ) - testFile, err := ioutil.TempFile("", "conf-test") - if err != nil { - panic("error creating temp file for test: " + err.Error()) - } - defer os.Remove(testFile.Name()) - testFile.Write([]byte(`TEST_INT_TWO 2 -TEST_INT_THREE 2 - `)) - err = testFile.Close() - if err != nil { - panic("error closing temp file for test: " + err.Error()) +func TestParse_Args(t *testing.T) { + t.Log("Given the need to capture remaining command line arguments after flags.") + { + type configArgs struct { + Port int + Args conf.Args + } + + args := []string{"--port", "9000", "migrate", "seed"} + + want := configArgs{ + Port: 9000, + Args: conf.Args{"migrate", "seed"}, + } + + var cfg configArgs + if err := conf.Parse(args, "TEST", &cfg); err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) + + if diff := cmp.Diff(want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) } - prepArgs( - "--test-int-three", "3", - ) - var c simpleConfPriority - err = Parse(&c, - WithConfigFile(testFile.Name()), - ) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestIntTwo == 2) - assert(t, c.TestIntThree == 3) } -func TestParseNonRefIsError(t *testing.T) { - prepArgs() - prepEnv() - var c simpleConf - err := Parse(c) - assert(t, err == ErrInvalidStruct) -} +func TestErrors(t *testing.T) { + t.Log("Given the need to validate errors that can occur with Parse.") + { + t.Logf("\tTest: %d\tWhen passing bad values to Parse.", 0) + { + f := func(t *testing.T) { + var cfg struct { + TestInt int + TestString string + TestBool bool + } + err := conf.Parse(nil, "TEST", cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to accept a value by value.", failed) + } + t.Logf("\t%s\tShould NOT be able to accept a value by value : %s", success, err) + } + t.Run("not-by-ref", f) -func TestParseNonStructIsError(t *testing.T) { - prepArgs() - prepEnv() - var s string - err := Parse(&s) - assert(t, err == ErrInvalidStruct) -} + f = func(t *testing.T) { + var cfg []string + err := conf.Parse(nil, "TEST", &cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to pass anything but a struct value.", failed) + } + t.Logf("\t%s\tShould NOT be able to pass anything but a struct value : %s", success, err) + } + t.Run("not-struct-value", f) + } + + t.Logf("\tTest: %d\tWhen bad tags to Parse.", 1) + { + f := func(t *testing.T) { + var cfg struct { + TestInt int `conf:"default:"` + TestString string + TestBool bool + } + err := conf.Parse(nil, "TEST", &cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to accept tag missing value.", failed) + } + t.Logf("\t%s\tShould NOT be able to accept tag missing value : %s", success, err) + } + t.Run("tag-missing-value", f) -func TestSkipedFieldIsSkipped(t *testing.T) { - type skipTest struct { - TestString string `conf:"-"` - TestInt int + f = func(t *testing.T) { + var cfg struct { + TestInt int `conf:"short:ab"` + TestString string + TestBool bool + } + err := conf.Parse(nil, "TEST", &cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to accept invalid short tag.", failed) + } + t.Logf("\t%s\tShould NOT be able to accept invalid short tag : %s", success, err) + } + t.Run("tag-bad-short", f) + } + + t.Logf("\tTest: %d\tWhen required values are missing.", 2) + { + f := func(t *testing.T) { + var cfg struct { + TestInt int `conf:"required, default:1"` + TestString string + TestBool bool + } + err := conf.Parse(nil, "TEST", &cfg) + if err == nil { + t.Fatalf("\t%s\tShould fail for missing required value.", failed) + } + t.Logf("\t%s\tShould fail for missing required value : %s", success, err) + } + t.Run("required-missing-value", f) + } + + t.Logf("\tTest: %d\tWhen struct has no fields.", 2) + { + f := func(t *testing.T) { + var cfg struct { + testInt int `conf:"required, default:1"` + testString string + testBool bool + } + err := conf.Parse(nil, "TEST", &cfg) + if err == nil { + t.Fatalf("\t%s\tShould fail for struct with no exported fields.", failed) + } + t.Logf("\t%s\tShould fail for struct with no exported fields : %s", success, err) + } + t.Run("struct-missing-fields", f) + } } - var c skipTest - prepArgs() - prepEnv( - "TEST_STRING", "no", - "TEST_INT", "1", - ) - err := Parse(&c) - - assert(t, err == nil) - assert(t, c.TestString == "") - assert(t, c.TestInt == 1) } -func TestTagMissingValueIsError(t *testing.T) { - type bad struct { - TestBad string `conf:"default:"` +func TestUsage(t *testing.T) { + tt := struct { + name string + envs map[string]string + }{ + name: "one-example", + envs: map[string]string{"TEST_AN_INT": "1", "TEST_A_STRING": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME_VAR": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, } - var c bad - prepArgs() - prepEnv() - err := Parse(&c) - assert(t, err.Error() == `conf: error parsing tags for field TestBad: tag "default" missing a value`) -} + t.Log("Given the need validate usage output.") + { + t.Logf("\tTest: %d\tWhen using a basic struct.", 0) + { + os.Clearenv() + for k, v := range tt.envs { + os.Setenv(k, v) + } -func TestBadShortTagIsError(t *testing.T) { - type badShort struct { - TestBad string `conf:"short:ab"` - } - var c badShort - prepArgs() - prepEnv() - err := Parse(&c) + var cfg config + if err := conf.Parse(nil, "TEST", &cfg); err != nil { + fmt.Print(err) + return + } - assert(t, err.Error() == `conf: error parsing tags for field TestBad: short value must be a single rune, got "ab"`) -} + got, err := conf.Usage("TEST", &cfg) + if err != nil { + fmt.Print(err) + return + } -func TestCannotSetRequiredAndDefaultTags(t *testing.T) { - type badShort struct { - TestBad string `conf:"required,default:n"` - } - var c badShort - prepArgs() - prepEnv() - err := Parse(&c) + got = strings.TrimRight(got, " \n") + want := `Usage: conf.test [options] [arguments] - assert(t, err.Error() == "conf: error parsing tags for field TestBad: cannot set both `required` and `default`") -} +OPTIONS + --an-int/$TEST_AN_INT (default: 9) + --a-string/-s/$TEST_A_STRING (default: B) + --bool/$TEST_BOOL + --ip-name/$TEST_IP_NAME_VAR (default: localhost) + --ip-ip/$TEST_IP_IP (default: 127.0.0.0) + --name/$TEST_NAME (default: bill) + --e-dur/-d/$TEST_DURATION (default: 1s) + --help/-h + display this help message` -func TestHierarchicalFieldNames(t *testing.T) { - type conf1 struct { - FieldOne string - } - type conf2 struct { - One conf1 - FieldTwo string - } - var c conf2 - prepArgs("--one-field-one=1") - prepEnv("FIELD_TWO", "2") - err := Parse(&c) - assert(t, err == nil) - assert(t, c.One.FieldOne == "1") - assert(t, c.FieldTwo == "2") -} + gotS := strings.Split(got, "\n") + wantS := strings.Split(want, "\n") + if diff := cmp.Diff(gotS, wantS); diff != "" { + t.Errorf("\t%s\tShould match the output byte for byte. See diff:", failed) + t.Log(diff) + } + t.Logf("\t%s\tShould match byte for byte the output.", success) + } -func TestEmbeddedFieldNames(t *testing.T) { - type Conf1 struct { - FieldOne string - } - type conf2 struct { - Conf1 - FieldTwo string + t.Logf("\tTest: %d\tWhen using a struct with arguments.", 1) + { + var cfg struct { + Port int + Args conf.Args + } + + got, err := conf.Usage("TEST", &cfg) + if err != nil { + fmt.Print(err) + return + } + + got = strings.TrimRight(got, " \n") + want := `Usage: conf.test [options] [arguments] + +OPTIONS + --port/$TEST_PORT + --help/-h + display this help message` + + gotS := strings.Split(got, "\n") + wantS := strings.Split(want, "\n") + if diff := cmp.Diff(gotS, wantS); diff != "" { + t.Errorf("\t%s\tShould match the output byte for byte. See diff:", failed) + t.Log(diff) + } + t.Logf("\t%s\tShould match byte for byte the output.", success) + } } - var c conf2 - - prepEnv("FIELD_ONE", "1") - prepArgs("--field-two=2") - err := Parse(&c) - assert(t, err == nil) - assert(t, c.FieldOne == "1") - assert(t, c.FieldTwo == "2") } -func prepEnv(keyvals ...string) { - if len(keyvals)%2 != 0 { - panic("prepENV must have even number of keyvals") +func ExampleString() { + tt := struct { + name string + envs map[string]string + }{ + name: "one-example", + envs: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, } + os.Clearenv() - for i := 0; i < len(keyvals); i += 2 { - os.Setenv(keyvals[i], keyvals[i+1]) + for k, v := range tt.envs { + os.Setenv(k, v) } -} -func prepArgs(args ...string) { - os.Args = append([]string{"testing"}, args...) -} + var cfg config + if err := conf.Parse(nil, "TEST", &cfg); err != nil { + fmt.Print(err) + return + } -func assert(t *testing.T, testresult bool) { - t.Helper() - if !testresult { - t.Fatal() + out, err := conf.String(&cfg) + if err != nil { + fmt.Print(err) + return } + + fmt.Print(out) + + // Output: + // --an-int=1 + // --a-string/-s=B + // --bool=true + // --ip-name=localhost + // --ip-ip=127.0.0.0 + // --name=andy + // --e-dur/-d=1m0s } diff --git a/configfilesource.go b/configfilesource.go deleted file mode 100644 index d4b10d3..0000000 --- a/configfilesource.go +++ /dev/null @@ -1,66 +0,0 @@ -package conf - -import ( - "bufio" - "os" - "strings" -) - -// confSource is a source for config files in an extremely simple format. Each -// line is tokenized as a single key/value pair. The first whitespace-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. -type confSource struct { - m map[string]string -} - -func newConfSource(filename string) (*confSource, error) { - m := make(map[string]string) - - cf, err := os.Open(filename) - if err != nil { - return nil, err - } - defer cf.Close() - - s := bufio.NewScanner(cf) - for s.Scan() { - line := strings.TrimSpace(s.Text()) - if line == "" { - continue // skip empties - } - - if line[0] == '#' { - continue // skip comments - } - - var ( - name string - value string - index = strings.IndexRune(line, ' ') - ) - if index < 0 { - name, value = line, "true" // boolean option - } else { - name, value = line[:index], strings.TrimSpace(line[index:]) - } - - if i := strings.Index(value, " #"); i >= 0 { - value = strings.TrimSpace(value[:i]) - } - - m[name] = value - } - return &confSource{ - m: m, - }, nil -} - -// Get returns the stringfied value stored at the specified key in the plain -// config file -func (p *confSource) Get(key []string) (string, bool) { - k := getEnvName(key) - value, ok := p.m[k] - return value, ok -} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..829807c --- /dev/null +++ b/doc.go @@ -0,0 +1,89 @@ +/* +Package conf provides support for using environmental variables and command +line arguments for configuration. + +It is compatible with the GNU extensions to the POSIX recommendations +for command-line options. See +http://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html + +There are no hard bindings for this package. This package takes a struct +value and parses it for both the environment and flags. It supports several tags +to customize the flag options. + + default - Provides the default value for the help + env - Allows for overriding the default variable name. + flag - Allows for overriding the default flag name. + short - Denotes a shorthand option for the flag. + noprint - Denotes to not include the field in any display string. + required - Denotes a value must be provided. + help - Provides a description for the help. + +The field name and any parent struct name will be used for the long form of +the command name unless the name is overridden. + +As an example, this config struct: + + type ip struct { + Name string `conf:"default:localhost,env:IP_NAME_VAR"` + IP string `conf:"default:127.0.0.0"` + } + type Embed struct { + Name string `conf:"default:bill"` + Duration time.Duration `conf:"default:1s,flag:e-dur,short:d"` + } + type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip string `conf:"-"` + IP ip + Embed + } + +Would produce the following usage output: + +Usage: conf.test [options] [arguments] + +OPTIONS + --an-int/$CRUD_AN_INT (default: 9) + --a-string/-s/$CRUD_A_STRING (default: B) + --bool/$CRUD_BOOL + --ip-name/$CRUD_IP_NAME_VAR (default: localhost) + --ip-ip/$CRUD_IP_IP (default: 127.0.0.0) + --name/$CRUD_NAME (default: bill) + --e-dur/-d/$CRUD_DURATION (default: 1s) + --help/-h + display this help message + +The API is a single call to Parse + + // Parse(args []string, namespace string, cfgStruct interface{}, sources ...Sourcer) error + + if err := conf.Parse(os.Args, "CRUD", &cfg); err != nil { + log.Fatalf("main : Parsing Config : %v", err) + } + +Additionally, if the config struct has a field of the slice type conf.Args +then it will be populated with any remaining arguments from the command line +after flags have been processed. + +For example a program with a config struct like this: + + var cfg struct { + Port int + Args conf.Args + } + +If that program is executed from the command line like this: + + $ my-program --port=9000 serve http + +Then the cfg.Args field will contain the string values ["serve", "http"]. +The Args type has a method Num for convenient access to these arguments +such as this: + + arg0 := cfg.Args.Num(0) // "serve" + arg1 := cfg.Args.Num(1) // "http" + arg2 := cfg.Args.Num(2) // "" empty string: not enough arguments +*/ +package conf diff --git a/envsource.go b/envsource.go deleted file mode 100644 index 4108cd1..0000000 --- a/envsource.go +++ /dev/null @@ -1,10 +0,0 @@ -package conf - -import "os" - -type envSource struct{} - -func (e *envSource) Get(key []string) (string, bool) { - varName := getEnvName(key) - return os.LookupEnv(varName) -} diff --git a/fields.go b/fields.go index 8f3f489..efd100e 100644 --- a/fields.go +++ b/fields.go @@ -1,35 +1,39 @@ package conf import ( + "encoding" "fmt" "reflect" + "strconv" "strings" + "time" + "unicode" ) -// field maintains information about a field in the configuration struct +// field maintains information about a field in the configuration struct. type field struct { name string - key []string + flagKey []string + envKey []string field reflect.Value options fieldOptions - // important for flag parsing or any other source where booleans might be - // treated specially + + // Important for flag parsing or any other source where + // booleans might be treated specially. boolField bool - // for usage - flagName string - envName string } type fieldOptions struct { - //allow for alternate name, perhaps - short rune - help string - defaultStr string - noprint bool - required bool + help string + defaultVal string + envName string + flagName string + shortFlagChar rune + noprint bool + required bool } -// extractFields uses reflection to examine the struct and generate the keys +// extractFields uses reflection to examine the struct and generate the keys. func extractFields(prefix []string, target interface{}) ([]field, error) { if prefix == nil { prefix = []string{} @@ -45,48 +49,56 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { } targetType := s.Type() - fields := []field{} + var fields []field for i := 0; i < s.NumField(); i++ { f := s.Field(i) structField := targetType.Field(i) - // get the conf tags associated with this item (if any) + // Get the conf tags associated with this item (if any). fieldTags := structField.Tag.Get("conf") - // if it's ignored or can't be set, move on + // If it's ignored or can't be set, move on. if !f.CanSet() || fieldTags == "-" { continue } fieldName := structField.Name - // break name into constituent pieces via CamelCase parser - fieldKey := append(prefix, camelSplit(fieldName)...) - // get and options + // Get and options. TODO: Need more. fieldOpts, err := parseTag(fieldTags) if err != nil { return nil, fmt.Errorf("conf: error parsing tags for field %s: %s", fieldName, err) } - // Drill down through pointers until we bottom out at type or nil + // Generate the field key. This could be ignored. + fieldKey := append(prefix, camelSplit(fieldName)...) + + // Drill down through pointers until we bottom out at type or nil. for f.Kind() == reflect.Ptr { if f.IsNil() { - // not a struct, leave it alone + + // It's not a struct so leave it alone. if f.Type().Elem().Kind() != reflect.Struct { break } - // It is a struct, zero it out + + // It is a struct so zero it out. f.Set(reflect.New(f.Type().Elem())) } f = f.Elem() } - // if we've found a struct, drill down, appending fields as we go - if f.Kind() == reflect.Struct { - // skip if it can deserialize itself + switch { + + // If we've found a struct, drill down, appending fields as we go. + case f.Kind() == reflect.Struct: + + // Skip if it can deserialize itself. if setterFrom(f) == nil && textUnmarshaler(f) == nil && binaryUnmarshaler(f) == nil { - // prefix for any subkeys is the fieldKey, unless it's anonymous, then it's just the prefix so far + + // Prefix for any subkeys is the fieldKey, unless it's + // anonymous, then it's just the prefix so far. innerPrefix := fieldKey if structField.Anonymous { innerPrefix = prefix @@ -99,31 +111,43 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { } fields = append(fields, innerFields...) } - } else { - // append the field - fields = append(fields, field{ + default: + envKey := fieldKey + if fieldOpts.envName != "" { + envKey = strings.Split(fieldOpts.envName, "_") + } + + flagKey := fieldKey + if fieldOpts.flagName != "" { + flagKey = strings.Split(fieldOpts.flagName, "-") + } + + fld := field{ name: fieldName, - key: fieldKey, - flagName: getFlagName(fieldKey), - envName: getEnvName(fieldKey), + envKey: envKey, + flagKey: flagKey, field: f, options: fieldOpts, boolField: f.Kind() == reflect.Bool, - }) + } + fields = append(fields, fld) } } + return fields, nil } func parseTag(tagStr string) (fieldOptions, error) { - f := fieldOptions{} + var f fieldOptions if tagStr == "" { return f, nil } + tagParts := strings.Split(tagStr, ",") for _, tagPart := range tagParts { vals := strings.SplitN(tagPart, ":", 2) tagProp := vals[0] + switch len(vals) { case 1: switch tagProp { @@ -142,19 +166,226 @@ func parseTag(tagStr string) (fieldOptions, error) { if len([]rune(tagPropVal)) != 1 { return f, fmt.Errorf("short value must be a single rune, got %q", tagPropVal) } - f.short = []rune(tagPropVal)[0] + f.shortFlagChar = []rune(tagPropVal)[0] case "default": - f.defaultStr = tagPropVal + f.defaultVal = tagPropVal + case "env": + f.envName = tagPropVal + case "flag": + f.flagName = tagPropVal case "help": f.help = tagPropVal } + default: + // TODO: Do we check for integrity issues here? } } - // sanity check + // Perform a sanity check. switch { - case f.required && f.defaultStr != "": + case f.required && f.defaultVal != "": return f, fmt.Errorf("cannot set both `required` and `default`") } + return f, nil } + +// camelSplit takes a string based on camel case and splits it. +func camelSplit(src string) []string { + if src == "" { + return []string{} + } + if len(src) < 2 { + return []string{src} + } + + runes := []rune(src) + + lastClass := charClass(runes[0]) + lastIdx := 0 + out := []string{} + + // Split into fields based on class of unicode character. + for i, r := range runes { + class := charClass(r) + + // If the class has transitioned. + if class != lastClass { + + // If going from uppercase to lowercase, we want to retain the last + // uppercase letter for names like FOOBar, which should split to + // FOO Bar. + switch { + case lastClass == classUpper && class != classNumber: + if i-lastIdx > 1 { + out = append(out, string(runes[lastIdx:i-1])) + lastIdx = i - 1 + } + default: + out = append(out, string(runes[lastIdx:i])) + lastIdx = i + } + } + + if i == len(runes)-1 { + out = append(out, string(runes[lastIdx:])) + } + lastClass = class + } + + return out +} + +func processField(value string, field reflect.Value) error { + typ := field.Type() + + // Look for a Set method. + setter := setterFrom(field) + if setter != nil { + return setter.Set(value) + } + + if t := textUnmarshaler(field); t != nil { + return t.UnmarshalText([]byte(value)) + } + + if b := binaryUnmarshaler(field); b != nil { + return b.UnmarshalBinary([]byte(value)) + } + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + if field.IsNil() { + field.Set(reflect.New(typ)) + } + field = field.Elem() + } + + switch typ.Kind() { + case reflect.String: + field.SetString(value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var ( + val int64 + err error + ) + if field.Kind() == reflect.Int64 && typ.PkgPath() == "time" && typ.Name() == "Duration" { + var d time.Duration + d, err = time.ParseDuration(value) + val = int64(d) + } else { + val, err = strconv.ParseInt(value, 0, typ.Bits()) + } + if err != nil { + return err + } + + field.SetInt(val) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val, err := strconv.ParseUint(value, 0, typ.Bits()) + if err != nil { + return err + } + field.SetUint(val) + case reflect.Bool: + val, err := strconv.ParseBool(value) + if err != nil { + return err + } + field.SetBool(val) + case reflect.Float32, reflect.Float64: + val, err := strconv.ParseFloat(value, typ.Bits()) + if err != nil { + return err + } + field.SetFloat(val) + case reflect.Slice: + vals := strings.Split(value, ",") + sl := reflect.MakeSlice(typ, len(vals), len(vals)) + for i, val := range vals { + err := processField(val, sl.Index(i)) + if err != nil { + return err + } + } + field.Set(sl) + case reflect.Map: + mp := reflect.MakeMap(typ) + if len(strings.TrimSpace(value)) != 0 { + pairs := strings.Split(value, ",") + for _, pair := range pairs { + kvpair := strings.Split(pair, ":") + if len(kvpair) != 2 { + return fmt.Errorf("invalid map item: %q", pair) + } + k := reflect.New(typ.Key()).Elem() + err := processField(kvpair[0], k) + if err != nil { + return err + } + v := reflect.New(typ.Elem()).Elem() + err = processField(kvpair[1], v) + if err != nil { + return err + } + mp.SetMapIndex(k, v) + } + } + field.Set(mp) + } + return nil +} + +func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) { + + // It may be impossible for a struct field to fail this check. + if !field.CanInterface() { + return + } + + var ok bool + fn(field.Interface(), &ok) + if !ok && field.CanAddr() { + fn(field.Addr().Interface(), &ok) + } +} + +// Setter is implemented by types can self-deserialize values. +// Any type that implements flag.Value also implements Setter. +type Setter interface { + Set(value string) error +} + +func setterFrom(field reflect.Value) (s Setter) { + interfaceFrom(field, func(v interface{}, ok *bool) { s, *ok = v.(Setter) }) + return s +} + +func textUnmarshaler(field reflect.Value) (t encoding.TextUnmarshaler) { + interfaceFrom(field, func(v interface{}, ok *bool) { t, *ok = v.(encoding.TextUnmarshaler) }) + return t +} + +func binaryUnmarshaler(field reflect.Value) (b encoding.BinaryUnmarshaler) { + interfaceFrom(field, func(v interface{}, ok *bool) { b, *ok = v.(encoding.BinaryUnmarshaler) }) + return b +} + +const ( + classLower int = iota + classUpper + classNumber + classOther +) + +func charClass(r rune) int { + switch { + case unicode.IsLower(r): + return classLower + case unicode.IsUpper(r): + return classUpper + case unicode.IsDigit(r): + return classNumber + } + return classOther +} diff --git a/flagsource.go b/flagsource.go deleted file mode 100644 index 806542b..0000000 --- a/flagsource.go +++ /dev/null @@ -1,152 +0,0 @@ -package conf - -import ( - "errors" - "fmt" - "os" -) - -type flagSource struct { - found map[string]string -} - -var errHelpWanted = errors.New("help wanted") - -// TODO?: make missing flags optionally throw error -func newFlagSource(fields []field, exempt []string) (*flagSource, []string, error) { - found := make(map[string]string, len(fields)) - expected := make(map[string]*field, len(fields)) - shorts := make(map[string]string, len(fields)) - exemptFlags := make(map[string]struct{}, len(exempt)) - - // some flags are special, like for specifying a config file flag, which - // we definitely want to inspect, but don't represent field data - for _, exemptFlag := range exempt { - if exemptFlag != "" { - exemptFlags[exemptFlag] = struct{}{} - } - } - - for i, field := range fields { - expected[field.flagName] = &fields[i] - if field.options.short != 0 { - shorts[string(field.options.short)] = field.flagName - } - } - - args := make([]string, len(os.Args)-1) - copy(args, os.Args[1:]) - - if len(args) != 0 { - //adapted from 'flag' package - for { - if len(args) == 0 { - break - } - // look at the next arg - s := args[0] - // if it's too short or doesn't begin with a `-`, assume we're at the end of the flags - if len(s) < 2 || s[0] != '-' { - break - } - numMinuses := 1 - if s[1] == '-' { - numMinuses++ - if len(s) == 2 { // "--" terminates the flags - args = args[1:] - break - } - } - name := s[numMinuses:] - if len(name) == 0 || name[0] == '-' || name[0] == '=' { - return nil, nil, fmt.Errorf("bad flag syntax: %s", s) - } - - // it's a flag. does it have an argument? - args = args[1:] - hasValue := false - value := "" - for i := 1; i < len(name); i++ { // equals cannot be first - if name[i] == '=' { - value = name[i+1:] - hasValue = true - name = name[0:i] - break - } - } - if name == "help" || name == "h" || name == "?" { - return nil, nil, errHelpWanted - } - - if long, ok := shorts[name]; ok { - name = long - } - - if expected[name] == nil { - if _, ok := exemptFlags[name]; !ok { - return nil, nil, fmt.Errorf("flag provided but not defined: -%s", name) - } - } - - // if we don't have a value yet, it's possible the flag was not in the - // -flag=value format which means it might still have a value which would be - // the next argument, provided the next argument isn't a flag - if !hasValue { - if len(args) > 0 && args[0][0] != '-' { - // doesn't look like a flag. Must be a value - value, args = args[0], args[1:] - } else { - // we wanted a value but found the end or another flag. The only time this is okay - // is if this is a boolean flag, in which case `-flag` is okay, because it is assumed - // to be the same as `-flag true` - if expected[name].boolField { - value = "true" - } else { - return nil, nil, fmt.Errorf("flag needs an argument: -%s", name) - } - } - } - found[name] = value - } - } - - return &flagSource{ - found: found, - }, args, nil -} - -func (f *flagSource) Get(key []string) (string, bool) { - flagStr := getFlagName(key) - val, found := f.found[flagStr] - return val, found -} - -/* -Portions Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -*/ diff --git a/names.go b/names.go deleted file mode 100644 index 2b20091..0000000 --- a/names.go +++ /dev/null @@ -1,77 +0,0 @@ -package conf - -import ( - "strings" - "unicode" -) - -func getEnvName(key []string) string { - return strings.ToUpper(strings.Join(key, `_`)) -} - -func getFlagName(key []string) string { - return strings.ToLower(strings.Join(key, `-`)) -} - -// split string based on camel case -func camelSplit(src string) []string { - if src == "" { - return []string{} - } - if len(src) < 2 { - return []string{src} - } - - runes := []rune(src) - - lastClass := charClass(runes[0]) - lastIdx := 0 - out := []string{} - - // split into fields based on class of unicode character - for i, r := range runes { - class := charClass(r) - // if the class has transitioned - if class != lastClass { - // if going from uppercase to lowercase, we want to retain the last - // uppercase letter for names like FOOBar, which should split to - // FOO Bar - if lastClass == classUpper && class != classNumber { - if i-lastIdx > 1 { - out = append(out, string(runes[lastIdx:i-1])) - lastIdx = i - 1 - } - } else { - out = append(out, string(runes[lastIdx:i])) - lastIdx = i - } - } - - if i == len(runes)-1 { - out = append(out, string(runes[lastIdx:])) - } - lastClass = class - - } - - return out -} - -const ( - classLower int = iota - classUpper - classNumber - classOther -) - -func charClass(r rune) int { - switch { - case unicode.IsLower(r): - return classLower - case unicode.IsUpper(r): - return classUpper - case unicode.IsDigit(r): - return classNumber - } - return classOther -} diff --git a/options.go b/options.go deleted file mode 100644 index be41dae..0000000 --- a/options.go +++ /dev/null @@ -1,30 +0,0 @@ -package conf - -// Option represents a change to the default parsing -type Option func(c *context) - -// WithConfigFile tells parse to attempt to read from the specified file, if it -// is found. -func WithConfigFile(filename string) Option { - return func(c *context) { - c.confFile = filename - } -} - -// WithConfigFileFlag tells parse to look for a flag called `flagname` and, if -// it is found, to attempt to load configuration from this file. If the flag -// is specified, it will override the value provided to WithConfigFile, if that -// has been specified. If the file is not found, the program will exit with an -// error. -func WithConfigFileFlag(flagname string) Option { - return func(c *context) { - c.confFlag = flagname - } -} - -// WithSource adds additional configuration sources for configuration parsing -func WithSource(source Source) Option { - return func(c *context) { - c.sources = append(c.sources, source) - } -} diff --git a/print.go b/print.go deleted file mode 100644 index 883abb4..0000000 --- a/print.go +++ /dev/null @@ -1,27 +0,0 @@ -package conf - -import ( - "fmt" - "strings" -) - -// String returns a stringified version of the provided conf-tagged -// struct, minus any fields tagged with `noprint` -func String(v interface{}) (string, error) { - fields, err := extractFields(nil, v) - if err != nil { - return "", err - } - var s strings.Builder - for i, field := range fields { - if !field.options.noprint { - s.WriteString(field.envName) - s.WriteString("=") - s.WriteString(fmt.Sprintf("%v", field.field.Interface())) - if i < len(fields)-1 { - s.WriteString(" ") - } - } - } - return s.String(), nil -} diff --git a/process.go b/process.go deleted file mode 100644 index 30f53d4..0000000 --- a/process.go +++ /dev/null @@ -1,143 +0,0 @@ -package conf - -import ( - "encoding" - "fmt" - "reflect" - "strconv" - "strings" - "time" -) - -func processField(value string, field reflect.Value) error { - typ := field.Type() - - // look for Set method - setter := setterFrom(field) - if setter != nil { - return setter.Set(value) - } - - if t := textUnmarshaler(field); t != nil { - return t.UnmarshalText([]byte(value)) - } - - if b := binaryUnmarshaler(field); b != nil { - return b.UnmarshalBinary([]byte(value)) - } - - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - if field.IsNil() { - field.Set(reflect.New(typ)) - } - field = field.Elem() - } - - switch typ.Kind() { - case reflect.String: - field.SetString(value) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - var ( - val int64 - err error - ) - if field.Kind() == reflect.Int64 && typ.PkgPath() == "time" && typ.Name() == "Duration" { - var d time.Duration - d, err = time.ParseDuration(value) - val = int64(d) - } else { - val, err = strconv.ParseInt(value, 0, typ.Bits()) - } - if err != nil { - return err - } - - field.SetInt(val) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - val, err := strconv.ParseUint(value, 0, typ.Bits()) - if err != nil { - return err - } - field.SetUint(val) - case reflect.Bool: - val, err := strconv.ParseBool(value) - if err != nil { - return err - } - field.SetBool(val) - case reflect.Float32, reflect.Float64: - val, err := strconv.ParseFloat(value, typ.Bits()) - if err != nil { - return err - } - field.SetFloat(val) - case reflect.Slice: - vals := strings.Split(value, ",") - sl := reflect.MakeSlice(typ, len(vals), len(vals)) - for i, val := range vals { - err := processField(val, sl.Index(i)) - if err != nil { - return err - } - } - field.Set(sl) - case reflect.Map: - mp := reflect.MakeMap(typ) - if len(strings.TrimSpace(value)) != 0 { - pairs := strings.Split(value, ",") - for _, pair := range pairs { - kvpair := strings.Split(pair, ":") - if len(kvpair) != 2 { - return fmt.Errorf("invalid map item: %q", pair) - } - k := reflect.New(typ.Key()).Elem() - err := processField(kvpair[0], k) - if err != nil { - return err - } - v := reflect.New(typ.Elem()).Elem() - err = processField(kvpair[1], v) - if err != nil { - return err - } - mp.SetMapIndex(k, v) - } - } - field.Set(mp) - } - return nil -} - -func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) { - // it may be impossible for a struct field to fail this check - if !field.CanInterface() { - return - } - var ok bool - fn(field.Interface(), &ok) - if !ok && field.CanAddr() { - fn(field.Addr().Interface(), &ok) - } -} - -// Setter is implemented by types can self-deserialize values. -// Any type that implements flag.Value also implements Setter. -type Setter interface { - Set(value string) error -} - -func setterFrom(field reflect.Value) (s Setter) { - interfaceFrom(field, func(v interface{}, ok *bool) { s, *ok = v.(Setter) }) - return s -} - -func textUnmarshaler(field reflect.Value) (t encoding.TextUnmarshaler) { - interfaceFrom(field, func(v interface{}, ok *bool) { t, *ok = v.(encoding.TextUnmarshaler) }) - return t -} - -func binaryUnmarshaler(field reflect.Value) (b encoding.BinaryUnmarshaler) { - interfaceFrom(field, func(v interface{}, ok *bool) { b, *ok = v.(encoding.BinaryUnmarshaler) }) - return b -} diff --git a/sources.go b/sources.go new file mode 100644 index 0000000..22f8488 --- /dev/null +++ b/sources.go @@ -0,0 +1,190 @@ +package conf + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// env is a source for environmental variables. +type env struct { + m map[string]string +} + +// newSourceEnv accepts a namespace and parses the environment into a Env for +// use by the configuration package. +func newSourceEnv(namespace string) *env { + m := make(map[string]string) + + // Create the uppercase version to meet the standard {NAMESPACE_} format. + uspace := fmt.Sprintf("%s_", strings.ToUpper(namespace)) + + // Loop and match each variable using the uppercase namespace. + for _, val := range os.Environ() { + if !strings.HasPrefix(val, uspace) { + continue + } + + idx := strings.Index(val, "=") + m[strings.ToUpper(strings.TrimPrefix(val[0:idx], uspace))] = val[idx+1:] + } + + return &env{m: m} +} + +// Source implements the confg.Sourcer interface. It returns the stringfied value +// stored at the specified key from the environment. +func (e *env) Source(fld field) (string, bool) { + k := strings.ToUpper(strings.Join(fld.envKey, `_`)) + v, ok := e.m[k] + return v, ok +} + +// envUsage constructs a usage string for the environment variable. +func envUsage(namespace string, fld field) string { + return "$" + strings.ToUpper(namespace) + "_" + strings.ToUpper(strings.Join(fld.envKey, `_`)) +} + +// ============================================================================= + +// ErrHelpWanted provides an indication help was requested. +var ErrHelpWanted = errors.New("help wanted") + +// flag is a source for command line arguments. +type flag struct { + m map[string]string + args []string +} + +// newSourceFlag parsing a string of command line arguments. NewFlag will return +// errHelpWanted, if the help flag is identifyed. This code is adapted +// from the Go standard library flag package. +func newSourceFlag(args []string) (*flag, error) { + m := make(map[string]string) + + if len(args) != 0 { + for { + if len(args) == 0 { + break + } + + // Look at the next arg. + s := args[0] + + // If it's too short or doesn't begin with a `-`, assume we're at + // the end of the flags. + if len(s) < 2 || s[0] != '-' { + break + } + + numMinuses := 1 + if s[1] == '-' { + numMinuses++ + if len(s) == 2 { // "--" terminates the flags + args = args[1:] + break + } + } + + name := s[numMinuses:] + if len(name) == 0 || name[0] == '-' || name[0] == '=' { + return nil, fmt.Errorf("bad flag syntax: %s", s) + } + + // It's a flag. Does it have an argument? + args = args[1:] + hasValue := false + value := "" + for i := 1; i < len(name); i++ { // equals cannot be first + if name[i] == '=' { + value = name[i+1:] + hasValue = true + name = name[0:i] + break + } + } + + if name == "help" || name == "h" || name == "?" { + return nil, ErrHelpWanted + } + + // If we don't have a value yet, it's possible the flag was not in the + // -flag=value format which means it might still have a value which would be + // the next argument, provided the next argument isn't a flag. + if !hasValue { + if len(args) > 0 && args[0][0] != '-' { + + // Doesn't look like a flag. Must be a value. + value, args = args[0], args[1:] + } else { + + // We assume this is a boolean flag. + value = "true" + } + } + + // Store the flag/value pair. + m[name] = value + } + } + + return &flag{m: m, args: args}, nil +} + +// Source implements the confg.Sourcer interface. Returns the stringfied value +// stored at the specified key from the flag source. +func (f *flag) Source(fld field) (string, bool) { + if fld.options.shortFlagChar != 0 { + flagKey := []string{string(fld.options.shortFlagChar)} + k := strings.ToLower(strings.Join(flagKey, `-`)) + if val, found := f.m[k]; found { + return val, found + } + } + + k := strings.ToLower(strings.Join(fld.flagKey, `-`)) + val, found := f.m[k] + return val, found +} + +// flagUsage constructs a usage string for the flag argument. +func flagUsage(fld field) string { + usage := "--" + strings.ToLower(strings.Join(fld.flagKey, `-`)) + if fld.options.shortFlagChar != 0 { + flagKey := []string{string(fld.options.shortFlagChar)} + usage += "/-" + strings.ToLower(strings.Join(flagKey, `-`)) + } + + return usage +} + +/* +Portions Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ diff --git a/usage.go b/usage.go index b84f53d..0bdd68a 100644 --- a/usage.go +++ b/usage.go @@ -3,67 +3,63 @@ package conf import ( "fmt" "os" + "path" "reflect" - "sort" "strings" "text/tabwriter" ) -func printUsage(fields []field, c context) { +func fmtUsage(namespace string, fields []field) string { + var sb strings.Builder - // sort the fields, by their long name - sort.SliceStable(fields, func(i, j int) bool { - return fields[i].flagName < fields[j].flagName - }) - - // put conf and help last - if c.confFlag != "" { - confFlagField := field{ - flagName: c.confFlag, - } - if c.confFile != "" { - confFlagField.options.defaultStr = c.confFile - confFlagField.options.help = "the 'filename' to load configuration from" - } - fields = append(fields, confFlagField) - } fields = append(fields, field{ - flagName: "help", + name: "help", boolField: true, + field: reflect.ValueOf(true), + flagKey: []string{"help"}, options: fieldOptions{ - short: 'h', - help: "display this help message", + shortFlagChar: 'h', + help: "display this help message", }}) - fmt.Fprintf(os.Stderr, "Usage: %s [options] [arguments]\n\n", os.Args[0]) + _, file := path.Split(os.Args[0]) + fmt.Fprintf(&sb, "Usage: %s [options] [arguments]\n\n", file) - fmt.Fprintln(os.Stderr, "OPTIONS") + fmt.Fprintln(&sb, "OPTIONS") w := new(tabwriter.Writer) - w.Init(os.Stderr, 0, 4, 2, ' ', tabwriter.TabIndent) + w.Init(&sb, 0, 4, 2, ' ', tabwriter.TabIndent) + + for _, fld := range fields { - for _, f := range fields { - typeName, help := getTypeAndHelp(&f) - fmt.Fprintf(w, " --%s", f.flagName) - if f.options.short != 0 { - fmt.Fprintf(w, "/-%s", string(f.options.short)) + // Skip printing usage info for fields that just hold arguments. + if fld.field.Type() == argsT { + continue } - if f.envName != "" { - fmt.Fprintf(w, "/$%s", f.envName) + + fmt.Fprintf(w, " %s", flagUsage(fld)) + + // Do not display env vars for help since they aren't respected. + if fld.name != "help" { + fmt.Fprintf(w, "/%s", envUsage(namespace, fld)) + } + + typeName, help := getTypeAndHelp(&fld) + + // Do not display type info for help because it would show but our + // parsing does not really treat --help as a boolean field. Its presence + // always indicates true even if they do --help=false. + if fld.name != "help" { + fmt.Fprintf(w, "\t%s", typeName) } - fmt.Fprintf(w, " %s\t%s\t\n", typeName, getOptString(f)) + + fmt.Fprintf(w, "\t%s\n", getOptString(fld)) if help != "" { - fmt.Fprintf(w, " %s\t\t\n", help) + fmt.Fprintf(w, " %s\n", help) } } + w.Flush() - fmt.Fprintf(os.Stderr, "\n") - if c.confFile != "" { - fmt.Fprintf(os.Stderr, "FILES\n %s\n %s", c.confFile, "The system-wide configuration file") - if c.confFlag != "" { - fmt.Fprintf(os.Stderr, ` (overridden by --%s)`, c.confFlag) - } - fmt.Fprint(os.Stderr, "\n\n") - } + return sb.String() } // getTypeAndHelp extracts the type and help message for a single field for @@ -75,10 +71,11 @@ func printUsage(fields []field, c context) { // 'true' value and their absence with a 'false' value. If a type cannot be // determined, it will simply give the name "value". Slices will be annotated // as ",[Type...]", where "Type" is whatever type name was chosen. -// (adapted from package flag) -func getTypeAndHelp(f *field) (name string, usage string) { - // Look for a single-quoted name - usage = f.options.help +// (adapted from package flag). +func getTypeAndHelp(fld *field) (name string, usage string) { + + // Look for a single-quoted name. + usage = fld.options.help for i := 0; i < len(usage); i++ { if usage[i] == '\'' { for j := i + 1; j < len(usage); j++ { @@ -92,13 +89,15 @@ func getTypeAndHelp(f *field) (name string, usage string) { } var isSlice bool - if f.field.IsValid() { - t := f.field.Type() - // if it's a pointer, we want to deref + if fld.field.IsValid() { + t := fld.field.Type() + + // If it's a pointer, we want to deref. if t.Kind() == reflect.Ptr { t = t.Elem() } - // if it's a slice, we want the type of the slice elements + + // If it's a slice, we want the type of the slice elements. if t.Kind() == reflect.Slice { t = t.Elem() isSlice = true @@ -108,14 +107,11 @@ func getTypeAndHelp(f *field) (name string, usage string) { if name == "" { switch t.Kind() { case reflect.Bool: - if !isSlice { - return "", usage - } - name = "" + name = "bool" case reflect.Float32, reflect.Float64: name = "float" case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - typ := f.field.Type() + typ := fld.field.Type() if typ.PkgPath() == "time" && typ.Name() == "Duration" { name = "duration" } else { @@ -130,6 +126,7 @@ func getTypeAndHelp(f *field) (name string, usage string) { } } } + switch { case isSlice: name = fmt.Sprintf("<%s>,[%s...]", name, name) @@ -140,16 +137,16 @@ func getTypeAndHelp(f *field) (name string, usage string) { return } -func getOptString(f field) string { +func getOptString(fld field) string { opts := make([]string, 0, 3) - if f.options.required { + if fld.options.required { opts = append(opts, "required") } - if f.options.noprint { + if fld.options.noprint { opts = append(opts, "noprint") } - if f.options.defaultStr != "" { - opts = append(opts, fmt.Sprintf("default: %s", f.options.defaultStr)) + if fld.options.defaultVal != "" { + opts = append(opts, fmt.Sprintf("default: %s", fld.options.defaultVal)) } if len(opts) > 0 { return fmt.Sprintf("(%s)", strings.Join(opts, `,`))