add subcommand aliases

This commit is contained in:
Alex Flint 2023-10-08 20:09:05 -04:00
parent 5ec29ce755
commit 0142b0b842
5 changed files with 136 additions and 116 deletions

View File

@ -62,6 +62,7 @@ type spec struct {
// command represents a named subcommand, or the top-level command // command represents a named subcommand, or the top-level command
type command struct { type command struct {
name string name string
aliases []string
help string help string
dest path dest path
specs []*spec specs []*spec
@ -153,7 +154,7 @@ type Parser struct {
epilogue string epilogue string
// the following field changes during processing of command line arguments // the following field changes during processing of command line arguments
lastCmd *command subcommand []string
} }
// Versioned is the interface that the destination struct should implement to // Versioned is the interface that the destination struct should implement to
@ -384,18 +385,24 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) {
} }
case key == "subcommand": case key == "subcommand":
// decide on a name for the subcommand // decide on a name for the subcommand
cmdname := value var cmdnames []string
if cmdname == "" { if value == "" {
cmdname = strings.ToLower(field.Name) cmdnames = []string{strings.ToLower(field.Name)}
} else {
cmdnames = strings.Split(value, "|")
}
for i := range cmdnames {
cmdnames[i] = strings.TrimSpace(cmdnames[i])
} }
// parse the subcommand recursively // parse the subcommand recursively
subcmd, err := cmdFromStruct(cmdname, subdest, field.Type) subcmd, err := cmdFromStruct(cmdnames[0], subdest, field.Type)
if err != nil { if err != nil {
errs = append(errs, err.Error()) errs = append(errs, err.Error())
return false return false
} }
subcmd.aliases = cmdnames[1:]
subcmd.parent = &cmd subcmd.parent = &cmd
subcmd.help = field.Tag.Get("help") subcmd.help = field.Tag.Get("help")
@ -514,13 +521,13 @@ func (p *Parser) MustParse(args []string) {
err := p.Parse(args) err := p.Parse(args)
switch { switch {
case err == ErrHelp: case err == ErrHelp:
p.writeHelpForSubcommand(p.config.Out, p.lastCmd) p.WriteHelpForSubcommand(p.config.Out, p.subcommand...)
p.config.Exit(0) p.config.Exit(0)
case err == ErrVersion: case err == ErrVersion:
fmt.Fprintln(p.config.Out, p.version) fmt.Fprintln(p.config.Out, p.version)
p.config.Exit(0) p.config.Exit(0)
case err != nil: case err != nil:
p.failWithSubcommand(err.Error(), p.lastCmd) p.FailSubcommand(err.Error(), p.subcommand...)
} }
} }
@ -577,7 +584,7 @@ func (p *Parser) process(args []string) error {
// union of specs for the chain of subcommands encountered so far // union of specs for the chain of subcommands encountered so far
curCmd := p.cmd curCmd := p.cmd
p.lastCmd = curCmd p.subcommand = nil
// make a copy of the specs because we will add to this list each time we expand a subcommand // 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)) specs := make([]*spec, len(curCmd.specs))
@ -648,7 +655,7 @@ func (p *Parser) process(args []string) error {
} }
curCmd = subcmd curCmd = subcmd
p.lastCmd = curCmd p.subcommand = append(p.subcommand, arg)
continue continue
} }
@ -842,6 +849,11 @@ func findSubcommand(cmds []*command, name string) *command {
if cmd.name == name { if cmd.name == name {
return cmd return cmd
} }
for _, alias := range cmd.aliases {
if alias == name {
return cmd
}
}
} }
return nil return nil
} }

View File

@ -883,7 +883,8 @@ func TestEnvironmentVariableInSubcommandIgnored(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = p.Parse([]string{"sub"}) err = p.Parse([]string{"sub"})
assert.NoError(t, err) require.NoError(t, err)
require.NotNil(t, args.Sub)
assert.Equal(t, "", args.Sub.Foo) assert.Equal(t, "", args.Sub.Foo)
} }
@ -1731,7 +1732,8 @@ func TestSubcommandGlobalFlag_InCommand_Strict_Inner(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = p.Parse([]string{"sub", "-g"}) err = p.Parse([]string{"sub", "-g"})
assert.NoError(t, err) require.NoError(t, err)
assert.False(t, args.Global) assert.False(t, args.Global)
require.NotNil(t, args.Sub)
assert.True(t, args.Sub.Guard) assert.True(t, args.Sub.Guard)
} }

View File

