diff --git a/README.md b/README.md index f105b17..a891a61 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,70 @@ 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] + +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 diff --git a/example_test.go b/example_test.go index fd64777..2bd74a2 100644 --- a/example_test.go +++ b/example_test.go @@ -253,6 +253,55 @@ 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 + } + + MustParse(&args) + + // This is only necessary when running inside golang's runnable example harness + osExit = func(int) {} + stdout = os.Stdout + + 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] + // + // 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 diff --git a/parse.go b/parse.go index 0175977..f2fc7bc 100644 --- a/parse.go +++ b/parse.go @@ -63,11 +63,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") @@ -206,7 +218,7 @@ func NewParser(config Config, dests ...interface{}) (*Parser, error) { panic(fmt.Sprintf("%s is not a pointer (did you forget an ampersand?)", t)) } - err := p.cmd.parseFieldsFromStructPointer(t) + err := p.cmd.parseFieldsFromStructPointer(t, false) if err != nil { return nil, err } @@ -214,7 +226,7 @@ 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 p.cmd.specs { + for _, spec := range p.cmd.specs() { // get the value defaultString, defaultValue, err := p.defaultVal(spec.dest) if err != nil { @@ -248,7 +260,7 @@ func NewParser(config Config, dests ...interface{}) (*Parser, 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) error { +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 fmt.Errorf("subcommands must be pointers to structs but %s is a %s", @@ -260,7 +272,33 @@ func (cmd *command) parseFieldsFromStructPointer(t reflect.Type) error { 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) +} +// 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 @@ -342,19 +380,47 @@ func (cmd *command) parseFieldsFromStructPointer(t reflect.Type) error { } 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 if subCmd.name == "" { subCmd.name = strings.ToLower(field.Name) } // parse the subcommand recursively - err := subCmd.parseFieldsFromStructPointer(field.Type) + err := subCmd.parseFieldsFromStructPointer(field.Type, false) if err != nil { errs = append(errs, err.Error()) return false } 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) + + // 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 @@ -417,7 +483,7 @@ func (cmd *command) parseFieldsFromStructPointer(t reflect.Type) 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 @@ -429,7 +495,7 @@ func (cmd *command) parseFieldsFromStructPointer(t reflect.Type) error { // 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 } @@ -531,8 +597,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 { @@ -571,11 +636,11 @@ func (p *Parser) process(args []string) error { p.val(subcmd.dest) // add the new options to the set of allowed options - 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 } diff --git a/parse_test.go b/parse_test.go index 2fa3d92..fe3197a 100644 --- a/parse_test.go +++ b/parse_test.go @@ -288,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 @@ -705,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"` diff --git a/usage.go b/usage.go index 7a480c3..e8a1d49 100644 --- a/usage.go +++ b/usage.go @@ -70,7 +70,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) @@ -216,24 +216,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 { @@ -242,26 +236,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) @@ -296,6 +282,45 @@ 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 []*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) + } + } + + if cmd.parent != nil && len(shortOptions)+len(longOptions) == 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) + } + + // 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 != "" { @@ -304,6 +329,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) } diff --git a/usage_test.go b/usage_test.go index be5894a..0350521 100644 --- a/usage_test.go +++ b/usage_test.go @@ -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,200 @@ 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] + +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] []" + + expectedHelp := ` +Usage: example [-s] [--global] [] + +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 +700,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)