init
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user