github.com/kat-co/cmd@v0.0.0-20140616103059-5da365f9d57e/supercommand.go (about)

     1  // Copyright 2012, 2013 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package cmd
     5  
     6  import (
     7  	"bytes"
     8  	"fmt"
     9  	"io/ioutil"
    10  	"sort"
    11  	"strings"
    12  
    13  	"github.com/juju/loggo"
    14  	"launchpad.net/gnuflag"
    15  )
    16  
    17  var logger = loggo.GetLogger("juju.cmd")
    18  
    19  type topic struct {
    20  	short string
    21  	long  func() string
    22  	// Help aliases are not output when topics are listed, but are used
    23  	// to search for the help topic
    24  	alias bool
    25  }
    26  
    27  type UnrecognizedCommand struct {
    28  	Name string
    29  }
    30  
    31  func (e *UnrecognizedCommand) Error() string {
    32  	return fmt.Sprintf("unrecognized command: %s", e.Name)
    33  }
    34  
    35  // MissingCallback defines a function that will be used by the SuperCommand if
    36  // the requested subcommand isn't found.
    37  type MissingCallback func(ctx *Context, subcommand string, args []string) error
    38  
    39  // SuperCommandParams provides a way to have default parameter to the
    40  // `NewSuperCommand` call.
    41  type SuperCommandParams struct {
    42  	// UsagePrefix should be set when the SuperCommand is
    43  	// actually a subcommand of some other SuperCommand;
    44  	// if NotifyRun is called, it name will be prefixed accordingly,
    45  	// unless UsagePrefix is identical to Name.
    46  	UsagePrefix string
    47  
    48  	// Notify, if not nil, is called when the SuperCommand
    49  	// is about to run a sub-command.
    50  	NotifyRun func(cmdName string)
    51  
    52  	Name            string
    53  	Purpose         string
    54  	Doc             string
    55  	Log             *Log
    56  	MissingCallback MissingCallback
    57  	Aliases         []string
    58  	Version         string
    59  }
    60  
    61  // NewSuperCommand creates and initializes a new `SuperCommand`, and returns
    62  // the fully initialized structure.
    63  func NewSuperCommand(params SuperCommandParams) *SuperCommand {
    64  	command := &SuperCommand{
    65  		Name:            params.Name,
    66  		Purpose:         params.Purpose,
    67  		Doc:             params.Doc,
    68  		Log:             params.Log,
    69  		usagePrefix:     params.UsagePrefix,
    70  		missingCallback: params.MissingCallback,
    71  		Aliases:         params.Aliases,
    72  		version:         params.Version,
    73  		notifyRun:       params.NotifyRun,
    74  	}
    75  	command.init()
    76  	return command
    77  }
    78  
    79  // SuperCommand is a Command that selects a subcommand and assumes its
    80  // properties; any command line arguments that were not used in selecting
    81  // the subcommand are passed down to it, and to Run a SuperCommand is to run
    82  // its selected subcommand.
    83  type SuperCommand struct {
    84  	CommandBase
    85  	Name            string
    86  	Purpose         string
    87  	Doc             string
    88  	Log             *Log
    89  	Aliases         []string
    90  	version         string
    91  	usagePrefix     string
    92  	subcmds         map[string]Command
    93  	commonflags     *gnuflag.FlagSet
    94  	flags           *gnuflag.FlagSet
    95  	subcmd          Command
    96  	showHelp        bool
    97  	showDescription bool
    98  	showVersion     bool
    99  	missingCallback MissingCallback
   100  	notifyRun       func(string)
   101  }
   102  
   103  // IsSuperCommand implements Command.IsSuperCommand
   104  func (c *SuperCommand) IsSuperCommand() bool {
   105  	return true
   106  }
   107  
   108  func (c *SuperCommand) init() {
   109  	if c.subcmds != nil {
   110  		return
   111  	}
   112  	help := &helpCommand{
   113  		super: c,
   114  	}
   115  	help.init()
   116  	c.subcmds = map[string]Command{"help": help}
   117  	if c.version != "" {
   118  		c.subcmds["version"] = newVersionCommand(c.version)
   119  	}
   120  }
   121  
   122  // AddHelpTopic adds a new help topic with the description being the short
   123  // param, and the full text being the long param.  The description is shown in
   124  // 'help topics', and the full text is shown when the command 'help <name>' is
   125  // called.
   126  func (c *SuperCommand) AddHelpTopic(name, short, long string, aliases ...string) {
   127  	c.subcmds["help"].(*helpCommand).addTopic(name, short, echo(long), aliases...)
   128  }
   129  
   130  // AddHelpTopicCallback adds a new help topic with the description being the
   131  // short param, and the full text being defined by the callback function.
   132  func (c *SuperCommand) AddHelpTopicCallback(name, short string, longCallback func() string) {
   133  	c.subcmds["help"].(*helpCommand).addTopic(name, short, longCallback)
   134  }
   135  
   136  // Register makes a subcommand available for use on the command line. The
   137  // command will be available via its own name, and via any supplied aliases.
   138  func (c *SuperCommand) Register(subcmd Command) {
   139  	info := subcmd.Info()
   140  	c.insert(info.Name, subcmd)
   141  	for _, name := range info.Aliases {
   142  		c.insert(name, subcmd)
   143  	}
   144  }
   145  
   146  func (c *SuperCommand) insert(name string, subcmd Command) {
   147  	if _, found := c.subcmds[name]; found || name == "help" {
   148  		panic(fmt.Sprintf("command already registered: %s", name))
   149  	}
   150  	c.subcmds[name] = subcmd
   151  }
   152  
   153  // describeCommands returns a short description of each registered subcommand.
   154  func (c *SuperCommand) describeCommands(simple bool) string {
   155  	var lineFormat = "    %-*s - %s"
   156  	var outputFormat = "commands:\n%s"
   157  	if simple {
   158  		lineFormat = "%-*s  %s"
   159  		outputFormat = "%s"
   160  	}
   161  	cmds := make([]string, len(c.subcmds))
   162  	i := 0
   163  	longest := 0
   164  	for name := range c.subcmds {
   165  		if len(name) > longest {
   166  			longest = len(name)
   167  		}
   168  		cmds[i] = name
   169  		i++
   170  	}
   171  	sort.Strings(cmds)
   172  	for i, name := range cmds {
   173  		info := c.subcmds[name].Info()
   174  		purpose := info.Purpose
   175  		if name != info.Name {
   176  			purpose = "alias for " + info.Name
   177  		}
   178  		cmds[i] = fmt.Sprintf(lineFormat, longest, name, purpose)
   179  	}
   180  	return fmt.Sprintf(outputFormat, strings.Join(cmds, "\n"))
   181  }
   182  
   183  // Info returns a description of the currently selected subcommand, or of the
   184  // SuperCommand itself if no subcommand has been specified.
   185  func (c *SuperCommand) Info() *Info {
   186  	if c.subcmd != nil {
   187  		info := *c.subcmd.Info()
   188  		info.Name = fmt.Sprintf("%s %s", c.Name, info.Name)
   189  		return &info
   190  	}
   191  	docParts := []string{}
   192  	if doc := strings.TrimSpace(c.Doc); doc != "" {
   193  		docParts = append(docParts, doc)
   194  	}
   195  	if cmds := c.describeCommands(false); cmds != "" {
   196  		docParts = append(docParts, cmds)
   197  	}
   198  	return &Info{
   199  		Name:    c.Name,
   200  		Args:    "<command> ...",
   201  		Purpose: c.Purpose,
   202  		Doc:     strings.Join(docParts, "\n\n"),
   203  		Aliases: c.Aliases,
   204  	}
   205  }
   206  
   207  const helpPurpose = "show help on a command or other topic"
   208  
   209  // SetCommonFlags creates a new "commonflags" flagset, whose
   210  // flags are shared with the argument f; this enables us to
   211  // add non-global flags to f, which do not carry into subcommands.
   212  func (c *SuperCommand) SetCommonFlags(f *gnuflag.FlagSet) {
   213  	if c.Log != nil {
   214  		c.Log.AddFlags(f)
   215  	}
   216  	f.BoolVar(&c.showHelp, "h", false, helpPurpose)
   217  	f.BoolVar(&c.showHelp, "help", false, "")
   218  	// In the case where we are providing the basis for a plugin,
   219  	// plugins are required to support the --description argument.
   220  	// The Purpose attribute will be printed (if defined), allowing
   221  	// plugins to provide a sensible line of text for 'juju help plugins'.
   222  	f.BoolVar(&c.showDescription, "description", false, "")
   223  	c.commonflags = gnuflag.NewFlagSet(c.Info().Name, gnuflag.ContinueOnError)
   224  	c.commonflags.SetOutput(ioutil.Discard)
   225  	f.VisitAll(func(flag *gnuflag.Flag) {
   226  		c.commonflags.Var(flag.Value, flag.Name, flag.Usage)
   227  	})
   228  }
   229  
   230  // SetFlags adds the options that apply to all commands, particularly those
   231  // due to logging.
   232  func (c *SuperCommand) SetFlags(f *gnuflag.FlagSet) {
   233  	c.SetCommonFlags(f)
   234  	// Only flags set by SetCommonFlags are passed on to subcommands.
   235  	// Any flags added below only take effect when no subcommand is
   236  	// specified (e.g. command --version).
   237  	if c.version != "" {
   238  		f.BoolVar(&c.showVersion, "version", false, "Show the command's version and exit")
   239  	}
   240  	c.flags = f
   241  }
   242  
   243  // For a SuperCommand, we want to parse the args with
   244  // allowIntersperse=false. This will mean that the args may contain other
   245  // options that haven't been defined yet, and that only options that relate
   246  // to the SuperCommand itself can come prior to the subcommand name.
   247  func (c *SuperCommand) AllowInterspersedFlags() bool {
   248  	return false
   249  }
   250  
   251  // Init initializes the command for running.
   252  func (c *SuperCommand) Init(args []string) error {
   253  	if c.showDescription {
   254  		return CheckEmpty(args)
   255  	}
   256  	if len(args) == 0 {
   257  		c.subcmd = c.subcmds["help"]
   258  		return nil
   259  	}
   260  
   261  	found := false
   262  	// Look for the command.
   263  	if c.subcmd, found = c.subcmds[args[0]]; !found {
   264  		if c.missingCallback != nil {
   265  			c.subcmd = &missingCommand{
   266  				callback:  c.missingCallback,
   267  				superName: c.Name,
   268  				name:      args[0],
   269  				args:      args[1:],
   270  			}
   271  			// Yes return here, no Init called on missing Command.
   272  			return nil
   273  		}
   274  		return fmt.Errorf("unrecognized command: %s %s", c.Name, args[0])
   275  	}
   276  	args = args[1:]
   277  	if c.subcmd.IsSuperCommand() {
   278  		f := gnuflag.NewFlagSet(c.Info().Name, gnuflag.ContinueOnError)
   279  		f.SetOutput(ioutil.Discard)
   280  		c.subcmd.SetFlags(f)
   281  	} else {
   282  		c.subcmd.SetFlags(c.commonflags)
   283  	}
   284  	if err := c.commonflags.Parse(c.subcmd.AllowInterspersedFlags(), args); err != nil {
   285  		return err
   286  	}
   287  	args = c.commonflags.Args()
   288  	if c.showHelp {
   289  		// We want to treat help for the command the same way we would if we went "help foo".
   290  		args = []string{c.subcmd.Info().Name}
   291  		c.subcmd = c.subcmds["help"]
   292  	}
   293  	return c.subcmd.Init(args)
   294  }
   295  
   296  // Run executes the subcommand that was selected in Init.
   297  func (c *SuperCommand) Run(ctx *Context) error {
   298  	if c.showDescription {
   299  		if c.Purpose != "" {
   300  			fmt.Fprintf(ctx.Stdout, "%s\n", c.Purpose)
   301  		} else {
   302  			fmt.Fprintf(ctx.Stdout, "%s: no description available\n", c.Info().Name)
   303  		}
   304  		return nil
   305  	}
   306  	if c.subcmd == nil {
   307  		panic("Run: missing subcommand; Init failed or not called")
   308  	}
   309  	if c.Log != nil {
   310  		if err := c.Log.Start(ctx); err != nil {
   311  			return err
   312  		}
   313  	}
   314  	if c.notifyRun != nil {
   315  		name := c.Name
   316  		if c.usagePrefix != "" && c.usagePrefix != name{
   317  			name = c.usagePrefix + " " + name
   318  		}
   319  		c.notifyRun(name)
   320  	}
   321  	err := c.subcmd.Run(ctx)
   322  	if err != nil && err != ErrSilent {
   323  		logger.Errorf("%v", err)
   324  		// Now that this has been logged, don't log again in cmd.Main.
   325  		if !IsRcPassthroughError(err) {
   326  			err = ErrSilent
   327  		}
   328  	} else {
   329  		logger.Infof("command finished")
   330  	}
   331  	return err
   332  }
   333  
   334  type missingCommand struct {
   335  	CommandBase
   336  	callback  MissingCallback
   337  	superName string
   338  	name      string
   339  	args      []string
   340  }
   341  
   342  // Missing commands only need to supply Info for the interface, but this is
   343  // never called.
   344  func (c *missingCommand) Info() *Info {
   345  	return nil
   346  }
   347  
   348  func (c *missingCommand) Run(ctx *Context) error {
   349  	err := c.callback(ctx, c.name, c.args)
   350  	_, isUnrecognized := err.(*UnrecognizedCommand)
   351  	if !isUnrecognized {
   352  		return err
   353  	}
   354  	return &UnrecognizedCommand{c.superName + " " + c.name}
   355  }
   356  
   357  type helpCommand struct {
   358  	CommandBase
   359  	super     *SuperCommand
   360  	topic     string
   361  	topicArgs []string
   362  	topics    map[string]topic
   363  }
   364  
   365  func (c *helpCommand) init() {
   366  	c.topics = map[string]topic{
   367  		"commands": {
   368  			short: "Basic help for all commands",
   369  			long:  func() string { return c.super.describeCommands(true) },
   370  		},
   371  		"global-options": {
   372  			short: "Options common to all commands",
   373  			long:  func() string { return c.globalOptions() },
   374  		},
   375  		"topics": {
   376  			short: "Topic list",
   377  			long:  func() string { return c.topicList() },
   378  		},
   379  	}
   380  }
   381  
   382  func echo(s string) func() string {
   383  	return func() string { return s }
   384  }
   385  
   386  func (c *helpCommand) addTopic(name, short string, long func() string, aliases ...string) {
   387  	if _, found := c.topics[name]; found {
   388  		panic(fmt.Sprintf("help topic already added: %s", name))
   389  	}
   390  	c.topics[name] = topic{short, long, false}
   391  	for _, alias := range aliases {
   392  		if _, found := c.topics[alias]; found {
   393  			panic(fmt.Sprintf("help topic already added: %s", alias))
   394  		}
   395  		c.topics[alias] = topic{short, long, true}
   396  	}
   397  }
   398  
   399  func (c *helpCommand) globalOptions() string {
   400  	buf := &bytes.Buffer{}
   401  	fmt.Fprintf(buf, `Global Options
   402  
   403  These options may be used with any command, and may appear in front of any
   404  command.
   405  
   406  `)
   407  
   408  	f := gnuflag.NewFlagSet("", gnuflag.ContinueOnError)
   409  	c.super.SetCommonFlags(f)
   410  	f.SetOutput(buf)
   411  	f.PrintDefaults()
   412  	return buf.String()
   413  }
   414  
   415  func (c *helpCommand) topicList() string {
   416  	var topics []string
   417  	longest := 0
   418  	for name, topic := range c.topics {
   419  		if topic.alias {
   420  			continue
   421  		}
   422  		if len(name) > longest {
   423  			longest = len(name)
   424  		}
   425  		topics = append(topics, name)
   426  	}
   427  	sort.Strings(topics)
   428  	for i, name := range topics {
   429  		shortHelp := c.topics[name].short
   430  		topics[i] = fmt.Sprintf("%-*s  %s", longest, name, shortHelp)
   431  	}
   432  	return fmt.Sprintf("%s", strings.Join(topics, "\n"))
   433  }
   434  
   435  func (c *helpCommand) Info() *Info {
   436  	return &Info{
   437  		Name:    "help",
   438  		Args:    "[topic]",
   439  		Purpose: helpPurpose,
   440  		Doc: `
   441  See also: topics
   442  `,
   443  	}
   444  }
   445  
   446  func (c *helpCommand) Init(args []string) error {
   447  	switch len(args) {
   448  	case 0:
   449  	case 1:
   450  		c.topic = args[0]
   451  	default:
   452  		if c.super.missingCallback == nil {
   453  			return fmt.Errorf("extra arguments to command help: %q", args[1:])
   454  		} else {
   455  			c.topic = args[0]
   456  			c.topicArgs = args[1:]
   457  		}
   458  	}
   459  	return nil
   460  }
   461  
   462  func (c *helpCommand) Run(ctx *Context) error {
   463  	if c.super.showVersion {
   464  		v := newVersionCommand(c.super.version)
   465  		v.SetFlags(c.super.flags)
   466  		v.Init(nil)
   467  		return v.Run(ctx)
   468  	}
   469  
   470  	// If there is no help topic specified, print basic usage.
   471  	if c.topic == "" {
   472  		if _, ok := c.topics["basics"]; ok {
   473  			c.topic = "basics"
   474  		} else {
   475  			// At this point, "help" is selected as the SuperCommand's
   476  			// sub-command, but we want the info to be printed
   477  			// as if there was nothing selected.
   478  			c.super.subcmd = nil
   479  
   480  			info := c.super.Info()
   481  			f := gnuflag.NewFlagSet(info.Name, gnuflag.ContinueOnError)
   482  			c.SetFlags(f)
   483  			ctx.Stdout.Write(info.Help(f))
   484  			return nil
   485  		}
   486  	}
   487  	// If the topic is a registered subcommand, then run the help command with it
   488  	if helpcmd, ok := c.super.subcmds[c.topic]; ok {
   489  		info := helpcmd.Info()
   490  		info.Name = fmt.Sprintf("%s %s", c.super.Name, info.Name)
   491  		if c.super.usagePrefix != "" {
   492  			info.Name = fmt.Sprintf("%s %s", c.super.usagePrefix, info.Name)
   493  		}
   494  		f := gnuflag.NewFlagSet(info.Name, gnuflag.ContinueOnError)
   495  		helpcmd.SetFlags(f)
   496  		ctx.Stdout.Write(info.Help(f))
   497  		return nil
   498  	}
   499  	// Look to see if the topic is a registered topic.
   500  	topic, ok := c.topics[c.topic]
   501  	if ok {
   502  		fmt.Fprintf(ctx.Stdout, "%s\n", strings.TrimSpace(topic.long()))
   503  		return nil
   504  	}
   505  	// If we have a missing callback, call that with --help
   506  	if c.super.missingCallback != nil {
   507  		helpArgs := []string{"--help"}
   508  		if len(c.topicArgs) > 0 {
   509  			helpArgs = append(helpArgs, c.topicArgs...)
   510  		}
   511  		subcmd := &missingCommand{
   512  			callback:  c.super.missingCallback,
   513  			superName: c.super.Name,
   514  			name:      c.topic,
   515  			args:      helpArgs,
   516  		}
   517  		err := subcmd.Run(ctx)
   518  		_, isUnrecognized := err.(*UnrecognizedCommand)
   519  		if !isUnrecognized {
   520  			return err
   521  		}
   522  	}
   523  	return fmt.Errorf("unknown command or topic for %s", c.topic)
   524  }