commit 20c6ac26c53a7d8560d0b07dc5c07a58abbfe2d0 Author: sashakoshka@tebibyte.media Date: Sat Nov 30 22:12:55 2024 -0500 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a58c32 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# go-cli + +CLI module for Go programs. diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..c4e895b --- /dev/null +++ b/cli.go @@ -0,0 +1,418 @@ +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 + // 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 -- if specified, and if not returns - +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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d09357 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.tebibyte.media/sashakoshka/go-cli + +go 1.23.3