This commit is contained in:
Sebastiaan Pasterkamp 2023-03-05 15:19:37 +00:00 committed by GitHub
commit 8bf6cbfe06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 846 additions and 99 deletions

View File

@ -583,6 +583,71 @@ if p.Subcommand() == nil {
}
```
### Option groups
Option groups are a hybrid between subcommands and embedded structs. Option
groups create logical collections of related arguments with a help description,
and can be embedded in other groups and subcommands. Option groups can combine
configuration structs of multiple modules without requiring embedding.
```go
type Repository struct {
URL string `arg:"--host" help:"URL of the repository" default:"docker.io"`
User string `arg:"--user,env:REPO_USERNAME" help:"username to connect as"`
Password string `arg:"--,env:REPO_PASSWORD" help:"password to connect with"`
}
type BuildCmd struct {
Context string
Tag string
}
type PushCmd struct {
Repo Repository `arg:"group:Repository" help:"Change the default registry to push to."`
Tag string `help:"Tag"`
}
var args struct {
Build *BuildCmd `arg:"subcommand:build"`
Push *PushCmd `arg:"subcommand:push"`
Quiet bool `arg:"-q" help:"Quiet"` // this flag is global to all subcommands
}
arg.MustParse(&args)
switch {
case args.Build != nil:
fmt.Printf("build %s as %q\n", args.Build.Context, args.Build.Tag)
case args.Push != nil:
fmt.Printf("push %q to %q\n", args.Push.Tag, args.Push.Repo.URL)
}
```
The push command help message would look like:
```text
Usage: example push [--tag TAG] [--host HOST] [--user USER]
Options:
--tag TAG Tag
Repository options:
Change the default registry to push to.
--host HOST URL of the repository [default: docker.io]
--user USER username to connect as [env: REPO_USERNAME]
(environment only) password to connect with [env: REPO_PASSWORD]
Global options:
--quiet, -q Quiet
--help, -h display this help and exit
```
Some additional rules apply when working with option groups:
* The `group` tag can only be used with fields that are structs or pointers to structs.
* Specifying default values in nested struct pointers _always_ result in an initialized struct.
* Option groups may not contain any positionals.
* Option groups cannot contain sub-commands.
```
### API Documentation
https://godoc.org/github.com/alexflint/go-arg

View File

