github.com/creachadair/vocab@v0.0.4-0.20190826174139-2654f99cba48/vocab.go (about)

     1  // Copyright (C) 2019 Michael J. Fromberger. All Rights Reserved.
     2  
     3  // Package vocab handles flag parsing and dispatch for a nested language of
     4  // commands and subcommands. In this model, a command-line is treated as a
     5  // phrase in a simple grammar:
     6  //
     7  //    command = name [flags] [command]
     8  //
     9  // Each name may be either a command in itself, or a group of subcommands with
    10  // a shared set of flags, or both.
    11  //
    12  // You describe command vocabulary with nested struct values, whose fields
    13  // define flags and subcommands to be executed.  The implementation of a
    14  // command is provided by by implementing the vocab.Runner interface. Commands
    15  // may pass shared state to their subcommands by attaching it to a context
    16  // value that is propagated down the vocabulary tree.
    17  //
    18  // Basic usage outline:
    19  //
    20  //    itm, err := vocab.New("toolname", v)
    21  //    ...
    22  //    if err := itm.Dispatch(ctx, args); err != nil {
    23  //       log.Fatalf("Dispatch failed: %v, err)
    24  //    }
    25  //
    26  package vocab
    27  
    28  import (
    29  	"context"
    30  	"flag"
    31  	"fmt"
    32  	"io"
    33  	"os"
    34  	"reflect"
    35  	"sort"
    36  	"strings"
    37  	"text/tabwriter"
    38  	"time"
    39  
    40  	"bitbucket.org/creachadair/stringset"
    41  	"golang.org/x/xerrors"
    42  )
    43  
    44  // A Runner executes the behaviour of a command. If a command implements the
    45  // Run method, it will be used to invoke the command after flag parsing.
    46  type Runner interface {
    47  	// Run executes the command with the specified arguments.
    48  	//
    49  	// The context passed to run contains any values attached by the Init
    50  	// methods of enclosing commands.
    51  	Run(ctx context.Context, args []string) error
    52  }
    53  
    54  // RunFunc implements the vocab.Runner interface by calling a function with the
    55  // matching signature. This can be used to embed command implementations into
    56  // the fields of a struct type with corresponding signatures.
    57  type RunFunc func(context.Context, []string) error
    58  
    59  // Run satisfies the vocab.Runner interface.
    60  func (rf RunFunc) Run(ctx context.Context, args []string) error { return rf(ctx, args) }
    61  
    62  // An Initializer sets up the environment for a subcommand. If a command
    63  // implements the Init method, it will be called before dispatching control to
    64  // a subcommand.
    65  type Initializer interface {
    66  	// Init prepares a command for execution of the named subcommand with the
    67  	// given arguments, prior to parsing the subcommand's flags. The name is the
    68  	// resolved canonical name of the subcommand, and the first element of args
    69  	// is the name as written (which may be an alias).
    70  	//
    71  	// If the returned context is not nil, it replaces ctx in the subcommand;
    72  	// otherwise ctx is used. If init reports an error, the command execution
    73  	// will fail.
    74  	Init(ctx context.Context, name string, args []string) (context.Context, error)
    75  }
    76  
    77  // New constructs a vocabulary item from the given value. The root value must
    78  // either itself implement the vocab.Runner interface, or be a (pointer to a)
    79  // struct value whose field annotations describe subcommand vocabulary.
    80  //
    81  // To define a field as implementing a subcommand, use the "vocab:" tag to
    82  // define its name:
    83  //
    84  //    type Cmd struct{
    85  //       A Type1  `vocab:"first"`
    86  //       B *Type2 `vocab:"second"`
    87  //    }
    88  //
    89  // The field types in this example must similarly implement vocab.Runner, or be
    90  // structs with their own corresponding annotations.  During dispatch, an
    91  // argument list beginning with "first" will dispatch through A, and an
    92  // argument list beginning with "second" will dispatch through B.  The nesting
    93  // may occur to arbitrary depth, but note that New does not handle cycles.
    94  //
    95  // A subcommand may also have aliases, specified as:
    96  //
    97  //    vocab:"name,alias1,alias2,..."
    98  //
    99  // The names and aliases must be unique within a given value.
   100  //
   101  // You can also attach flag to struct fields using the "flag:" tag:
   102  //
   103  //    flag:"name,description"
   104  //
   105  // The name becomes the flag string, and the description its help text.  The
   106  // field must either be one of the standard types understood by the flag
   107  // package, or its pointer must implement the flag.Value interface. In each
   108  // case the default value for the flag is the current value of the field.
   109  //
   110  // Documentation
   111  //
   112  // In addition to its name, each command has "summary" and "help" strings. The
   113  // summary is a short (typically one-line) synopsis, and help is a longer and
   114  // more explanatory (possibly multi-line) description. There are three ways to
   115  // associate these strings with a command:
   116  //
   117  // If the command implements vocab.Summarizer, its Summary method is used to
   118  // generate the summary string.  Otherwise, if the command has a blank field
   119  // ("_") whose tag begins with "help-summary:", the rest of that tag is used as
   120  // the summary string. Otherwise, if the command's type is used as a field of
   121  // an enclosing command type with a "help-summary:" comment tag, that text is
   122  // used as the summary string for the command.
   123  //
   124  // If the type implements vocab.Helper, its Help method is used to generate the
   125  // full help string.  Otherwise, if the command has a blank field ("_") whose
   126  // tag begins with "help-long:", the rest of that tag is used as the long help
   127  // string. Otherwise, if the command's type is used as a field of an enclosing
   128  // command type with a "help-log:" comment tag, that text is used as the long
   129  // help string for the command.
   130  //
   131  // Caveat: Although the Go grammar allows arbitrary string literals as struct
   132  // field tags, there is a strong convention supported by the reflect package
   133  // and "go vet" for single-line tags with key:"value" structure. This package
   134  // will accept multi-line unquoted tags, but be aware that some lint tools may
   135  // complain if you use them. You can use standard string escapes (e.g., "\n")
   136  // in the quoted values of tags to avoid this, at the cost of a long line.
   137  func New(name string, root interface{}) (*Item, error) {
   138  	itm, err := newItem(name, root)
   139  	if err != nil {
   140  		return nil, err
   141  	} else if itm.init == nil && itm.run == nil && len(itm.items) == 0 {
   142  		return nil, xerrors.New("value does not implement any vocabulary")
   143  	}
   144  	return itm, nil
   145  }
   146  
   147  // An Item represents a parsed command tree.
   148  type Item struct {
   149  	cmd  interface{}
   150  	run  func(context.Context, []string) error
   151  	init func(context.Context, string, []string) (context.Context, error)
   152  
   153  	name     string            // the canonical name of this command
   154  	summary  func() string     // the summary text, if defined
   155  	helpText func() string     // the long help text, if defined
   156  	fs       *flag.FlagSet     // the flags for the command (if nil, none are defined)
   157  	hasFlags bool              // whether any flags were explicitly defined
   158  	items    map[string]*Item  // :: name → subcommand
   159  	alias    map[string]string // :: subcommand alias → name
   160  	out      io.Writer         // output writer
   161  }
   162  
   163  // SetOutput sets the output writer for m and all its nested subcommands to w.
   164  // It will panic if w == nil.
   165  func (m *Item) SetOutput(w io.Writer) {
   166  	if w == nil {
   167  		panic("vocab: output writer is nil")
   168  	}
   169  	m.out = w
   170  	m.fs.SetOutput(w)
   171  	for _, itm := range m.items {
   172  		itm.SetOutput(w)
   173  	}
   174  }
   175  
   176  // shortHelp prints a brief summary of m to its output writer.
   177  func (m *Item) shortHelp() { m.printHelp(false) }
   178  
   179  // longHelp prints complete help for m to its output writer.
   180  func (m *Item) longHelp() { m.printHelp(true) }
   181  
   182  // printHelp prints a summary of m to w, including help text if full == true.
   183  func (m *Item) printHelp(full bool) {
   184  	w := m.out
   185  
   186  	// Always print a summary line giving the command name.
   187  	summary := "(undocumented)"
   188  	if m.summary != nil {
   189  		summary = m.summary()
   190  	}
   191  	fmt.Fprint(w, m.name, ": ", summary, "\n")
   192  
   193  	// If full help is requested, include help text and flags.
   194  	if full {
   195  		if m.helpText != nil {
   196  			fmt.Fprint(w, "\n", m.helpText(), "\n")
   197  		}
   198  		if m.hasFlags {
   199  			fmt.Fprintln(w, "\nFlags:")
   200  			m.fs.SetOutput(w)
   201  			m.fs.PrintDefaults()
   202  		}
   203  	}
   204  
   205  	// If any subcommands are defined, summarize them.
   206  	if len(m.items) != 0 {
   207  		amap := make(map[string][]string)
   208  		for alias, name := range m.alias {
   209  			amap[name] = append(amap[name], alias)
   210  		}
   211  
   212  		fmt.Fprintln(w, "\nSubcommands:")
   213  		tw := tabwriter.NewWriter(w, 0, 8, 3, ' ', 0)
   214  		for _, name := range stringset.FromKeys(m.items).Elements() {
   215  			summary := "(undocumented)"
   216  			if s := m.items[name].summary; s != nil {
   217  				summary = s()
   218  			}
   219  			fmt.Fprint(tw, "  ", name, "\t", summary)
   220  
   221  			// Include aliases in the summary, if any exist.
   222  			if as := amap[name]; len(as) != 0 {
   223  				sort.Strings(as)
   224  				fmt.Fprintf(tw, " (alias: %s)", strings.Join(as, ", "))
   225  			}
   226  			fmt.Fprintln(tw)
   227  		}
   228  		tw.Flush()
   229  	}
   230  }
   231  
   232  // findCommand reports whether key is the name or registered alias of any
   233  // subcommand of m, and if so returns its item.
   234  func (m *Item) findCommand(key string) (*Item, bool) {
   235  	itm, ok := m.items[key]
   236  	if !ok {
   237  		itm, ok = m.items[m.alias[key]]
   238  	}
   239  	return itm, ok
   240  }
   241  
   242  // Resolve traverses the vocabulary of m to find the command described by path.
   243  // The path should contain only names; Resolve does not parse flags.
   244  // It returns the last item successfully reached, along with the unresolved
   245  // tail of the path (which is empty if the path was fully resolved).
   246  func (m *Item) Resolve(path []string) (*Item, []string) {
   247  	cur := m
   248  	for i, arg := range path {
   249  		next, ok := cur.findCommand(arg)
   250  		if !ok {
   251  			return cur, path[i:]
   252  		}
   253  		cur = next
   254  	}
   255  	return cur, nil
   256  }
   257  
   258  // Dispatch traverses the vocabulary of m parsing and executing the described
   259  // commands.
   260  func (m *Item) Dispatch(ctx context.Context, args []string) error {
   261  	if err := m.fs.Parse(args); err == flag.ErrHelp {
   262  		return nil // the usage message already contains the short help
   263  	} else if err != nil {
   264  		return xerrors.Errorf("parsing flags for %q: %w", m.name, err)
   265  	} else {
   266  		args = m.fs.Args()
   267  	}
   268  
   269  	// Check whether there is a subcommand that can follow from m.
   270  	if len(args) != 0 {
   271  		if sub, ok := m.findCommand(args[0]); ok {
   272  			// Having found a subcommand, give m a chance to initialize itself and
   273  			// update the context before invoking the subcommand.
   274  			if m.init != nil {
   275  				pctx, err := m.init(ctx, sub.name, args)
   276  				if err != nil {
   277  					return xerrors.Errorf("subcommand %q: %w", m.name, err)
   278  				} else if pctx != nil {
   279  					ctx = pctx
   280  				}
   281  			}
   282  
   283  			// Dispatch to the subcommand with the remaining arguments.
   284  			return sub.Dispatch(withParent(ctx, m), args[1:])
   285  		}
   286  
   287  		// No matching subcommand; fall through and let this command handle it.
   288  	}
   289  
   290  	if m.run != nil {
   291  		return m.run(ctx, args)
   292  	} else if len(args) != 0 {
   293  		return xerrors.Errorf("no command found matching %q", args)
   294  	}
   295  	m.shortHelp()
   296  	return nil // TODO: Return something like flag.ErrHelp
   297  }
   298  
   299  // parentItemKey identifies the parent of a subcommand in the context.  This is
   300  // used by the help system to locate the item for which help is requested, and
   301  // is not exposed outside the package.
   302  type parentItemKey struct{}
   303  
   304  func withParent(ctx context.Context, m *Item) context.Context {
   305  	return context.WithValue(ctx, parentItemKey{}, m)
   306  }
   307  
   308  func parentItem(ctx context.Context) *Item {
   309  	if v := ctx.Value(parentItemKey{}); v != nil {
   310  		return v.(*Item)
   311  	}
   312  	return nil
   313  }
   314  
   315  func newItem(name string, x interface{}) (*Item, error) {
   316  	// Requirements: x must either be a struct or a non-nil pointer to a struct,
   317  	// or must implement the vocab.Runnier interface.
   318  	r, isRunner := x.(Runner)
   319  	v := reflect.Indirect(reflect.ValueOf(x))
   320  	isStruct := v.Kind() == reflect.Struct
   321  	if !isRunner && !isStruct {
   322  		return nil, xerrors.Errorf("value must be a struct (have %v)", v.Kind())
   323  	}
   324  
   325  	item := &Item{
   326  		cmd:   x,
   327  		name:  name,
   328  		fs:    flag.NewFlagSet(name, flag.ContinueOnError),
   329  		items: make(map[string]*Item),
   330  		alias: make(map[string]string),
   331  		out:   os.Stderr,
   332  	}
   333  	item.fs.Usage = func() { item.shortHelp() }
   334  
   335  	// Cache the callbacks, if they are defined.
   336  	if isRunner {
   337  		item.run = r.Run
   338  	}
   339  	if in, ok := x.(Initializer); ok {
   340  		item.init = in.Init
   341  	}
   342  	if sum, ok := x.(Summarizer); ok {
   343  		item.summary = sum.Summary
   344  	}
   345  	if help, ok := x.(Helper); ok {
   346  		item.helpText = help.Help
   347  	}
   348  
   349  	if !isStruct {
   350  		return item, nil // nothing more to do here
   351  	}
   352  
   353  	// At this point we know x is a struct.  Scan its field tags for
   354  	// annotations.
   355  	//
   356  	// To indicate that the field is a subcommand:
   357  	//   vocab:"name" or vocab:"name,alias,..."
   358  	//
   359  	// To attach a flag to a field:
   360  	//   flag:"name,description"
   361  	//
   362  	t := v.Type()
   363  	for i := 0; i < t.NumField(); i++ {
   364  		ft := t.Field(i) // field type metadata (name, tags)
   365  		fv := v.Field(i) // field value
   366  
   367  		// Annotation: flag:"name,description".
   368  		// This requires x is a pointer.
   369  		if tag := ft.Tag.Get("flag"); tag != "" && ft.PkgPath == "" {
   370  			if fv.Kind() != reflect.Ptr {
   371  				if !fv.CanAddr() {
   372  					return nil, xerrors.Errorf("cannot flag field %q of type %T", ft.Name, x)
   373  				}
   374  				fv = fv.Addr()
   375  			} else if !fv.Elem().IsValid() {
   376  				return nil, xerrors.Errorf("cannot flag pointer field %q with nil value", ft.Name)
   377  			}
   378  			fname, help := tag, tag
   379  			if i := strings.Index(tag, ","); i >= 0 {
   380  				fname, help = tag[:i], tag[i+1:]
   381  			}
   382  			if err := registerFlag(item.fs, fv.Interface(), fname, help); err != nil {
   383  				return nil, xerrors.Errorf("flagged field %q: %w", ft.Name, err)
   384  			}
   385  			item.hasFlags = true
   386  			continue
   387  		}
   388  
   389  		// Annotation: vocab:"name" or vocab:"name,alias1,alias2,..."
   390  		if tag := ft.Tag.Get("vocab"); tag != "" {
   391  			names := strings.Split(tag, ",")
   392  			if fv.Kind() != reflect.Ptr && fv.CanAddr() {
   393  				fv = fv.Addr()
   394  			}
   395  			if !fv.CanInterface() {
   396  				return nil, xerrors.Errorf("vocab field %q: cannot capture unexported value", ft.Name)
   397  			}
   398  			sub, err := newItem(names[0], fv.Interface())
   399  			if err != nil {
   400  				return nil, xerrors.Errorf("vocab field %q: %w", ft.Name, err)
   401  			} else if _, ok := item.items[sub.name]; ok {
   402  				return nil, xerrors.Errorf("duplicate subcommand %q", name)
   403  			}
   404  
   405  			// If the field returned a command but lacks documentation, check for
   406  			// help tags on the field.
   407  			if sub.summary == nil {
   408  				if hs := ft.Tag.Get("help-summary"); hs != "" {
   409  					sub.summary = func() string { return hs }
   410  				}
   411  			}
   412  			if sub.helpText == nil {
   413  				if hs := ft.Tag.Get("help-long"); hs != "" {
   414  					sub.helpText = func() string { return hs }
   415  				}
   416  			}
   417  
   418  			item.items[sub.name] = sub
   419  			for _, a := range names[1:] {
   420  				if old, ok := item.alias[a]; ok && old != sub.name {
   421  					return nil, xerrors.Errorf("duplicate alias %q (%q, %q)", a, old, sub.name)
   422  				}
   423  				item.alias[a] = sub.name
   424  			}
   425  			continue
   426  		}
   427  
   428  		// Check for help annotations embedded in blank fields. Note that we do
   429  		// not require these tags to have the canonical format: In particular,
   430  		// the quotes may be omitted, and internal whitespace is preserved.
   431  		if ft.Name == "_" {
   432  			if t := fieldTag("help-summary", ft); t != "" && item.summary == nil {
   433  				item.summary = func() string { return t }
   434  			}
   435  			if t := fieldTag("help-long", ft); t != "" && item.helpText == nil {
   436  				item.helpText = func() string { return t }
   437  			}
   438  		}
   439  	}
   440  	return item, nil
   441  }
   442  
   443  func registerFlag(fs *flag.FlagSet, fv interface{}, name, help string) error {
   444  	switch t := fv.(type) {
   445  	case flag.Value:
   446  		fs.Var(t, name, help)
   447  	case *bool:
   448  		fs.BoolVar(t, name, *t, help)
   449  	case *time.Duration:
   450  		fs.DurationVar(t, name, *t, help)
   451  	case *float64:
   452  		fs.Float64Var(t, name, *t, help)
   453  	case *int64:
   454  		fs.Int64Var(t, name, *t, help)
   455  	case *int:
   456  		fs.IntVar(t, name, *t, help)
   457  	case *string:
   458  		fs.StringVar(t, name, *t, help)
   459  	case *uint64:
   460  		fs.Uint64Var(t, name, *t, help)
   461  	case *uint:
   462  		fs.UintVar(t, name, *t, help)
   463  	default:
   464  		return xerrors.Errorf("type %T does not implement flag.Value", fv)
   465  	}
   466  	return nil
   467  }
   468  
   469  func fieldTag(name string, ft reflect.StructField) string {
   470  	if t := ft.Tag.Get(name); t != "" {
   471  		return t
   472  	}
   473  	s := string(ft.Tag)
   474  	if t := strings.TrimPrefix(s, name+":"); t != s {
   475  		return cleanString(t)
   476  	}
   477  	return ""
   478  }
   479  
   480  // cleanString removes surrounding whitespace and quotation marks from s.
   481  func cleanString(s string) string {
   482  	return strings.TrimSpace(strings.Trim(strings.TrimSpace(s), `"`))
   483  }