github.com/bobziuchkovski/writ@v0.8.9/command.go (about)

     1  // Copyright (c) 2016 Bob Ziuchkovski
     2  //
     3  // Permission is hereby granted, free of charge, to any person obtaining a copy
     4  // of this software and associated documentation files (the "Software"), to deal
     5  // in the Software without restriction, including without limitation the rights
     6  // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     7  // copies of the Software, and to permit persons to whom the Software is
     8  // furnished to do so, subject to the following conditions:
     9  //
    10  // The above copyright notice and this permission notice shall be included in
    11  // all copies or substantial portions of the Software.
    12  //
    13  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    14  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    15  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
    16  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    17  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    18  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    19  // THE SOFTWARE.
    20  
    21  package writ
    22  
    23  import (
    24  	"bytes"
    25  	"fmt"
    26  	"io"
    27  	"os"
    28  	"reflect"
    29  	"strings"
    30  	"text/template"
    31  	"unicode"
    32  )
    33  
    34  type commandError struct {
    35  	err error
    36  }
    37  
    38  func (e commandError) Error() string {
    39  	return e.err.Error()
    40  }
    41  
    42  // panicCommand reports invalid use of the Command type
    43  func panicCommand(format string, values ...interface{}) {
    44  	e := commandError{fmt.Errorf(format, values...)}
    45  	panic(e)
    46  }
    47  
    48  // Path represents a parsed Command list as returned by Command.Decode().
    49  // It is used to differentiate between user selection of commands and
    50  // subcommands.
    51  type Path []*Command
    52  
    53  // String returns the names of each command joined by spaces.
    54  func (p Path) String() string {
    55  	var parts []string
    56  	for _, cmd := range p {
    57  		parts = append(parts, cmd.Name)
    58  	}
    59  	return strings.Join(parts, " ")
    60  }
    61  
    62  // First returns the first command of the path.  This is the top-level/root command
    63  // where Decode() was invoked.
    64  func (p Path) First() *Command {
    65  	return p[0]
    66  }
    67  
    68  // Last returns the last command of the path.  This is the user-selected command.
    69  func (p Path) Last() *Command {
    70  	return p[len(p)-1]
    71  }
    72  
    73  // findOption searches for the named option on the nearest ancestor command
    74  func (p Path) findOption(name string) *Option {
    75  	for i := len(p) - 1; i >= 0; i-- {
    76  		o := p[i].Option(name)
    77  		if o != nil {
    78  			return o
    79  		}
    80  	}
    81  	return nil
    82  }
    83  
    84  // New reads the input spec, searching for fields tagged with "option",
    85  // "flag", or "command".  The field type and tags are used to construct
    86  // a corresponding Command instance, which can be used to decode program
    87  // arguments.  See the package overview documentation for details.
    88  //
    89  // NOTE: The spec value must be a pointer to a struct.
    90  func New(name string, spec interface{}) *Command {
    91  	cmd := parseCommandSpec(name, spec, nil)
    92  	cmd.validate()
    93  	return cmd
    94  }
    95  
    96  // Command specifies program options and subcommands.
    97  //
    98  // NOTE: If building a *Command directly without New(), the Help output
    99  // will be empty by default.  Most applications will want to set the
   100  // Help.Usage and Help.CommandGroups / Help.OptionGroups fields as
   101  // appropriate.
   102  type Command struct {
   103  	// Required
   104  	Name string
   105  
   106  	// Optional
   107  	Aliases     []string
   108  	Options     []*Option
   109  	Subcommands []*Command
   110  	Help        Help
   111  	Description string // Commands without descriptions are hidden
   112  }
   113  
   114  // String returns the command's name.
   115  func (c *Command) String() string {
   116  	return c.Name
   117  }
   118  
   119  // Decode parses the given arguments according to GNU getopt_long conventions.
   120  // It matches Option arguments, both short and long-form, and decodes those
   121  // arguments with the matched Option's Decoder field. If the Command has
   122  // associated subcommands, the subcommand names are matched and extracted
   123  // from the start of the positional arguments.
   124  //
   125  // To avoid ambiguity, subcommand matching terminates at the first unmatched
   126  // positional argument.  Similarly, option names are matched against the
   127  // command hierarchy as it exists at the point the option is encountered.  If
   128  // command "first" has a subcommand "second", and "second" has an option
   129  // "foo", then "first second --foo" is valid but "first --foo second" returns
   130  // an error.  If the two commands, "first" and "second", both specify a "bar"
   131  // option, then "first --bar second" decodes "bar" on "first", whereas
   132  // "first second --bar" decodes "bar" on "second".
   133  //
   134  // As with GNU getopt_long, a bare "--" argument terminates argument parsing.
   135  // All arguments after the first "--" argument are considered positional
   136  // parameters.
   137  func (c *Command) Decode(args []string) (path Path, positional []string, err error) {
   138  	c.validate()
   139  	c.setDefaults()
   140  	return parseArgs(c, args)
   141  }
   142  
   143  // Subcommand locates subcommands on the method receiver.  It returns a match
   144  // if any of the receiver's subcommands have a matching name or alias.  Otherwise
   145  // it returns nil.
   146  func (c *Command) Subcommand(name string) *Command {
   147  	for _, sub := range c.Subcommands {
   148  		if sub.Name == name {
   149  			return sub
   150  		}
   151  		for _, a := range sub.Aliases {
   152  			if a == name {
   153  				return sub
   154  			}
   155  		}
   156  	}
   157  	return nil
   158  }
   159  
   160  // Option locates options on the method receiver.  It returns a match if any of
   161  // the receiver's options have a matching name.  Otherwise it returns nil.  Options
   162  // are searched only on the method receiver, not any of it's subcommands.
   163  func (c *Command) Option(name string) *Option {
   164  	for _, o := range c.Options {
   165  		for _, n := range o.Names {
   166  			if name == n {
   167  				return o
   168  			}
   169  		}
   170  	}
   171  	return nil
   172  }
   173  
   174  // GroupOptions is used to build OptionGroups for help output.  It searches the
   175  // method receiver for the named options and returns a corresponding OptionGroup.
   176  // If any of the named options are not found, GroupOptions panics.
   177  func (c *Command) GroupOptions(names ...string) OptionGroup {
   178  	var group OptionGroup
   179  	for _, n := range names {
   180  		o := c.Option(n)
   181  		if o == nil {
   182  			panicCommand("Option not found: %s", n)
   183  		}
   184  		group.Options = append(group.Options, o)
   185  	}
   186  	return group
   187  }
   188  
   189  // GroupCommands is used to build CommandGroups for help output.  It searches the
   190  // method receiver for the named subcommands and returns a corresponding CommandGroup.
   191  // If any of the named subcommands are not found, GroupCommands panics.
   192  func (c *Command) GroupCommands(names ...string) CommandGroup {
   193  	var group CommandGroup
   194  	for _, n := range names {
   195  		c := c.Subcommand(n)
   196  		if c == nil {
   197  			panicCommand("Option not found: %s", n)
   198  		}
   199  		group.Commands = append(group.Commands, c)
   200  	}
   201  	return group
   202  }
   203  
   204  // WriteHelp renders help output to the given io.Writer.  Output is influenced
   205  // by the Command's Help field.  See the Help type for details.
   206  func (c *Command) WriteHelp(w io.Writer) error {
   207  	var tmpl *template.Template
   208  	if c.Help.Template != nil {
   209  		tmpl = c.Help.Template
   210  	} else {
   211  		tmpl = defaultTemplate
   212  	}
   213  
   214  	buf := bytes.NewBuffer(nil)
   215  	err := tmpl.Execute(buf, c)
   216  	if err != nil {
   217  		panicCommand("failed to render help: %s", err)
   218  	}
   219  	_, err = buf.WriteTo(w)
   220  	return err
   221  }
   222  
   223  // ExitHelp writes help output and terminates the program.  If err is nil,
   224  // the output is written to os.Stdout and the program terminates with a 0 exit
   225  // code.  Otherwise, both the help output and error message are written to
   226  // os.Stderr and the program terminates with a 1 exit code.
   227  func (c *Command) ExitHelp(err error) {
   228  	if err == nil {
   229  		c.WriteHelp(os.Stdout)
   230  		os.Exit(0)
   231  	}
   232  	c.WriteHelp(os.Stderr)
   233  	fmt.Fprintf(os.Stderr, "\nError: %s\n", err)
   234  	os.Exit(1)
   235  }
   236  
   237  // validate command spec
   238  func (c *Command) validate() {
   239  	if c.Name == "" {
   240  		panicCommand("Command name cannot be empty")
   241  	}
   242  	if strings.HasPrefix(c.Name, "-") {
   243  		panicCommand("Command names cannot begin with '-' (command %s)", c.Name)
   244  	}
   245  	runes := []rune(c.Name)
   246  	for _, r := range runes {
   247  		if unicode.IsSpace(r) {
   248  			panicCommand("Command names cannot have spaces (command %q)", c.Name)
   249  		}
   250  	}
   251  
   252  	for _, a := range c.Aliases {
   253  		if strings.HasPrefix(a, "-") {
   254  			panicCommand("Command aliases cannot begin with '-' (command %s, alias %s)", c.Name, a)
   255  		}
   256  		runes := []rune(a)
   257  		for _, r := range runes {
   258  			if unicode.IsSpace(r) {
   259  				panicCommand("Command aliases cannot have spaces (command %s, alias %q)", c.Name, a)
   260  			}
   261  		}
   262  	}
   263  
   264  	seen := make(map[string]bool)
   265  	for _, sub := range c.Subcommands {
   266  		sub.validate()
   267  		subnames := append(sub.Aliases, sub.Name)
   268  		for _, name := range subnames {
   269  			_, present := seen[name]
   270  			if present {
   271  				panicCommand("command names must be unique (%s is specified multiple times)", name)
   272  			}
   273  			seen[name] = true
   274  		}
   275  	}
   276  
   277  	seen = make(map[string]bool)
   278  	for _, o := range c.Options {
   279  		o.validate()
   280  		for _, name := range o.Names {
   281  			_, present := seen[name]
   282  			if present {
   283  				panicCommand("option names must be unique (%s is specified multiple times)", name)
   284  			}
   285  			seen[name] = true
   286  		}
   287  	}
   288  }
   289  
   290  func (c *Command) setDefaults() {
   291  	for _, opt := range c.Options {
   292  		defaulter, ok := opt.Decoder.(OptionDefaulter)
   293  		if ok {
   294  			defaulter.SetDefault()
   295  		}
   296  	}
   297  	for _, sub := range c.Subcommands {
   298  		sub.setDefaults()
   299  	}
   300  }
   301  
   302  /*
   303   * Argument parsing
   304   */
   305  
   306  func parseArgs(c *Command, args []string) (path Path, positional []string, err error) {
   307  	path = Path{c}
   308  	positional = make([]string, 0) // positional args should never be nil
   309  
   310  	seen := make(map[*Option]bool)
   311  	parseCmd, parseOpt := true, true
   312  	for i := 0; i < len(args); i++ {
   313  		a := args[i]
   314  		if parseCmd {
   315  			subcmd := path.Last().Subcommand(a)
   316  			if subcmd != nil {
   317  				path = append(path, subcmd)
   318  				continue
   319  			}
   320  		}
   321  
   322  		if parseOpt && strings.HasPrefix(a, "-") {
   323  			if a == "-" {
   324  				positional = append(positional, a)
   325  				parseCmd = false
   326  				continue
   327  			}
   328  			if a == "--" {
   329  				parseOpt = false
   330  				parseCmd = false
   331  				continue
   332  			}
   333  
   334  			var opt *Option
   335  			opt, args, err = processOption(path, args, i)
   336  			if err != nil {
   337  				return
   338  			}
   339  			_, present := seen[opt]
   340  			if present && !opt.Plural {
   341  				err = fmt.Errorf("option %q specified too many times", args[i])
   342  				return
   343  			}
   344  			seen[opt] = true
   345  			continue
   346  		}
   347  
   348  		// Unmatched positional arg
   349  		parseCmd = false
   350  		positional = append(positional, a)
   351  	}
   352  	return
   353  }
   354  
   355  func processOption(path Path, args []string, optidx int) (opt *Option, newargs []string, err error) {
   356  	if strings.HasPrefix(args[optidx], "--") {
   357  		return processLongOption(path, args, optidx)
   358  	}
   359  	return processShortOption(path, args, optidx)
   360  }
   361  
   362  func processLongOption(path Path, args []string, optidx int) (opt *Option, newargs []string, err error) {
   363  	keyval := strings.SplitN(strings.TrimPrefix(args[optidx], "--"), "=", 2)
   364  	name := keyval[0]
   365  	newargs = args
   366  
   367  	opt = path.findOption(name)
   368  	if opt == nil {
   369  		err = fmt.Errorf("option '--%s' is not recognized", name)
   370  		return
   371  	}
   372  	if opt.Flag {
   373  		if len(keyval) == 2 {
   374  			err = fmt.Errorf("flag '--%s' does not accept an argument", name)
   375  		} else {
   376  			err = opt.Decoder.Decode("")
   377  		}
   378  	} else {
   379  		if len(keyval) == 2 {
   380  			err = opt.Decoder.Decode(keyval[1])
   381  		} else {
   382  			if len(args[optidx:]) < 2 {
   383  				err = fmt.Errorf("option '--%s' requires an argument", name)
   384  			} else {
   385  				// Consume the next arg
   386  				err = opt.Decoder.Decode(args[optidx+1])
   387  				newargs = duplicateArgs(args)
   388  				newargs = append(newargs[:optidx+1], newargs[optidx+2:]...)
   389  			}
   390  		}
   391  	}
   392  	return
   393  }
   394  
   395  func processShortOption(path Path, args []string, optidx int) (opt *Option, newargs []string, err error) {
   396  	keyval := strings.SplitN(strings.TrimPrefix(args[optidx], "-"), "", 2)
   397  	name := keyval[0]
   398  	newargs = args
   399  
   400  	opt = path.findOption(name)
   401  	if opt == nil {
   402  		err = fmt.Errorf("option '-%s' is not recognized", name)
   403  		return
   404  	}
   405  	if opt.Flag {
   406  		err = opt.Decoder.Decode("")
   407  		if len(keyval) == 2 {
   408  			// Short-form options are aggregated.  TODO: Cleanup
   409  			// Rewrite current arg as -<name> and append remaining aggregate opts as a new arg after the current one
   410  			newargs = duplicateArgs(args)
   411  			newargs = append(newargs[:optidx+1], append([]string{"-" + keyval[1]}, newargs[optidx+1:]...)...)
   412  			newargs[optidx] = "-" + name
   413  		}
   414  	} else {
   415  		if len(keyval) == 2 {
   416  			err = opt.Decoder.Decode(keyval[1])
   417  		} else {
   418  			if len(args[optidx:]) < 2 {
   419  				err = fmt.Errorf("option '-%s' requires an argument", name)
   420  			} else {
   421  				// Consume the next arg
   422  				err = opt.Decoder.Decode(args[optidx+1])
   423  				newargs = duplicateArgs(args)
   424  				newargs = append(newargs[:optidx+1], newargs[optidx+2:]...)
   425  			}
   426  		}
   427  	}
   428  	return
   429  }
   430  
   431  func duplicateArgs(args []string) []string {
   432  	dupe := make([]string, len(args))
   433  	for i := range args {
   434  		dupe[i] = args[i]
   435  	}
   436  	return dupe
   437  }
   438  
   439  /*
   440   * Command spec parsing
   441   */
   442  
   443  var (
   444  	decoderPtr *OptionDecoder
   445  	decoderT   = reflect.TypeOf(decoderPtr).Elem()
   446  
   447  	aliasTag       = "alias"
   448  	commandTag     = "command"
   449  	defaultTag     = "default"
   450  	descriptionTag = "description"
   451  	envTag         = "env"
   452  	flagTag        = "flag"
   453  	optionTag      = "option"
   454  	placeholderTag = "placeholder"
   455  	invalidTags    = map[string][]string{
   456  		commandTag: {defaultTag, envTag, flagTag, optionTag, placeholderTag},
   457  		flagTag:    {aliasTag, commandTag, defaultTag, envTag, optionTag, placeholderTag},
   458  		optionTag:  {aliasTag, commandTag, flagTag},
   459  	}
   460  )
   461  
   462  func parseCommandSpec(name string, spec interface{}, path Path) *Command {
   463  	rval := reflect.ValueOf(spec)
   464  	if rval.Kind() != reflect.Ptr {
   465  		panicCommand("command spec must be a pointer to struct type, not %s", rval.Kind())
   466  	}
   467  	if rval.Elem().Kind() != reflect.Struct {
   468  		panicCommand("command spec must be a pointer to struct type, not %s", rval.Kind())
   469  	}
   470  	rval = rval.Elem()
   471  
   472  	cmd := &Command{Name: name}
   473  	path = append(path, cmd)
   474  
   475  	for i := 0; i < rval.Type().NumField(); i++ {
   476  		field := rval.Type().Field(i)
   477  		fieldVal := rval.FieldByIndex(field.Index)
   478  		if field.Tag.Get(commandTag) != "" {
   479  			cmd.Subcommands = append(cmd.Subcommands, parseCommandField(field, fieldVal, path))
   480  			continue
   481  		}
   482  		if field.Tag.Get(flagTag) != "" {
   483  			cmd.Options = append(cmd.Options, parseFlagField(field, fieldVal))
   484  			continue
   485  		}
   486  		if field.Tag.Get(optionTag) != "" {
   487  			cmd.Options = append(cmd.Options, parseOptionField(field, fieldVal))
   488  			continue
   489  		}
   490  	}
   491  
   492  	var visibleOpts []*Option
   493  	for _, opt := range cmd.Options {
   494  		if opt.Description != "" {
   495  			visibleOpts = append(visibleOpts, opt)
   496  		}
   497  	}
   498  	if len(visibleOpts) > 0 {
   499  		cmd.Help.OptionGroups = []OptionGroup{
   500  			{Options: visibleOpts, Header: "Available Options:"},
   501  		}
   502  	}
   503  	var visibleSubs []*Command
   504  	for _, sub := range cmd.Subcommands {
   505  		if sub.Description != "" {
   506  			visibleSubs = append(visibleSubs, sub)
   507  		}
   508  	}
   509  	if len(visibleSubs) > 0 {
   510  		cmd.Help.CommandGroups = []CommandGroup{
   511  			{Commands: visibleSubs, Header: "Available Commands:"},
   512  		}
   513  	}
   514  	cmd.Help.Usage = fmt.Sprintf("Usage: %s [OPTION]... [ARG]...", path.String())
   515  	return cmd
   516  }
   517  
   518  func parseCommandField(field reflect.StructField, fieldVal reflect.Value, path Path) *Command {
   519  	checkTags(field, commandTag)
   520  	checkExported(field, commandTag)
   521  
   522  	names := parseCommaNames(field.Tag.Get(commandTag))
   523  	if len(names) == 0 {
   524  		panicCommand("commands must have a name (field %s)", field.Name)
   525  	}
   526  	if len(names) != 1 {
   527  		panicCommand("commands must have a single name (field %s)", field.Name)
   528  	}
   529  
   530  	cmd := parseCommandSpec(names[0], fieldVal.Addr().Interface(), path)
   531  	cmd.Aliases = parseCommaNames(field.Tag.Get(aliasTag))
   532  	cmd.Description = field.Tag.Get(descriptionTag)
   533  	cmd.validate()
   534  	return cmd
   535  }
   536  
   537  func parseFlagField(field reflect.StructField, fieldVal reflect.Value) *Option {
   538  	checkTags(field, flagTag)
   539  	checkExported(field, flagTag)
   540  
   541  	names := parseCommaNames(field.Tag.Get(flagTag))
   542  	if len(names) == 0 {
   543  		panicCommand("at least one flag name must be specified (field %s)", field.Name)
   544  	}
   545  
   546  	opt := &Option{
   547  		Names:       names,
   548  		Flag:        true,
   549  		Description: field.Tag.Get(descriptionTag),
   550  	}
   551  
   552  	if field.Type.Implements(decoderT) {
   553  		opt.Decoder = fieldVal.Interface().(OptionDecoder)
   554  	} else if fieldVal.CanAddr() && reflect.PtrTo(field.Type).Implements(decoderT) {
   555  		opt.Decoder = fieldVal.Addr().Interface().(OptionDecoder)
   556  	} else {
   557  		switch field.Type.Kind() {
   558  		case reflect.Bool:
   559  			opt.Decoder = NewFlagDecoder(fieldVal.Addr().Interface().(*bool))
   560  		case reflect.Int:
   561  			opt.Decoder = NewFlagAccumulator(fieldVal.Addr().Interface().(*int))
   562  			opt.Plural = true
   563  		default:
   564  			panicCommand("field type not valid as a flag -- did you mean to use %q instead? (field %s)", "option", field.Name)
   565  		}
   566  	}
   567  
   568  	opt.validate()
   569  	return opt
   570  }
   571  
   572  func parseOptionField(field reflect.StructField, fieldVal reflect.Value) *Option {
   573  	checkTags(field, optionTag)
   574  	checkExported(field, optionTag)
   575  
   576  	names := parseCommaNames(field.Tag.Get(optionTag))
   577  	if len(names) == 0 {
   578  		panicCommand("at least one option name must be specified (field %s)", field.Name)
   579  	}
   580  
   581  	opt := &Option{
   582  		Names:       names,
   583  		Description: field.Tag.Get(descriptionTag),
   584  		Placeholder: field.Tag.Get(placeholderTag),
   585  	}
   586  
   587  	if field.Type.Implements(decoderT) {
   588  		opt.Decoder = fieldVal.Interface().(OptionDecoder)
   589  	} else if fieldVal.CanAddr() && reflect.PtrTo(field.Type).Implements(decoderT) {
   590  		opt.Decoder = fieldVal.Addr().Interface().(OptionDecoder)
   591  	} else {
   592  		if fieldVal.Kind() == reflect.Bool {
   593  			panicCommand("bool fields are not valid as options.  Use a %q tag instead (field %s)", "flag", field.Name)
   594  		}
   595  		if fieldVal.Kind() == reflect.Slice || fieldVal.Kind() == reflect.Map {
   596  			opt.Plural = true
   597  		}
   598  		opt.Decoder = NewOptionDecoder(fieldVal.Addr().Interface())
   599  	}
   600  
   601  	defaultArg := field.Tag.Get(defaultTag)
   602  	if defaultArg != "" {
   603  		opt.Decoder = NewDefaulter(opt.Decoder, defaultArg)
   604  	}
   605  	envName := field.Tag.Get(envTag)
   606  	if envName != "" {
   607  		opt.Decoder = NewEnvDefaulter(opt.Decoder, envName)
   608  	}
   609  
   610  	opt.validate()
   611  	return opt
   612  }
   613  
   614  func checkTags(field reflect.StructField, fieldType string) {
   615  	badTags, present := invalidTags[fieldType]
   616  	if !present {
   617  		panic("BUG: fieldType not present in invalidTags map")
   618  	}
   619  	for _, t := range badTags {
   620  		if field.Tag.Get(t) != "" {
   621  			panicCommand("tag %s is not valid for %ss (field %s)", t, fieldType, field.Name)
   622  		}
   623  	}
   624  }
   625  
   626  func checkExported(field reflect.StructField, fieldType string) {
   627  	if field.PkgPath != "" && !field.Anonymous {
   628  		panicCommand("%ss must be exported (field %s)", fieldType, field.Name)
   629  	}
   630  }
   631  
   632  func parseCommaNames(spec string) []string {
   633  	isSep := func(r rune) bool {
   634  		return r == ',' || unicode.IsSpace(r)
   635  	}
   636  	return strings.FieldsFunc(spec, isSep)
   637  }