diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3396e46..4483d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: matrix: # These are the release channels. # Hermit will handle installing the right patch. - go: ["1.20", "1.21"] + go: ["1.23", "1.24"] steps: - name: Checkout code uses: actions/checkout@v4 @@ -35,7 +35,7 @@ jobs: matrix: # These are versions for GitHub's setup-go. # '.x' will pick the latest patch release. - go: ["1.20.x", "1.21.x"] + go: ["1.23.x", "1.24.x"] steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.golangci.yml b/.golangci.yml index e8980bf..1eb0b92 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,21 +7,16 @@ output: linters: enable-all: true disable: - - maligned - lll - gochecknoglobals - wsl - funlen - gocognit - - gomnd - goprintffuncname - paralleltest - nlreturn - - goerr113 - - ifshort - testpackage - wrapcheck - - exhaustivestruct - forbidigo - gci - godot @@ -29,9 +24,6 @@ linters: - cyclop - errorlint - nestif - - golint - - scopelint - - interfacer - tagliatelle - thelper - godox @@ -41,16 +33,19 @@ linters: - exhaustruct - nonamedreturns - nilnil - - nosnakecase # deprecated since v1.48.1 - - structcheck # deprecated since v1.49.0 - - deadcode # deprecated since v1.49.0 - - varcheck # deprecated since v1.49.0 - depguard # nothing to guard against yet - tagalign # hurts readability of kong tags + - tenv # deprecated since v1.64, but not removed yet + - mnd + - perfsprint + - err113 + - copyloopvar + - intrange + - nakedret + - recvcheck # value receivers are intentionally used for copies linters-settings: govet: - check-shadowing: true # These govet checks are disabled by default, but they're useful. enable: - niliness @@ -76,6 +71,7 @@ issues: - 'bad syntax for struct tag key' - 'bad syntax for struct tag pair' - 'result .* \(error\) is always nil' + - 'Error return value of `fmt.Fprintln` is not checked' exclude-rules: # Don't warn on unused parameters. diff --git a/README.md b/README.md index 8272f1a..44979c8 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,16 @@ [![](https://godoc.org/github.com/alecthomas/kong?status.svg)](http://godoc.org/github.com/alecthomas/kong) [![CircleCI](https://img.shields.io/circleci/project/github/alecthomas/kong.svg)](https://circleci.com/gh/alecthomas/kong) [![Go Report Card](https://goreportcard.com/badge/github.com/alecthomas/kong)](https://goreportcard.com/report/github.com/alecthomas/kong) [![Slack chat](https://img.shields.io/static/v1?logo=slack&style=flat&label=slack&color=green&message=gophers)](https://gophers.slack.com/messages/CN9DS8YF3) - - +- [Version 1.0.0 Release](#version-100-release) - [Introduction](#introduction) - [Help](#help) - [Help as a user of a Kong application](#help-as-a-user-of-a-kong-application) - [Defining help in Kong](#defining-help-in-kong) - [Command handling](#command-handling) - [Switch on the command string](#switch-on-the-command-string) - - [Attach a Run... error method to each command](#attach-a-run-error-method-to-each-command) -- [Hooks: BeforeReset, BeforeResolve, BeforeApply, AfterApply and the Bind option](#hooks-beforereset-beforeresolve-beforeapply-afterapply-and-the-bind-option) + - [Attach a `Run(...) error` method to each command](#attach-a-run-error-method-to-each-command) +- [Hooks: BeforeReset(), BeforeResolve(), BeforeApply(), AfterApply()](#hooks-beforereset-beforeresolve-beforeapply-afterapply) +- [The Bind() option](#the-bind-option) - [Flags](#flags) - [Commands and sub-commands](#commands-and-sub-commands) - [Branching positional arguments](#branching-positional-arguments) @@ -25,22 +25,26 @@ - [Nested data structure](#nested-data-structure) - [Custom named decoders](#custom-named-decoders) - [Supported field types](#supported-field-types) -- [Custom decoders mappers](#custom-decoders-mappers) +- [Custom decoders (mappers)](#custom-decoders-mappers) - [Supported tags](#supported-tags) - [Plugins](#plugins) - [Dynamic Commands](#dynamic-commands) - [Variable interpolation](#variable-interpolation) - [Validation](#validation) - [Modifying Kong's behaviour](#modifying-kongs-behaviour) - - [Namehelp and Descriptionhelp - set the application name description](#namehelp-and-descriptionhelp---set-the-application-name-description) - - [Configurationloader, paths... - load defaults from configuration files](#configurationloader-paths---load-defaults-from-configuration-files) - - [Resolver... - support for default values from external sources](#resolver---support-for-default-values-from-external-sources) - - [\*Mapper... - customising how the command-line is mapped to Go values](#mapper---customising-how-the-command-line-is-mapped-to-go-values) - - [ConfigureHelpHelpOptions and HelpHelpFunc - customising help](#configurehelphelpoptions-and-helphelpfunc---customising-help) - - [Bind... - bind values for callback hooks and Run methods](#bind---bind-values-for-callback-hooks-and-run-methods) + - [`Name(help)` and `Description(help)` - set the application name description](#namehelp-and-descriptionhelp---set-the-application-name-description) + - [`Configuration(loader, paths...)` - load defaults from configuration files](#configurationloader-paths---load-defaults-from-configuration-files) + - [`Resolver(...)` - support for default values from external sources](#resolver---support-for-default-values-from-external-sources) + - [`*Mapper(...)` - customising how the command-line is mapped to Go values](#mapper---customising-how-the-command-line-is-mapped-to-go-values) + - [`ConfigureHelp(HelpOptions)` and `Help(HelpFunc)` - customising help](#configurehelphelpoptions-and-helphelpfunc---customising-help) + - [Injecting values into `Run()` methods](#injecting-values-into-run-methods) - [Other options](#other-options) - +## Version 1.0.0 Release + +Kong has been stable for a long time, so it seemed appropriate to cut a 1.0 release. + +There is one breaking change, [#436](https://github.com/alecthomas/kong/pull/436), which should effect relatively few users. ## Introduction @@ -177,7 +181,7 @@ Flags: #### Showing an _argument_'s detailed help -Custom help will only be shown for _positional arguments with named fields_ ([see the README section on positional arguments for more details on what that means](../../../README.md#branching-positional-arguments)) +Custom help will only be shown for _positional arguments with named fields_ ([see the README section on positional arguments for more details on what that means](#branching-positional-arguments)) **Contextual argument help** @@ -302,17 +306,21 @@ func main() { ``` -## Hooks: BeforeReset(), BeforeResolve(), BeforeApply(), AfterApply() and the Bind() option +## Hooks: BeforeReset(), BeforeResolve(), BeforeApply(), AfterApply() -If a node in the grammar has a `BeforeReset(...)`, `BeforeResolve -(...)`, `BeforeApply(...) error` and/or `AfterApply(...) error` method, those -methods will be called before values are reset, before validation/assignment, -and after validation/assignment, respectively. +If a node in the CLI, or any of its embedded fields, implements a `BeforeReset(...) error`, `BeforeResolve +(...) error`, `BeforeApply(...) error` and/or `AfterApply(...) error` method, those will be called as Kong +resets, resolves, validates, and assigns values to the node. + +| Hook | Description | +| --------------- | ----------------------------------------------------------------------------------------------- | +| `BeforeReset` | Invoked before values are reset to their defaults (as defined by the grammar) or to zero values | +| `BeforeResolve` | Invoked before resolvers are applied to a node | +| `BeforeApply` | Invoked before the traced command line arguments are applied to the grammar | +| `AfterApply` | Invoked after command line arguments are applied to the grammar **and validated**` | The `--help` flag is implemented with a `BeforeReset` hook. -Arguments to hooks are provided via the `Run(...)` method or `Bind(...)` option. `*Kong`, `*Context` and `*Path` are also bound and finally, hooks can also contribute bindings via `kong.Context.Bind()` and `kong.Context.BindTo()`. - eg. ```go @@ -338,38 +346,42 @@ func main() { } ``` -Another example of using hooks is load the env-file: +It's also possible to register these hooks with the functional options +`kong.WithBeforeReset`, `kong.WithBeforeResolve`, `kong.WithBeforeApply`, and +`kong.WithAfterApply`. + +## The Bind() option + +Arguments to hooks are provided via the `Run(...)` method or `Bind(...)` option. `*Kong`, `*Context`, `*Path` and parent commands are also bound and finally, hooks can also contribute bindings via `kong.Context.Bind()` and `kong.Context.BindTo()`. + +eg: ```go -package main +type CLI struct { + Debug bool `help:"Enable debug mode."` -import ( - "fmt" - "git.company.lan/gopkg/kong" - "github.com/joho/godotenv" -) + Rm RmCmd `cmd:"" help:"Remove files."` + Ls LsCmd `cmd:"" help:"List paths."` +} -type EnvFlag string +type AuthorName string -// BeforeResolve loads env file. -func (c EnvFlag) BeforeReset(ctx *kong.Context, trace *kong.Path) error { - path := string(ctx.FlagValue(trace.Flag).(EnvFlag)) // nolint - path = kong.ExpandPath(path) - if err := godotenv.Load(path); err != nil { - return err - } +// ... +func (l *LsCmd) Run(cli *CLI) error { +// use cli.Debug here !! return nil } -var CLI struct { - EnvFile EnvFlag - Flag `env:"FLAG"` +func (r *RmCmD) Run(author AuthorName) error{ +// use binded author here + return nil } func main() { - _ = kong.Parse(&CLI) - fmt.Println(CLI.Flag) -} + var cli CLI + + ctx := kong.Parse(&cli, Bind(AuthorName("penguin"))) + err := ctx.Run() ``` ## Flags @@ -553,36 +565,44 @@ Tags can be in two forms: Both can coexist with standard Tag parsing. -| Tag | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `cmd:""` | If present, struct is a command. | -| `arg:""` | If present, field is an argument. Required by default. | -| `env:"X,Y,..."` | Specify envars to use for default value. The envs are resolved in the declared order. The first value found is used. | -| `name:"X"` | Long name, for overriding field name. | -| `help:"X"` | Help text. | -| `type:"X"` | Specify [named types](#custom-named-decoders) to use. | -| `placeholder:"X"` | Placeholder text. | -| `default:"X"` | Default value. | -| `default:"1"` | On a command, make it the default. | -| `default:"withargs"` | On a command, make it the default and allow args/flags from that command | -| `short:"X"` | Short name, if flag. | -| `aliases:"X,Y"` | One or more aliases (for cmd or flag). | -| `required:""` | If present, flag/arg is required. | -| `optional:""` | If present, flag/arg is optional. | -| `hidden:""` | If present, command or flag is hidden. | -| `negatable:""` | If present on a `bool` field, supports prefixing a flag with `--no-` to invert the default value | -| `format:"X"` | Format for parsing input, if supported. | -| `sep:"X"` | Separator for sequences (defaults to ","). May be `none` to disable splitting. | -| `mapsep:"X"` | Separator for maps (defaults to ";"). May be `none` to disable splitting. | -| `enum:"X,Y,..."` | Set of valid values allowed for this flag. An enum field must be `required` or have a valid `default`. | -| `group:"X"` | Logical group for a flag or command. | -| `xor:"X,Y,..."` | Exclusive OR groups for flags. Only one flag in the group can be used which is restricted within the same command. When combined with `required`, at least one of the `xor` group will be required. | -| `prefix:"X"` | Prefix for all sub-flags. | -| `envprefix:"X"` | Envar prefix for all sub-flags. | -| `set:"K=V"` | Set a variable for expansion by child elements. Multiples can occur. | -| `embed:""` | If present, this field's children will be embedded in the parent. Useful for composition. | -| `passthrough:""` | If present on a positional argument, it stops flag parsing when encountered, as if `--` was processed before. Useful for external command wrappers, like `exec`. On a command it requires that the command contains only one argument of type `[]string` which is then filled with everything following the command, unparsed. | -| `-` | Ignore the field. Useful for adding non-CLI fields to a configuration struct. e.g `` `kong:"-"` `` | +| Tag | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `cmd:""` | If present, struct is a command. | +| `arg:""` | If present, field is an argument. Required by default. | +| `env:"X,Y,..."` | Specify envars to use for default value. The envs are resolved in the declared order. The first value found is used. | +| `name:"X"` | Long name, for overriding field name. | +| `help:"X"` | Help text. | +| `type:"X"` | Specify [named types](#custom-named-decoders) to use. | +| `placeholder:"X"` | Placeholder input, if flag. e.g. `` `placeholder:""` `` will show `--flag-name=` when displaying help. | +| `default:"X"` | Default value. | +| `default:"1"` | On a command, make it the default. | +| `default:"withargs"` | On a command, make it the default and allow args/flags from that command | +| `short:"X"` | Short name, if flag. | +| `aliases:"X,Y"` | One or more aliases (for cmd or flag). | +| `required:""` | If present, flag/arg is required. | +| `optional:""` | If present, flag/arg is optional. | +| `hidden:""` | If present, command or flag is hidden. | +| `negatable:""` | If present on a `bool` field, supports prefixing a flag with `--no-` to invert the default value | +| `negatable:"X"` | If present on a `bool` field, supports `--X` to invert the default value | +| `format:"X"` | Format for parsing input, if supported. | +| `sep:"X"` | Separator for sequences (defaults to ","). May be `none` to disable splitting. | +| `mapsep:"X"` | Separator for maps (defaults to ";"). May be `none` to disable splitting. | +| `enum:"X,Y,..."` | Set of valid values allowed for this flag. An enum field must be `required` or have a valid `default`. | +| `group:"X"` | Logical group for a flag or command. | +| `xor:"X,Y,..."` | Exclusive OR groups for flags. Only one flag in the group can be used which is restricted within the same command. When combined with `required`, at least one of the `xor` group will be required. | +| `and:"X,Y,..."` | AND groups for flags. All flags in the group must be used in the same command. When combined with `required`, all flags in the group will be required. | +| `prefix:"X"` | Prefix for all sub-flags. | +| `envprefix:"X"` | Envar prefix for all sub-flags. | +| `xorprefix:"X"` | Prefix for all sub-flags in XOR/AND groups. | +| `set:"K=V"` | Set a variable for expansion by child elements. Multiples can occur. | +| `embed:""` | If present, this field's children will be embedded in the parent. Useful for composition. | +| `passthrough:""`[^1] | If present on a positional argument, it stops flag parsing when encountered, as if `--` was processed before. Useful for external command wrappers, like `exec`. On a command it requires that the command contains only one argument of type `[]string` which is then filled with everything following the command, unparsed. | +| `-` | Ignore the field. Useful for adding non-CLI fields to a configuration struct. e.g `` `kong:"-"` `` | + +[^1]: + `` can be `partial` or `all` (the default). `all` will pass through all arguments including flags, including + flags. `partial` will validate flags until the first positional argument is encountered, then pass through all remaining + positional arguments. ## Plugins @@ -611,8 +631,8 @@ also supports dynamically adding commands via `kong.DynamicCommand()`. ## Variable interpolation -Kong supports limited variable interpolation into help strings, enum lists and -default values. +Kong supports limited variable interpolation into help strings, placeholder strings, +enum lists and default values. Variables are in the form: @@ -654,8 +674,7 @@ func main() { ## Validation Kong does validation on the structure of a command-line, but also supports -extensible validation. Any node in the tree may implement the following -interface: +extensible validation. Any node in the tree may implement either of the following interfaces: ```go type Validatable interface { @@ -663,12 +682,18 @@ type Validatable interface { } ``` +```go +type Validatable interface { + Validate(kctx *kong.Context) error + } +``` + If one of these nodes is in the active command-line it will be called during normal validation. ## Modifying Kong's behaviour -Each Kong parser can be configured via functional options passed to `New(cli interface{}, options...Option)`. +Each Kong parser can be configured via functional options passed to `New(cli any, options...Option)`. The full set of options can be found [here](https://godoc.org/github.com/alecthomas/kong#Option). @@ -726,20 +751,25 @@ All builtin Go types (as well as a bunch of useful stdlib types like `time.Time` 1. `NamedMapper(string, Mapper)` and using the tag key `type:""`. 2. `KindMapper(reflect.Kind, Mapper)`. 3. `TypeMapper(reflect.Type, Mapper)`. -4. `ValueMapper(interface{}, Mapper)`, passing in a pointer to a field of the grammar. +4. `ValueMapper(any, Mapper)`, passing in a pointer to a field of the grammar. ### `ConfigureHelp(HelpOptions)` and `Help(HelpFunc)` - customising help The default help output is usually sufficient, but if not there are two solutions. 1. Use `ConfigureHelp(HelpOptions)` to configure how help is formatted (see [HelpOptions](https://godoc.org/github.com/alecthomas/kong#HelpOptions) for details). -2. Custom help can be wired into Kong via the `Help(HelpFunc)` option. The `HelpFunc` is passed a `Context`, which contains the parsed context for the current command-line. See the implementation of `PrintHelp` for an example. +2. Custom help can be wired into Kong via the `Help(HelpFunc)` option. The `HelpFunc` is passed a `Context`, which contains the parsed context for the current command-line. See the implementation of `DefaultHelpPrinter` for an example. 3. Use `ValueFormatter(HelpValueFormatter)` if you want to just customize the help text that is accompanied by flags and arguments. 4. Use `Groups([]Group)` if you want to customize group titles or add a header. -### `Bind(...)` - bind values for callback hooks and Run() methods +### Injecting values into `Run()` methods -See the [section on hooks](#hooks-beforeresolve-beforeapply-afterapply-and-the-bind-option) for details. +There are several ways to inject values into `Run()` methods: + +1. Use `Bind()` to bind values directly. +2. Use `BindTo()` to bind values to an interface type. +3. Use `BindToProvider()` to bind values to a function that provides the value. +4. Implement `Provide() error` methods on the command structure. ### Other options diff --git a/_examples/server/go.mod b/_examples/server/go.mod index 8cdfbca..717b5d3 100644 --- a/_examples/server/go.mod +++ b/_examples/server/go.mod @@ -1,17 +1,25 @@ module kong_server -go 1.13 +go 1.23.0 + +toolchain go1.24.4 require ( git.company.lan/gopkg/kong v0.0.0-00010101000000-000000000000 github.com/alecthomas/colour v0.1.0 github.com/chzyer/readline v1.5.1 - github.com/gliderlabs/ssh v0.3.7 + github.com/gliderlabs/ssh v0.3.8 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/kr/pty v1.1.8 + golang.org/x/term v0.32.0 +) + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/creack/pty v1.1.7 // indirect github.com/mattn/go-isatty v0.0.12 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/term v0.18.0 + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) replace git.company.lan/gopkg/kong => ../../ diff --git a/_examples/server/go.sum b/_examples/server/go.sum index 84e0d6f..5ac367e 100644 --- a/_examples/server/go.sum +++ b/_examples/server/go.sum @@ -1,5 +1,5 @@ -github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= -github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrDUk= github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= @@ -14,8 +14,8 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= -github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= -github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -24,53 +24,11 @@ github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= diff --git a/_examples/shell/help/main.go b/_examples/shell/help/main.go index 42d2157..139b7e4 100644 --- a/_examples/shell/help/main.go +++ b/_examples/shell/help/main.go @@ -7,14 +7,16 @@ import ( ) var cli struct { - Flag flagWithHelp `help:"Regular flag help"` + Flag flagWithHelp `help:"${flag_help}"` Echo commandWithHelp `cmd:"" help:"Regular command help"` } type flagWithHelp bool -func (f *flagWithHelp) Help() string { - return "🏁 additional flag help" +// See https://github.com/alecthomas/kong?tab=readme-ov-file#variable-interpolation +var vars = kong.Vars{ + "flag_help": "Extended flag help that might be too long for directly " + + "including in the struct tag field", } type commandWithHelp struct { @@ -41,7 +43,8 @@ func main() { kong.ConfigureHelp(kong.HelpOptions{ Compact: true, Summary: false, - })) + }), + vars) switch ctx.Command() { case "echo ": fmt.Println(cli.Echo.Msg) diff --git a/bin/.go-1.22.1.pkg b/bin/.go-1.24.4.pkg similarity index 100% rename from bin/.go-1.22.1.pkg rename to bin/.go-1.24.4.pkg diff --git a/bin/.golangci-lint-1.55.2.pkg b/bin/.golangci-lint-1.64.5.pkg similarity index 100% rename from bin/.golangci-lint-1.55.2.pkg rename to bin/.golangci-lint-1.64.5.pkg diff --git a/bin/.lefthook-1.11.13.pkg b/bin/.lefthook-1.11.13.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.lefthook-1.11.13.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/go b/bin/go index 2b44c98..2625879 120000 --- a/bin/go +++ b/bin/go @@ -1 +1 @@ -.go-1.22.1.pkg \ No newline at end of file +.go-1.24.4.pkg \ No newline at end of file diff --git a/bin/gofmt b/bin/gofmt index 2b44c98..2625879 120000 --- a/bin/gofmt +++ b/bin/gofmt @@ -1 +1 @@ -.go-1.22.1.pkg \ No newline at end of file +.go-1.24.4.pkg \ No newline at end of file diff --git a/bin/golangci-lint b/bin/golangci-lint index 5b19a9d..1bb9797 120000 --- a/bin/golangci-lint +++ b/bin/golangci-lint @@ -1 +1 @@ -.golangci-lint-1.55.2.pkg \ No newline at end of file +.golangci-lint-1.64.5.pkg \ No newline at end of file diff --git a/bin/lefthook b/bin/lefthook new file mode 120000 index 0000000..b6dd74f --- /dev/null +++ b/bin/lefthook @@ -0,0 +1 @@ +.lefthook-1.11.13.pkg \ No newline at end of file diff --git a/build.go b/build.go index 1cfe1c3..63afcd4 100644 --- a/build.go +++ b/build.go @@ -9,9 +9,9 @@ import ( // Plugins are dynamically embedded command-line structures. // // Each element in the Plugins list *must* be a pointer to a structure. -type Plugins []interface{} +type Plugins []any -func build(k *Kong, ast interface{}) (app *Application, err error) { +func build(k *Kong, ast any) (app *Application, err error) { v := reflect.ValueOf(ast) iv := reflect.Indirect(v) if v.Kind() != reflect.Ptr || iv.Kind() != reflect.Struct { @@ -51,6 +51,10 @@ type flattenedField struct { func flattenedFields(v reflect.Value, ptag *Tag) (out []flattenedField, err error) { v = reflect.Indirect(v) + if v.Kind() != reflect.Struct { + return out, nil + } + ignored := map[string]bool{} for i := 0; i < v.NumField(); i++ { ft := v.Type().Field(i) fv := v.Field(i) @@ -58,7 +62,8 @@ func flattenedFields(v reflect.Value, ptag *Tag) (out []flattenedField, err erro if err != nil { return nil, err } - if tag.Ignored { + if tag.Ignored || ignored[ft.Name] { + ignored[ft.Name] = true continue } // Assign group if it's not already set. @@ -68,6 +73,7 @@ func flattenedFields(v reflect.Value, ptag *Tag) (out []flattenedField, err erro // Accumulate prefixes. tag.Prefix = ptag.Prefix + tag.Prefix tag.EnvPrefix = ptag.EnvPrefix + tag.EnvPrefix + tag.XorPrefix = ptag.XorPrefix + tag.XorPrefix // Combine parent vars. tag.Vars = ptag.Vars.CloneWith(tag.Vars) // Command and embedded structs can be pointers, so we hydrate them now. @@ -102,13 +108,31 @@ func flattenedFields(v reflect.Value, ptag *Tag) (out []flattenedField, err erro } out = append(out, sub...) } + out = removeIgnored(out, ignored) return out, nil } +func removeIgnored(fields []flattenedField, ignored map[string]bool) []flattenedField { + j := 0 + for i := 0; i < len(fields); i++ { + if ignored[fields[i].field.Name] { + continue + } + if i != j { + fields[j] = fields[i] + } + j++ + } + if j != len(fields) { + fields = fields[:j] + } + return fields +} + // Build a Node in the Kong data model. // // "v" is the value to create the node from, "typ" is the output Node type. -func buildNode(k *Kong, v reflect.Value, typ NodeType, tag *Tag, seenFlags map[string]bool) (*Node, error) { +func buildNode(k *Kong, v reflect.Value, typ NodeType, tag *Tag, seenFlags map[string]bool) (*Node, error) { //nolint:gocyclo node := &Node{ Type: typ, Target: v, @@ -144,6 +168,18 @@ MAIN: } } + if len(tag.Xor) != 0 { + for i := range tag.Xor { + tag.Xor[i] = tag.XorPrefix + tag.Xor[i] + } + } + + if len(tag.And) != 0 { + for i := range tag.And { + tag.And[i] = tag.XorPrefix + tag.And[i] + } + } + // Nested structs are either commands or args, unless they implement the Mapper interface. if field.value.Kind() == reflect.Struct && (tag.Cmd || tag.Arg) && k.registry.ForValue(fv) == nil { typ := CommandNode @@ -170,6 +206,9 @@ MAIN: if flag.Short != 0 { delete(seenFlags, "-"+string(flag.Short)) } + if negFlag := negatableFlagName(flag.Name, flag.Tag.Negatable); negFlag != "" { + delete(seenFlags, negFlag) + } for _, aflag := range flag.Aliases { delete(seenFlags, "--"+aflag) } @@ -275,17 +314,18 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv } value := &Value{ - Name: name, - Help: tag.Help, - OrigHelp: tag.Help, - HasDefault: tag.HasDefault, - Default: tag.Default, - DefaultValue: reflect.New(fv.Type()).Elem(), - Mapper: mapper, - Tag: tag, - Target: fv, - Enum: tag.Enum, - Passthrough: tag.Passthrough, + Name: name, + Help: tag.Help, + OrigHelp: tag.Help, + HasDefault: tag.HasDefault, + Default: tag.Default, + DefaultValue: reflect.New(fv.Type()).Elem(), + Mapper: mapper, + Tag: tag, + Target: fv, + Enum: tag.Enum, + Passthrough: tag.Passthrough, + PassthroughMode: tag.PassthroughMode, // Flags are optional by default, and args are required by default. Required: (!tag.Arg && tag.Required) || (tag.Arg && !tag.Optional), @@ -312,6 +352,13 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv } seenFlags["-"+string(tag.Short)] = true } + if tag.Negatable != "" { + negFlag := negatableFlagName(value.Name, tag.Negatable) + if seenFlags[negFlag] { + return failField(v, ft, "duplicate negation flag %s", negFlag) + } + seenFlags[negFlag] = true + } flag := &Flag{ Value: value, Aliases: tag.Aliases, @@ -320,6 +367,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv Envs: tag.Envs, Group: buildGroupForKey(k, tag.Group), Xor: tag.Xor, + And: tag.And, Hidden: tag.Hidden, } value.Flag = flag diff --git a/callbacks.go b/callbacks.go index 45ef0d2..6096a26 100644 --- a/callbacks.go +++ b/callbacks.go @@ -6,7 +6,59 @@ import ( "strings" ) -type bindings map[reflect.Type]func() (reflect.Value, error) +// binding is a single binding registered with Kong. +type binding struct { + // fn is a function that returns a value of the target type. + fn reflect.Value + + // val is a value of the target type. + // Must be set if done and singleton are true. + val reflect.Value + + // singleton indicates whether the binding is a singleton. + // If true, the binding will be resolved once and cached. + singleton bool + + // done indicates whether a singleton binding has been resolved. + // If singleton is false, this field is ignored. + done bool +} + +// newValueBinding builds a binding with an already resolved value. +func newValueBinding(v reflect.Value) *binding { + return &binding{val: v, done: true, singleton: true} +} + +// newFunctionBinding builds a binding with a function +// that will return a value of the target type. +// +// The function signature must be func(...) (T, error) or func(...) T +// where parameters are recursively resolved. +func newFunctionBinding(f reflect.Value, singleton bool) *binding { + return &binding{fn: f, singleton: singleton} +} + +// Get returns the pre-resolved value for the binding, +// or false if the binding is not resolved. +func (b *binding) Get() (v reflect.Value, ok bool) { + return b.val, b.done +} + +// Set sets the value of the binding to the given value, +// marking it as resolved. +// +// If the binding is not a singleton, this method does nothing. +func (b *binding) Set(v reflect.Value) { + if b.singleton { + b.val = v + b.done = true + } +} + +// A map of type to function that returns a value of that type. +// +// The function should have the signature func(...) (T, error). Arguments are recursively resolved. +type bindings map[reflect.Type]*binding func (b bindings) String() string { out := []string{} @@ -16,35 +68,36 @@ func (b bindings) String() string { return "bindings{" + strings.Join(out, ", ") + "}" } -func (b bindings) add(values ...interface{}) bindings { +func (b bindings) add(values ...any) bindings { for _, v := range values { - v := v - b[reflect.TypeOf(v)] = func() (reflect.Value, error) { return reflect.ValueOf(v), nil } + val := reflect.ValueOf(v) + b[val.Type()] = newValueBinding(val) } return b } -func (b bindings) addTo(impl, iface interface{}) { - valueOf := reflect.ValueOf(impl) - b[reflect.TypeOf(iface).Elem()] = func() (reflect.Value, error) { return valueOf, nil } +func (b bindings) addTo(impl, iface any) { + val := reflect.ValueOf(impl) + b[reflect.TypeOf(iface).Elem()] = newValueBinding(val) } -func (b bindings) addProvider(provider interface{}) error { +func (b bindings) addProvider(provider any, singleton bool) error { pv := reflect.ValueOf(provider) t := pv.Type() - if t.Kind() != reflect.Func || t.NumIn() != 0 || t.NumOut() != 2 || t.Out(1) != reflect.TypeOf((*error)(nil)).Elem() { - return fmt.Errorf("%T must be a function with the signature func()(T, error)", provider) + if t.Kind() != reflect.Func { + return fmt.Errorf("%T must be a function", provider) + } + + if t.NumOut() == 0 { + return fmt.Errorf("%T must be a function with the signature func(...)(T, error) or func(...) T", provider) + } + if t.NumOut() == 2 { + if t.Out(1) != reflect.TypeOf((*error)(nil)).Elem() { + return fmt.Errorf("missing error; %T must be a function with the signature func(...)(T, error) or func(...) T", provider) + } } rt := pv.Type().Out(0) - b[rt] = func() (reflect.Value, error) { - out := pv.Call(nil) - errv := out[1] - var err error - if !errv.IsNil() { - err = errv.Interface().(error) //nolint - } - return out[0], err - } + b[rt] = newFunctionBinding(pv, singleton) return nil } @@ -74,32 +127,67 @@ func getMethod(value reflect.Value, name string) reflect.Value { return method } +// getMethods gets all methods with the given name from the given value +// and any embedded fields. +// +// Returns a slice of bound methods that can be called directly. +func getMethods(value reflect.Value, name string) (methods []reflect.Value) { + if value.Kind() == reflect.Ptr { + value = value.Elem() + } + if !value.IsValid() { + return + } + + if method := getMethod(value, name); method.IsValid() { + methods = append(methods, method) + } + + if value.Kind() != reflect.Struct { + return + } + // If the current value is a struct, also consider embedded fields. + // Two kinds of embedded fields are considered if they're exported: + // + // - standard Go embedded fields + // - fields tagged with `embed:""` + t := value.Type() + for i := 0; i < value.NumField(); i++ { + fieldValue := value.Field(i) + field := t.Field(i) + + if !field.IsExported() { + continue + } + + // Consider a field embedded if it's actually embedded + // or if it's tagged with `embed:""`. + _, isEmbedded := field.Tag.Lookup("embed") + isEmbedded = isEmbedded || field.Anonymous + if isEmbedded { + methods = append(methods, getMethods(fieldValue, name)...) + } + } + return +} + func callFunction(f reflect.Value, bindings bindings) error { if f.Kind() != reflect.Func { return fmt.Errorf("expected function, got %s", f.Type()) } - in := []reflect.Value{} t := f.Type() if t.NumOut() != 1 || !t.Out(0).Implements(callbackReturnSignature) { return fmt.Errorf("return value of %s must implement \"error\"", t) } - for i := 0; i < t.NumIn(); i++ { - pt := t.In(i) - if argf, ok := bindings[pt]; ok { - argv, err := argf() - if err != nil { - return err - } - in = append(in, argv) - } else { - return fmt.Errorf("couldn't find binding of type %s for parameter %d of %s(), use kong.Bind(%s)", pt, i, t, pt) - } + out, err := callAnyFunction(f, bindings) + if err != nil { + return err } - out := f.Call(in) - if out[0].IsNil() { + ferr := out[0] + if ferrv := reflect.ValueOf(ferr); !ferrv.IsValid() || ((ferrv.Kind() == reflect.Interface || ferrv.Kind() == reflect.Pointer) && ferrv.IsNil()) { return nil } - return out[0].Interface().(error) //nolint + return ferr.(error) //nolint:forcetypeassert } func callAnyFunction(f reflect.Value, bindings bindings) (out []any, err error) { @@ -110,15 +198,29 @@ func callAnyFunction(f reflect.Value, bindings bindings) (out []any, err error) t := f.Type() for i := 0; i < t.NumIn(); i++ { pt := t.In(i) - if argf, ok := bindings[pt]; ok { - argv, err := argf() - if err != nil { - return nil, err - } - in = append(in, argv) - } else { + binding, ok := bindings[pt] + if !ok { return nil, fmt.Errorf("couldn't find binding of type %s for parameter %d of %s(), use kong.Bind(%s)", pt, i, t, pt) } + + // Don't need to call the function if the value is already resolved. + if val, ok := binding.Get(); ok { + in = append(in, val) + continue + } + + // Recursively resolve binding functions. + argv, err := callAnyFunction(binding.fn, bindings) + if err != nil { + return nil, fmt.Errorf("%s: %w", pt, err) + } + if ferrv := reflect.ValueOf(argv[len(argv)-1]); ferrv.IsValid() && ferrv.Type().Implements(callbackReturnSignature) && !ferrv.IsNil() { + return nil, ferrv.Interface().(error) //nolint:forcetypeassert + } + + val := reflect.ValueOf(argv[0]) + binding.Set(val) + in = append(in, val) } outv := f.Call(in) out = make([]any, len(outv)) diff --git a/config_test.go b/config_test.go index 148fc99..e286131 100644 --- a/config_test.go +++ b/config_test.go @@ -15,12 +15,10 @@ func TestMultipleConfigLoading(t *testing.T) { } cli.Flag = "first" - first, cleanFirst := makeConfig(t, &cli) - defer cleanFirst() + first := makeConfig(t, &cli) cli.Flag = "" - second, cleanSecond := makeConfig(t, &cli) - defer cleanSecond() + second := makeConfig(t, &cli) p := mustNew(t, &cli, kong.Configuration(kong.JSON, first, second)) _, err := p.Parse(nil) @@ -34,20 +32,19 @@ func TestConfigValidation(t *testing.T) { } cli.Flag = "invalid" - conf, cleanConf := makeConfig(t, &cli) - defer cleanConf() + conf := makeConfig(t, &cli) p := mustNew(t, &cli, kong.Configuration(kong.JSON, conf)) _, err := p.Parse(nil) assert.Error(t, err) } -func makeConfig(t *testing.T, config interface{}) (path string, cleanup func()) { +func makeConfig(t *testing.T, config any) (path string) { t.Helper() - w, err := os.CreateTemp("", "") + w, err := os.CreateTemp(t.TempDir(), "") assert.NoError(t, err) defer w.Close() err = json.NewEncoder(w).Encode(config) assert.NoError(t, err) - return w.Name(), func() { os.Remove(w.Name()) } + return w.Name() } diff --git a/context.go b/context.go index b2bfea6..6a4989f 100644 --- a/context.go +++ b/context.go @@ -26,6 +26,9 @@ type Path struct { // True if this Path element was created as the result of a resolver. Resolved bool + + // Remaining tokens after this node + remainder []Token } // Node returns the Node associated with this Path, or nil if Path is a non-Node. @@ -64,6 +67,15 @@ func (p *Path) Visitable() Visitable { return nil } +// Remainder returns the remaining unparsed args after this Path element. +func (p *Path) Remainder() []string { + args := []string{} + for _, token := range p.remainder { + args = append(args, token.String()) + } + return args +} + // Context contains the current parse context. type Context struct { *Kong @@ -87,14 +99,15 @@ type Context struct { // This just constructs a new trace. To fully apply the trace you must call Reset(), Resolve(), // Validate() and Apply(). func Trace(k *Kong, args []string) (*Context, error) { + s := Scan(args...).AllowHyphenPrefixedParameters(k.allowHyphenated) c := &Context{ Kong: k, Args: args, Path: []*Path{ - {App: k.Model, Flags: k.Model.Flags}, + {App: k.Model, Flags: k.Model.Flags, remainder: s.PeekAll()}, }, values: map[*Value]reflect.Value{}, - scan: Scan(args...), + scan: s, bindings: bindings{}, } c.Error = c.trace(c.Model.Node) @@ -102,7 +115,7 @@ func Trace(k *Kong, args []string) (*Context, error) { } // Bind adds bindings to the Context. -func (c *Context) Bind(args ...interface{}) { +func (c *Context) Bind(args ...any) { c.bindings.add(args...) } @@ -111,7 +124,7 @@ func (c *Context) Bind(args ...interface{}) { // This will typically have to be called like so: // // BindTo(impl, (*MyInterface)(nil)) -func (c *Context) BindTo(impl, iface interface{}) { +func (c *Context) BindTo(impl, iface any) { c.bindings.addTo(impl, iface) } @@ -119,8 +132,20 @@ func (c *Context) BindTo(impl, iface interface{}) { // // This is useful when the Run() function of different commands require different values that may // not all be initialisable from the main() function. -func (c *Context) BindToProvider(provider interface{}) error { - return c.bindings.addProvider(provider) +// +// "provider" must be a function with the signature func(...) (T, error) or func(...) T, +// where ... will be recursively injected with bound values. +func (c *Context) BindToProvider(provider any) error { + return c.bindings.addProvider(provider, false /* singleton */) +} + +// BindSingletonProvider allows binding of provider functions. +// The provider will be called once and the result cached. +// +// "provider" must be a function with the signature func(...) (T, error) or func(...) T, +// where ... will be recursively injected with bound values. +func (c *Context) BindSingletonProvider(provider any) error { + return c.bindings.addProvider(provider, true /* singleton */) } // Value returns the value for a particular path element. @@ -208,7 +233,7 @@ func (c *Context) Validate() error { //nolint: gocyclo desc = node.Path() } if validate := isValidatable(value); validate != nil { - if err := validate.Validate(); err != nil { + if err := validate.Validate(c); err != nil { if desc != "" { return fmt.Errorf("%s: %w", desc, err) } @@ -259,7 +284,7 @@ func (c *Context) Validate() error { //nolint: gocyclo if err := checkMissingPositionals(positionals, node.Positional); err != nil { return err } - if err := checkXorDuplicates(c.Path); err != nil { + if err := checkXorDuplicatedAndAndMissing(c.Path); err != nil { return err } @@ -306,7 +331,7 @@ func (c *Context) AddResolver(resolver Resolver) { } // FlagValue returns the set value of a flag if it was encountered and exists, or its default value. -func (c *Context) FlagValue(flag *Flag) interface{} { +func (c *Context) FlagValue(flag *Flag) any { for _, trace := range c.Path { if trace.Flag == flag { v, ok := c.values[trace.Flag.Value] @@ -347,6 +372,7 @@ func (c *Context) endParsing() { } } +//nolint:maintidx func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo positional := 0 node.Active = true @@ -383,9 +409,13 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo // Indicates end of parsing. All remaining arguments are treated as positional arguments only. case v == "--": - c.scan.Pop() c.endParsing() + // Pop the -- token unless the next positional argument accepts passthrough arguments. + if !(positional < len(node.Positional) && node.Positional[positional].Passthrough) { + c.scan.Pop() + } + // Long flag. case strings.HasPrefix(v, "--"): c.scan.Pop() @@ -420,12 +450,22 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo case FlagToken: if err := c.parseFlag(flags, token.String()); err != nil { - return err + if isUnknownFlagError(err) && positional < len(node.Positional) && node.Positional[positional].PassthroughMode == PassThroughModeAll { + c.scan.Pop() + c.scan.PushTyped(token.String(), PositionalArgumentToken) + } else { + return err + } } case ShortFlagToken: if err := c.parseFlag(flags, token.String()); err != nil { - return err + if isUnknownFlagError(err) && positional < len(node.Positional) && node.Positional[positional].PassthroughMode == PassThroughModeAll { + c.scan.Pop() + c.scan.PushTyped(token.String(), PositionalArgumentToken) + } else { + return err + } } case FlagValueToken: @@ -450,6 +490,7 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo c.Path = append(c.Path, &Path{ Parent: node, Positional: arg, + remainder: c.scan.PeekAll(), }) positional++ break @@ -481,9 +522,10 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo if branch.Type == CommandNode && branch.Name == token.Value { c.scan.Pop() c.Path = append(c.Path, &Path{ - Parent: node, - Command: branch, - Flags: branch.Flags, + Parent: node, + Command: branch, + Flags: branch.Flags, + remainder: c.scan.PeekAll(), }) return c.trace(branch) } @@ -495,9 +537,10 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo arg := branch.Argument if err := arg.Parse(c.scan, c.getValue(arg)); err == nil { c.Path = append(c.Path, &Path{ - Parent: node, - Argument: branch, - Flags: branch.Flags, + Parent: node, + Argument: branch, + Flags: branch.Flags, + remainder: c.scan.PeekAll(), }) return c.trace(branch) } @@ -508,9 +551,10 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo // matches, take the branch of the default command if node.DefaultCmd != nil && node.DefaultCmd.Tag.Default == "withargs" { c.Path = append(c.Path, &Path{ - Parent: node, - Command: node.DefaultCmd, - Flags: node.DefaultCmd.Flags, + Parent: node, + Command: node.DefaultCmd, + Flags: node.DefaultCmd.Flags, + remainder: c.scan.PeekAll(), }) return c.trace(node.DefaultCmd) } @@ -523,19 +567,25 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo return c.maybeSelectDefault(flags, node) } +// IgnoreDefault can be implemented by flags that want to be applied before any default commands. +type IgnoreDefault interface { + IgnoreDefault() +} + // End of the line, check for a default command, but only if we're not displaying help, // otherwise we'd only ever display the help for the default command. func (c *Context) maybeSelectDefault(flags []*Flag, node *Node) error { for _, flag := range flags { - if flag.Name == "help" && flag.Set { + if _, ok := flag.Target.Interface().(IgnoreDefault); ok && flag.Set { return nil } } if node.DefaultCmd != nil { c.Path = append(c.Path, &Path{ - Parent: node.DefaultCmd, - Command: node.DefaultCmd, - Flags: node.DefaultCmd.Flags, + Parent: node.DefaultCmd, + Command: node.DefaultCmd, + Flags: node.DefaultCmd.Flags, + remainder: c.scan.PeekAll(), }) } return nil @@ -557,7 +607,7 @@ func (c *Context) Resolve() error { } // Pick the last resolved value. - var selected interface{} + var selected any for _, resolver := range resolvers { s, err := resolver.Resolve(c, path, flag) if err != nil { @@ -580,8 +630,9 @@ func (c *Context) Resolve() error { return err } inserted = append(inserted, &Path{ - Flag: flag, - Resolved: true, + Flag: flag, + Resolved: true, + remainder: c.scan.PeekAll(), }) } } @@ -700,13 +751,13 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) { candidates = append(candidates, alias) } - neg := "--no-" + flag.Name - if !matched && !(match == neg && flag.Tag.Negatable) { + neg := negatableFlagName(flag.Name, flag.Tag.Negatable) + if !matched && match != neg { continue } // Found a matching flag. c.scan.Pop() - if match == neg && flag.Tag.Negatable { + if match == neg && flag.Tag.Negatable != "" { flag.Negated = true } err := flag.Parse(c.scan, c.getValue(flag.Value)) @@ -725,16 +776,29 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) { } flag.Value.Apply(value) } - c.Path = append(c.Path, &Path{Flag: flag}) + c.Path = append(c.Path, &Path{ + Flag: flag, + remainder: c.scan.PeekAll(), + }) return nil } - return findPotentialCandidates(match, candidates, "unknown flag %s", match) + return &unknownFlagError{Cause: findPotentialCandidates(match, candidates, "unknown flag %s", match)} } +func isUnknownFlagError(err error) bool { + var unknown *unknownFlagError + return errors.As(err, &unknown) +} + +type unknownFlagError struct{ Cause error } + +func (e *unknownFlagError) Unwrap() error { return e.Cause } +func (e *unknownFlagError) Error() string { return e.Cause.Error() } + // Call an arbitrary function filling arguments with bound values. -func (c *Context) Call(fn any, binds ...interface{}) (out []interface{}, err error) { +func (c *Context) Call(fn any, binds ...any) (out []any, err error) { fv := reflect.ValueOf(fn) - bindings := c.Kong.bindings.clone().add(binds...).add(c).merge(c.bindings) //nolint:govet + bindings := c.Kong.bindings.clone().add(binds...).add(c).merge(c.bindings) return callAnyFunction(fv, bindings) } @@ -744,7 +808,7 @@ func (c *Context) Call(fn any, binds ...interface{}) (out []interface{}, err err // // Any passed values will be bindable to arguments of the target Run() method. Additionally, // all parent nodes in the command structure will be bound. -func (c *Context) RunNode(node *Node, binds ...interface{}) (err error) { +func (c *Context) RunNode(node *Node, binds ...any) (err error) { type targetMethod struct { node *Node method reflect.Value @@ -757,6 +821,19 @@ func (c *Context) RunNode(node *Node, binds ...interface{}) (err error) { methodBinds = methodBinds.clone() for p := node; p != nil; p = p.Parent { methodBinds = methodBinds.add(p.Target.Addr().Interface()) + // Try value and pointer to value. + for _, p := range []reflect.Value{p.Target, p.Target.Addr()} { + t := p.Type() + for i := 0; i < p.NumMethod(); i++ { + methodt := t.Method(i) + if strings.HasPrefix(methodt.Name, "Provide") { + method := p.Method(i) + if err := methodBinds.addProvider(method.Interface(), false /* singleton */); err != nil { + return fmt.Errorf("%s.%s: %w", t.Name(), methodt.Name, err) + } + } + } + } } if method.IsValid() { methods = append(methods, targetMethod{node, method, methodBinds}) @@ -765,11 +842,6 @@ func (c *Context) RunNode(node *Node, binds ...interface{}) (err error) { if len(methods) == 0 { return fmt.Errorf("no Run() method found in hierarchy of %s", c.Selected().Summary()) } - _, err = c.Apply() - if err != nil { - return err - } - for _, method := range methods { if err = callFunction(method.method, method.binds); err != nil { return err @@ -782,21 +854,27 @@ func (c *Context) RunNode(node *Node, binds ...interface{}) (err error) { // // Any passed values will be bindable to arguments of the target Run() method. Additionally, // all parent nodes in the command structure will be bound. -func (c *Context) Run(binds ...interface{}) (err error) { +func (c *Context) Run(binds ...any) (err error) { node := c.Selected() if node == nil { - if len(c.Path) > 0 { - selected := c.Path[0].Node() - if selected.Type == ApplicationNode { - method := getMethod(selected.Target, "Run") - if method.IsValid() { - return c.RunNode(selected, binds...) - } + if len(c.Path) == 0 { + return fmt.Errorf("no command selected") + } + selected := c.Path[0].Node() + if selected.Type == ApplicationNode { + method := getMethod(selected.Target, "Run") + if method.IsValid() { + node = selected } } - return fmt.Errorf("no command selected") + + if node == nil { + return fmt.Errorf("no command selected") + } } - return c.RunNode(node, binds...) + runErr := c.RunNode(node, binds...) + err = c.Kong.applyHook(c, "AfterRun") + return errors.Join(runErr, err) } // PrintUsage to Kong's stdout. @@ -811,23 +889,35 @@ func (c *Context) PrintUsage(summary bool) error { func checkMissingFlags(flags []*Flag) error { xorGroupSet := map[string]bool{} xorGroup := map[string][]string{} + andGroupSet := map[string]bool{} + andGroup := map[string][]string{} missing := []string{} + andGroupRequired := getRequiredAndGroupMap(flags) for _, flag := range flags { + for _, and := range flag.And { + flag.Required = andGroupRequired[and] + } if flag.Set { for _, xor := range flag.Xor { xorGroupSet[xor] = true } + for _, and := range flag.And { + andGroupSet[and] = true + } } if !flag.Required || flag.Set { continue } - if len(flag.Xor) > 0 { + if len(flag.Xor) > 0 || len(flag.And) > 0 { for _, xor := range flag.Xor { if xorGroupSet[xor] { continue } xorGroup[xor] = append(xorGroup[xor], flag.Summary()) } + for _, and := range flag.And { + andGroup[and] = append(andGroup[and], flag.Summary()) + } } else { missing = append(missing, flag.Summary()) } @@ -837,6 +927,11 @@ func checkMissingFlags(flags []*Flag) error { missing = append(missing, strings.Join(flags, " or ")) } } + for _, flags := range andGroup { + if len(flags) > 1 { + missing = append(missing, strings.Join(flags, " and ")) + } + } if len(missing) == 0 { return nil @@ -847,6 +942,18 @@ func checkMissingFlags(flags []*Flag) error { return fmt.Errorf("missing flags: %s", strings.Join(missing, ", ")) } +func getRequiredAndGroupMap(flags []*Flag) map[string]bool { + andGroupRequired := map[string]bool{} + for _, flag := range flags { + for _, and := range flag.And { + if flag.Required { + andGroupRequired[and] = true + } + } + } + return andGroupRequired +} + func checkMissingChildren(node *Node) error { missing := []string{} @@ -883,7 +990,7 @@ func checkMissingChildren(node *Node) error { if len(missing) == 1 { return fmt.Errorf("expected %s", missing[0]) } - return fmt.Errorf("expected one of %s", strings.Join(missing, ", ")) + return fmt.Errorf("expected one of %s", strings.Join(missing, ", ")) } // If we're missing any positionals and they're required, return an error. @@ -943,7 +1050,7 @@ func checkEnum(value *Value, target reflect.Value) error { } enums = append(enums, fmt.Sprintf("%q", enum)) } - return fmt.Errorf("%s must be one of %s but got %q", value.ShortSummary(), strings.Join(enums, ","), target.Interface()) + return fmt.Errorf("%s must be one of %s but got %q", value.ShortSummary(), strings.Join(enums, ","), fmt.Sprintf("%v", target.Interface())) } } @@ -957,6 +1064,20 @@ func checkPassthroughArg(target reflect.Value) bool { } } +func checkXorDuplicatedAndAndMissing(paths []*Path) error { + errs := []string{} + if err := checkXorDuplicates(paths); err != nil { + errs = append(errs, err.Error()) + } + if err := checkAndMissing(paths); err != nil { + errs = append(errs, err.Error()) + } + if len(errs) > 0 { + return errors.New(strings.Join(errs, ", ")) + } + return nil +} + func checkXorDuplicates(paths []*Path) error { for _, path := range paths { seen := map[string]*Flag{} @@ -975,7 +1096,39 @@ func checkXorDuplicates(paths []*Path) error { return nil } -func findPotentialCandidates(needle string, haystack []string, format string, args ...interface{}) error { +func checkAndMissing(paths []*Path) error { + for _, path := range paths { + missingMsgs := []string{} + andGroups := map[string][]*Flag{} + for _, flag := range path.Flags { + for _, and := range flag.And { + andGroups[and] = append(andGroups[and], flag) + } + } + for _, flags := range andGroups { + oneSet := false + notSet := []*Flag{} + flagNames := []string{} + for _, flag := range flags { + flagNames = append(flagNames, flag.Name) + if flag.Set { + oneSet = true + } else { + notSet = append(notSet, flag) + } + } + if len(notSet) > 0 && oneSet { + missingMsgs = append(missingMsgs, fmt.Sprintf("--%s must be used together", strings.Join(flagNames, " and --"))) + } + } + if len(missingMsgs) > 0 { + return fmt.Errorf("%s", strings.Join(missingMsgs, ", ")) + } + } + return nil +} + +func findPotentialCandidates(needle string, haystack []string, format string, args ...any) error { if len(haystack) == 0 { return fmt.Errorf(format, args...) } @@ -995,12 +1148,23 @@ func findPotentialCandidates(needle string, haystack []string, format string, ar } type validatable interface{ Validate() error } +type extendedValidatable interface { + Validate(kctx *Context) error +} -func isValidatable(v reflect.Value) validatable { +// Proxy a validatable function to the extendedValidatable interface +type validatableFunc func() error + +func (f validatableFunc) Validate(kctx *Context) error { return f() } + +func isValidatable(v reflect.Value) extendedValidatable { if !v.IsValid() || (v.Kind() == reflect.Ptr || v.Kind() == reflect.Slice || v.Kind() == reflect.Map) && v.IsNil() { return nil } if validate, ok := v.Interface().(validatable); ok { + return validatableFunc(validate.Validate) + } + if validate, ok := v.Interface().(extendedValidatable); ok { return validate } if v.CanAddr() { diff --git a/defaults.go b/defaults.go index f6728d7..9489fb3 100644 --- a/defaults.go +++ b/defaults.go @@ -1,7 +1,7 @@ package kong // ApplyDefaults if they are not already set. -func ApplyDefaults(target interface{}, options ...Option) error { +func ApplyDefaults(target any, options ...Option) error { app, err := New(target, options...) if err != nil { return err diff --git a/error.go b/error.go index 18225ef..e79a15d 100644 --- a/error.go +++ b/error.go @@ -5,8 +5,17 @@ package kong // It contains the parse Context that triggered the error. type ParseError struct { error - Context *Context + Context *Context + exitCode int } // Unwrap returns the original cause of the error. func (p *ParseError) Unwrap() error { return p.error } + +// ExitCode returns the status that Kong should exit with if it fails with a ParseError. +func (p *ParseError) ExitCode() int { + if p.exitCode == 0 { + return exitNotOk + } + return p.exitCode +} diff --git a/exit.go b/exit.go new file mode 100644 index 0000000..4925f48 --- /dev/null +++ b/exit.go @@ -0,0 +1,32 @@ +package kong + +import "errors" + +const ( + exitOk = 0 + exitNotOk = 1 + + // Semantic exit codes from https://github.com/square/exit?tab=readme-ov-file#about + exitUsageError = 80 +) + +// ExitCoder is an interface that may be implemented by an error value to +// provide an integer exit code. The method ExitCode should return an integer +// that is intended to be used as the exit code for the application. +type ExitCoder interface { + ExitCode() int +} + +// exitCodeFromError returns the exit code for the given error. +// If err implements the exitCoder interface, the ExitCode method is called. +// Otherwise, exitCodeFromError returns 0 if err is nil, and 1 if it is not. +func exitCodeFromError(err error) int { + var e ExitCoder + if errors.As(err, &e) { + return e.ExitCode() + } else if err == nil { + return exitOk + } + + return exitNotOk +} diff --git a/global.go b/global.go index d4b3cb5..babe1e1 100644 --- a/global.go +++ b/global.go @@ -5,7 +5,7 @@ import ( ) // Parse constructs a new parser and parses the default command-line. -func Parse(cli interface{}, options ...Option) *Context { +func Parse(cli any, options ...Option) *Context { parser, err := New(cli, options...) if err != nil { panic(err) diff --git a/go.mod b/go.mod index 3cdb832..a9b28e6 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module git.company.lan/gopkg/kong require ( - github.com/alecthomas/assert/v2 v2.6.0 + github.com/alecthomas/assert/v2 v2.11.0 github.com/alecthomas/repr v0.4.0 ) require github.com/hexops/gotextdiff v1.0.3 // indirect -go 1.18 +go 1.20 diff --git a/go.sum b/go.sum index 49fc088..f571a34 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= -github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/guesswidth.go b/guesswidth.go index dfdc3f5..2c19cac 100644 --- a/guesswidth.go +++ b/guesswidth.go @@ -1,5 +1,4 @@ -//go:build appengine || (!linux && !freebsd && !darwin && !dragonfly && !netbsd && !openbsd) -// +build appengine !linux,!freebsd,!darwin,!dragonfly,!netbsd,!openbsd +//go:build tinygo || appengine || (!linux && !freebsd && !darwin && !dragonfly && !netbsd && !openbsd) package kong diff --git a/guesswidth_unix.go b/guesswidth_unix.go index 0170055..7fc5d02 100644 --- a/guesswidth_unix.go +++ b/guesswidth_unix.go @@ -1,5 +1,4 @@ -//go:build (!appengine && linux) || freebsd || darwin || dragonfly || netbsd || openbsd -// +build !appengine,linux freebsd darwin dragonfly netbsd openbsd +//go:build !tinygo && ((!appengine && linux) || freebsd || darwin || dragonfly || netbsd || openbsd) package kong diff --git a/help.go b/help.go index cf5a912..8da1555 100644 --- a/help.go +++ b/help.go @@ -14,9 +14,11 @@ const ( ) // Help flag. -type helpValue bool +type helpFlag bool -func (h helpValue) BeforeReset(ctx *Context) error { +func (h helpFlag) IgnoreDefault() {} + +func (h helpFlag) BeforeReset(ctx *Context) error { options := ctx.Kong.helpOptions options.Summary = false err := ctx.Kong.help(options, ctx) @@ -386,7 +388,7 @@ func newHelpWriter(ctx *Context, options HelpOptions) *helpWriter { return w } -func (h *helpWriter) Printf(format string, args ...interface{}) { +func (h *helpWriter) Printf(format string, args ...any) { h.Print(fmt.Sprintf(format, args...)) } @@ -415,7 +417,7 @@ func (h *helpWriter) Write(w io.Writer) error { func (h *helpWriter) Wrap(text string) { w := bytes.NewBuffer(nil) - doc.ToText(w, strings.TrimSpace(text), "", " ", h.width) + doc.ToText(w, strings.TrimSpace(text), "", " ", h.width) //nolint:staticcheck // cross-package links not possible for _, line := range strings.Split(strings.TrimSpace(w.String()), "\n") { h.Print(line) } @@ -470,7 +472,7 @@ func writeTwoColumns(w *helpWriter, rows [][2]string) { for _, row := range rows { buf := bytes.NewBuffer(nil) - doc.ToText(buf, row[1], "", strings.Repeat(" ", defaultIndent), w.width-leftSize-defaultColumnPadding) + doc.ToText(buf, row[1], "", strings.Repeat(" ", defaultIndent), w.width-leftSize-defaultColumnPadding) //nolint:staticcheck // cross-package links not possible lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") line := fmt.Sprintf("%-*s", leftSize, row[0]) @@ -491,27 +493,22 @@ func formatFlag(haveShort bool, flag *Flag) string { name := flag.Name isBool := flag.IsBool() isCounter := flag.IsCounter() + + short := "" if flag.Short != 0 { - if isBool && flag.Tag.Negatable { - flagString += fmt.Sprintf("-%c, --[no-]%s", flag.Short, name) - } else { - flagString += fmt.Sprintf("-%c, --%s", flag.Short, name) - } - } else { - if isBool && flag.Tag.Negatable { - if haveShort { - flagString = fmt.Sprintf(" --[no-]%s", name) - } else { - flagString = fmt.Sprintf("--[no-]%s", name) - } - } else { - if haveShort { - flagString += fmt.Sprintf(" --%s", name) - } else { - flagString += fmt.Sprintf("--%s", name) - } - } + short = "-" + string(flag.Short) + ", " + } else if haveShort { + short = " " } + + if isBool && flag.Tag.Negatable == negatableDefault { + name = "[no-]" + name + } else if isBool && flag.Tag.Negatable != "" { + name += "/" + flag.Tag.Negatable + } + + flagString += fmt.Sprintf("%s--%s", short, name) + if !isBool && !isCounter { flagString += fmt.Sprintf("=%s", flag.FormatPlaceHolder()) } diff --git a/help_test.go b/help_test.go index 41e91cf..d857ac2 100644 --- a/help_test.go +++ b/help_test.go @@ -51,7 +51,7 @@ func TestHelpOptionalArgs(t *testing.T) { assert.NoError(t, err) }) assert.True(t, exited) - expected := `Usage: test-app [ []] [flags] + expected := `Usage: test-app [ []] Arguments: [] One optional arg. @@ -71,6 +71,7 @@ func TestHelp(t *testing.T) { Map map[string]int `help:"A map of strings to ints."` Required bool `required help:"A required flag."` Sort bool `negatable short:"s" help:"Is sortable or not."` + Approve bool `negatable:"deny" help:"Approve or deny message."` One struct { Flag string `help:"Nested flag."` @@ -82,8 +83,7 @@ func TestHelp(t *testing.T) { Three threeArg `arg help:"Sub-sub-arg."` - Four struct { - } `cmd help:"Sub-sub-command."` + Four struct{} `cmd help:"Sub-sub-command."` } `cmd help:"Another subcommand."` } @@ -118,6 +118,7 @@ Flags: --map=KEY=VALUE;... A map of strings to ints. --required A required flag. -s, --[no-]sort Is sortable or not. + --approve/deny Approve or deny message. Commands: one --required [flags] @@ -159,6 +160,7 @@ Flags: --map=KEY=VALUE;... A map of strings to ints. --required A required flag. -s, --[no-]sort Is sortable or not. + --approve/deny Approve or deny message. --flag=STRING Nested flag under two. --required-two @@ -189,8 +191,7 @@ func TestFlagsLast(t *testing.T) { Three threeArg `arg help:"Sub-sub-arg."` - Four struct { - } `cmd help:"Sub-sub-command."` + Four struct{} `cmd help:"Sub-sub-command."` } `cmd help:"Another subcommand."` } @@ -293,8 +294,7 @@ func TestHelpTree(t *testing.T) { Two struct { Three threeArg `arg help:"Sub-sub-arg."` - Four struct { - } `cmd help:"Sub-sub-command." aliases:"for,fore"` + Four struct{} `cmd help:"Sub-sub-command." aliases:"for,fore"` } `cmd help:"Another subcommand."` } @@ -320,7 +320,7 @@ func TestHelpTree(t *testing.T) { assert.NoError(t, err) }) assert.True(t, exited) - expected := `Usage: test-app [flags] + expected := `Usage: test-app A test app. @@ -353,7 +353,7 @@ Run "test-app --help" for more information on a command. assert.NoError(t, err) }) assert.True(t, exited) - expected := `Usage: test-app one (un,uno) [flags] + expected := `Usage: test-app one (un,uno) subcommand one @@ -387,8 +387,7 @@ func TestHelpCompactNoExpand(t *testing.T) { Two struct { Three threeArg `arg help:"Sub-sub-arg."` - Four struct { - } `cmd help:"Sub-sub-command." aliases:"for,fore"` + Four struct{} `cmd help:"Sub-sub-command." aliases:"for,fore"` } `cmd help:"Another subcommand."` } @@ -414,7 +413,7 @@ func TestHelpCompactNoExpand(t *testing.T) { assert.NoError(t, err) }) assert.True(t, exited) - expected := `Usage: test-app [flags] + expected := `Usage: test-app A test app. @@ -443,7 +442,7 @@ Run "test-app --help" for more information on a command. assert.NoError(t, err) }) assert.True(t, exited) - expected := `Usage: test-app one (un,uno) [flags] + expected := `Usage: test-app one (un,uno) subcommand one @@ -600,7 +599,7 @@ func TestAutoGroup(t *testing.T) { if node, ok := parent.(*kong.Node); ok { return &kong.Group{ Key: node.Name, - Title: strings.Title(node.Name) + " flags:", //nolint + Title: strings.Title(node.Name) + " flags:", //nolint:staticcheck // strings.Title in test is okay } } return nil @@ -787,16 +786,17 @@ func TestUsageOnError(t *testing.T) { Flag string `help:"A required flag." required` } w := &strings.Builder{} + exitCode := -1 p := mustNew(t, &cli, kong.Writers(w, w), kong.Description("Some description."), - kong.Exit(func(int) {}), + kong.Exit(func(code int) { exitCode = code }), kong.UsageOnError(), ) _, err := p.Parse([]string{}) p.FatalIfErrorf(err) - expected := `Usage: test --flag=STRING [flags] + expected := `Usage: test --flag=STRING Some description. @@ -807,6 +807,7 @@ Flags: test: error: missing flags: --flag=STRING ` assert.Equal(t, expected, w.String()) + assert.Equal(t, 80, exitCode) } func TestShortUsageOnError(t *testing.T) { @@ -814,22 +815,24 @@ func TestShortUsageOnError(t *testing.T) { Flag string `help:"A required flag." required` } w := &strings.Builder{} + exitCode := -1 p := mustNew(t, &cli, kong.Writers(w, w), kong.Description("Some description."), - kong.Exit(func(int) {}), + kong.Exit(func(code int) { exitCode = code }), kong.ShortUsageOnError(), ) _, err := p.Parse([]string{}) assert.Error(t, err) p.FatalIfErrorf(err) - expected := `Usage: test --flag=STRING [flags] + expected := `Usage: test --flag=STRING Run "test --help" for more information. test: error: missing flags: --flag=STRING ` assert.Equal(t, expected, w.String()) + assert.Equal(t, 80, exitCode) } func TestCustomShortUsageOnError(t *testing.T) { @@ -841,10 +844,11 @@ func TestCustomShortUsageOnError(t *testing.T) { fmt.Fprintln(ctx.Stdout, "🤷 wish I could help") return nil } + exitCode := -1 p := mustNew(t, &cli, kong.Writers(w, w), kong.Description("Some description."), - kong.Exit(func(int) {}), + kong.Exit(func(code int) { exitCode = code }), kong.ShortHelp(shortHelp), kong.ShortUsageOnError(), ) @@ -857,4 +861,5 @@ func TestCustomShortUsageOnError(t *testing.T) { test: error: missing flags: --flag=STRING ` assert.Equal(t, expected, w.String()) + assert.Equal(t, 80, exitCode) } diff --git a/hooks.go b/hooks.go index d166b08..e95d21b 100644 --- a/hooks.go +++ b/hooks.go @@ -1,19 +1,32 @@ package kong +// BeforeReset is a documentation-only interface describing hooks that run before defaults values are applied. +type BeforeReset interface { + // This is not the correct signature - see README for details. + BeforeReset(args ...any) error +} + // BeforeResolve is a documentation-only interface describing hooks that run before resolvers are applied. type BeforeResolve interface { // This is not the correct signature - see README for details. - BeforeResolve(args ...interface{}) error + BeforeResolve(args ...any) error } // BeforeApply is a documentation-only interface describing hooks that run before values are set. type BeforeApply interface { // This is not the correct signature - see README for details. - BeforeApply(args ...interface{}) error + BeforeApply(args ...any) error } // AfterApply is a documentation-only interface describing hooks that run after values are set. type AfterApply interface { // This is not the correct signature - see README for details. - AfterApply(args ...interface{}) error + AfterApply(args ...any) error +} + +// AfterRun is a documentation-only interface describing hooks that run after Run() returns. +type AfterRun interface { + // This is not the correct signature - see README for details. + // AfterRun is called after Run() returns. + AfterRun(args ...any) error } diff --git a/kong.go b/kong.go index 2e2f98a..1ab7666 100644 --- a/kong.go +++ b/kong.go @@ -15,7 +15,7 @@ var ( callbackReturnSignature = reflect.TypeOf((*error)(nil)).Elem() ) -func failField(parent reflect.Value, field reflect.StructField, format string, args ...interface{}) error { +func failField(parent reflect.Value, field reflect.StructField, format string, args ...any) error { name := parent.Type().Name() if name == "" { name = "" @@ -24,7 +24,7 @@ func failField(parent reflect.Value, field reflect.StructField, format string, a } // Must creates a new Parser or panics if there is an error. -func Must(ast interface{}, options ...Option) *Kong { +func Must(ast any, options ...Option) *Kong { k, err := New(ast, options...) if err != nil { panic(err) @@ -56,27 +56,30 @@ type Kong struct { registry *Registry ignoreFields []*regexp.Regexp - noDefaultHelp bool - usageOnError usageOnError - help HelpPrinter - shortHelp HelpPrinter - helpFormatter HelpValueFormatter - helpOptions HelpOptions - helpFlag *Flag - groups []Group - vars Vars - flagNamer func(string) string + noDefaultHelp bool + allowHyphenated bool + usageOnError usageOnError + help HelpPrinter + shortHelp HelpPrinter + helpFormatter HelpValueFormatter + helpOptions HelpOptions + helpFlag *Flag + groups []Group + vars Vars + flagNamer func(string) string // Set temporarily by Options. These are applied after build(). postBuildOptions []Option embedded []embedded dynamicCommands []*dynamicCommand + + hooks map[string][]reflect.Value } // New creates a new Kong parser on grammar. // // See the README (https://git.company.lan/gopkg/kong) for usage instructions. -func New(grammar interface{}, options ...Option) (*Kong, error) { +func New(grammar any, options ...Option) (*Kong, error) { k := &Kong{ Exit: os.Exit, Stdout: os.Stdout, @@ -84,6 +87,7 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { registry: NewRegistry().RegisterDefaults(), vars: Vars{}, bindings: bindings{}, + hooks: make(map[string][]reflect.Value), helpFormatter: DefaultHelpValueFormatter, ignoreFields: make([]*regexp.Regexp, 0), flagNamer: func(s string) string { @@ -117,7 +121,7 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { // Embed any embedded structs. for _, embed := range k.embedded { - tag, err := parseTagString(strings.Join(embed.tags, " ")) //nolint:govet + tag, err := parseTagString(strings.Join(embed.tags, " ")) if err != nil { return nil, err } @@ -167,9 +171,42 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { k.bindings.add(k.vars) + if err = checkOverlappingXorAnd(k); err != nil { + return nil, err + } + return k, nil } +func checkOverlappingXorAnd(k *Kong) error { + xorGroups := map[string][]string{} + andGroups := map[string][]string{} + for _, flag := range k.Model.Node.Flags { + for _, xor := range flag.Xor { + xorGroups[xor] = append(xorGroups[xor], flag.Name) + } + for _, and := range flag.And { + andGroups[and] = append(andGroups[and], flag.Name) + } + } + for xor, xorSet := range xorGroups { + for and, andSet := range andGroups { + overlappingEntries := []string{} + for _, xorTag := range xorSet { + for _, andTag := range andSet { + if xorTag == andTag { + overlappingEntries = append(overlappingEntries, xorTag) + } + } + } + if len(overlappingEntries) > 1 { + return fmt.Errorf("invalid xor and combination, %s and %s overlap with more than one: %s", xor, and, overlappingEntries) + } + } + } + return nil +} + type varStack []Vars func (v *varStack) head() Vars { return (*v)[len(*v)-1] } @@ -216,19 +253,19 @@ func (k *Kong) interpolateValue(value *Value, vars Vars) (err error) { return fmt.Errorf("enum for %s: %s", value.Summary(), err) } - updatedVars := map[string]string{ - "default": value.Default, - "enum": value.Enum, - } if value.Default, err = interpolate(value.Default, vars, nil); err != nil { return fmt.Errorf("default value for %s: %s", value.Summary(), err) } if value.Enum, err = interpolate(value.Enum, vars, nil); err != nil { return fmt.Errorf("enum value for %s: %s", value.Summary(), err) } + updatedVars := map[string]string{ + "default": value.Default, + "enum": value.Enum, + } if value.Flag != nil { for i, env := range value.Flag.Envs { - if value.Flag.Envs[i], err = interpolate(env, vars, nil); err != nil { + if value.Flag.Envs[i], err = interpolate(env, vars, updatedVars); err != nil { return fmt.Errorf("env value for %s: %s", value.Summary(), err) } } @@ -237,6 +274,11 @@ func (k *Kong) interpolateValue(value *Value, vars Vars) (err error) { if len(value.Flag.Envs) != 0 { updatedVars["env"] = value.Flag.Envs[0] } + + value.Flag.PlaceHolder, err = interpolate(value.Flag.PlaceHolder, vars, updatedVars) + if err != nil { + return fmt.Errorf("placeholder value for %s: %s", value.Summary(), err) + } } value.Help, err = interpolate(value.Help, vars, updatedVars) if err != nil { @@ -250,7 +292,7 @@ func (k *Kong) extraFlags() []*Flag { if k.noDefaultHelp { return nil } - var helpTarget helpValue + var helpTarget helpFlag value := reflect.ValueOf(&helpTarget).Elem() helpFlag := &Flag{ Short: 'h', @@ -278,11 +320,11 @@ func (k *Kong) extraFlags() []*Flag { // invalid one, which will report a normal error). func (k *Kong) Parse(args []string) (ctx *Context, err error) { ctx, err = Trace(k, args) - if err != nil { - return nil, err + if err != nil { // Trace is not expected to return an err + return nil, &ParseError{error: err, Context: ctx, exitCode: exitUsageError} } if ctx.Error != nil { - return nil, &ParseError{error: ctx.Error, Context: ctx} + return nil, &ParseError{error: ctx.Error, Context: ctx, exitCode: exitUsageError} } if err = k.applyHook(ctx, "BeforeReset"); err != nil { return nil, &ParseError{error: err, Context: ctx} @@ -299,11 +341,11 @@ func (k *Kong) Parse(args []string) (ctx *Context, err error) { if err = k.applyHook(ctx, "BeforeApply"); err != nil { return nil, &ParseError{error: err, Context: ctx} } - if _, err = ctx.Apply(); err != nil { + if _, err = ctx.Apply(); err != nil { // Apply is not expected to return an err return nil, &ParseError{error: err, Context: ctx} } if err = ctx.Validate(); err != nil { - return nil, &ParseError{error: err, Context: ctx} + return nil, &ParseError{error: err, Context: ctx, exitCode: exitUsageError} } if err = k.applyHook(ctx, "AfterApply"); err != nil { return nil, &ParseError{error: err, Context: ctx} @@ -328,22 +370,30 @@ func (k *Kong) applyHook(ctx *Context, name string) error { default: panic("unsupported Path") } - method := getMethod(value, name) - if !method.IsValid() { - continue - } - binds := k.bindings.clone() - binds.add(ctx, trace) - binds.add(trace.Node().Vars().CloneWith(k.vars)) - binds.merge(ctx.bindings) - if err := callFunction(method, binds); err != nil { - return err + for _, method := range k.getMethods(value, name) { + binds := k.bindings.clone() + binds.add(ctx, trace) + binds.add(trace.Node().Vars().CloneWith(k.vars)) + binds.merge(ctx.bindings) + if err := callFunction(method, binds); err != nil { + return err + } } } // Path[0] will always be the app root. return k.applyHookToDefaultFlags(ctx, ctx.Path[0].Node(), name) } +func (k *Kong) getMethods(value reflect.Value, name string) []reflect.Value { + return append( + // Identify callbacks by reflecting on value + getMethods(value, name), + + // Identify callbacks that were registered with a kong.Option + k.hooks[name]..., + ) +} + // Call hook on any unset flags with default values. func (k *Kong) applyHookToDefaultFlags(ctx *Context, node *Node, name string) error { if node == nil { @@ -359,21 +409,19 @@ func (k *Kong) applyHookToDefaultFlags(ctx *Context, node *Node, name string) er if !flag.HasDefault || ctx.values[flag.Value].IsValid() || !flag.Target.IsValid() { continue } - method := getMethod(flag.Target, name) - if !method.IsValid() { - continue - } - path := &Path{Flag: flag} - if err := callFunction(method, binds.clone().add(path)); err != nil { - return next(err) + for _, method := range getMethods(flag.Target, name) { + path := &Path{Flag: flag} + if err := callFunction(method, binds.clone().add(path)); err != nil { + return next(err) + } } } return next(nil) }) } -func formatMultilineMessage(w io.Writer, leaders []string, format string, args ...interface{}) { - lines := strings.Split(fmt.Sprintf(format, args...), "\n") +func formatMultilineMessage(w io.Writer, leaders []string, format string, args ...any) { + lines := strings.Split(strings.TrimRight(fmt.Sprintf(format, args...), "\n"), "\n") leader := "" for _, l := range leaders { if l == "" { @@ -388,25 +436,27 @@ func formatMultilineMessage(w io.Writer, leaders []string, format string, args . } // Printf writes a message to Kong.Stdout with the application name prefixed. -func (k *Kong) Printf(format string, args ...interface{}) *Kong { +func (k *Kong) Printf(format string, args ...any) *Kong { formatMultilineMessage(k.Stdout, []string{k.Model.Name}, format, args...) return k } // Errorf writes a message to Kong.Stderr with the application name prefixed. -func (k *Kong) Errorf(format string, args ...interface{}) *Kong { +func (k *Kong) Errorf(format string, args ...any) *Kong { formatMultilineMessage(k.Stderr, []string{k.Model.Name, "error"}, format, args...) return k } -// Fatalf writes a message to Kong.Stderr with the application name prefixed then exits with a non-zero status. -func (k *Kong) Fatalf(format string, args ...interface{}) { +// Fatalf writes a message to Kong.Stderr with the application name prefixed then exits with status 1. +func (k *Kong) Fatalf(format string, args ...any) { k.Errorf(format, args...) k.Exit(1) } // FatalIfErrorf terminates with an error message if err != nil. -func (k *Kong) FatalIfErrorf(err error, args ...interface{}) { +// If the error implements the ExitCoder interface, the ExitCode() method is called and +// the application exits with that status. Otherwise, the application exits with status 1. +func (k *Kong) FatalIfErrorf(err error, args ...any) { if err == nil { return } @@ -426,7 +476,8 @@ func (k *Kong) FatalIfErrorf(err error, args ...interface{}) { fmt.Fprintln(k.Stdout) } } - k.Fatalf("%s", msg) + k.Errorf("%s", msg) + k.Exit(exitCodeFromError(err)) } // LoadConfig from path using the loader configured via Configuration(loader). diff --git a/kong_test.go b/kong_test.go index 70e1352..499fe13 100644 --- a/kong_test.go +++ b/kong_test.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "sort" "strings" "testing" @@ -13,7 +14,7 @@ import ( "git.company.lan/gopkg/kong" ) -func mustNew(t *testing.T, cli interface{}, options ...kong.Option) *kong.Kong { +func mustNew(t *testing.T, cli any, options ...kong.Option) *kong.Kong { t.Helper() options = append([]kong.Option{ kong.Name("test"), @@ -47,6 +48,25 @@ func TestPositionalArguments(t *testing.T) { }) } +func TestRemainderReturnsUnparsedArgs(t *testing.T) { + var cli struct { + User struct { + Create struct { + ID int `kong:"arg"` + First string `kong:"arg"` + Last string `kong:"arg"` + } `kong:"cmd"` + } `kong:"cmd"` + } + p := mustNew(t, &cli) + args := []string{"user", "create", "10", "Alec", "Thomas"} + ctx, err := p.Parse(args) + assert.NoError(t, err) + for i, x := range ctx.Path { + assert.Equal(t, strings.Join(args[i:], " "), strings.Join(x.Remainder(), " ")) + } +} + func TestBranchingArgument(t *testing.T) { /* app user create @@ -356,8 +376,9 @@ func TestTraceErrorPartiallySucceeds(t *testing.T) { } type commandWithNegatableFlag struct { - Flag bool `kong:"default='true',negatable"` - ran bool + Flag bool `kong:"default='true',negatable"` + Custom bool `kong:"default='true',negatable='standard'"` + ran bool } func (c *commandWithNegatableFlag) Run() error { @@ -367,34 +388,64 @@ func (c *commandWithNegatableFlag) Run() error { func TestNegatableFlag(t *testing.T) { tests := []struct { - name string - args []string - expected bool + name string + args []string + expectedFlag bool + expectedCustom bool }{ { - name: "no flag", - args: []string{"cmd"}, - expected: true, + name: "no flag", + args: []string{"cmd"}, + expectedFlag: true, + expectedCustom: true, }, { - name: "boolean flag", - args: []string{"cmd", "--flag"}, - expected: true, + name: "boolean flag", + args: []string{"cmd", "--flag"}, + expectedFlag: true, + expectedCustom: true, }, { - name: "inverted boolean flag", - args: []string{"cmd", "--flag=false"}, - expected: false, + name: "custom boolean flag", + args: []string{"cmd", "--custom"}, + expectedFlag: true, + expectedCustom: true, }, { - name: "negated boolean flag", - args: []string{"cmd", "--no-flag"}, - expected: false, + name: "inverted boolean flag", + args: []string{"cmd", "--flag=false"}, + expectedFlag: false, + expectedCustom: true, }, { - name: "inverted negated boolean flag", - args: []string{"cmd", "--no-flag=false"}, - expected: true, + name: "custom inverted boolean flag", + args: []string{"cmd", "--custom=false"}, + expectedFlag: true, + expectedCustom: false, + }, + { + name: "negated boolean flag", + args: []string{"cmd", "--no-flag"}, + expectedFlag: false, + expectedCustom: true, + }, + { + name: "custom negated boolean flag", + args: []string{"cmd", "--standard"}, + expectedFlag: true, + expectedCustom: false, + }, + { + name: "inverted negated boolean flag", + args: []string{"cmd", "--no-flag=false"}, + expectedFlag: true, + expectedCustom: true, + }, + { + name: "inverted custom negated boolean flag", + args: []string{"cmd", "--standard=false"}, + expectedFlag: true, + expectedCustom: true, }, } for _, tt := range tests { @@ -407,16 +458,47 @@ func TestNegatableFlag(t *testing.T) { p := mustNew(t, &cli) kctx, err := p.Parse(tt.args) assert.NoError(t, err) - assert.Equal(t, tt.expected, cli.Cmd.Flag) + assert.Equal(t, tt.expectedFlag, cli.Cmd.Flag) + assert.Equal(t, tt.expectedCustom, cli.Cmd.Custom) err = kctx.Run() assert.NoError(t, err) - assert.Equal(t, tt.expected, cli.Cmd.Flag) + assert.Equal(t, tt.expectedFlag, cli.Cmd.Flag) + assert.Equal(t, tt.expectedCustom, cli.Cmd.Custom) assert.True(t, cli.Cmd.ran) }) } } +func TestDuplicateNegatableLong(t *testing.T) { + cli2 := struct { + NoFlag bool + Flag bool `negatable:""` // negation duplicates NoFlag + }{} + _, err := kong.New(&cli2) + assert.EqualError(t, err, ".Flag: duplicate negation flag --no-flag") + + cli3 := struct { + One bool + Two bool `negatable:"one"` // negation duplicates Flag2 + }{} + _, err = kong.New(&cli3) + assert.EqualError(t, err, ".Two: duplicate negation flag --one") +} + +func TestDuplicateNegatableFlagsInSubcommands(t *testing.T) { + cli2 := struct { + Sub struct { + Negated bool `negatable:"nope-"` + } `cmd:""` + Sub2 struct { + Negated bool `negatable:"nope-"` + } `cmd:""` + }{} + _, err := kong.New(&cli2) + assert.NoError(t, err) +} + func TestExistingNoFlag(t *testing.T) { var cli struct { Cmd struct { @@ -506,6 +588,65 @@ func TestHooks(t *testing.T) { } } +func TestGlobalHooks(t *testing.T) { + var cli struct { + One struct { + Two string `kong:"arg,optional"` + Three string + } `cmd:""` + } + + called := []string{} + log := func(name string) any { + return func(value *kong.Path) error { + switch { + case value.App != nil: + called = append(called, fmt.Sprintf("%s (app)", name)) + + case value.Positional != nil: + called = append(called, fmt.Sprintf("%s (arg) %s", name, value.Positional.Name)) + + case value.Flag != nil: + called = append(called, fmt.Sprintf("%s (flag) %s", name, value.Flag.Name)) + + case value.Argument != nil: + called = append(called, fmt.Sprintf("%s (arg) %s", name, value.Argument.Name)) + + case value.Command != nil: + called = append(called, fmt.Sprintf("%s (cmd) %s", name, value.Command.Name)) + } + return nil + } + } + p := mustNew(t, &cli, + kong.WithBeforeReset(log("BeforeReset")), + kong.WithBeforeResolve(log("BeforeResolve")), + kong.WithBeforeApply(log("BeforeApply")), + kong.WithAfterApply(log("AfterApply")), + ) + + _, err := p.Parse([]string{"one", "two", "--three=THREE"}) + assert.NoError(t, err) + assert.Equal(t, []string{ + "BeforeReset (app)", + "BeforeReset (cmd) one", + "BeforeReset (arg) two", + "BeforeReset (flag) three", + "BeforeResolve (app)", + "BeforeResolve (cmd) one", + "BeforeResolve (arg) two", + "BeforeResolve (flag) three", + "BeforeApply (app)", + "BeforeApply (cmd) one", + "BeforeApply (arg) two", + "BeforeApply (flag) three", + "AfterApply (app)", + "AfterApply (cmd) one", + "AfterApply (arg) two", + "AfterApply (flag) three", + }, called) +} + func TestShort(t *testing.T) { var cli struct { Bool bool `short:"b"` @@ -593,11 +734,24 @@ func TestSliceWithDisabledSeparator(t *testing.T) { } func TestMultilineMessage(t *testing.T) { - w := &bytes.Buffer{} - var cli struct{} - p := mustNew(t, &cli, kong.Writers(w, w)) - p.Printf("hello\nworld") - assert.Equal(t, "test: hello\n world\n", w.String()) + tests := []struct { + name string + text string + want string + }{ + {"Simple", "hello\nworld", "test: hello\n world\n"}, + {"WithNewline", "hello\nworld\n", "test: hello\n world\n"}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + w := &bytes.Buffer{} + var cli struct{} + p := mustNew(t, &cli, kong.Writers(w, w)) + p.Printf("%s", test.text) + assert.Equal(t, test.want, w.String()) + }) + } } type cmdWithRun struct { @@ -671,8 +825,8 @@ func TestPassesThroughOriginalCommandError(t *testing.T) { func TestInterpolationIntoModel(t *testing.T) { var cli struct { - Flag string `default:"${default_value}" help:"Help, I need ${somebody}" enum:"${enum}"` - EnumRef string `enum:"a,b" required:"" help:"One of ${enum}"` + Flag string `default:"${default_value}" help:"Help, I need ${somebody}" enum:"${enum}" placeholder:"${enum}"` + EnumRef string `enum:"a,b" required:"" help:"One of ${enum}" placeholder:"${enum}"` EnvRef string `env:"${env}" help:"God ${env}"` } _, err := kong.New(&cli) @@ -692,7 +846,9 @@ func TestInterpolationIntoModel(t *testing.T) { assert.Equal(t, "Help, I need chickens!", flag.Help) assert.Equal(t, map[string]bool{"a": true, "b": true, "c": true, "d": true}, flag.EnumMap()) assert.Equal(t, []string{"a", "b", "c", "d"}, flag.EnumSlice()) + assert.Equal(t, "a,b,c,d", flag.PlaceHolder) assert.Equal(t, "One of a,b", flag2.Help) + assert.Equal(t, "a,b", flag2.PlaceHolder) assert.Equal(t, []string{"SAVE_THE_QUEEN"}, flag3.Envs) assert.Equal(t, "God SAVE_THE_QUEEN", flag3.Help) } @@ -780,6 +936,43 @@ func TestExcludedField(t *testing.T) { assert.Error(t, err) } +func TestExcludeEmbeddedField(t *testing.T) { + type Embedded struct { + Flag string + Excluded string + } + type Embedded2 struct { + Flag2 string + Excluded string + } + var cli struct { + Embedded + Excluded string `kong:"-"` + Embedded2 + } + var cli2 struct { + Embedded Embedded `kong:"embed"` + Excluded string `kong:"-"` + Embedded2 Embedded2 `kong:"embed"` + } + + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--flag=foo"}) + assert.NoError(t, err) + _, err = p.Parse([]string{"--flag-2=foo"}) + assert.NoError(t, err) + _, err = p.Parse([]string{"--excluded=foo"}) + assert.Error(t, err) + + p = mustNew(t, &cli2) + _, err = p.Parse([]string{"--flag=foo"}) + assert.NoError(t, err) + _, err = p.Parse([]string{"--flag-2=foo"}) + assert.NoError(t, err) + _, err = p.Parse([]string{"--excluded=foo"}) + assert.Error(t, err) +} + func TestUnnamedFieldEmbeds(t *testing.T) { type Embed struct { Flag string @@ -850,15 +1043,6 @@ func TestParentBindings(t *testing.T) { assert.Equal(t, "foo", cli.Command.value) } -func TestNumericParamErrors(t *testing.T) { - var cli struct { - Name string - } - parser := mustNew(t, &cli) - _, err := parser.Parse([]string{"--name", "-10"}) - assert.EqualError(t, err, `--name: expected string value but got "-10" (short flag); perhaps try --name="-10"?`) -} - func TestDefaultValueIsHyphen(t *testing.T) { var cli struct { Flag string `default:"-"` @@ -879,14 +1063,12 @@ func TestDefaultEnumValidated(t *testing.T) { } func TestEnvarEnumValidated(t *testing.T) { - restore := tempEnv(map[string]string{ - "FLAG": "invalid", - }) - defer restore() var cli struct { Flag string `env:"FLAG" required:"" enum:"valid"` } - p := mustNew(t, &cli) + p := newEnvParser(t, &cli, envMap{ + "FLAG": "invalid", + }) _, err := p.Parse(nil) assert.EqualError(t, err, "--flag must be one of \"valid\" but got \"invalid\"") } @@ -906,6 +1088,21 @@ func TestXor(t *testing.T) { assert.NoError(t, err) } +func TestAnd(t *testing.T) { + var cli struct { + Hello bool `and:"another"` + One bool `and:"group"` + Two string `and:"group"` + } + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--hello", "--one"}) + assert.EqualError(t, err, "--one and --two must be used together") + + p = mustNew(t, &cli) + _, err = p.Parse([]string{"--one", "--two=hi", "--hello"}) + assert.NoError(t, err) +} + func TestXorChild(t *testing.T) { var cli struct { One bool `xor:"group"` @@ -923,6 +1120,23 @@ func TestXorChild(t *testing.T) { assert.Error(t, err, "--two and --three can't be used together") } +func TestAndChild(t *testing.T) { + var cli struct { + One bool `and:"group"` + Cmd struct { + Two string `and:"group"` + Three string `and:"group"` + } `cmd` + } + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--one", "cmd", "--two=hi", "--three=hello"}) + assert.NoError(t, err) + + p = mustNew(t, &cli) + _, err = p.Parse([]string{"--two=hi", "cmd"}) + assert.Error(t, err, "--two and --three must be used together") +} + func TestMultiXor(t *testing.T) { var cli struct { Hello bool `xor:"one,two"` @@ -939,6 +1153,57 @@ func TestMultiXor(t *testing.T) { assert.EqualError(t, err, "--hello and --two can't be used together") } +func TestMultiAnd(t *testing.T) { + var cli struct { + Hello bool `and:"one,two"` + One bool `and:"one"` + Two string `and:"two"` + } + + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--hello"}) + // Split and combine error so messages always will be in the same order + // when testing + missingMsgs := strings.Split(err.Error(), ", ") + sort.Strings(missingMsgs) + err = fmt.Errorf("%s", strings.Join(missingMsgs, ", ")) + assert.EqualError(t, err, "--hello and --one must be used together, --hello and --two must be used together") + + p = mustNew(t, &cli) + _, err = p.Parse([]string{"--two=foo"}) + assert.EqualError(t, err, "--hello and --two must be used together") +} + +func TestXorAnd(t *testing.T) { + var cli struct { + Hello bool `xor:"one" and:"two"` + One bool `xor:"one"` + Two string `and:"two"` + } + + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--hello"}) + assert.EqualError(t, err, "--hello and --two must be used together") + + p = mustNew(t, &cli) + _, err = p.Parse([]string{"--one"}) + assert.NoError(t, err) + + p = mustNew(t, &cli) + _, err = p.Parse([]string{"--hello", "--one"}) + assert.EqualError(t, err, "--hello and --one can't be used together, --hello and --two must be used together") +} + +func TestOverLappingXorAnd(t *testing.T) { + var cli struct { + Hello bool `xor:"one" and:"two"` + One bool `xor:"one" and:"two"` + Two string `xor:"one" and:"two"` + } + _, err := kong.New(&cli) + assert.EqualError(t, err, "invalid xor and combination, one and two overlap with more than one: [hello one two]") +} + func TestXorRequired(t *testing.T) { var cli struct { One bool `xor:"one,two" required:""` @@ -959,6 +1224,26 @@ func TestXorRequired(t *testing.T) { assert.EqualError(t, err, "missing flags: --four, --one or --three, --one or --two") } +func TestAndRequired(t *testing.T) { + var cli struct { + One bool `and:"one,two" required:""` + Two bool `and:"one" required:""` + Three bool `and:"two"` + Four bool `required:""` + } + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--one", "--two", "--three"}) + assert.EqualError(t, err, "missing flags: --four") + + p = mustNew(t, &cli) + _, err = p.Parse([]string{"--four"}) + assert.EqualError(t, err, "missing flags: --one and --three, --one and --two") + + p = mustNew(t, &cli) + _, err = p.Parse([]string{}) + assert.EqualError(t, err, "missing flags: --four, --one and --three, --one and --two") +} + func TestXorRequiredMany(t *testing.T) { var cli struct { One bool `xor:"one" required:""` @@ -978,6 +1263,21 @@ func TestXorRequiredMany(t *testing.T) { assert.EqualError(t, err, "missing flags: --one or --two or --three") } +func TestAndRequiredMany(t *testing.T) { + var cli struct { + One bool `and:"one" required:""` + Two bool `and:"one" required:""` + Three bool `and:"one" required:""` + } + p := mustNew(t, &cli) + _, err := p.Parse([]string{}) + assert.EqualError(t, err, "missing flags: --one and --two and --three") + + p = mustNew(t, &cli) + _, err = p.Parse([]string{"--three"}) + assert.EqualError(t, err, "missing flags: --one and --two") +} + func TestEnumSequence(t *testing.T) { var cli struct { State []string `enum:"a,b,c" default:"a"` @@ -1040,10 +1340,9 @@ func TestIssue153(t *testing.T) { Ls LsCmd `cmd help:"List paths."` } - p, revert := newEnvParser(t, &cli, envMap{ + p := newEnvParser(t, &cli, envMap{ "CMD_PATHS": "hello", }) - defer revert() _, err := p.Parse([]string{"ls"}) assert.NoError(t, err) assert.Equal(t, []string{"hello"}, cli.Ls.Paths) @@ -1272,6 +1571,19 @@ func TestValidateArg(t *testing.T) { assert.EqualError(t, err, ": flag error") } +type extendedValidateFlag string + +func (v *extendedValidateFlag) Validate(kctx *kong.Context) error { return errors.New("flag error") } + +func TestExtendedValidateFlag(t *testing.T) { + cli := struct { + Flag extendedValidateFlag + }{} + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--flag=one"}) + assert.EqualError(t, err, "--flag: flag error") +} + func TestPointers(t *testing.T) { cli := struct { Mapped *mappedValue @@ -1297,6 +1609,12 @@ func (d *dynamicCommand) Run() error { return nil } +type commandFunc func() error + +func (cf commandFunc) Run() error { + return cf() +} + func TestDynamicCommands(t *testing.T) { cli := struct { One struct{} `cmd:"one"` @@ -1304,9 +1622,12 @@ func TestDynamicCommands(t *testing.T) { help := &strings.Builder{} two := &dynamicCommand{} three := &dynamicCommand{} + fourRan := false + four := commandFunc(func() error { fourRan = true; return nil }) p := mustNew(t, &cli, kong.DynamicCommand("two", "", "", &two), kong.DynamicCommand("three", "", "", three, "hidden"), + kong.DynamicCommand("four", "", "", &four), kong.Writers(help, help), kong.Exit(func(int) {})) kctx, err := p.Parse([]string{"two", "--flag=flag"}) @@ -1317,8 +1638,15 @@ func TestDynamicCommands(t *testing.T) { assert.NoError(t, err) assert.True(t, two.ran) + kctx, err = p.Parse([]string{"four"}) + assert.NoError(t, err) + assert.False(t, fourRan) + err = kctx.Run() + assert.NoError(t, err) + assert.True(t, fourRan) + _, err = p.Parse([]string{"--help"}) - assert.EqualError(t, err, `expected one of "one", "two"`) + assert.EqualError(t, err, `expected one of "one", "two", "four"`) assert.NotContains(t, help.String(), "three", help.String()) } @@ -1460,7 +1788,7 @@ func TestOptionReturnsErr(t *testing.T) { func TestEnumValidation(t *testing.T) { tests := []struct { name string - cli interface{} + cli any fail bool }{ { @@ -1526,6 +1854,89 @@ func TestEnumValidation(t *testing.T) { } } +func TestPassthroughArgs(t *testing.T) { + tests := []struct { + name string + args []string + flag string + cmdArgs []string + }{ + { + "NoArgs", + []string{}, + "", + []string(nil), + }, + { + "RecognizedFlagAndArgs", + []string{"--flag", "foobar", "something"}, + "foobar", + []string{"something"}, + }, + { + "DashDashBetweenArgs", + []string{"foo", "--", "bar"}, + "", + []string{"foo", "--", "bar"}, + }, + { + "DashDash", + []string{"--", "--flag", "foobar"}, + "", + []string{"--", "--flag", "foobar"}, + }, + { + "UnrecognizedFlagAndArgs", + []string{"--unrecognized-flag", "something"}, + "", + []string{"--unrecognized-flag", "something"}, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + var cli struct { + Flag string + Args []string `arg:"" optional:"" passthrough:""` + } + p := mustNew(t, &cli) + _, err := p.Parse(test.args) + assert.NoError(t, err) + assert.Equal(t, test.flag, cli.Flag) + assert.Equal(t, test.cmdArgs, cli.Args) + }) + } +} + +func TestPassthroughPartial(t *testing.T) { + var cli struct { + Flag string + Args []string `arg:"" optional:"" passthrough:"partial"` + } + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--flag", "foobar", "something"}) + assert.NoError(t, err) + assert.Equal(t, "foobar", cli.Flag) + assert.Equal(t, []string{"something"}, cli.Args) + _, err = p.Parse([]string{"--invalid", "foobar", "something"}) + assert.EqualError(t, err, "unknown flag --invalid") +} + +func TestPassthroughAll(t *testing.T) { + var cli struct { + Flag string + Args []string `arg:"" optional:"" passthrough:"all"` + } + p := mustNew(t, &cli) + _, err := p.Parse([]string{"--flag", "foobar", "something"}) + assert.NoError(t, err) + assert.Equal(t, "foobar", cli.Flag) + assert.Equal(t, []string{"something"}, cli.Args) + _, err = p.Parse([]string{"--invalid", "foobar", "something"}) + assert.NoError(t, err) + assert.Equal(t, []string{"--invalid", "foobar", "something"}, cli.Args) +} + func TestPassthroughCmd(t *testing.T) { tests := []struct { name string @@ -1651,7 +2062,7 @@ func TestVersionFlagShouldStillWork(t *testing.T) { func TestSliceDecoderHelpfulErrorMsg(t *testing.T) { tests := []struct { name string - cli interface{} + cli any args []string err string }{ @@ -1701,7 +2112,7 @@ func TestSliceDecoderHelpfulErrorMsg(t *testing.T) { func TestMapDecoderHelpfulErrorMsg(t *testing.T) { tests := []struct { name string - cli interface{} + cli any args []string expected string }{ @@ -2027,3 +2438,269 @@ func TestEnumPtrOmittedNoDefault(t *testing.T) { assert.NotZero(t, ctx) assert.Zero(t, cli.X) } + +func TestIntEnum(t *testing.T) { + var cli struct { + Enum int `enum:"1,2,3" default:"1"` + } + k, err := kong.New(&cli) + assert.NoError(t, err) + _, err = k.Parse([]string{"--enum=123"}) + assert.EqualError(t, err, `--enum must be one of "1","2","3" but got "123"`) +} + +func TestRecursiveVariableExpansion(t *testing.T) { + var cli struct { + Config string `type:"path" default:"${config_file}" help:"Default: ${default}"` + } + k := mustNew(t, &cli, kong.Vars{"config_file": "/etc/config"}, kong.Exit(func(int) {})) + w := &strings.Builder{} + k.Stderr = w + k.Stdout = w + _, err := k.Parse([]string{"--help"}) + assert.NoError(t, err) + assert.Contains(t, w.String(), "Default: /etc/config") +} + +type afterRunCLI struct { + runCalled bool `kong:"-"` + afterRunCalled bool `kong:"-"` +} + +func (c *afterRunCLI) Run() error { + c.runCalled = true + return nil +} + +func (c *afterRunCLI) AfterRun() error { + c.afterRunCalled = true + return nil +} + +func TestAfterRun(t *testing.T) { + var cli afterRunCLI + k := mustNew(t, &cli) + kctx, err := k.Parse([]string{}) + assert.NoError(t, err) + err = kctx.Run() + assert.NoError(t, err) + assert.Equal(t, afterRunCLI{runCalled: true, afterRunCalled: true}, cli) +} + +type ProvidedString string + +type providerCLI struct { + Sub providerSubCommand `cmd:""` +} + +type providerSubCommand struct{} + +func (p *providerCLI) ProvideFoo() (ProvidedString, error) { + return ProvidedString("foo"), nil +} + +func (p *providerSubCommand) Run(t *testing.T, ps ProvidedString) error { + assert.Equal(t, ProvidedString("foo"), ps) + return nil +} + +func TestProviderMethods(t *testing.T) { + k := mustNew(t, &providerCLI{}) + kctx, err := k.Parse([]string{"sub"}) + assert.NoError(t, err) + err = kctx.Run(t) + assert.NoError(t, err) +} + +type EmbeddedCallback struct { + Nested NestedCallback `embed:""` + + Embedded bool +} + +func (e *EmbeddedCallback) AfterApply() error { + e.Embedded = true + return nil +} + +type taggedEmbeddedCallback struct { + NestedCallback + + Tagged bool +} + +func (e *taggedEmbeddedCallback) AfterApply() error { + e.Tagged = true + return nil +} + +type NestedCallback struct { + nested bool +} + +func (n *NestedCallback) AfterApply() error { + n.nested = true + return nil +} + +type EmbeddedRoot struct { + EmbeddedCallback + Tagged taggedEmbeddedCallback `embed:""` + Root bool +} + +func (e *EmbeddedRoot) AfterApply() error { + e.Root = true + return nil +} + +func TestEmbeddedCallbacks(t *testing.T) { + actual := &EmbeddedRoot{} + k := mustNew(t, actual) + _, err := k.Parse(nil) + assert.NoError(t, err) + expected := &EmbeddedRoot{ + EmbeddedCallback: EmbeddedCallback{ + Embedded: true, + Nested: NestedCallback{ + nested: true, + }, + }, + Tagged: taggedEmbeddedCallback{ + Tagged: true, + NestedCallback: NestedCallback{ + nested: true, + }, + }, + Root: true, + } + assert.Equal(t, expected, actual) +} + +type applyCalledOnce struct { + Dev bool +} + +func (c *applyCalledOnce) AfterApply() error { + c.Dev = false + return nil +} + +func (c applyCalledOnce) Run() error { + if c.Dev { + return fmt.Errorf("--dev should not be set") + } + return nil +} + +func TestApplyCalledOnce(t *testing.T) { + cli := &applyCalledOnce{} + kctx, err := mustNew(t, cli).Parse([]string{"--dev"}) + assert.NoError(t, err) + err = kctx.Run() + assert.NoError(t, err) +} + +func TestCustomTypeNoEllipsis(t *testing.T) { + type CLI struct { + Flag []byte `type:"existingfile"` + } + var cli CLI + p := mustNew(t, &cli, kong.Exit(func(int) {})) + w := &strings.Builder{} + p.Stderr = w + p.Stdout = w + _, err := p.Parse([]string{"--help"}) + assert.NoError(t, err) + help := w.String() + assert.NotContains(t, help, "...") +} + +func TestPrefixXorIssue343(t *testing.T) { + type DBConfig struct { + Password string `help:"Password" xor:"password" optional:""` + PasswordFile string `help:"File which content will be used for a password" xor:"password" optional:""` + PasswordCommand string `help:"Command to run to retrieve password" xor:"password" optional:""` + } + + type SourceTargetConfig struct { + Source DBConfig `help:"Database config of source to be copied from" prefix:"source-" xorprefix:"source-" embed:""` + Target DBConfig `help:"Database config of source to be copied from" prefix:"target-" xorprefix:"target-" embed:""` + } + + cli := SourceTargetConfig{} + kctx := mustNew(t, &cli) + _, err := kctx.Parse([]string{"--source-password=foo", "--target-password=bar"}) + assert.NoError(t, err) + _, err = kctx.Parse([]string{"--source-password-file=foo", "--source-password=bar"}) + assert.Error(t, err) +} + +func TestIssue483EmptyRootNodeNoRun(t *testing.T) { + var emptyCLI struct{} + parser, err := kong.New(&emptyCLI) + assert.NoError(t, err) + + kctx, err := parser.Parse([]string{}) + assert.NoError(t, err) + + err = kctx.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no command selected") +} + +type providerWithoutErrorCLI struct { +} + +func (p *providerWithoutErrorCLI) Run(name string) error { + if name == "Bob" { + return nil + } + return fmt.Errorf("name %s is not Bob", name) +} + +func TestProviderWithoutError(t *testing.T) { + k := mustNew(t, &providerWithoutErrorCLI{}) + kctx, err := k.Parse(nil) + assert.NoError(t, err) + err = kctx.BindToProvider(func() string { return "Bob" }) + assert.NoError(t, err) + err = kctx.Run() + assert.NoError(t, err) +} + +func TestParseHyphenParameter(t *testing.T) { + type shortFlag struct { + Flag string `short:"f"` + Other string `short:"o"` + Numeric int `short:"n"` + } + + t.Run("ShortFlag", func(t *testing.T) { + actual := &shortFlag{} + _, err := mustNew(t, actual, kong.WithHyphenPrefixedParameters(true)).Parse([]string{"-f", "-foo"}) + assert.NoError(t, err) + assert.Equal(t, &shortFlag{Flag: "-foo"}, actual) + }) + + t.Run("LongFlag", func(t *testing.T) { + actual := &shortFlag{} + _, err := mustNew(t, actual, kong.WithHyphenPrefixedParameters(true)).Parse([]string{"--flag", "-foo"}) + assert.NoError(t, err) + assert.Equal(t, &shortFlag{Flag: "-foo"}, actual) + }) + + t.Run("ParamMatchesFlag", func(t *testing.T) { + actual := &shortFlag{} + _, err := mustNew(t, actual, kong.WithHyphenPrefixedParameters(true)).Parse([]string{"--flag", "-oo"}) + assert.NoError(t, err) + assert.Equal(t, &shortFlag{Flag: "-oo"}, actual) + }) + + t.Run("NegativeNumber", func(t *testing.T) { + actual := &shortFlag{} + _, err := mustNew(t, actual, kong.WithHyphenPrefixedParameters(true)).Parse([]string{"--numeric", "-10"}) + assert.NoError(t, err) + assert.Equal(t, &shortFlag{Numeric: -10}, actual) + }) +} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..28ba9ad --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,11 @@ +output: + - success + - failure +pre-push: + parallel: true + jobs: + - name: test + run: go test -v ./... + + - name: lint + run: golangci-lint run diff --git a/levenshtein.go b/levenshtein.go index 1816f30..fe11745 100644 --- a/levenshtein.go +++ b/levenshtein.go @@ -2,8 +2,8 @@ package kong import "unicode/utf8" -// https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Go -// License: https://creativecommons.org/licenses/by-sa/3.0/ +// Copied from https://github.com/daviddengcn/go-algs/blob/fe23fabd9d0670e4675326040ba7c285c7117b4c/ed/ed.go#L31 +// License: https://github.com/daviddengcn/go-algs/blob/fe23fabd9d0670e4675326040ba7c285c7117b4c/LICENSE func levenshtein(a, b string) int { f := make([]int, utf8.RuneCountInString(b)+1) @@ -31,7 +31,7 @@ func levenshtein(a, b string) int { return f[len(f)-1] } -func min(a, b int) int { +func min(a, b int) int { //nolint:predeclared if a <= b { return a } diff --git a/mapper.go b/mapper.go index 584bb00..7e97836 100644 --- a/mapper.go +++ b/mapper.go @@ -251,7 +251,7 @@ func (r *Registry) RegisterType(typ reflect.Type, mapper Mapper) *Registry { } // RegisterValue registers a Mapper by pointer to the field value. -func (r *Registry) RegisterValue(ptr interface{}, mapper Mapper) *Registry { +func (r *Registry) RegisterValue(ptr any, mapper Mapper) *Registry { key := reflect.ValueOf(ptr) if key.Kind() != reflect.Ptr { panic("expected a pointer") @@ -387,7 +387,7 @@ func intDecoder(bits int) MapperFunc { //nolint: dupl default: return fmt.Errorf("expected an int but got %q (%T)", t, t.Value) } - n, err := strconv.ParseInt(sv, 10, bits) + n, err := strconv.ParseInt(sv, 0, bits) if err != nil { return fmt.Errorf("expected a valid %d bit int but got %q", bits, sv) } @@ -416,7 +416,7 @@ func uintDecoder(bits int) MapperFunc { //nolint: dupl default: return fmt.Errorf("expected an int but got %q (%T)", t, t.Value) } - n, err := strconv.ParseUint(sv, 10, bits) + n, err := strconv.ParseUint(sv, 0, bits) if err != nil { return fmt.Errorf("expected a valid %d bit uint but got %q", bits, sv) } @@ -473,7 +473,7 @@ func mapDecoder(r *Registry) MapperFunc { case string: childScanner = ScanAsType(t.Type, SplitEscaped(v, mapsep)...) - case []map[string]interface{}: + case []map[string]any: for _, m := range v { err := jsonTranscode(m, target.Addr().Interface()) if err != nil { @@ -482,7 +482,7 @@ func mapDecoder(r *Registry) MapperFunc { } return nil - case map[string]interface{}: + case map[string]any: return jsonTranscode(v, target.Addr().Interface()) default: @@ -548,11 +548,11 @@ func sliceDecoder(r *Registry) MapperFunc { case string: childScanner = ScanAsType(t.Type, SplitEscaped(v, sep)...) - case []interface{}: + case []any: return jsonTranscode(v, target.Addr().Interface()) default: - v = []interface{}{v} + v = []any{v} return jsonTranscode(v, target.Addr().Interface()) } } else { @@ -922,7 +922,7 @@ func (f *FileContentFlag) Decode(ctx *DecodeContext) error { //nolint: revive return nil } -func jsonTranscode(in, out interface{}) error { +func jsonTranscode(in, out any) error { data, err := json.Marshal(in) if err != nil { return err diff --git a/mapper_test.go b/mapper_test.go index af7d692..cd867e5 100644 --- a/mapper_test.go +++ b/mapper_test.go @@ -268,9 +268,8 @@ func TestFileContentFlag(t *testing.T) { var cli struct { File kong.FileContentFlag } - f, err := os.CreateTemp("", "") + f, err := os.CreateTemp(t.TempDir(), "") assert.NoError(t, err) - defer os.Remove(f.Name()) fmt.Fprint(f, "hello world") f.Close() _, err = mustNew(t, &cli).Parse([]string{"--file", f.Name()}) @@ -282,9 +281,8 @@ func TestNamedFileContentFlag(t *testing.T) { var cli struct { File kong.NamedFileContentFlag } - f, err := os.CreateTemp("", "") + f, err := os.CreateTemp(t.TempDir(), "") assert.NoError(t, err) - defer os.Remove(f.Name()) fmt.Fprint(f, "hello world") f.Close() _, err = mustNew(t, &cli).Parse([]string{"--file", f.Name()}) @@ -397,6 +395,31 @@ func TestNumbers(t *testing.T) { I64: math.MinInt64, }, cli) }) + t.Run("Integer literals", func(t *testing.T) { + integerLiterals := "10_0" + + _, err := p.Parse([]string{ + fmt.Sprintf("--i-8=%s", integerLiterals), + fmt.Sprintf("--i-16=%s", integerLiterals), + fmt.Sprintf("--i-32=%s", integerLiterals), + fmt.Sprintf("--i-64=%s", integerLiterals), + fmt.Sprintf("--u-8=%s", integerLiterals), + fmt.Sprintf("--u-16=%s", integerLiterals), + fmt.Sprintf("--u-32=%s", integerLiterals), + fmt.Sprintf("--u-64=%s", integerLiterals), + }) + assert.NoError(t, err) + assert.Equal(t, CLI{ + I8: int8(100), + I16: int16(100), + I32: int32(100), + I64: int64(100), + U8: uint8(100), + U16: uint16(100), + U32: uint32(100), + U64: uint64(100), + }, cli) + }) } func TestJSONLargeNumber(t *testing.T) { @@ -756,3 +779,31 @@ func TestValuesThatLookLikeFlags(t *testing.T) { assert.NoError(t, err) assert.Equal(t, map[string]string{"-foo": "-bar"}, cli.Map) } + +type DecodeCLI struct { + Foo DecodeFoo `env:"FOO"` +} + +type DecodeFoo struct { + Bar string +} + +func (f DecodeFoo) Decode(ctx *kong.DecodeContext) error { + ctx.Value.Target.Set(reflect.ValueOf(struct { + Bar string + }{"baz"})) + return nil +} + +func TestDecode(t *testing.T) { + c := &DecodeCLI{} + parser, err := kong.New(c) + assert.NoError(t, err) + + t.Setenv("FOO", "foo") + + _, err = parser.Parse([]string{}) + assert.NoError(t, err) + + assert.Equal(t, c.Foo.Bar, "baz") +} diff --git a/model.go b/model.go index 8d1f82f..33a6f33 100644 --- a/model.go +++ b/model.go @@ -69,7 +69,7 @@ func (n *Node) Leaf() bool { // Find a command/argument/flag by pointer to its field. // // Returns nil if not found. Panics if ptr is not a pointer. -func (n *Node) Find(ptr interface{}) *Node { +func (n *Node) Find(ptr any) *Node { key := reflect.ValueOf(ptr) if key.Kind() != reflect.Ptr { panic("expected a pointer") @@ -167,6 +167,9 @@ func (n *Node) Summary() string { allFlags = append(allFlags, n.Parent.Flags...) } for _, flag := range allFlags { + if _, ok := flag.Target.Interface().(helpFlag); ok { + continue + } if !flag.Required { summary += " [flags]" break @@ -239,23 +242,24 @@ func (n *Node) ClosestGroup() *Group { // A Value is either a flag or a variable positional argument. type Value struct { - Flag *Flag // Nil if positional argument. - Name string - Help string - OrigHelp string // Original help string, without interpolated variables. - HasDefault bool - Default string - DefaultValue reflect.Value - Enum string - Mapper Mapper - Tag *Tag - Target reflect.Value - Required bool - Set bool // Set to true when this value is set through some mechanism. - Format string // Formatting directive, if applicable. - Position int // Position (for positional arguments). - Passthrough bool // Set to true to stop flag parsing when encountered. - Active bool // Denotes the value is part of an active branch in the CLI. + Flag *Flag // Nil if positional argument. + Name string + Help string + OrigHelp string // Original help string, without interpolated variables. + HasDefault bool + Default string + DefaultValue reflect.Value + Enum string + Mapper Mapper + Tag *Tag + Target reflect.Value + Required bool + Set bool // Set to true when this value is set through some mechanism. + Format string // Formatting directive, if applicable. + Position int // Position (for positional arguments). + Passthrough bool // Deprecated: Use PassthroughMode instead. Set to true to stop flag parsing when encountered. + PassthroughMode PassthroughMode // + Active bool // Denotes the value is part of an active branch in the CLI. } // EnumMap returns a map of the enums in this value. @@ -405,6 +409,7 @@ type Flag struct { *Value Group *Group // Logical grouping when displaying. May also be used by configuration loaders to group options logically. Xor []string + And []string PlaceHolder string Envs []string Aliases []string @@ -431,7 +436,7 @@ func (f *Flag) FormatPlaceHolder() string { return placeholderHelper.PlaceHolder(f) } tail := "" - if f.Value.IsSlice() && f.Value.Tag.Sep != -1 { + if f.Value.IsSlice() && f.Value.Tag.Sep != -1 && f.Tag.Type == "" { tail += string(f.Value.Tag.Sep) + "..." } if f.PlaceHolder != "" { @@ -444,7 +449,7 @@ func (f *Flag) FormatPlaceHolder() string { return f.Default + tail } if f.Value.IsMap() { - if f.Value.Tag.MapSep != -1 { + if f.Value.Tag.MapSep != -1 && f.Tag.Type == "" { tail = string(f.Value.Tag.MapSep) + "..." } return "KEY=VALUE" + tail diff --git a/model_test.go b/model_test.go index 37bd632..2bc83b2 100644 --- a/model_test.go +++ b/model_test.go @@ -1,6 +1,7 @@ package kong_test import ( + "bytes" "testing" "github.com/alecthomas/assert/v2" @@ -70,3 +71,22 @@ func TestFlagString(t *testing.T) { assert.Equal(t, want, flag.String()) } } + +func TestIgnoreHelpInUsage(t *testing.T) { + var cli struct { + One string `required:""` + } + + k := mustNew(t, &cli) + w := &bytes.Buffer{} + k.Stdout = w + k.Exit = func(code int) {} + _, err := k.Parse([]string{"--help"}) + assert.Error(t, err) + assert.Equal(t, `Usage: test --one=STRING + +Flags: + -h, --help Show context-sensitive help. + --one=STRING +`, w.String()) +} diff --git a/negatable.go b/negatable.go new file mode 100644 index 0000000..7d8902a --- /dev/null +++ b/negatable.go @@ -0,0 +1,19 @@ +package kong + +// negatableDefault is a placeholder value for the Negatable tag to indicate +// the negated flag is --no-. This is needed as at the time of +// parsing a tag, the field's flag name is not yet known. +const negatableDefault = "_" + +// negatableFlagName returns the name of the flag for a negatable field, or +// an empty string if the field is not negatable. +func negatableFlagName(name, negation string) string { + switch negation { + case "": + return "" + case negatableDefault: + return "--no-" + name + default: + return "--" + negation + } +} diff --git a/options.go b/options.go index d01aeec..a1fa242 100644 --- a/options.go +++ b/options.go @@ -55,6 +55,16 @@ func Exit(exit func(int)) Option { }) } +// WithHyphenPrefixedParameters enables or disables hyphen-prefixed parameters. +// +// These are disabled by default. +func WithHyphenPrefixedParameters(enable bool) Option { + return OptionFunc(func(k *Kong) error { + k.allowHyphenated = enable + return nil + }) +} + type embedded struct { strct any tags []string @@ -79,7 +89,7 @@ type dynamicCommand struct { help string group string tags []string - cmd interface{} + cmd any } // DynamicCommand registers a dynamically constructed command with the root of the CLI. @@ -87,8 +97,12 @@ type dynamicCommand struct { // This is useful for command-line structures that are extensible via user-provided plugins. // // "tags" is a list of extra tag strings to parse, in the form :"". -func DynamicCommand(name, help, group string, cmd interface{}, tags ...string) Option { +func DynamicCommand(name, help, group string, cmd any, tags ...string) Option { return OptionFunc(func(k *Kong) error { + if run := getMethod(reflect.Indirect(reflect.ValueOf(cmd)), "Run"); !run.IsValid() { + return fmt.Errorf("kong: DynamicCommand %q must be a type with a 'Run' method; got %T", name, cmd) + } + k.dynamicCommands = append(k.dynamicCommands, &dynamicCommand{ name: name, help: help, @@ -119,6 +133,40 @@ func PostBuild(fn func(*Kong) error) Option { }) } +// WithBeforeReset registers a hook to run before fields values are reset to their defaults +// (as specified in the grammar) or to zero values. +func WithBeforeReset(fn any) Option { + return withHook("BeforeReset", fn) +} + +// WithBeforeResolve registers a hook to run before resolvers are applied. +func WithBeforeResolve(fn any) Option { + return withHook("BeforeResolve", fn) +} + +// WithBeforeApply registers a hook to run before command line arguments are applied to the grammar. +func WithBeforeApply(fn any) Option { + return withHook("BeforeApply", fn) +} + +// WithAfterApply registers a hook to run after values are applied to the grammar and validated. +func WithAfterApply(fn any) Option { + return withHook("AfterApply", fn) +} + +// withHook registers a named hook. +func withHook(name string, fn any) Option { + value := reflect.ValueOf(fn) + if value.Kind() != reflect.Func { + panic(fmt.Errorf("expected function, got %s", value.Type())) + } + + return OptionFunc(func(k *Kong) error { + k.hooks[name] = append(k.hooks[name], value) + return nil + }) +} + // Name overrides the application name. func Name(name string) Option { return PostBuild(func(k *Kong) error { @@ -152,7 +200,7 @@ func KindMapper(kind reflect.Kind, mapper Mapper) Option { } // ValueMapper registers a mapper to a field value. -func ValueMapper(ptr interface{}, mapper Mapper) Option { +func ValueMapper(ptr any, mapper Mapper) Option { return OptionFunc(func(k *Kong) error { k.registry.RegisterValue(ptr, mapper) return nil @@ -187,7 +235,7 @@ func Writers(stdout, stderr io.Writer) Option { // AfterApply(...) error // // Called before validation/assignment, and immediately after validation/assignment, respectively. -func Bind(args ...interface{}) Option { +func Bind(args ...any) Option { return OptionFunc(func(k *Kong) error { k.bindings.add(args...) return nil @@ -197,20 +245,42 @@ func Bind(args ...interface{}) Option { // BindTo allows binding of implementations to interfaces. // // BindTo(impl, (*iface)(nil)) -func BindTo(impl, iface interface{}) Option { +func BindTo(impl, iface any) Option { return OptionFunc(func(k *Kong) error { k.bindings.addTo(impl, iface) return nil }) } -// BindToProvider allows binding of provider functions. +// BindToProvider binds an injected value to a provider function. +// +// The provider function must have one of the following signatures: +// +// func(...) (T, error) +// func(...) T +// +// Where arguments to the function are injected by Kong. // // This is useful when the Run() function of different commands require different values that may // not all be initialisable from the main() function. -func BindToProvider(provider interface{}) Option { +func BindToProvider(provider any) Option { return OptionFunc(func(k *Kong) error { - return k.bindings.addProvider(provider) + return k.bindings.addProvider(provider, false /* singleton */) + }) +} + +// BindSingletonProvider binds an injected value to a provider function. +// The provider function must have the signature: +// +// func(...) (T, error) +// func(...) T +// +// Unlike [BindToProvider], the provider function will only be called +// at most once, and the result will be cached and reused +// across multiple recipients of the injected value. +func BindSingletonProvider(provider any) Option { + return OptionFunc(func(k *Kong) error { + return k.bindings.addProvider(provider, true /* singleton */) }) } diff --git a/options_test.go b/options_test.go index ca41720..791cb64 100644 --- a/options_test.go +++ b/options_test.go @@ -89,11 +89,13 @@ func TestCallbackCustomError(t *testing.T) { } type bindToProviderCLI struct { + Filled bool `default:"true"` Called bool Cmd bindToProviderCmd `cmd:""` } type boundThing struct { + Filled bool } type bindToProviderCmd struct{} @@ -105,7 +107,10 @@ func (*bindToProviderCmd) Run(cli *bindToProviderCLI, b *boundThing) error { func TestBindToProvider(t *testing.T) { var cli bindToProviderCLI - app, err := New(&cli, BindToProvider(func() (*boundThing, error) { return &boundThing{}, nil })) + app, err := New(&cli, BindToProvider(func(cli *bindToProviderCLI) (*boundThing, error) { + assert.True(t, cli.Filled, "CLI struct should have already been populated by Kong") + return &boundThing{Filled: cli.Filled}, nil + })) assert.NoError(t, err) ctx, err := app.Parse([]string{"cmd"}) assert.NoError(t, err) @@ -114,6 +119,43 @@ func TestBindToProvider(t *testing.T) { assert.True(t, cli.Called) } +func TestBindSingletonProvider(t *testing.T) { + type ( + Connection struct{} + ClientA struct{ conn *Connection } + ClientB struct{ conn *Connection } + ) + + var numConnections int + newConnection := func() *Connection { + numConnections++ + return &Connection{} + } + + var cli struct{} + app, err := New(&cli, + BindSingletonProvider(newConnection), + BindToProvider(func(conn *Connection) *ClientA { + return &ClientA{conn: conn} + }), + BindToProvider(func(conn *Connection) *ClientB { + return &ClientB{conn: conn} + }), + ) + assert.NoError(t, err) + + ctx, err := app.Parse([]string{}) + assert.NoError(t, err) + + _, err = ctx.Call(func(a *ClientA, b *ClientB) { + assert.NotZero(t, a.conn) + assert.NotZero(t, b.conn) + + assert.Equal(t, 1, numConnections, "expected newConnection to be called only once") + }) + assert.NoError(t, err) +} + func TestFlagNamer(t *testing.T) { var cli struct { SomeFlag string @@ -122,3 +164,22 @@ func TestFlagNamer(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "SOMEFLAG", app.Model.Flags[1].Name) } + +type npError string + +func (e npError) Error() string { + return "ERROR: " + string(e) +} + +func TestCallbackNonPointerError(t *testing.T) { + method := func() error { + return npError("failed") + } + + var cli struct{} + + p, err := New(&cli) + assert.NoError(t, err) + err = callFunction(reflect.ValueOf(method), p.bindings) + assert.EqualError(t, err, "ERROR: failed") +} diff --git a/resolver.go b/resolver.go index 05be7f6..3e37ca7 100644 --- a/resolver.go +++ b/resolver.go @@ -14,15 +14,15 @@ type Resolver interface { Validate(app *Application) error // Resolve the value for a Flag. - Resolve(context *Context, parent *Path, flag *Flag) (interface{}, error) + Resolve(context *Context, parent *Path, flag *Flag) (any, error) } // ResolverFunc is a convenience type for non-validating Resolvers. -type ResolverFunc func(context *Context, parent *Path, flag *Flag) (interface{}, error) +type ResolverFunc func(context *Context, parent *Path, flag *Flag) (any, error) var _ Resolver = ResolverFunc(nil) -func (r ResolverFunc) Resolve(context *Context, parent *Path, flag *Flag) (interface{}, error) { //nolint: revive +func (r ResolverFunc) Resolve(context *Context, parent *Path, flag *Flag) (any, error) { //nolint: revive return r(context, parent, flag) } func (r ResolverFunc) Validate(app *Application) error { return nil } //nolint: revive @@ -31,12 +31,12 @@ func (r ResolverFunc) Validate(app *Application) error { return nil } //nolint: // // Flag names are used as JSON keys indirectly, by tring snake_case and camelCase variants. func JSON(r io.Reader) (Resolver, error) { - values := map[string]interface{}{} + values := map[string]any{} err := json.NewDecoder(r).Decode(&values) if err != nil { return nil, err } - var f ResolverFunc = func(context *Context, parent *Path, flag *Flag) (interface{}, error) { + var f ResolverFunc = func(context *Context, parent *Path, flag *Flag) (any, error) { name := strings.ReplaceAll(flag.Name, "-", "_") snakeCaseName := snakeCase(flag.Name) raw, ok := values[name] @@ -47,7 +47,7 @@ func JSON(r io.Reader) (Resolver, error) { } raw = values for _, part := range strings.Split(name, ".") { - if values, ok := raw.(map[string]interface{}); ok { + if values, ok := raw.(map[string]any); ok { raw, ok = values[part] if !ok { return nil, nil @@ -63,6 +63,6 @@ func JSON(r io.Reader) (Resolver, error) { } func snakeCase(name string) string { - name = strings.Join(strings.Split(strings.Title(name), "-"), "") //nolint: staticcheck + name = strings.Join(strings.Split(strings.Title(name), "-"), "") //nolint:staticcheck // Unicode punctuation not an issue return strings.ToLower(name[:1]) + name[1:] } diff --git a/resolver_test.go b/resolver_test.go index 24afdff..9bd902c 100644 --- a/resolver_test.go +++ b/resolver_test.go @@ -2,7 +2,6 @@ package kong_test import ( "errors" - "os" "reflect" "strings" "testing" @@ -13,23 +12,13 @@ import ( type envMap map[string]string -func tempEnv(env envMap) func() { - for k, v := range env { - os.Setenv(k, v) - } - - return func() { - for k := range env { - os.Unsetenv(k) - } - } -} - -func newEnvParser(t *testing.T, cli interface{}, env envMap, options ...kong.Option) (*kong.Kong, func()) { +func newEnvParser(t *testing.T, cli any, env envMap, options ...kong.Option) *kong.Kong { t.Helper() - restoreEnv := tempEnv(env) + for name, value := range env { + t.Setenv(name, value) + } parser := mustNew(t, cli, options...) - return parser, restoreEnv + return parser } func TestEnvarsFlagBasic(t *testing.T) { @@ -39,7 +28,7 @@ func TestEnvarsFlagBasic(t *testing.T) { Interp string `env:"${kongInterp}"` } kongInterpEnv := "KONG_INTERP" - parser, unsetEnvs := newEnvParser(t, &cli, + parser := newEnvParser(t, &cli, envMap{ "KONG_STRING": "bye", "KONG_SLICE": "5,2,9", @@ -49,7 +38,6 @@ func TestEnvarsFlagBasic(t *testing.T) { "kongInterp": kongInterpEnv, }, ) - defer unsetEnvs() _, err := parser.Parse([]string{}) assert.NoError(t, err) @@ -63,14 +51,13 @@ func TestEnvarsFlagMultiple(t *testing.T) { FirstENVPresent string `env:"KONG_TEST1_1,KONG_TEST1_2"` SecondENVPresent string `env:"KONG_TEST2_1,KONG_TEST2_2"` } - parser, unsetEnvs := newEnvParser(t, &cli, + parser := newEnvParser(t, &cli, envMap{ "KONG_TEST1_1": "value1.1", "KONG_TEST1_2": "value1.2", "KONG_TEST2_2": "value2.2", }, ) - defer unsetEnvs() _, err := parser.Parse([]string{}) assert.NoError(t, err) @@ -82,8 +69,7 @@ func TestEnvarsFlagOverride(t *testing.T) { var cli struct { Flag string `env:"KONG_FLAG"` } - parser, restoreEnv := newEnvParser(t, &cli, envMap{"KONG_FLAG": "bye"}) - defer restoreEnv() + parser := newEnvParser(t, &cli, envMap{"KONG_FLAG": "bye"}) _, err := parser.Parse([]string{"--flag=hello"}) assert.NoError(t, err) @@ -94,8 +80,7 @@ func TestEnvarsTag(t *testing.T) { var cli struct { Slice []int `env:"KONG_NUMBERS"` } - parser, restoreEnv := newEnvParser(t, &cli, envMap{"KONG_NUMBERS": "5,2,9"}) - defer restoreEnv() + parser := newEnvParser(t, &cli, envMap{"KONG_NUMBERS": "5,2,9"}) _, err := parser.Parse([]string{}) assert.NoError(t, err) @@ -109,8 +94,7 @@ func TestEnvarsEnvPrefix(t *testing.T) { var cli struct { Anonymous `envprefix:"KONG_"` } - parser, restoreEnv := newEnvParser(t, &cli, envMap{"KONG_NUMBERS": "1,2,3"}) - defer restoreEnv() + parser := newEnvParser(t, &cli, envMap{"KONG_NUMBERS": "1,2,3"}) _, err := parser.Parse([]string{}) assert.NoError(t, err) @@ -125,8 +109,7 @@ func TestEnvarsEnvPrefixMultiple(t *testing.T) { var cli struct { Anonymous `envprefix:"KONG_"` } - parser, restoreEnv := newEnvParser(t, &cli, envMap{"KONG_NUMBERS1_1": "1,2,3", "KONG_NUMBERS2_2": "5,6,7"}) - defer restoreEnv() + parser := newEnvParser(t, &cli, envMap{"KONG_NUMBERS1_1": "1,2,3", "KONG_NUMBERS2_2": "5,6,7"}) _, err := parser.Parse([]string{}) assert.NoError(t, err) @@ -144,8 +127,7 @@ func TestEnvarsNestedEnvPrefix(t *testing.T) { var cli struct { Anonymous `envprefix:"KONG_"` } - parser, restoreEnv := newEnvParser(t, &cli, envMap{"KONG_ANON_STRING": "abc"}) - defer restoreEnv() + parser := newEnvParser(t, &cli, envMap{"KONG_ANON_STRING": "abc"}) _, err := parser.Parse([]string{}) assert.NoError(t, err) @@ -156,15 +138,13 @@ func TestEnvarsWithDefault(t *testing.T) { var cli struct { Flag string `env:"KONG_FLAG" default:"default"` } - parser, restoreEnv := newEnvParser(t, &cli, envMap{}) - defer restoreEnv() + parser := newEnvParser(t, &cli, envMap{}) _, err := parser.Parse(nil) assert.NoError(t, err) assert.Equal(t, "default", cli.Flag) - parser, restoreEnv = newEnvParser(t, &cli, envMap{"KONG_FLAG": "moo"}) - defer restoreEnv() + parser = newEnvParser(t, &cli, envMap{"KONG_FLAG": "moo"}) _, err = parser.Parse(nil) assert.NoError(t, err) assert.Equal(t, "moo", cli.Flag) @@ -194,7 +174,7 @@ func TestEnv(t *testing.T) { } // With the prefix - parser, unsetEnvs := newEnvParser(t, &cli, envMap{ + parser := newEnvParser(t, &cli, envMap{ "KONG_ONE_FLAG": "one", "KONG_TWO_FLAG": "two", "KONG_THREE_FLAG": "three", @@ -202,14 +182,13 @@ func TestEnv(t *testing.T) { "KONG_FIVE": "true", "KONG_SIX": "true", }, kong.DefaultEnvars("KONG")) - defer unsetEnvs() _, err := parser.Parse(nil) assert.NoError(t, err) assert.Equal(t, expected, cli) // Without the prefix - parser, unsetEnvs = newEnvParser(t, &cli, envMap{ + parser = newEnvParser(t, &cli, envMap{ "ONE_FLAG": "one", "TWO_FLAG": "two", "THREE_FLAG": "three", @@ -217,7 +196,6 @@ func TestEnv(t *testing.T) { "FIVE": "true", "SIX": "true", }, kong.DefaultEnvars("")) - defer unsetEnvs() _, err = parser.Parse(nil) assert.NoError(t, err) @@ -281,10 +259,10 @@ func TestResolversWithMappers(t *testing.T) { Flag string `env:"KONG_MOO" type:"upper"` } - restoreEnv := tempEnv(envMap{"KONG_MOO": "meow"}) - defer restoreEnv() + t.Setenv("KONG_MOO", "meow") - parser := mustNew(t, &cli, + parser := newEnvParser(t, &cli, + envMap{"KONG_MOO": "meow"}, kong.NamedMapper("upper", testUppercaseMapper{}), ) _, err := parser.Parse([]string{}) @@ -297,7 +275,7 @@ func TestResolverWithBool(t *testing.T) { Bool bool } - var resolver kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (interface{}, error) { + var resolver kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (any, error) { if flag.Name == "bool" { return true, nil } @@ -316,14 +294,14 @@ func TestLastResolverWins(t *testing.T) { Int []int } - var first kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (interface{}, error) { + var first kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (any, error) { if flag.Name == "int" { return 1, nil } return nil, nil } - var second kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (interface{}, error) { + var second kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (any, error) { if flag.Name == "int" { return 2, nil } @@ -340,7 +318,7 @@ func TestResolverSatisfiesRequired(t *testing.T) { var cli struct { Int int `required` } - var resolver kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (interface{}, error) { + var resolver kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (any, error) { if flag.Name == "int" { return 1, nil } @@ -358,7 +336,7 @@ func TestResolverTriggersHooks(t *testing.T) { Flag hookValue } - var first kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (interface{}, error) { + var first kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (any, error) { if flag.Name == "flag" { return "one", nil } @@ -377,7 +355,7 @@ type validatingResolver struct { } func (v *validatingResolver) Validate(app *kong.Application) error { return v.err } -func (v *validatingResolver) Resolve(context *kong.Context, parent *kong.Path, flag *kong.Flag) (interface{}, error) { +func (v *validatingResolver) Resolve(context *kong.Context, parent *kong.Path, flag *kong.Flag) (any, error) { return nil, nil } diff --git a/scanner.go b/scanner.go index c8a8bd6..511bf8f 100644 --- a/scanner.go +++ b/scanner.go @@ -41,7 +41,7 @@ func (t TokenType) String() string { // Token created by Scanner. type Token struct { - Value interface{} + Value any Type TokenType } @@ -111,7 +111,8 @@ func (t Token) IsValue() bool { // // [{FlagToken, "foo"}, {FlagValueToken, "bar"}] type Scanner struct { - args []Token + allowHyphenated bool + args []Token } // ScanAsType creates a new Scanner from args with the given type. @@ -133,6 +134,14 @@ func ScanFromTokens(tokens ...Token) *Scanner { return &Scanner{args: tokens} } +// AllowHyphenPrefixedParameters enables or disables hyphen-prefixed flag parameters on this Scanner. +// +// Disabled by default. +func (s *Scanner) AllowHyphenPrefixedParameters(enable bool) *Scanner { + s.allowHyphenated = enable + return s +} + // Len returns the number of input arguments. func (s *Scanner) Len() int { return len(s.args) @@ -162,7 +171,7 @@ func (e *expectedError) Error() string { // "context" is used to assist the user if the value can not be popped, eg. "expected value but got " func (s *Scanner) PopValue(context string) (Token, error) { t := s.Pop() - if !t.IsValue() { + if !s.allowHyphenated && !t.IsValue() { return t, &expectedError{context, t} } return t, nil @@ -171,7 +180,7 @@ func (s *Scanner) PopValue(context string) (Token, error) { // PopValueInto pops a value token into target or returns an error. // // "context" is used to assist the user if the value can not be popped, eg. "expected value but got " -func (s *Scanner) PopValueInto(context string, target interface{}) error { +func (s *Scanner) PopValueInto(context string, target any) error { t, err := s.PopValue(context) if err != nil { return err @@ -203,14 +212,19 @@ func (s *Scanner) Peek() Token { return s.args[0] } +// PeekAll remaining tokens +func (s *Scanner) PeekAll() []Token { + return s.args +} + // Push an untyped Token onto the front of the Scanner. -func (s *Scanner) Push(arg interface{}) *Scanner { +func (s *Scanner) Push(arg any) *Scanner { s.PushToken(Token{Value: arg}) return s } // PushTyped pushes a typed token onto the front of the Scanner. -func (s *Scanner) PushTyped(arg interface{}, typ TokenType) *Scanner { +func (s *Scanner) PushTyped(arg any, typ TokenType) *Scanner { s.PushToken(Token{Value: arg, Type: typ}) return s } diff --git a/tag.go b/tag.go index 3e37c19..a2bc4a9 100644 --- a/tag.go +++ b/tag.go @@ -9,36 +9,51 @@ import ( "unicode/utf8" ) +// PassthroughMode indicates how parameters are passed through when "passthrough" is set. +type PassthroughMode int + +const ( + // PassThroughModeNone indicates passthrough mode is disabled. + PassThroughModeNone PassthroughMode = iota + // PassThroughModeAll indicates that all parameters, including flags, are passed through. It is the default. + PassThroughModeAll + // PassThroughModePartial will validate flags until the first positional argument is encountered, then pass through all remaining positional arguments. + PassThroughModePartial +) + // Tag represents the parsed state of Kong tags in a struct field tag. type Tag struct { - Ignored bool // Field is ignored by Kong. ie. kong:"-" - Cmd bool - Arg bool - Required bool - Optional bool - Name string - Help string - Type string - TypeName string - HasDefault bool - Default string - Format string - PlaceHolder string - Envs []string - Short rune - Hidden bool - Sep rune - MapSep rune - Enum string - Group string - Xor []string - Vars Vars - Prefix string // Optional prefix on anonymous structs. All sub-flags will have this prefix. - EnvPrefix string - Embed bool - Aliases []string - Negatable bool - Passthrough bool + Ignored bool // Field is ignored by Kong. ie. kong:"-" + Cmd bool + Arg bool + Required bool + Optional bool + Name string + Help string + Type string + TypeName string + HasDefault bool + Default string + Format string + PlaceHolder string + Envs []string + Short rune + Hidden bool + Sep rune + MapSep rune + Enum string + Group string + Xor []string + And []string + Vars Vars + Prefix string // Optional prefix on anonymous structs. All sub-flags will have this prefix. + EnvPrefix string + XorPrefix string // Optional prefix on XOR/AND groups. + Embed bool + Aliases []string + Negatable string + Passthrough bool // Deprecated: use PassthroughMode instead. + PassthroughMode PassthroughMode // Storage for all tag keys for arbitrary lookups. items map[string][]string @@ -249,14 +264,23 @@ func hydrateTag(t *Tag, typ reflect.Type) error { //nolint: gocyclo for _, xor := range t.GetAll("xor") { t.Xor = append(t.Xor, strings.FieldsFunc(xor, tagSplitFn)...) } + for _, and := range t.GetAll("and") { + t.And = append(t.And, strings.FieldsFunc(and, tagSplitFn)...) + } t.Prefix = t.Get("prefix") t.EnvPrefix = t.Get("envprefix") + t.XorPrefix = t.Get("xorprefix") t.Embed = t.Has("embed") - negatable := t.Has("negatable") - if negatable && !isBool && !isBoolPtr { - return fmt.Errorf("negatable can only be set on booleans") + if t.Has("negatable") { + if !isBool && !isBoolPtr { + return fmt.Errorf("negatable can only be set on booleans") + } + negatable := t.Get("negatable") + if negatable == "" { + negatable = negatableDefault // placeholder for default negation of --no- + } + t.Negatable = negatable } - t.Negatable = negatable aliases := t.Get("aliases") if len(aliases) > 0 { t.Aliases = append(t.Aliases, strings.FieldsFunc(aliases, tagSplitFn)...) @@ -280,6 +304,17 @@ func hydrateTag(t *Tag, typ reflect.Type) error { //nolint: gocyclo return fmt.Errorf("passthrough only makes sense for positional arguments or commands") } t.Passthrough = passthrough + if t.Passthrough { + passthroughMode := t.Get("passthrough") + switch passthroughMode { + case "partial": + t.PassthroughMode = PassThroughModePartial + case "all", "": + t.PassthroughMode = PassThroughModeAll + default: + return fmt.Errorf("invalid passthrough mode %q, must be one of 'partial' or 'all'", passthroughMode) + } + } return nil } diff --git a/util_test.go b/util_test.go index 5ec0131..78302ef 100644 --- a/util_test.go +++ b/util_test.go @@ -16,9 +16,8 @@ func TestConfigFlag(t *testing.T) { Flag string } - w, err := os.CreateTemp("", "") + w, err := os.CreateTemp(t.TempDir(), "") assert.NoError(t, err) - defer os.Remove(w.Name()) w.WriteString(`{"flag": "hello world"}`) //nolint: errcheck w.Close()