@ -1,5 +1,7 @@
package arg package arg
import "fmt"
// Subcommand returns the user struct for the subcommand selected by // Subcommand returns the user struct for the subcommand selected by
// the command line arguments most recently processed by the parser. // the command line arguments most recently processed by the parser.
// The return value is always a pointer to a struct. If no subcommand // The return value is always a pointer to a struct. If no subcommand
@ -7,31 +9,35 @@ package arg
// no command line arguments have been processed by this parser then it // no command line arguments have been processed by this parser then it
// returns nil. // returns nil.
func (p *Parser) Subcommand() interface{} { func (p *Parser) Subcommand() interface{} {
if p.lastCmd == nil || p.lastCmd.parent == nil { if len(p.subcommand) == 0 {
return nil return nil
} }
return p.val(p.lastCmd.dest).Interface() cmd, err := p.lookupCommand(p.subcommand...)
if err != nil {
return nil
}
return p.val(cmd.dest).Interface()
} }
// SubcommandNames returns the sequence of subcommands specified by the // SubcommandNames returns the sequence of subcommands specified by the
// user. If no subcommands were given then it returns an empty slice. // user. If no subcommands were given then it returns an empty slice.
func (p *Parser) SubcommandNames() []string { func (p *Parser) SubcommandNames() []string {
if p.lastCmd == nil { return p.subcommand
return nil
} }
// make a list of ancestor commands // lookupCommand finds a subcommand based on a sequence of subcommand names. The
var ancestors []string // first string should be a top-level subcommand, the next should be a child
cur := p.lastCmd // subcommand of that subcommand, and so on. If no strings are given then the
for cur.parent != nil { // we want to exclude the root // root command is returned. If no such subcommand exists then an error is
ancestors = append(ancestors, cur.name) // returned.
cur = cur.parent func (p *Parser) lookupCommand(path ...string) (*command, error) {
cmd := p.cmd
for _, name := range path {
found := findSubcommand(cmd.subcommands, name)
if found == nil {
return nil, fmt.Errorf("%q is not a subcommand of %s", name, cmd.name)
} }
cmd = found
// reverse the list
out := make([]string, len(ancestors))
for i := 0; i < len(ancestors); i++ {
out[i] = ancestors[len(ancestors)-i-1]
} }
return out return cmd, nil
} }

140
usage.go
View File

