fspl/cli/cli.go

419 lines
11 KiB
Go

package cli
import "os"
import "io"
import "fmt"
import "sort"
import "errors"
import "strings"
import "strconv"
// Cli represents a command line interface. It is not tied to os.Args, and it is
// possible to create multiple Cli's to have so many sub-commands in one
// program.
type Cli struct {
Logger
// Description describes the application.
Description string
// The syntax description of the command. It appears in Usage after the
// name of the program (os.Args[0]). If not specified, "[OPTIONS]..."
// will be used instead.
Syntax string
// Flag is a set of flags to parse.
Flags []*Flag
// Args is a list of leftover arguments that were not parsed.
Args []string
// Sub contains sub-commands.
Sub map[string] *Cli
// Super contains the string of commands used to invoke this Cli.
// For example, a command invoked using
// <program> foo bar
// Would have Super set to
// "foo bar"
// This is automatically filled out by Cli.AddSub.
Super string
}
// New creates a new Cli from a command description and a set of flags. These
// flags should be created as variables before passing them to New(), so that
// their values can be extracted after Parse is called.
func New (description string, flags ...*Flag) *Cli {
return &Cli {
Description: description,
Flags: flags,
Sub: make(map[string] *Cli),
}
}
// AddSub adds a sub-command to this command. It automatically fills out the
// sub-command's Super field, and alters the prefix of its Logger to match.
func (this *Cli) AddSub (name string, sub *Cli) {
super := this.Super
if super != "" { super += " " }
super += name
sub.Super = super
sub.Prefix = os.Args[0] + " " + super
this.Sub[name] = sub
}
// Parse parses the given set of command line arguments according to the flags
// defined within the Cli. The first element of the argument slice is always
// dropped, because it is assumed that it just contains the command name. If the
// second element matches up with the name of a sub-command, parsing is
// deferred to that sub-command instead. The sub-command will be given all but
// the first element of args. This method will return the address of the
// command that did the parsing.
func (this *Cli) Parse (args []string) (*Cli, error) {
// try to find a sub-command
if this.Sub != nil && len(args) > 1 {
if sub, ok := this.Sub[args[1]]; ok {
return sub.Parse(args[1:])
}
}
// strip out program name
if len(args) > 0 { args = args[1:] }
next := func () string {
args = args[1:]
if len(args) > 0 {
return args[0]
} else {
return ""
}
}
processFlag := func (flag *Flag, allowInput bool) error {
if flag.Validate == nil {
flag.Value = "true"
} else {
if len(args) < 1 || !allowInput {
return errors.New(
flag.String() +
" requires a value")
}
value := next()
err := flag.Validate(value)
if err != nil {
return errors.New(fmt.Sprint (
"bad value for ", flag.String(), ": ",
err))
}
flag.Value = value
}
if flag.Found != nil { flag.Found(this, flag.Value) }
return nil
}
// process args one by one
for ; len(args) > 0; next() {
arg := args[0]
switch {
case arg == "--":
// halt parsing
this.Args = append(this.Args, args[1:]...)
return nil, nil
case strings.HasPrefix(arg, "--"):
// long flag
flag, ok := this.LongFlag(arg[2:])
if !ok {
return nil, errors.New("unknown flag: " + arg)
}
err := processFlag(flag, true)
if err != nil { return nil, err }
case strings.HasPrefix(arg, "-") && len(arg) > 1:
// one or more short flags
arg := arg[1:]
for _, part := range arg {
flag, ok := this.ShortFlag(part)
if !ok {
return nil, errors.New (
"unknown flag: -" +
string(part))
}
err := processFlag(flag, len(arg) == 1)
if err != nil { return nil, err }
}
default:
// not a flag
this.Args = append(this.Args, arg)
}
}
return this, nil
}
// ParseOrExit is like Parse, but if an error occurs while parsing the argument
// list, it prints out Usage and exits with error status 2.
func (this *Cli) ParseOrExit (args []string) *Cli {
command, err := this.Parse(args)
if err != nil {
fmt.Fprintf(this, "%s: %v\n\n", os.Args[0], err)
this.Usage()
os.Exit(2)
}
return command
}
// ShortFlag searches for and returns the flag with the given short form. If it
// was found, it returns true. If it was not found, it returns nil, false.
func (this *Cli) ShortFlag (short rune) (*Flag, bool) {
if short == 0 { return nil, false }
for _, flag := range this.Flags {
if flag.Short == short { return flag, true }
}
return nil, false
}
// LongFlag searches for and returns the flag with the given long form. If it
// was found, it returns true. If it was not found, it returns nil, false.
func (this *Cli) LongFlag (long string) (*Flag, bool) {
if long == "" { return nil, false }
for _, flag := range this.Flags {
if flag.Long == long { return flag, true }
}
return nil, false
}
// Usage prints out usage/help information.
func (this *Cli) Usage () {
// syntax
fmt.Fprint(this, "Usage:")
fmt.Fprint(this, " ", os.Args[0])
if this.Super != "" {
fmt.Fprint(this, " ", this.Super)
}
hasSubCommands := this.Sub != nil && len(this.Sub) > 0
if hasSubCommands {
fmt.Fprint(this, " [COMMAND]")
}
if this.Syntax == "" {
fmt.Fprint(this, " [OPTION]...")
} else {
fmt.Fprint(this, " ", this.Syntax)
}
fmt.Fprint(this, "\n\n")
// description
if this.Description != "" {
fmt.Fprintf(this, "%s\n\n", this.Description)
}
// fit the longest flag
longest := 0
for _, flag := range this.Flags {
if len(flag.Long) > longest {
longest = len(flag.Long)
}
}
format := fmt.Sprint("\t%-", longest + 8, "s%s\n")
// flags
for _, flag := range this.Flags {
shortLong := ""
if flag.Short == 0 {
shortLong += " "
} else {
shortLong += "-" + string(flag.Short)
}
if flag.Short != 0 && flag.Long != "" {
shortLong += ", "
}
if flag.Long != "" {
shortLong += "--" + flag.Long
}
fmt.Fprintf(this, format, shortLong, flag.Help)
}
// commands
if hasSubCommands {
fmt.Fprint(this, "\nCommands:\n\n")
names := sortMapKeys(this.Sub)
// fit the longest command
longest := 0
for _, name := range names {
if len(name) > longest {
longest = len(name)
}
}
format := fmt.Sprint("\t%-", longest + 2, "s%s\n")
for _, name := range names {
fmt.Fprintf(this, format, name, this.Sub[name].Description)
}
}
}
// Logger prints messages to an output writer.
type Logger struct {
// Writer specifies the writer to output to. If it is nil, the Logger
// will output to os.Stderr. If you wish to silence the output, set it
// to io.Discard.
io.Writer
// Debug determines whether or not debug messages will be logged.
Debug bool
// Prefix is printed before all messages. If this is an empty string,
// os.Args[0] will be used.
Prefix string
}
// Println logs a normal message.
func (this *Logger) Println (v ...any) {
fmt.Fprintf(this, "%s: %s", this.prefix(), fmt.Sprintln(v...))
}
// Errorln logs an error message.
func (this *Logger) Errorln (v ...any) {
fmt.Fprintf(this, "%s: %s", this.prefix(), fmt.Sprintln(v...))
}
// Warnln logs a warning.
func (this *Logger) Warnln (v ...any) {
fmt.Fprintf(this, "%s: warning: %s", this.prefix(), fmt.Sprintln(v...))
}
// Debugln logs debugging information. It will only print the message if the
// Debug field is set to true.
func (this *Logger) Debugln (v ...any) {
if !this.Debug { return }
fmt.Fprintf(this, "%s: debug: %s", this.prefix(), fmt.Sprintln(v...))
}
func (this *Logger) prefix () string {
if this.Prefix == "" {
return os.Args[0]
} else {
return this.Prefix
}
}
// Write writes to the Logger's writer. If it is nil, it writes to os.Stderr.
func (this *Logger) Write (data []byte) (n int, err error) {
if this.Writer == nil {
return os.Stderr.Write(data)
} else {
return this.Writer.Write(data)
}
}
// Flag is a command line option.
type Flag struct {
Short rune // The short form of the flag (-l)
Long string // Long form of the flag (--long-form)
Help string // Help text to display by the flag
// Validate is an optional function that is called when an input is
// passed to the flag. It checks whether or not the input is valid, and
// returns an error if it isn't. If this function is nil, the flag is
// assumed to have no input.
Validate func (string) error
// Value contains the input given to the flag, and is filled out when
// the flags are parsed. If this is a non-input flag (if Validate is
// nil), this will be set to true. If the flag was not specified, Value
// will be unchanged.
Value string
// Found is an optional function that is called each time the flag is
// found, and has a valid argument (if applicable). The value is passed
// to the function, as well as the command that it was found in.
Found func (*Cli, string)
}
// String returns --<LongForm> if specified, and if not returns -<ShortForm>
func (this *Flag) String () string {
if this.Long == "" {
return fmt.Sprintf("-%c", this.Short)
} else {
return fmt.Sprintf("--%s", this.Long)
}
}
// NewFlag creates a new flag that does not take in a value.
func NewFlag (short rune, long string, help string) *Flag {
return &Flag {
Short: short,
Long: long,
Help: help,
}
}
// NewInputFlag creates a new flag that does take in a value. This function will
// panic if the given validation function is nil.
func NewInputFlag (short rune, long string, help string, defaul string, validate func (string) error) *Flag {
if validate == nil {
panic("validate must be non-nil for a flag to take in a value")
}
return &Flag {
Short: short,
Long: long,
Help: help,
Value: defaul,
Validate: validate,
}
}
// NewHelp creates a new help flag activated by --help or -h. It shows help
// information for the command that it is a part of, and causes the program to
// exit with a status of 2.
func NewHelp () *Flag {
flag := NewFlag('h', "help", "Display usage information and exit")
flag.Found = func (command *Cli, value string) {
command.Usage()
os.Exit(2)
}
return flag
}
// ValString is a validation function that always returns nil, accepting any
// string.
func ValString (value string) error {
return nil
}
// ValInt is a validation function that returns an error if the value is not an
// integer.
func ValInt (value string) error {
_, err := strconv.Atoi(value)
return err
}
// NewValSet returns a validation function that accepts a set of strings.
func NewValSet (allowed ...string) func (value string) error {
return func (value string) error {
allowedFmt := ""
for index, test := range allowed {
if test == value { return nil }
if index > 0 { allowedFmt += ", " }
allowedFmt += test
}
return errors.New (
"value must be one of (" + allowedFmt + ")")
}
}
func sortMapKeys[T any] (unsorted map[string] T) []string {
keys := make([]string, len(unsorted))
index := 0
for key := range unsorted {
keys[index] = key
index ++
}
sort.Strings(keys)
return keys
}