wand/lib.go

246 lines
11 KiB
Go

// Wand provides utilities for constructing command line applications from functions, with automatic parameter
// binding from command line arguments, environment variables and configuration files, and automatically
// generated help and documentation.
package wand
import (
"fmt"
"os"
"path"
)
// Config represents one or more configuration sources.
type Config struct {
file func(Cmd) *file
merge []Config
fromOption bool
optional bool
test string
}
// Cmd represents a command, a subcommand or a subcommand group.
type Cmd struct {
name string
impl any
group bool
subcommands []Cmd
isDefault bool
minPositional int
maxPositional int
shortForms []string
helpFor *Cmd
version string
}
// Command defines a command or a subcommand.
//
// The name argument is expected to be a valid symbol, with non-leading dashes allowed
// (^[a-zA-Z_][a-zA-Z_0-9-]*$). It is optional to set the name of the top level command, and it is inferred from
// the executing binary's name. The name of the executable should also be a symbol, otherwise the automatic
// binding of the environment variables may not work properly.
//
// The implementation argument needs to be a function. The input parameters of the function need to be bindable,
// or either an io.Reader or io.Writer. Bindable means that the type can accept scalar values, like numbers,
// strings, time and duration, or it has fields like a struct. Pointers to bindable types are also bindable.
//
// Scalar arguments are considered as positional arguments of the command. Variadic arguments are supported.
//
// The fields of struct arguments define which command line options are supported by the command. Input
// for the options is accepted both from command line flags or environment variables, and, if defined, from
// configuration. Values defined in the environment override the configuration, and values passed in as command
// line flags override the environment (not propagated automatically to the environment of any child processes).
// Zero or more struct parameters are accepted, e.g. func(g Globals, o Options).
//
// The names of the command line flags are inferred from the struct path and field names, and are expected in
// kebab case with double leading dashes, unless a short form is defined. Option values are accepted both
// with = or just space separated following the flag. In case of bool options, omitting the value is considered
// as true value. Since the struct path and field names are collapsed into their flat kebab case representation,
// this results in ambiguity, e.g. Foo.BarBaz and Foo.Bar.Baz. In such cases, the types of the ambigous fields
// must be compatible, and each such field will be bound to the same input value. Slice fields are supported,
// they accept zero or more options of the same name, every option value is bound as an item of the slice, e.g.
// --foo one --foo two --foo three.
//
// The names of the environment variables are inferred from the struct path and field names, are prefixed with
// the name of the execting binary, and are expected in lower or upper snake case. E.g. for a field InputValue:
// FOO_BAR_INPUT_VALUE=42 /usr/bin/foo-bar. The same ambiguity rules apply as in case of the command line
// flags. Slice fields are supported, the values slice fields can be separated by the : character, like in case
// of the PATH environment variable. When necessary, the : character can be escaped as \:.
//
// For the implementation function, zero or one io.Reader and zero or one io.Writer parameter is accepted. When
// present, io.Reader is poplated by os.Stdin and io.Writer is populated by os.Stdout.
//
// The implementation function can have zero or more output parameters. If the last output parameter is of type
// error, and the returned value is not nil, the rest of the output parameters will be ignored and the error
// will be printed onto os.Stderr, and the command will exit with a non zero code. In case of no error, every
// return value will be printed on its own line, and if a return value is a slice, then every item will also be
// printed on its own line. If a return value is an io.Reader, that reader will be copied onto os.Stdout. If a
// values is a primitive value, it will be printed onto os.Stdout using the stdlib fmt package. Complex values
// will be printed to os.Stdout using code.squareroundforest.org/arpio/notation.
//
// A command can have zero or more subcommands. When executing a subcommand, the subcommands path must be at the
// start of the command line expression. E.g. foo bar baz --qux 42 corge, where bar is a subcommand of foo and
// baz is a subcommand of bar. All rules that apply to the top command, also apply to the subcommands.
//
// If a struct field doesn't override it, a --help command line flag will be automatically injected, and calling
// it will display an automatically generated help. Similarly, a help subcommand is also automatically injected
// under every command in the command tree, if a subcommand does not have already taken the name help. The help
// subcommand has the same effect as the --help flag. When the user provides invalid input, the command exits
// with a non zero code, and displays a short suggestion to the user on how to display this help.
//
// The automatically generated help can display the command synopsis and the possible args and options, but it
// will not contain any descriptions. Wand is meant to be used together with docreflect, which can extract the
// godoc documentation of the implementation function during development time, and compile it into the final
// binary. When done so, the automatically generated help will include the godocs of the implementation function
// and the description of the fields that serve as the command line options. It is also possible to generate man
// pages or markdown from the godoc documentation. For more details, see the documentation of the wand tool.
//
// When executing a command, there is two distinct stages of validation. The first one validates that the
// command definition itself is valid, while the second stage validates the user input against the command
// definition. The validation of the command definition happens without considering the user input, and errors
// are prefixed with "program error:". This way we can know during development time if the command definition is
// valid or not.
func Command(name string, impl any, subcmds ...Cmd) Cmd {
return Cmd{name: name, impl: impl, subcommands: subcmds}
}
// Group is like command but without implementation. It is used to group subcommands. It must have at least one
// subcommand. Optionally, one of the subcommands can be set as the default. When a default is set, and calling
// the group without specifying a subcommand, the default subcommand will be executed.
func Group(name string, subcmds ...Cmd) Cmd {
return Cmd{name: name, group: true, subcommands: subcmds}
}
// Default sets a subcommand as the default, to be used in a group.
func Default(cmd Cmd) Cmd {
cmd.isDefault = true
return cmd
}
// Args can be used to specify the minimum and the maximum number of positional arguments when the
// implementation function has variadic parameters.
func Args(cmd Cmd, min, max int) Cmd {
cmd.minPositional = min
cmd.maxPositional = max
return cmd
}
// ShortForm can be used to define short-form flags for command line options. E.g:
// ShortForm(cmd, "f", "foo", "b", "bar", "z", "baz"). In which case the resulting command be called as:
// my-command -f one -b two -z three. If say the Foo and Bar fields are of type boolean, then the flags can be
// grouped as:
// my-command -fbz three. The defined short forms apply to the entire command tree represented by the cmd
// parameter.
func ShortForm(cmd Cmd, f ...string) Cmd {
if len(f)%2 != 0 {
f = f[:len(f)-1]
}
cmd.shortForms = append(cmd.shortForms, f...)
for i := range cmd.subcommands {
cmd.subcommands[i] = ShortForm(
cmd.subcommands[i],
f...,
)
}
return cmd
}
// Version inserts a subcommand that, when called, displays the provided version.
func Version(cmd Cmd, version string) Cmd {
cmd.subcommands = append(
cmd.subcommands,
Cmd{name: "version", version: version},
)
return cmd
}
// ConfigFile defines a configuration file source. Configuration files, when defined, are used in the entire
// execution scope, regardless of which subcommand is called, and the subcommands interpret only those config
// file fields that apply to them.
//
// The configuration files are expected to use the ini file format, e.g values like foo_bar = 42 on separate
// lines. Repeated values are interpreted as distinct values for slice fields. Multiple config files are merged,
// such that value definitions in the config files with the higher index override the value definitions in the
// config files with lower indices. Discarding a value in a lower definition can be done with setting the key
// without a value, e.g: foo_bar. The formal definition of the used ini file format can be found here:
// ./ini.treerack.
//
// When a configuration file is not marked as optional with the OptionalConfig() function, it is expected to be
// provided by the user.
//
// Instead of using the static ConfigFile(name) definition, consider one or more of the dynamic definitions:
// Etc(), UserConfig(), ConfigFromOption() or SystemConfig().
func ConfigFile(name string) Config {
return Config{
file: func(Cmd) *file { return fileReader(name) },
}
}
// Etc defines an optional system wide configuration file found at /etc/<name of the binary>/config.
func Etc() Config {
return OptionalConfig(Config{
file: func(cmd Cmd) *file {
return fileReader(path.Join("/etc", cmd.name, "config"))
},
})
}
// UserConfig defines an optional user specific config file found at ~/.<name of the binary>/config or
// ~/.config/<name of the binary>/config.
func UserConfig() Config {
return OptionalConfig(
MergeConfig(
Config{
file: func(cmd Cmd) *file {
return fileReader(path.Join(os.Getenv("HOME"), fmt.Sprintf(".%s", cmd.name), "config"))
},
},
Config{
file: func(cmd Cmd) *file {
return fileReader(path.Join(os.Getenv("HOME"), ".config", cmd.name, "config"))
},
},
),
)
}
// ConfigFromOption defines zero or more optional config files provided by the command line option --config.
// When used, multiple such config files can be specified by the user.
func ConfigFromOption() Config {
return Config{fromOption: true}
}
// SystemConfig defines a typical set of optional configuration files, merging the Etc(), UserConfig() and
// ConfigFromOption() definitions.
func SystemConfig() Config {
return MergeConfig(Etc(), UserConfig(), ConfigFromOption())
}
// MergeConfig merges multiple configuration definitions.
func MergeConfig(conf ...Config) Config {
return Config{
merge: conf,
}
}
// OptionalConfig marks a configuration file definition as optional. Without it, the configuration is expected
// to be present during executing the command.
func OptionalConfig(conf Config) Config {
conf.optional = true
for i := range conf.merge {
conf.merge[i] = OptionalConfig(conf.merge[i])
}
return conf
}
// Exec executes a command.
//
// The implementation parameter can be either a command, or a function, in which case it gets automatically
// wrapped by a command.
func Exec(impl any, conf ...Config) {
exec(os.Stdin, os.Stdout, os.Stderr, os.Exit, wrap(impl), MergeConfig(conf...), os.Environ(), os.Args)
}