@ -11,7 +11,7 @@ const colWidth = 25
// Fail prints usage information to stderr and exits with non-zero status // Fail prints usage information to stderr and exits with non-zero status
func (p *Parser) Fail(msg string) { func (p *Parser) Fail(msg string) {
p.failWithSubcommand(msg, p.cmd) p.FailSubcommand(msg)
} }
// FailSubcommand prints usage information for a specified subcommand to stderr, // FailSubcommand prints usage information for a specified subcommand to stderr,
@ -21,28 +21,19 @@ func (p *Parser) Fail(msg string) {
// a sequence of subcommand names starting with the top-level subcommand and so // a sequence of subcommand names starting with the top-level subcommand and so
// on down the tree. // on down the tree.
func (p *Parser) FailSubcommand(msg string, subcommand ...string) error { func (p *Parser) FailSubcommand(msg string, subcommand ...string) error {
cmd, err := p.lookupCommand(subcommand...) err := p.WriteUsageForSubcommand(p.config.Out, subcommand...)
if err != nil { if err != nil {
return err return err
} }
p.failWithSubcommand(msg, cmd)
return nil
}
// failWithSubcommand prints usage information for the given subcommand to stderr and exits with non-zero status
func (p *Parser) failWithSubcommand(msg string, cmd *command) {
p.writeUsageForSubcommand(p.config.Out, cmd)
fmt.Fprintln(p.config.Out, "error:", msg) fmt.Fprintln(p.config.Out, "error:", msg)
p.config.Exit(-1) p.config.Exit(-1)
return nil
} }
// WriteUsage writes usage information to the given writer // WriteUsage writes usage information to the given writer
func (p *Parser) WriteUsage(w io.Writer) { func (p *Parser) WriteUsage(w io.Writer) {
cmd := p.cmd p.WriteUsageForSubcommand(w, p.subcommand...)
if p.lastCmd != nil {
cmd = p.lastCmd
}
p.writeUsageForSubcommand(w, cmd)
} }
// WriteUsageForSubcommand writes the usage information for a specified // WriteUsageForSubcommand writes the usage information for a specified
@ -55,12 +46,7 @@ func (p *Parser) WriteUsageForSubcommand(w io.Writer, subcommand ...string) erro
if err != nil { if err != nil {
return err return err
} }
p.writeUsageForSubcommand(w, cmd)
return nil
}
// writeUsageForSubcommand writes usage information for the given subcommand
func (p *Parser) writeUsageForSubcommand(w io.Writer, cmd *command) {
var positionals, longOptions, shortOptions []*spec var positionals, longOptions, shortOptions []*spec
for _, spec := range cmd.specs { for _, spec := range cmd.specs {
switch { switch {
@ -77,18 +63,10 @@ func (p *Parser) writeUsageForSubcommand(w io.Writer, cmd *command) {
fmt.Fprintln(w, p.version) fmt.Fprintln(w, p.version)
} }
// make a list of ancestor commands so that we print with full context
var ancestors []string
ancestor := cmd
for ancestor != nil {
ancestors = append(ancestors, ancestor.name)
ancestor = ancestor.parent
}
// print the beginning of the usage string // print the beginning of the usage string
fmt.Fprint(w, "Usage:") fmt.Fprintf(w, "Usage: %s", p.cmd.name)
for i := len(ancestors) - 1; i >= 0; i-- { for _, s := range subcommand {
fmt.Fprint(w, " "+ancestors[i]) fmt.Fprint(w, " "+s)
} }
// write the option component of the usage message // write the option component of the usage message
@ -149,47 +127,66 @@ func (p *Parser) writeUsageForSubcommand(w io.Writer, cmd *command) {
} }
fmt.Fprint(w, "\n") fmt.Fprint(w, "\n")
return nil
} }
func printTwoCols(w io.Writer, left, help string, defaultVal string, envVal string) { // print prints a line like this:
lhs := " " + left //
// --option FOO A description of the option [default: 123]
//
// If the text on the left is longer than a certain threshold, the description is moved to the next line:
//
// --verylongoptionoption VERY_LONG_VARIABLE
// A description of the option [default: 123]
//
// If multiple "extras" are provided then they are put inside a single set of square brackets:
//
// --option FOO A description of the option [default: 123, env: FOO]
func print(w io.Writer, item, description string, bracketed ...string) {
lhs := " " + item
fmt.Fprint(w, lhs) fmt.Fprint(w, lhs)
if help != "" { if description != "" {
if len(lhs)+2 < colWidth { if len(lhs)+2 < colWidth {
fmt.Fprint(w, strings.Repeat(" ", colWidth-len(lhs))) fmt.Fprint(w, strings.Repeat(" ", colWidth-len(lhs)))
} else { } else {
fmt.Fprint(w, "\n"+strings.Repeat(" ", colWidth)) fmt.Fprint(w, "\n"+strings.Repeat(" ", colWidth))
} }
fmt.Fprint(w, help) fmt.Fprint(w, description)
} }
bracketsContent := []string{} var brack string
for _, s := range bracketed {
if defaultVal != "" { if s != "" {
bracketsContent = append(bracketsContent, if brack != "" {
fmt.Sprintf("default: %s", defaultVal), brack += ", "
) }
brack += s
}
} }
if envVal != "" { if brack != "" {
bracketsContent = append(bracketsContent, fmt.Fprintf(w, " [%s]", brack)
fmt.Sprintf("env: %s", envVal),
)
}
if len(bracketsContent) > 0 {
fmt.Fprintf(w, " [%s]", strings.Join(bracketsContent, ", "))
} }
fmt.Fprint(w, "\n") fmt.Fprint(w, "\n")
} }
func withDefault(s string) string {
if s == "" {
return ""
}
return "default: " + s
}
func withEnv(env string) string {
if env == "" {
return ""
}
return "env: " + env
}
// WriteHelp writes the usage string followed by the full help string for each option // WriteHelp writes the usage string followed by the full help string for each option
func (p *Parser) WriteHelp(w io.Writer) { func (p *Parser) WriteHelp(w io.Writer) {
cmd := p.cmd p.WriteHelpForSubcommand(w, p.subcommand...)
if p.lastCmd != nil {
cmd = p.lastCmd
}
p.writeHelpForSubcommand(w, cmd)
} }
// WriteHelpForSubcommand writes the usage string followed by the full help // WriteHelpForSubcommand writes the usage string followed by the full help
@ -202,12 +199,7 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error
if err != nil { if err != nil {
return err return err
} }
p.writeHelpForSubcommand(w, cmd)
return nil
}
// writeHelp writes the usage string for the given subcommand
func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) {
var positionals, longOptions, shortOptions, envOnlyOptions []*spec var positionals, longOptions, shortOptions, envOnlyOptions []*spec
var hasVersionOption bool var hasVersionOption bool
for _, spec := range cmd.specs { for _, spec := range cmd.specs {
@ -226,13 +218,13 @@ func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) {
if p.description != "" { if p.description != "" {
fmt.Fprintln(w, p.description) fmt.Fprintln(w, p.description)
} }
p.writeUsageForSubcommand(w, cmd) p.WriteUsageForSubcommand(w, subcommand...)
// write the list of positionals // write the list of positionals
if len(positionals) > 0 { if len(positionals) > 0 {
fmt.Fprint(w, "\nPositional arguments:\n") fmt.Fprint(w, "\nPositional arguments:\n")
for _, spec := range positionals { for _, spec := range positionals {
printTwoCols(w, spec.placeholder, spec.help, "", "") print(w, spec.placeholder, spec.help)
} }
} }
@ -296,13 +288,15 @@ func (p *Parser) writeHelpForSubcommand(w io.Writer, cmd *command) {
if len(cmd.subcommands) > 0 { if len(cmd.subcommands) > 0 {
fmt.Fprint(w, "\nCommands:\n") fmt.Fprint(w, "\nCommands:\n")
for _, subcmd := range cmd.subcommands { for _, subcmd := range cmd.subcommands {
printTwoCols(w, subcmd.name, subcmd.help, "", "") names := append([]string{subcmd.name}, subcmd.aliases...)
print(w, strings.Join(names, ", "), subcmd.help)
} }
} }
if p.epilogue != "" { if p.epilogue != "" {
fmt.Fprintln(w, "\n"+p.epilogue) fmt.Fprintln(w, "\n"+p.epilogue)
} }
return nil
} }
func (p *Parser) printOption(w io.Writer, spec *spec) { func (p *Parser) printOption(w io.Writer, spec *spec) {
@ -314,7 +308,7 @@ func (p *Parser) printOption(w io.Writer, spec *spec) {
ways = append(ways, synopsis(spec, "-"+spec.short)) ways = append(ways, synopsis(spec, "-"+spec.short))
} }
if len(ways) > 0 { if len(ways) > 0 {
printTwoCols(w, strings.Join(ways, ", "), spec.help, spec.defaultString, spec.env) print(w, strings.Join(ways, ", "), spec.help, withDefault(spec.defaultString), withEnv(spec.env))
} }
} }
@ -330,29 +324,7 @@ func (p *Parser) printEnvOnlyVar(w io.Writer, spec *spec) {
ways = append(ways, spec.help) ways = append(ways, spec.help)
} }
printTwoCols(w, spec.env, strings.Join(ways, " "), spec.defaultString, "") print(w, spec.env, strings.Join(ways, " "), withDefault(spec.defaultString))
}
// lookupCommand finds a subcommand based on a sequence of subcommand names. The
// first string should be a top-level subcommand, the next should be a child
// subcommand of that subcommand, and so on. If no strings are given then the
// root command is returned. If no such subcommand exists then an error is
// returned.
func (p *Parser) lookupCommand(path ...string) (*command, error) {
cmd := p.cmd
for _, name := range path {
var found *command
for _, child := range cmd.subcommands {
if child.name == name {
found = child
}
}
if found == nil {
return nil, fmt.Errorf("%q is not a subcommand of %s", name, cmd.name)
}
cmd = found
}
return cmd, nil
} }
func synopsis(spec *spec, form string) string { func synopsis(spec *spec, form string) string {

View File

@ -450,6 +450,8 @@ Global options:
_ = p.Parse([]string{"child", "nested", "value"}) _ = p.Parse([]string{"child", "nested", "value"})
assert.Equal(t, []string{"child", "nested"}, p.SubcommandNames())
var help bytes.Buffer var help bytes.Buffer
p.WriteHelp(&help) p.WriteHelp(&help)
assert.Equal(t, expectedHelp[1:], help.String()) assert.Equal(t, expectedHelp[1:], help.String())
@ -471,7 +473,7 @@ func TestNonexistentSubcommand(t *testing.T) {
var args struct { var args struct {
sub *struct{} `arg:"subcommand"` sub *struct{} `arg:"subcommand"`
} }
p, err := NewParser(Config{}, &args) p, err := NewParser(Config{Exit: func(int) {}}, &args)
require.NoError(t, err) require.NoError(t, err)
var b bytes.Buffer var b bytes.Buffer
@ -687,3 +689,29 @@ Options:
p.WriteHelp(&help) p.WriteHelp(&help)
assert.Equal(t, expectedHelp[1:], help.String()) assert.Equal(t, expectedHelp[1:], help.String())
} }
func TestHelpShowsSubcommandAliases(t *testing.T) {
expectedHelp := `
Usage: example <command> [<args>]
Options:
--help, -h display this help and exit
Commands:
remove, rm, r remove something from somewhere
simple do something simple
halt, stop stop now
`
var args struct {
Remove *struct{} `arg:"subcommand:remove|rm|r" help:"remove something from somewhere"`
Simple *struct{} `arg:"subcommand" help:"do something simple"`
Stop *struct{} `arg:"subcommand:halt|stop" help:"stop now"`
}
p, err := NewParser(Config{Program: "example"}, &args)
require.NoError(t, err)
var help bytes.Buffer
p.WriteHelp(&help)
assert.Equal(t, expectedHelp[1:], help.String())
}