From 232faad0a0251fb8c02780aa82cc0b6cd9d3bda4 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Wed, 13 Jun 2018 10:33:22 +1000 Subject: [PATCH] Add configuration loading + docs + linter fixes. --- .circleci/config.yml | 5 +++++ .golangci.yml | 33 ++++++++++++++++++++++++++++ README.md | 24 +++++++++++++++++++-- doc.go | 30 ++++++++++++++++++++++++++ global.go | 3 +++ guesswidth_unix.go | 4 ++-- kong.go | 4 ++-- kong_test.go | 2 ++ model.go | 10 +++++++++ options.go | 51 +++++++++++++++++++++++++++++++++++++++++++- options_test.go | 31 +++++++++++++++++++++++++++ resolver.go | 8 +++---- resolver_test.go | 13 +++++------ scanner.go | 10 +++++++++ tag.go | 7 ++++++ 15 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 .golangci.yml create mode 100644 doc.go diff --git a/.circleci/config.yml b/.circleci/config.yml index f8257b3..7122cbc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,8 +12,13 @@ jobs: command: | go get -v github.com/jstemmer/go-junit-report go get -v -t -d ./... + curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | bash -s latest mkdir ~/report when: always + - run: + name: Lint + command: | + ./bin/golangci-lint run - run: name: Test command: | diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e500bc5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,33 @@ +run: + tests: true + +output: + print-issued-lines: false + +linters: + enable-all: true + disable: + - maligned + +linters-settings: + govet: + check-shadowing: true + gocyclo: + min-complexity: 10 + dupl: + threshold: 100 + goconst: + min-len: 5 + min-occurrences: 3 + gocyclo: + min-complexity: 20 + +issues: + max-per-linter: 0 + max-same: 0 + exclude-use-default: false + exclude: + - '^(G104|G204):' + # Very commonly not checked. + - 'Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked' + - 'exported method (.*\.MarshalJSON|.*\.UnmarshalJSON) should have comment or be unexported' diff --git a/README.md b/README.md index e96c6fb..ddcce98 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ 1. [Commands and sub-commands](#commands-and-sub-commands) 1. [Supported tags](#supported-tags) 1. [Configuring Kong](#configuring-kong) + 1. [`Configuration(loader, paths...)` - load defaults from configuration files](#configurationloader-paths---load-defaults-from-configuration-files) + 1. [`Resolver(...)` - support for default values from external sources](#resolver---support-for-default-values-from-external-sources) 1. [`*Mapper(...)` - customising how the command-line is mapped to Go values](#mapper---customising-how-the-command-line-is-mapped-to-go-values) 1. [`Help(HelpFunc)` - customising help](#helphelpfunc---customising-help) 1. [`Hook(&field, HookFunc)` - callback hooks to execute when the command-line is parsed](#hookfield-hookfunc---callback-hooks-to-execute-when-the-command-line-is-parsed) @@ -159,7 +161,25 @@ Both can coexist with standard Tag parsing. ## Configuring Kong -Each Kong parser can be configured via functional options passed to `New(cli interface{}, options...Option)`. The full set of options can be found in `options.go`. +Each Kong parser can be configured via functional options passed to `New(cli interface{}, options...Option)`. + +The full set of options can be found in [options.go](https://github.com/alecthomas/kong/blob/master/options.go). + +### `Configuration(loader, paths...)` - load defaults from configuration files + +This option provides Kong with support for loading defaults from a set of configuration files. Each file is opened, if possible, and the loader called to create a resolver for that file. + +eg. + +```go +kong.Parse(&cli, kong.Configuration(kong.JSON, "/etc/myapp.json", "~/.myapp.json")) +``` + +### `Resolver(...)` - support for default values from external sources + +Resolvers are Kong's extension point for providing default values from external sources. As an example, support for environment variables via the `env` tag is provided by a resolver. There's also a builtin resolver for JSON configuration files. + +Example resolvers can be found in [resolver.go](https://github.com/alecthomas/kong/blob/master/resolver.go). ### `*Mapper(...)` - customising how the command-line is mapped to Go values @@ -176,7 +196,7 @@ type Mapper interface { } ``` -All builtin Go types (as well as a bunch of useful stdlib types like `time.Time`) have mapperss registered by default. Mappers for custom types can be added using `kong.??Mapper(...)` options. Mappers are applied to fields in four ways: +All builtin Go types (as well as a bunch of useful stdlib types like `time.Time`) have mappers registered by default. Mappers for custom types can be added using `kong.??Mapper(...)` options. Mappers are applied to fields in four ways: 1. `NamedMapper(string, Mapper)` and using the tag key `type:""`. 2. `KindMapper(reflect.Kind, Mapper)`. diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..6d7ca89 --- /dev/null +++ b/doc.go @@ -0,0 +1,30 @@ +// Package kong aims to support arbitrarily complex command-line structures with as little developer effort as possible. +// +// Here's an example: +// +// shell rm [-f] [-r] ... +// shell ls [ ...] +// +// This can be represented by the following command-line structure: +// +// package main +// +// import "github.com/alecthomas/kong" +// +// var CLI struct { +// Rm struct { +// Force bool `short:"f" help:"Force removal."` +// Recursive bool `short:"r" help:"Recursively remove files."` +// +// Paths []string `arg help:"Paths to remove." type:"path"` +// } `cmd help:"Remove files."` +// +// Ls struct { +// Paths []string `arg optional help:"Paths to list." type:"path"` +// } `cmd help:"List paths."` +// } +// +// func main() { +// kong.Parse(&CLI) +// } +package kong diff --git a/global.go b/global.go index 10aaa27..d90d130 100644 --- a/global.go +++ b/global.go @@ -19,6 +19,7 @@ func Parse(cli interface{}, options ...Option) string { return cmd } +// FatalIfErrorf terminates with an error message if err != nil. func FatalIfErrorf(err error, args ...interface{}) { if App == nil { panic("call kong.Parse() before using kong.FatalIfErrorf()") @@ -26,6 +27,7 @@ func FatalIfErrorf(err error, args ...interface{}) { App.FatalIfErrorf(err, args...) } +// Errorf writes a message to Kong.Stderr with the application name prefixed. func Errorf(format string, args ...interface{}) { if App == nil { panic("call kong.Parse() before using kong.Errorf()") @@ -33,6 +35,7 @@ func Errorf(format string, args ...interface{}) { App.Errorf(format, args...) } +// Printf writes a message to Kong.Stdout with the application name prefixed. func Printf(format string, args ...interface{}) { if App == nil { panic("call kong.Parse() before using kong.Printf()") diff --git a/guesswidth_unix.go b/guesswidth_unix.go index fbf7e22..b549ed4 100644 --- a/guesswidth_unix.go +++ b/guesswidth_unix.go @@ -26,9 +26,9 @@ func guessWidth(w io.Writer) int { if _, _, err := syscall.Syscall6( syscall.SYS_IOCTL, - uintptr(fd), + uintptr(fd), // nolint: unconvert uintptr(syscall.TIOCGWINSZ), - uintptr(unsafe.Pointer(&dimensions)), + uintptr(unsafe.Pointer(&dimensions)), // nolint: gas 0, 0, 0, ); err == 0 { return int(dimensions[1]) diff --git a/kong.go b/kong.go index 42b9c47..dc2fbf3 100644 --- a/kong.go +++ b/kong.go @@ -58,7 +58,7 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { before: map[reflect.Value]HookFunc{}, registry: NewRegistry().RegisterDefaults(), help: PrintHelp, - resolvers: []ResolverFunc{EnvResolver()}, + resolvers: []ResolverFunc{Envars()}, } for _, option := range options { @@ -123,7 +123,7 @@ func (k *Kong) Parse(args []string) (command string, err error) { if err != nil { return "", err } - if err := k.applyHooks(ctx); err != nil { + if err = k.applyHooks(ctx); err != nil { return "", err } if ctx.Error != nil { diff --git a/kong_test.go b/kong_test.go index 62fbd2d..1835ec4 100644 --- a/kong_test.go +++ b/kong_test.go @@ -112,6 +112,7 @@ func TestFlagSliceWithSeparator(t *testing.T) { } func TestArgSlice(t *testing.T) { + // nolint: govet var cli struct { Slice []int `arg` Flag bool @@ -124,6 +125,7 @@ func TestArgSlice(t *testing.T) { } func TestArgSliceWithSeparator(t *testing.T) { + // nolint: govet var cli struct { Slice []string `arg` Flag bool diff --git a/model.go b/model.go index 809d1d5..fe438c0 100644 --- a/model.go +++ b/model.go @@ -7,23 +7,29 @@ import ( "strings" ) +// Application is the root of the Kong model. type Application struct { Node HelpFlag *Flag } +// Argument represents a branching positional argument. type Argument = Node +// Command represents a command in the CLI. type Command = Node +// NodeType is an enum representing the type of a Node. type NodeType int +// Node type enumerations. const ( ApplicationNode NodeType = iota CommandNode ArgumentNode ) +// Node is a branch in the CLI. ie. a command or positional argument. type Node struct { Type NodeType Parent *Node @@ -37,6 +43,7 @@ type Node struct { Argument *Value // Populated when Type is ArgumentNode. } +// AllFlags returns flags from all ancestor branches encountered. func (n *Node) AllFlags() (out [][]*Flag) { if n.Parent != nil { out = append(out, n.Parent.AllFlags()...) @@ -186,6 +193,9 @@ func (v *Value) Apply(value reflect.Value) { v.Set = true } +// Reset this value to its default, either the zero value or the parsed result of its "default" tag. +// +// Does not include resolvers. func (v *Value) Reset() error { v.Value.Set(reflect.Zero(v.Value.Type())) if v.Default != "" { diff --git a/options.go b/options.go index 17e95cc..db43d7e 100755 --- a/options.go +++ b/options.go @@ -2,10 +2,14 @@ package kong import ( "io" + "os" + "os/user" + "path/filepath" "reflect" + "strings" ) -// Options apply optional changes to the Kong application. +// An Option applies optional changes to the Kong application. type Option func(k *Kong) // ExitFunction overrides the function used to terminate. This is useful for testing or interactive use. @@ -110,3 +114,48 @@ func Resolver(resolvers ...ResolverFunc) Option { k.resolvers = append(k.resolvers, resolvers...) } } + +// ConfigurationFunc is a function that builds a resolver from a file. +type ConfigurationFunc func(r io.Reader) (ResolverFunc, error) + +// Configuration provides Kong with support for loading defaults from a set of configuration files. +// +// Paths will be opened in order, and "loader" will be used to provide a ResolverFunc which is registered with Kong. +// +// Note: The JSON function is a ConfigurationFunc. +// +// ~ expansion will occur on the provided paths. +func Configuration(loader ConfigurationFunc, paths ...string) Option { + return func(k *Kong) { + for _, path := range paths { + path = expandPath(path) + r, err := os.Open(path) // nolint: gas + if err != nil { + continue + } + resolver, err := loader(r) + if err == nil { + k.resolvers = append(k.resolvers, resolver) + } + _ = r.Close() + } + } +} + +func expandPath(path string) string { + if filepath.IsAbs(path) { + return path + } + if strings.HasPrefix(path, "~/") { + user, err := user.Current() + if err != nil { + return path + } + return filepath.Join(user.HomeDir, path[2:]) + } + abspath, err := filepath.Abs(path) + if err != nil { + return path + } + return abspath +} diff --git a/options_test.go b/options_test.go index 61c7de1..fed4a81 100644 --- a/options_test.go +++ b/options_test.go @@ -1,6 +1,9 @@ package kong import ( + "encoding/json" + "io/ioutil" + "os" "testing" "github.com/stretchr/testify/require" @@ -16,3 +19,31 @@ func TestOptions(t *testing.T) { require.Nil(t, p.Stderr) require.Nil(t, p.Exit) } + +func TestConfigLoading(t *testing.T) { + first, err := ioutil.TempFile("", "") + require.NoError(t, err) + defer first.Close() + defer os.Remove(first.Name()) + second, err := ioutil.TempFile("", "") + require.NoError(t, err) + defer second.Close() + defer os.Remove(second.Name()) + + var cli struct { + Flag string `json:"flag,omitempty"` + } + + cli.Flag = "first" + err = json.NewEncoder(first).Encode(&cli) + require.NoError(t, err) + + cli.Flag = "" + err = json.NewEncoder(second).Encode(&cli) + require.NoError(t, err) + + p := mustNew(t, &cli, Configuration(JSON, first.Name(), second.Name())) + _, err = p.Parse(nil) + require.NoError(t, err) + require.Equal(t, "first", cli.Flag) +} diff --git a/resolver.go b/resolver.go index c2495fe..08520ae 100755 --- a/resolver.go +++ b/resolver.go @@ -11,10 +11,10 @@ import ( // ResolverFunc resolves a Flag value from an external source. type ResolverFunc func(context *Context, parent *Path, flag *Flag) (string, error) -// JSONResolver returns a Resolver that retrieves values from a JSON source. +// JSON returns a Resolver that retrieves values from a JSON source. // // Hyphens in flag names are replaced with underscores. -func JSONResolver(r io.Reader) (ResolverFunc, error) { +func JSON(r io.Reader) (ResolverFunc, error) { values := map[string]interface{}{} err := json.NewDecoder(r).Decode(&values) if err != nil { @@ -61,10 +61,10 @@ func jsonDecodeValue(sep rune, value interface{}) (string, error) { return "", fmt.Errorf("unsupported JSON value %v (of type %T)", value, value) } -// EnvResolver resolves flag values using the `env:""` tag. It ignores flags without this tag. +// Envars resolves flag values using the `env:""` tag. It ignores flags without this tag. // // This resolver is installed by default. -func EnvResolver() ResolverFunc { +func Envars() ResolverFunc { return func(context *Context, parent *Path, flag *Flag) (string, error) { if flag.Tag.Env == "" { return "", nil diff --git a/resolver_test.go b/resolver_test.go index 8e6c5ef..0bd02e1 100755 --- a/resolver_test.go +++ b/resolver_test.go @@ -30,7 +30,7 @@ func newEnvParser(t *testing.T, cli interface{}, env envMap) (*Kong, func()) { return parser, restoreEnv } -func TestEnvResolverFlagBasic(t *testing.T) { +func TestEnvarsFlagBasic(t *testing.T) { var cli struct { String string `env:"KONG_STRING"` Slice []int `env:"KONG_SLICE"` @@ -47,7 +47,7 @@ func TestEnvResolverFlagBasic(t *testing.T) { require.Equal(t, []int{5, 2, 9}, cli.Slice) } -func TestEnvResolverFlagOverride(t *testing.T) { +func TestEnvarsFlagOverride(t *testing.T) { var cli struct { Flag string `env:"KONG_FLAG"` } @@ -59,7 +59,7 @@ func TestEnvResolverFlagOverride(t *testing.T) { require.Equal(t, "hello", cli.Flag) } -func TestEnvResolverOnlyPopulateUsedBranches(t *testing.T) { +func TestEnvarsOnlyPopulateUsedBranches(t *testing.T) { // nolint var cli struct { UnvisitedArg struct { @@ -84,7 +84,7 @@ func TestEnvResolverOnlyPopulateUsedBranches(t *testing.T) { require.Equal(t, 0, cli.UnvisitedCmd.Int) } -func TestEnvResolverTag(t *testing.T) { +func TestEnvarsTag(t *testing.T) { var cli struct { Slice []int `env:"KONG_NUMBERS"` } @@ -96,7 +96,7 @@ func TestEnvResolverTag(t *testing.T) { require.Equal(t, []int{5, 2, 9}, cli.Slice) } -func TestJSONResolverBasic(t *testing.T) { +func TestJSONBasic(t *testing.T) { var cli struct { String string Slice []int @@ -111,7 +111,7 @@ func TestJSONResolverBasic(t *testing.T) { "slice_with_commas": ["a,b", "c"] }` - r, err := JSONResolver(strings.NewReader(json)) + r, err := JSON(strings.NewReader(json)) require.NoError(t, err) parser := mustNew(t, &cli, Resolver(r)) @@ -219,6 +219,7 @@ func TestLastResolverWins(t *testing.T) { } func TestResolverSatisfiesRequired(t *testing.T) { + // nolint: govet var cli struct { Int int `required` } diff --git a/scanner.go b/scanner.go index 583531b..765f3b3 100644 --- a/scanner.go +++ b/scanner.go @@ -21,6 +21,7 @@ const ( PositionalArgumentToken // ) +// Token created by Scanner. type Token struct { Value string Type TokenType @@ -42,10 +43,12 @@ func (t Token) String() string { } } +// IsEOL returns true if this Token is past the end of the line. func (t Token) IsEOL() bool { return t.Type == EOLToken } +// IsAny returns true if the token's type is any of those provided. func (t Token) IsAny(types ...TokenType) bool { for _, typ := range types { if t.Type == typ { @@ -75,6 +78,7 @@ type Scanner struct { args []Token } +// Scan creates a new Scanner from args with untyped tokens. func Scan(args ...string) *Scanner { s := &Scanner{} for _, arg := range args { @@ -83,10 +87,12 @@ func Scan(args ...string) *Scanner { return s } +// Len returns the number of input arguments. func (s *Scanner) Len() int { return len(s.args) } +// Pop the front token off the Scanner. func (s *Scanner) Pop() Token { if len(s.args) == 0 { return Token{Type: EOLToken} @@ -123,6 +129,7 @@ func (s *Scanner) PopUntil(predicate func(Token) bool) (values []string) { return } +// Peek at the next Token or return an EOLToken. func (s *Scanner) Peek() Token { if len(s.args) == 0 { return Token{Type: EOLToken} @@ -130,16 +137,19 @@ func (s *Scanner) Peek() Token { return s.args[0] } +// Push an untyped Token onto the front of the Scanner. func (s *Scanner) Push(arg string) *Scanner { s.PushToken(Token{Value: arg}) return s } +// PushTyped pushes a typed token onto the front of the Scanner. func (s *Scanner) PushTyped(arg string, typ TokenType) *Scanner { s.PushToken(Token{Value: arg, Type: typ}) return s } +// PushToken pushes a preconstructed Token onto the front of the Scanner. func (s *Scanner) PushToken(token Token) *Scanner { s.args = append([]Token{token}, s.args...) return s diff --git a/tag.go b/tag.go index fd445c0..17a71e6 100644 --- a/tag.go +++ b/tag.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" ) +// Tag represents the parsed state of Kong tags in a struct field tag. type Tag struct { Cmd bool Arg bool @@ -145,28 +146,34 @@ func parseTag(fv reflect.Value, ft reflect.StructField) *Tag { return t } +// Has returns true if the tag contained the given key. func (t *Tag) Has(k string) bool { _, ok := t.items[k] return ok } +// Get returns the value of the given tag. func (t *Tag) Get(k string) (string, bool) { s, ok := t.items[k] return s, ok } +// GetBool returns true if the given tag looks like a boolean truth string. func (t *Tag) GetBool(k string) (bool, error) { return strconv.ParseBool(t.items[k]) } +// GetFloat parses the given tag as a float64. func (t *Tag) GetFloat(k string) (float64, error) { return strconv.ParseFloat(t.items[k], 64) } +// GetInt parses the given tag as an int64. func (t *Tag) GetInt(k string) (int64, error) { return strconv.ParseInt(t.items[k], 10, 64) } +// GetRune parses the given tag as a rune. func (t *Tag) GetRune(k string) (rune, error) { r, _ := utf8.DecodeRuneInString(t.items[k]) if r == utf8.RuneError {