@ -250,6 +250,53 @@ func Example_helpTextWithSubcommand() {
// list list available items
}
// This example shows the usage string generated by go-arg when using argument groups
func Example_helpTextWithGroups() {
os.Args = split("./example push --help")
type Repository struct {
URL string `arg:"--host" help:"URL of the repository" default:"docker.io"`
User string `arg:"--user,env:REPO_USERNAME" help:"username to connect as"`
Password string `arg:"--,env:REPO_PASSWORD" help:"password to connect with"`
}
type BuildCmd struct {
Context string
Tag string
}
type PushCmd struct {
Repo Repository `arg:"group:Repository" help:"Change the default registry to push to."`
Tag string `help:"Tag"`
}
var args struct {
Build *BuildCmd `arg:"subcommand:build"`
Push *PushCmd `arg:"subcommand:push"`
Quiet bool `arg:"-q" help:"Quiet"` // this flag is global to all subcommands
}
// This is only necessary when running inside golang's runnable example harness
mustParseExit = func(int) {}
MustParse(&args)
// output:
// Usage: example push [--tag TAG] [--host HOST] [--user USER]
//
// Options:
// --tag TAG Tag
//
// Repository options:
//
// Change the default registry to push to.
//
// --host HOST URL of the repository [default: docker.io]
// --user USER username to connect as [env: REPO_USERNAME]
// (environment only) password to connect with [env: REPO_PASSWORD]
//
// Global options:
// --quiet, -q Quiet
// --help, -h display this help and exit
}
// This example shows the usage string generated by go-arg when using subcommands
func Example_helpTextWhenUsingSubcommand() {
// These are the args you would pass in on the command line

215
parse.go
View File

@ -64,11 +64,23 @@ type command struct {
name string
help string
dest path
specs []*spec
options []*spec
subcommands []*command
groups []*command
parent *command
}
// specs gets all the specs from this command plus all nested option groups,
// recursively through descendants
func (cmd command) specs() []*spec {
var specs []*spec
specs = append(specs, cmd.options...)
for _, grpcmd := range cmd.groups {
specs = append(specs, grpcmd.specs()...)
}
return specs
}
// ErrHelp indicates that -h or --help were provided
var ErrHelp = errors.New("help requested by user")
@ -234,13 +246,13 @@ func NewParser(config Config, dests ...interface{}) (*Parser, error) {
}
// process each of the destination values
for i, dest := range dests {
for _, dest := range dests {
t := reflect.TypeOf(dest)
if t.Kind() != reflect.Ptr {
panic(fmt.Sprintf("%s is not a pointer (did you forget an ampersand?)", t))
}
cmd, err := cmdFromStruct(name, path{root: i}, t)
err := p.cmd.parseFieldsFromStructPointer(t, false)
if err != nil {
return nil, err
}
@ -248,33 +260,22 @@ func NewParser(config Config, dests ...interface{}) (*Parser, error) {
// for backwards compatibility, add nonzero field values as defaults
// this applies only to the top-level command, not to subcommands (this inconsistency
// is the reason that this method for setting default values was deprecated)
for _, spec := range cmd.specs {
for _, spec := range p.cmd.specs() {
// get the value
v := p.val(spec.dest)
defaultString, defaultValue, err := p.defaultVal(spec.dest)
if err != nil {
return nil, err
}
// if the value is the "zero value" (e.g. nil pointer, empty struct) then ignore
if isZero(v) {
if defaultString == "" {
continue
}
// store as a default
spec.defaultValue = v
// we need a string to display in help text
// if MarshalText is implemented then use that
if m, ok := v.Interface().(encoding.TextMarshaler); ok {
s, err := m.MarshalText()
if err != nil {
return nil, fmt.Errorf("%v: error marshaling default value to string: %v", spec.dest, err)
spec.defaultString = defaultString
spec.defaultValue = defaultValue
}
spec.defaultString = string(s)
} else {
spec.defaultString = fmt.Sprintf("%v", v)
}
}
p.cmd.specs = append(p.cmd.specs, cmd.specs...)
p.cmd.subcommands = append(p.cmd.subcommands, cmd.subcommands...)
if dest, ok := dest.(Versioned); ok {
p.version = dest.Version()
@ -290,24 +291,48 @@ func NewParser(config Config, dests ...interface{}) (*Parser, error) {
return &p, nil
}
func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) {
// parseFieldsFromStructPointer ensures the destination structure is a pointer
// to a struct. This function should be called when parsing commands or
// subcommands as they can only be a struct pointer.
func (cmd *command) parseFieldsFromStructPointer(t reflect.Type, insideGroup bool) error {
// commands can only be created from pointers to structs
if t.Kind() != reflect.Ptr {
return nil, fmt.Errorf("subcommands must be pointers to structs but %s is a %s",
dest, t.Kind())
return fmt.Errorf("subcommands must be pointers to structs but %s is a %s",
cmd.dest, t.Kind())
}
t = t.Elem()
if t.Kind() != reflect.Struct {
return nil, fmt.Errorf("subcommands must be pointers to structs but %s is a pointer to %s",
dest, t.Kind())
return fmt.Errorf("subcommands must be pointers to structs but %s is a pointer to %s",
cmd.dest, t.Kind())
}
return cmd.parseStruct(t, insideGroup)
}
cmd := command{
name: name,
dest: dest,
// parseFieldsFromStructOrStructPointer ensures the destination structure is
// either a pointer to a struct, or a struct. This function should be called
// when parsing option groups as they can only be a struct, or a pointer to one.
func (cmd *command) parseFieldsFromStructOrStructPointer(t reflect.Type, insideGroup bool) error {
// option groups can only be created from structs or pointers to structs
typeHint := ""
if t.Kind() == reflect.Ptr {
typeHint = "a pointer to "
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return fmt.Errorf("option groups must be structs or pointers to structs, but %s is %s%s",
cmd.dest, typeHint, t.Kind())
}
return cmd.parseStruct(t, insideGroup)
}
// parseStruct populates the command instance based on the type and annotations
// of the target struct. As these command instances are used for either (sub)
// commands or option groups, please refer to the parseFieldsFromStructPointer
// or parseFieldsFromStructOrStructPointer respectively.
func (cmd *command) parseStruct(t reflect.Type, insideGroup bool) error {
var errs []string
walkFields(t, func(field reflect.StructField, t reflect.Type) bool {
// check for the ignore switch in the tag
@ -329,7 +354,7 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) {
}
// duplicate the entire path to avoid slice overwrites
subdest := dest.Child(field)
subdest := cmd.dest.Child(field)
spec := spec{
dest: subdest,
field: field,
@ -342,7 +367,6 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) {
}
// Look at the tag
var isSubcommand bool // tracks whether this field is a subcommand
for _, key := range strings.Split(tag, ",") {
if key == "" {
continue
@ -382,24 +406,55 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) {
spec.env = strings.ToUpper(field.Name)
}
case key == "subcommand":
subCmd := command{
name: value,
dest: subdest,
parent: cmd,
help: field.Tag.Get("help"),
}
cmd.subcommands = append(cmd.subcommands, &subCmd)
if insideGroup {
errs = append(errs, fmt.Sprintf("%s.%s: %s subcommands cannot be part of option groups",
t.Name(), field.Name, field.Type.String()))
return false
}
// decide on a name for the subcommand
cmdname := value
if cmdname == "" {
cmdname = strings.ToLower(field.Name)
if subCmd.name == "" {
subCmd.name = strings.ToLower(field.Name)
}
// parse the subcommand recursively
subcmd, err := cmdFromStruct(cmdname, subdest, field.Type)
err := subCmd.parseFieldsFromStructPointer(field.Type, false)
if err != nil {
errs = append(errs, err.Error())
return false
}
subcmd.parent = &cmd
subcmd.help = field.Tag.Get("help")
return true
case key == "group":
// parse the option group recursively
optGrp := command{
name: value,
dest: subdest,
parent: cmd,
help: field.Tag.Get("help"),
}
cmd.groups = append(cmd.groups, &optGrp)
cmd.subcommands = append(cmd.subcommands, subcmd)
isSubcommand = true
// decide on a name for the group
if optGrp.name == "" {
optGrp.name = strings.Title(field.Name)
}
err := optGrp.parseFieldsFromStructOrStructPointer(field.Type, true)
if err != nil {
errs = append(errs, err.Error())
return false
}
return false
default:
errs = append(errs, fmt.Sprintf("unrecognized tag '%s' on field %s", key, tag))
return false
@ -415,11 +470,6 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) {
spec.placeholder = strings.ToUpper(spec.field.Name)
}
// if this is a subcommand then we've done everything we need to do
if isSubcommand {
return false
}
// check whether this field is supported. It's good to do this here rather than
// wait until ParseValue because it means that a program with invalid argument
// fields will always fail regardless of whether the arguments it received
@ -467,28 +517,30 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) {
}
// add the spec to the list of specs
cmd.specs = append(cmd.specs, &spec)
cmd.options = append(cmd.options, &spec)
// if this was an embedded field then we already returned true up above
return false
})
if len(errs) > 0 {
return nil, errors.New(strings.Join(errs, "\n"))
return errors.New(strings.Join(errs, "\n"))
}
// check that we don't have both positionals and subcommands
var hasPositional bool
for _, spec := range cmd.specs {
for _, spec := range cmd.options {
if spec.positional {
hasPositional = true
}
}
if hasPositional && len(cmd.subcommands) > 0 {
return nil, fmt.Errorf("%s cannot have both subcommands and positional arguments", dest)
return fmt.Errorf("%s cannot have both subcommands and positional arguments",
cmd.dest)
}
return &cmd, nil
return nil
}
// Parse processes the given command line option, storing the results in the field
@ -579,8 +631,7 @@ func (p *Parser) process(args []string) error {
p.lastCmd = curCmd
// make a copy of the specs because we will add to this list each time we expand a subcommand
specs := make([]*spec, len(curCmd.specs))
copy(specs, curCmd.specs)
specs := curCmd.specs()
// deal with environment vars
if !p.config.IgnoreEnv {
@ -615,23 +666,20 @@ func (p *Parser) process(args []string) error {
return fmt.Errorf("invalid subcommand: %s", arg)
}
// instantiate the field to point to a new struct
v := p.val(subcmd.dest)
if v.IsNil() {
v.Set(reflect.New(v.Type().Elem())) // we already checked that all subcommands are struct pointers
}
// ensure the command struct exists (is not a nil pointer)
p.val(subcmd.dest)
// add the new options to the set of allowed options
if p.config.StrictSubcommands {
specs = make([]*spec, len(subcmd.specs))
copy(specs, subcmd.specs)
specs = make([]*spec, len(subcmd.specs()))
copy(specs, subcmd.specs())
} else {
specs = append(specs, subcmd.specs...)
specs = append(specs, subcmd.specs()...)
}
// capture environment vars for these new options
if !p.config.IgnoreEnv {
err := p.captureEnvVars(subcmd.specs, wasPresent)
err := p.captureEnvVars(subcmd.specs(), wasPresent)
if err != nil {
return err
}
@ -788,20 +836,57 @@ func isFlag(s string) bool {
return strings.HasPrefix(s, "-") && strings.TrimLeft(s, "-") != ""
}
// val returns a reflect.Value corresponding to the current value for the
// given path
func (p *Parser) val(dest path) reflect.Value {
// defaultVal returns the string representation of the value at dest if it is
// reachable without traversing nil pointers, but only if it does not represent
// the default value for the type.
func (p *Parser) defaultVal(dest path) (string, reflect.Value, error) {
v := p.roots[dest.root]
for _, field := range dest.fields {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return reflect.Value{}
return "", v, nil
}
v = v.Elem()
}
v = v.FieldByIndex(field.Index)
}
if !v.IsValid() || isZero(v) {
return "", v, nil
}
if defaultVal, ok := v.Interface().(encoding.TextMarshaler); ok {
str, err := defaultVal.MarshalText()
if err != nil {
return "", v, fmt.Errorf("%v: error marshaling default value to string: %w", dest, err)
}
return string(str), v, nil
}
return fmt.Sprintf("%v", v), v, nil
}
// val returns a reflect.Value corresponding to the current value for the
// given path initiating nil pointers in the path
func (p *Parser) val(dest path) reflect.Value {
v := p.roots[dest.root]
for _, field := range dest.fields {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
v.Set(reflect.New(v.Type().Elem()))
}
v = v.Elem()
}
v = v.FieldByIndex(field.Index)
}
// Don't return a nil-pointer
if v.Kind() == reflect.Ptr && v.IsNil() {
v.Set(reflect.New(v.Type().Elem()))
}
return v
}

View File

@ -44,12 +44,33 @@ func parseWithEnv(cmdline string, env []string, dest interface{}) (*Parser, erro
}
// split the environment vars
newEnv := map[string]string{}
oldEnv := map[string]string{}
for _, s := range env {
pos := strings.Index(s, "=")
if pos == -1 {
return nil, fmt.Errorf("missing equals sign in %q", s)
}
err := os.Setenv(s[:pos], s[pos+1:])
if orig, ok := os.LookupEnv(s[:pos]); ok {
oldEnv[s[:pos]] = orig
}
newEnv[s[:pos]] = s[pos+1:]
}
defer func() {
for key, _ := range newEnv {
if orig, ok := oldEnv[key]; ok {
_ = os.Setenv(key, orig)
} else {
_ = os.Unsetenv(key)
}
}
}()
for key, val := range newEnv {
err := os.Setenv(key, val)
if err != nil {
return nil, err
}
@ -267,6 +288,220 @@ func TestLongFlag(t *testing.T) {
assert.Equal(t, "xyz", args.Foo)
}
func TestGroupRequired(t *testing.T) {
var args struct {
Foo *struct {
Bar string `arg:"required"`
} `arg:"group"`
}
err := parse("", &args)
require.Error(t, err, "--bar is required")
}
func TestNonPointerGroup(t *testing.T) {
var args struct {
Group struct {
Foo string
} `arg:"group"`
}
_, err := NewParser(Config{IgnoreEnv: true}, &args)
require.NoError(t, err)
}
func TestNonStructGroup(t *testing.T) {
var args struct {
NotGroup int `arg:"group"`
}
_, err := NewParser(Config{IgnoreEnv: true}, &args)
require.Error(t, err, "option groups must be structs or pointers to structs, but args.NotGroup is int")
}
func TestNonStructPtrGroup(t *testing.T) {
var args struct {
NotGroup *int `arg:"group"`
}
_, err := NewParser(Config{IgnoreEnv: true}, &args)
require.Error(t, err, "option groups must be structs or pointers to structs, but args.NotGroup is a pointer to int")
}
func TestNoSubcommandInGroup(t *testing.T) {
var args struct {
Group struct {
Sub *struct{} `arg:"subcommand"`
} `arg:"group"`
}
_, err := NewParser(Config{IgnoreEnv: true}, &args)
require.Error(t, err, "subcommands cannot be part of option groups")
}
func TestGroupParsingWithoutArguments(t *testing.T) {
type perm struct {
Anent string
}
type opt struct {
Ional string
}
type def struct {
Ault string `default:"permanent"`
}
type args struct {
Foo string
PermanentGroup perm `arg:"group:Permanent"`
OptionalGroup *opt `arg:"group:Optional"`
OptionalWithDefault *def `arg:"group:With default"`
Input string `arg:"positional"`
}
var opts args
err := parse("", &opts)
require.NoError(t, err)
assert.Equal(t, args{
OptionalWithDefault: &def{
Ault: "permanent",
},
}, opts)
}
func TestGroupParsingWithPositional(t *testing.T) {
type perm struct {
Anent string
}
type opt struct {
Ional string
}
type def struct {
Ault string `default:"permanent"`
}
type args struct {
Foo string
PermanentGroup perm `arg:"group:Permanent"`
OptionalGroup *opt `arg:"group:Optional"`
OptionalWithDefault *def `arg:"group:With default"`
Input string `arg:"positional"`
}
var opts args
err := parse("input", &opts)
require.NoError(t, err)
assert.Equal(t, args{
OptionalWithDefault: &def{
Ault: "permanent",
},
Input: "input",
}, opts)
}
func TestGroupParsingOfGroupPointer(t *testing.T) {
type perm struct {
Anent string
}
type opt struct {
Ional string
}
type def struct {
Ault string `default:"permanent"`
}
type args struct {
Foo string
PermanentGroup perm `arg:"group:Permanent"`
OptionalGroup *opt `arg:"group:Optional"`
OptionalWithDefault *def `arg:"group:With default"`
Input string `arg:"positional"`
}
var opts args
err := parse("--ional pointer", &opts)
require.NoError(t, err)
assert.Equal(t, args{
OptionalGroup: &opt{
Ional: "pointer",
},
OptionalWithDefault: &def{
Ault: "permanent",
},
}, opts)
}
func TestGroupParsingOfGroupStruct(t *testing.T) {
type perm struct {
Anent string
}
type opt struct {
Ional string
}
type def struct {
Ault string `default:"permanent"`
}
type args struct {
Foo string
PermanentGroup perm `arg:"group:Permanent"`
OptionalGroup *opt `arg:"group:Optional"`
OptionalWithDefault *def `arg:"group:With default"`
Input string `arg:"positional"`
}
var opts args
err := parse("--anent struct", &opts)
require.NoError(t, err)
assert.Equal(t, args{
PermanentGroup: perm{
Anent: "struct",
},
OptionalWithDefault: &def{
Ault: "permanent",
},
}, opts)
}
func TestGroupParsingWithMixedTypes(t *testing.T) {
type perm struct {
Anent string
}
type opt struct {
Ional string
}
type def struct {
Ault string `default:"permanent"`
}
type args struct {
Foo string
PermanentGroup perm `arg:"group:Permanent"`
OptionalGroup *opt `arg:"group:Optional"`
OptionalWithDefault *def `arg:"group:With default"`
Input string `arg:"positional"`
}
var opts args
err := parse("--foo bar -ional pointer --anent struct last", &opts)
require.NoError(t, err)
assert.Equal(t, args{
Foo: "bar",
OptionalGroup: &opt{Ional: "pointer"},
PermanentGroup: perm{Anent: "struct"},
OptionalWithDefault: &def{Ault: "permanent"},
Input: "last",
}, opts)
}
func TestSlice(t *testing.T) {
var args struct {
Strings []string
@ -684,6 +919,84 @@ func TestMustParse(t *testing.T) {
assert.NotNil(t, parser)
}
func TestNewParserWithEnv(t *testing.T) {
type simpleEnv struct {
Foo string `arg:"env"`
}
// No env, no command line: value is empty
dest := &simpleEnv{}
_, err := parseWithEnv("", []string{}, dest)
require.NoError(t, err)
assert.Equal(t, &simpleEnv{Foo: ""}, dest)
// With env, no command line: value takes environment
dest = &simpleEnv{}
_, err = parseWithEnv("", []string{"FOO=env"}, dest)
require.NoError(t, err)
assert.Equal(t, &simpleEnv{Foo: "env"}, dest)
// No env, with command line: value takes argument
dest = &simpleEnv{}
_, err = parseWithEnv("--foo=cmd", []string{}, dest)
require.NoError(t, err)
assert.Equal(t, &simpleEnv{Foo: "cmd"}, dest)
// With env, with command line: value takes argument
dest = &simpleEnv{}
_, err = parseWithEnv("--foo=cmd", []string{"FOO=env"}, dest)
require.NoError(t, err)
assert.Equal(t, &simpleEnv{Foo: "cmd"}, dest)
}
func TestNewParserWithEnvAndDefault(t *testing.T) {
type envWithDefault struct {
Foo string `arg:"env" default:"def"`
}
// No env, no command line: value takes default
dest := &envWithDefault{}
_, err := parseWithEnv("", []string{}, dest)
require.NoError(t, err)
assert.Equal(t, &envWithDefault{Foo: "def"}, dest)
// With env, no command line: value takes environment
dest = &envWithDefault{}
_, err = parseWithEnv("", []string{"FOO=env"}, dest)
require.NoError(t, err)
assert.Equal(t, &envWithDefault{Foo: "env"}, dest)
// No env, with command line: value takes argument
dest = &envWithDefault{}
_, err = parseWithEnv("--foo=cmd", []string{}, dest)
require.NoError(t, err)
assert.Equal(t, &envWithDefault{Foo: "cmd"}, dest)
// With env, with command line: value takes argument
dest = &envWithDefault{}
_, err = parseWithEnv("--foo=cmd", []string{"FOO=env"}, dest)
require.NoError(t, err)
assert.Equal(t, &envWithDefault{Foo: "cmd"}, dest)
}
func TestNewParserWithoutArgumentWithEnvAndDefault(t *testing.T) {
type noArgWithEnvAndWithDefault struct {
Foo string `arg:"--,env" default:"def"`
}
// No env, no command line: value takes default
dest := &noArgWithEnvAndWithDefault{}
_, err := parseWithEnv("", []string{}, dest)
require.NoError(t, err)
assert.Equal(t, &noArgWithEnvAndWithDefault{Foo: "def"}, dest)
// With env, no command line: value takes environment
dest = &noArgWithEnvAndWithDefault{}
_, err = parseWithEnv("", []string{"FOO=env"}, dest)
require.NoError(t, err)
assert.Equal(t, &noArgWithEnvAndWithDefault{Foo: "env"}, dest)
}
func TestEnvironmentVariable(t *testing.T) {
var args struct {
Foo string `arg:"env"`
@ -901,6 +1214,17 @@ func TestParserMustParse(t *testing.T) {
}
}
func TestNonPointerSubcommand(t *testing.T) {
var args struct {
Sub struct {
Foo string `arg:"env"`
} `arg:"subcommand"`
}
_, err := NewParser(Config{IgnoreEnv: true}, &args)
require.Error(t, err, "subcommands must be pointers to structs but args.Sub is a struct")
}
type textUnmarshaler struct {
val int
}

View File

@ -1,7 +1,6 @@
package arg
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
@ -402,12 +401,8 @@ func TestValForNilStruct(t *testing.T) {
Sub *subcmd `arg:"subcommand"`
}
p, err := NewParser(Config{}, &cmd)
_, err := NewParser(Config{}, &cmd)
require.NoError(t, err)
typ := reflect.TypeOf(cmd)
subField, _ := typ.FieldByName("Sub")
v := p.val(path{fields: []reflect.StructField{subField, subField}})
assert.False(t, v.IsValid())
require.Nil(t, cmd.Sub)
}

View File

@ -62,7 +62,7 @@ func (p *Parser) WriteUsageForSubcommand(w io.Writer, subcommand ...string) erro
// writeUsageForSubcommand writes usage information for the given subcommand
func (p *Parser) writeUsageForSubcommand(w io.Writer, cmd *command) {
var positionals, longOptions, shortOptions []*spec
for _, spec := range cmd.specs {
for _, spec := range cmd.specs() {
switch {
case spec.positional:
positionals = append(positionals, spec)
@ -208,24 +208,18 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error
// writeHelp writes the usage string for the given subcommand
func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) {
var positionals, longOptions, shortOptions []*spec
for _, spec := range cmd.specs {
switch {
case spec.positional:
positionals = append(positionals, spec)
case spec.long != "":
longOptions = append(longOptions, spec)
case spec.short != "":
shortOptions = append(shortOptions, spec)
}
}
if p.description != "" {
fmt.Fprintln(w, p.description)
}
p.writeUsageForSubcommand(w, cmd)
// write the list of positionals
var positionals []*spec
for _, spec := range cmd.options {
if spec.positional {
positionals = append(positionals, spec)
}
}
if len(positionals) > 0 {
fmt.Fprint(w, "\nPositional arguments:\n")
for _, spec := range positionals {
@ -234,26 +228,18 @@ func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) {
}
// write the list of options with the short-only ones first to match the usage string
if len(shortOptions)+len(longOptions) > 0 || cmd.parent == nil {
fmt.Fprint(w, "\nOptions:\n")
for _, spec := range shortOptions {
p.printOption(w, spec)
}
for _, spec := range longOptions {
p.printOption(w, spec)
}
}
p.writeHelpForArguments(w, cmd, "Options", "")
// obtain a flattened list of options from all ancestors
var globals []*spec
ancestor := cmd.parent
for ancestor != nil {
globals = append(globals, ancestor.specs...)
globals = append(globals, ancestor.specs()...)
ancestor = ancestor.parent
}
// write the list of global options
if len(globals) > 0 {
if len(globals) > 0 || len(cmd.groups) > 0 {
fmt.Fprint(w, "\nGlobal options:\n")
for _, spec := range globals {
p.printOption(w, spec)
@ -288,6 +274,50 @@ func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) {
}
}
// writeHelpForArguments writes the list of short, long, and environment-only
// options in order.
func (p *Parser) writeHelpForArguments(w io.Writer, cmd *command, header, help string) {
var positionals, longOptions, shortOptions, envOnly []*spec
for _, spec := range cmd.options {
switch {
case spec.positional:
positionals = append(positionals, spec)
case spec.long != "":
longOptions = append(longOptions, spec)
case spec.short != "":
shortOptions = append(shortOptions, spec)
case spec.env != "":
envOnly = append(envOnly, spec)
}
}
if cmd.parent != nil && len(shortOptions)+len(longOptions)+len(envOnly) == 0 {
return
}
// write the list of options with the short-only ones first to match the usage string
fmt.Fprintf(w, "\n%v:\n", header)
if help != "" {
fmt.Fprintf(w, "\n%v\n\n", help)
}
for _, spec := range shortOptions {
p.printOption(w, spec)
}
for _, spec := range longOptions {
p.printOption(w, spec)
}
for _, spec := range envOnly {
p.printOption(w, spec)
}
// write the list of argument groups
if len(cmd.groups) > 0 {
for _, grpCmd := range cmd.groups {
p.writeHelpForArguments(w, grpCmd, fmt.Sprintf("%s options", grpCmd.name), grpCmd.help)
}
}
}
func (p *Parser) printOption(w io.Writer, spec *spec) {
ways := make([]string, 0, 2)
if spec.long != "" {
@ -296,6 +326,9 @@ func (p *Parser) printOption(w io.Writer, spec *spec) {
if spec.short != "" {
ways = append(ways, synopsis(spec, "-"+spec.short))
}
if spec.env != "" && len(ways) == 0 {
ways = append(ways, "(environment only)")
}
if len(ways) > 0 {
printTwoCols(w, strings.Join(ways, ", "), spec.help, spec.defaultString, spec.env)
}

View File

@ -50,7 +50,7 @@ Options:
--optimize OPTIMIZE, -O OPTIMIZE
optimization level
--ids IDS Ids
--values VALUES Values
--values VALUES Values [default: [3.14 42 256]]
--workers WORKERS, -w WORKERS
number of workers to start [default: 10, env: WORKERS]
--testenv TESTENV, -a TESTENV [env: TEST_ENV]
@ -74,6 +74,7 @@ Options:
}
args.Name = "Foo Bar"
args.Value = 42
args.Values = []float64{3.14, 42, 256}
args.File = &NameDotName{"scratch", "txt"}
p, err := NewParser(Config{Program: "example"}, &args)
require.NoError(t, err)
@ -489,6 +490,201 @@ func TestNonexistentSubcommand(t *testing.T) {
assert.Error(t, err)
}
func TestUsageWithOptionGroup(t *testing.T) {
expectedUsage := "Usage: example [--verbose] [--insecure] [--host HOST] [--port PORT] [--user USER] OUTPUT"
expectedHelp := `
Usage: example [--verbose] [--insecure] [--host HOST] [--port PORT] [--user USER] OUTPUT
Positional arguments:
OUTPUT
Options:
--verbose, -v verbosity level
Database options:
This block represents related arguments.
--insecure, -i disable tls
--host HOST hostname to connect to [default: localhost, env: DB_HOST]
--port PORT port to connect to [default: 3306, env: DB_PORT]
--user USER username to connect as [env: DB_USERNAME]
(environment only) password to connect with [env: DB_PASSWORD]
Global options:
--help, -h display this help and exit
`
type database struct {
Insecure bool `arg:"-i,--insecure" help:"disable tls"`
Host string `arg:"--host,env:DB_HOST" help:"hostname to connect to" default:"localhost"`
Port string `arg:"--port,env:DB_PORT" help:"port to connect to" default:"3306"`
User string `arg:"--user,env:DB_USERNAME" help:"username to connect as"`
Password string `arg:"--,env:DB_PASSWORD" help:"password to connect with"`
}
var args struct {
Verbose bool `arg:"-v" help:"verbosity level"`
Database *database `arg:"group" help:"This block represents related arguments."`
Output string `arg:"positional,required"`
}
os.Args[0] = "example"
p, err := NewParser(Config{}, &args)
require.NoError(t, err)
_ = p.Parse([]string{})
var help bytes.Buffer
p.WriteHelp(&help)
assert.Equal(t, expectedHelp[1:], help.String())
var usage bytes.Buffer
p.WriteUsage(&usage)
assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String()))
}
func TestUsageWithoutSubcommandAndOptionGroup(t *testing.T) {
expectedUsage := "Usage: example [-s] [--global] <command> [<args>]"
expectedHelp := `
Usage: example [-s] [--global] <command> [<args>]
Options:
--global, -g global option
Global group options:
This block represents related arguments.
-s global something
Global options:
--help, -h display this help and exit
Commands:
foo Command A
bar Command B
`
var args struct {
Global bool `arg:"-g" help:"global option"`
GlobalGroup *struct {
Something bool `arg:"-s,--" help:"global something"`
} `arg:"group:Global group" help:"This block represents related arguments."`
CommandA *struct {
OptionA bool `arg:"-a,--" help:"option for sub A"`
GroupA *struct {
GroupA bool `arg:"--group-a" help:"group belonging to cmd A"`
} `arg:"group:Group A" help:"This block belongs to command A."`
} `arg:"subcommand:foo" help:"Command A"`
CommandB *struct {
OptionB bool `arg:"-b,--" help:"option for sub B"`
GroupB *struct {
GroupB bool `arg:"--group-b" help:"group belonging to cmd B"`
NestedGroup *struct {
NestedGroup bool `arg:"--nested-group" help:"nested group belonging to group B of cmd B"`
} `arg:"group:Nested Group" help:"This block belongs to group B of command B."`
} `arg:"group:Group B" help:"This block belongs to command B."`
} `arg:"subcommand:bar" help:"Command B"`
}
os.Args[0] = "example"
p, err := NewParser(Config{}, &args)
require.NoError(t, err)
_ = p.Parse([]string{})
var help bytes.Buffer
p.WriteHelp(&help)
assert.Equal(t, expectedHelp[1:], help.String())
var help2 bytes.Buffer
p.WriteHelpForSubcommand(&help2)
assert.Equal(t, expectedHelp[1:], help2.String())
var usage bytes.Buffer
p.WriteUsage(&usage)
assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String()))
var usage2 bytes.Buffer
p.WriteUsageForSubcommand(&usage2)
assert.Equal(t, expectedUsage, strings.TrimSpace(usage2.String()))
}
func TestUsageWithSubcommandAndOptionGroup(t *testing.T) {
expectedUsage := "Usage: example bar [-b] [--group-b] [--nested-group]"
expectedHelp := `
Usage: example bar [-b] [--group-b] [--nested-group]
Options:
-b option for sub B
Group B options:
This block belongs to command B.
--group-b group belonging to cmd B
Nested Group options:
This block belongs to group B of command B.
--nested-group nested group belonging to group B of cmd B
Global options:
--global, -g global option
-s global something
--help, -h display this help and exit
`
var args struct {
Global bool `arg:"-g" help:"global option"`
GlobalGroup *struct {
Something bool `arg:"-s,--" help:"global something"`
} `arg:"group:Global group" help:"This block represents related arguments."`
CommandA *struct {
OptionA bool `arg:"-a,--" help:"option for sub A"`
GroupA *struct {
GroupA bool `arg:"--group-a" help:"group belonging to cmd A"`
} `arg:"group:Group A" help:"This block belongs to command A."`
} `arg:"subcommand:foo" help:"Command A"`
CommandB *struct {
OptionB bool `arg:"-b,--" help:"option for sub B"`
GroupB *struct {
GroupB bool `arg:"--group-b" help:"group belonging to cmd B"`
NestedGroup *struct {
NestedGroup bool `arg:"--nested-group" help:"nested group belonging to group B of cmd B"`
} `arg:"group:Nested Group" help:"This block belongs to group B of command B."`
} `arg:"group:Group B" help:"This block belongs to command B."`
} `arg:"subcommand:bar" help:"Command B"`
}
os.Args[0] = "example"
p, err := NewParser(Config{}, &args)
require.NoError(t, err)
_ = p.Parse([]string{"bar"})
var help bytes.Buffer
p.WriteHelp(&help)
assert.Equal(t, expectedHelp[1:], help.String())
var help2 bytes.Buffer
p.WriteHelpForSubcommand(&help2, "bar")
assert.Equal(t, expectedHelp[1:], help2.String())
var usage bytes.Buffer
p.WriteUsage(&usage)
assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String()))
var usage2 bytes.Buffer
p.WriteUsageForSubcommand(&usage2, "bar")
assert.Equal(t, expectedUsage, strings.TrimSpace(usage2.String()))
}
func TestUsageWithoutLongNames(t *testing.T) {
expectedUsage := "Usage: example [-a PLACEHOLDER] -b SHORTONLY2"
@ -505,7 +701,7 @@ Options:
ShortOnly2 string `arg:"-b,--,required" help:"some help2"`
}
p, err := NewParser(Config{Program: "example"}, &args)
require.NoError(t, err)
assert.NoError(t, err)
var help bytes.Buffer
p.WriteHelp(&help)
@ -551,6 +747,8 @@ Usage: example [-s SHORT]
Options:
-s SHORT [env: SHORT]
(environment only) [env: ENVONLY]
(environment only) [env: CUSTOM]
--help, -h display this help and exit
`
var args struct {