238 lines
7.5 KiB
Go
238 lines
7.5 KiB
Go
// Package cliutil is the CLI's plumbing: a small subcommand tree over the
|
|
// standard flag package, and table/JSON rendering.
|
|
//
|
|
// It is deliberately not a CLI framework, but not for the reason usually
|
|
// given. Measured on this machine, net/http and crypto/tls alone put the floor
|
|
// for any Go HTTP client at 5.4 MB stripped; the pages binary is 6.0 MB, and
|
|
// the same program written with cobra came out at 5.9 MB. Half a megabyte
|
|
// either way is noise next to the TLS stack, so "cobra is too big" would be a
|
|
// claim the numbers do not support.
|
|
//
|
|
// The actual reason is dependency surface. This tool has a fixed set of about
|
|
// a dozen commands whose flags are plain strings, bools and ints; it needs no
|
|
// shell completion, no generated man pages, no dynamic command registration.
|
|
// The tree below is the part of a framework it would use, and it is small
|
|
// enough to read in one sitting — which matters more for a binary that CI jobs
|
|
// download and run with a deployment credential in the environment.
|
|
package cliutil
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
)
|
|
|
|
// ErrUsage is returned when the command line does not name something runnable.
|
|
// The caller prints the usage text that accompanies it and exits non-zero.
|
|
var ErrUsage = errors.New("usage")
|
|
|
|
// Command is one node of the command tree. A node either runs (Exec) or has
|
|
// children (Sub), never both.
|
|
type Command struct {
|
|
// Name is the single word that selects this command.
|
|
Name string
|
|
// Args describes the positional arguments for the usage line, e.g. "<name>".
|
|
Args string
|
|
// Short is the one-line summary listed by the parent.
|
|
Short string
|
|
// Long is optional additional prose printed by --help.
|
|
Long string
|
|
|
|
// Flags registers this command's flags. It runs once per invocation, before
|
|
// parsing, so the variables it binds are the ones Exec reads.
|
|
Flags func(fs *flag.FlagSet)
|
|
|
|
// Exec runs the command with the positional arguments left after parsing.
|
|
Exec func(ctx context.Context, args []string) error
|
|
|
|
// Sub are the child commands, if any.
|
|
Sub []*Command
|
|
|
|
// parent is filled in during dispatch so usage text can print the full path.
|
|
parent *Command
|
|
}
|
|
|
|
// Persistent registers flags that every command in the tree accepts.
|
|
//
|
|
// The values it binds must already hold whatever an outer parse produced, and
|
|
// it must register them with those values as the defaults — see Globals.Register
|
|
// in internal/clicmd. Registering with a fixed default instead would reset a
|
|
// flag given before the subcommand name ("pages --server X project list"),
|
|
// because flag.StringVar assigns the default at registration time.
|
|
type Persistent func(fs *flag.FlagSet)
|
|
|
|
// Run parses args against c and dispatches. out receives usage and help text.
|
|
func Run(ctx context.Context, c *Command, args []string, out io.Writer, persistent Persistent) error {
|
|
fs := flag.NewFlagSet(c.path(), flag.ContinueOnError)
|
|
fs.SetOutput(out)
|
|
fs.Usage = func() { c.printUsage(out, fs) }
|
|
if persistent != nil {
|
|
persistent(fs)
|
|
}
|
|
if c.Flags != nil {
|
|
c.Flags(fs)
|
|
}
|
|
if len(c.Sub) == 0 {
|
|
args = permute(fs, args)
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
// flag has already printed the usage via fs.Usage.
|
|
return nil
|
|
}
|
|
// flag printed the message; adding our own would double it.
|
|
return ErrUsage
|
|
}
|
|
rest := fs.Args()
|
|
|
|
if len(c.Sub) > 0 && len(rest) > 0 {
|
|
for _, sub := range c.Sub {
|
|
if sub.Name == rest[0] {
|
|
sub.parent = c
|
|
return Run(ctx, sub, rest[1:], out, persistent)
|
|
}
|
|
}
|
|
fmt.Fprintf(out, "unknown command %q\n\n", rest[0])
|
|
c.printUsage(out, fs)
|
|
return ErrUsage
|
|
}
|
|
|
|
// A leaf, or a parent invoked without naming a child. The latter still gets
|
|
// its Exec so the root can answer --version before falling back to usage.
|
|
if c.Exec == nil {
|
|
c.printUsage(out, fs)
|
|
return ErrUsage
|
|
}
|
|
err := c.Exec(ctx, rest)
|
|
if errors.Is(err, ErrUsage) {
|
|
c.printUsage(out, fs)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// permute moves flags ahead of positional arguments, so that
|
|
//
|
|
// pages project create demo --spa
|
|
//
|
|
// works as well as the order the flag package wants:
|
|
//
|
|
// pages project create --spa demo
|
|
//
|
|
// Stopping at the first non-flag argument is right for a command that has
|
|
// children — the first positional there names the child, and its flags belong
|
|
// to it, not to us. A leaf has no such ambiguity, so it gets the permuting
|
|
// parse people expect from every other CLI they use.
|
|
//
|
|
// Two tokens are left exactly where they are: "--" terminates flags for good,
|
|
// and an unrecognised flag is passed through alone so that flag.Parse reports
|
|
// it rather than this function silently swallowing the argument after it.
|
|
func permute(fs *flag.FlagSet, args []string) []string {
|
|
known := map[string]*flag.Flag{}
|
|
fs.VisitAll(func(f *flag.Flag) { known[f.Name] = f })
|
|
|
|
var flags, rest []string
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if a == "--" {
|
|
rest = append(rest, args[i+1:]...)
|
|
break
|
|
}
|
|
// "-" alone is the conventional name for stdin, not a flag.
|
|
if len(a) < 2 || a[0] != '-' {
|
|
rest = append(rest, a)
|
|
continue
|
|
}
|
|
flags = append(flags, a)
|
|
name := strings.TrimLeft(a, "-")
|
|
if strings.ContainsRune(name, '=') {
|
|
continue // --name=value carries its own argument
|
|
}
|
|
// A non-boolean flag takes the next token with it. Booleans must not
|
|
// swallow anything: "--spa demo" is a flag and a positional.
|
|
if f, ok := known[name]; ok && !isBoolFlag(f) && i+1 < len(args) {
|
|
i++
|
|
flags = append(flags, args[i])
|
|
}
|
|
}
|
|
// The separator makes the tail positional even if a value there looks like
|
|
// a flag, which matters for paths and metadata values.
|
|
return append(flags, append([]string{"--"}, rest...)...)
|
|
}
|
|
|
|
func isBoolFlag(f *flag.Flag) bool {
|
|
b, ok := f.Value.(interface{ IsBoolFlag() bool })
|
|
return ok && b.IsBoolFlag()
|
|
}
|
|
|
|
// UsageErrorf returns an error that makes Run print the command's usage after
|
|
// the caller reports the message. Use it for "wrong number of arguments" and
|
|
// friends, where the fix is visible in the usage text.
|
|
func UsageErrorf(format string, args ...any) error {
|
|
return &usageError{msg: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
type usageError struct{ msg string }
|
|
|
|
func (e *usageError) Error() string { return e.msg }
|
|
|
|
// Is makes errors.Is(err, ErrUsage) true for any usageError.
|
|
func (e *usageError) Is(target error) bool { return target == ErrUsage }
|
|
|
|
// path is the space-separated command path, used in usage text and as the
|
|
// FlagSet name so flag's own error messages name the right command.
|
|
func (c *Command) path() string {
|
|
if c.parent == nil {
|
|
return c.Name
|
|
}
|
|
return c.parent.path() + " " + c.Name
|
|
}
|
|
|
|
func (c *Command) printUsage(out io.Writer, fs *flag.FlagSet) {
|
|
if c.Short != "" {
|
|
fmt.Fprintf(out, "%s — %s\n\n", c.path(), c.Short)
|
|
}
|
|
|
|
fmt.Fprintf(out, "Usage:\n %s", c.path())
|
|
if len(c.Sub) > 0 {
|
|
fmt.Fprint(out, " <command>")
|
|
}
|
|
if hasFlags(fs) {
|
|
fmt.Fprint(out, " [flags]")
|
|
}
|
|
if c.Args != "" {
|
|
fmt.Fprintf(out, " %s", c.Args)
|
|
}
|
|
fmt.Fprint(out, "\n")
|
|
|
|
if len(c.Sub) > 0 {
|
|
fmt.Fprint(out, "\nCommands:\n")
|
|
tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0)
|
|
subs := append([]*Command(nil), c.Sub...)
|
|
sort.Slice(subs, func(i, j int) bool { return subs[i].Name < subs[j].Name })
|
|
for _, sub := range subs {
|
|
fmt.Fprintf(tw, " %s\t%s\n", sub.Name, sub.Short)
|
|
}
|
|
tw.Flush()
|
|
}
|
|
|
|
if hasFlags(fs) {
|
|
fmt.Fprint(out, "\nFlags:\n")
|
|
fs.PrintDefaults()
|
|
}
|
|
|
|
if c.Long != "" {
|
|
fmt.Fprintf(out, "\n%s\n", strings.TrimSpace(c.Long))
|
|
}
|
|
}
|
|
|
|
func hasFlags(fs *flag.FlagSet) bool {
|
|
n := 0
|
|
fs.VisitAll(func(*flag.Flag) { n++ })
|
|
return n > 0
|
|
}
|