wand/lib.go

246 lines
11 KiB
Go
Raw Normal View History

2025-09-04 01:09:24 +02:00
// Wand provides utilities for constructing command line applications from functions, with automatic parameter
2026-01-07 17:07:46 +01:00
// binding from command line arguments, environment variables and configuration files, and automatically
// generated help and documentation.
2025-08-18 14:24:31 +02:00
package wand
2025-08-24 01:45:25 +02:00
import (
2025-08-24 04:46:54 +02:00
"fmt"
2025-08-24 01:45:25 +02:00
"os"
"path"
)
2026-01-07 17:07:46 +01:00
// Config represents one or more configuration sources.
2025-08-24 01:45:25 +02:00
type Config struct {
2025-08-24 04:46:54 +02:00
file func(Cmd) *file
2025-08-24 01:45:25 +02:00
merge []Config
fromOption bool
optional bool
2025-08-26 03:21:35 +02:00
test string
2025-08-24 01:45:25 +02:00
}
2025-08-18 14:24:31 +02:00
2026-01-07 17:07:46 +01:00
// Cmd represents a command, a subcommand or a subcommand group.
2025-08-18 14:24:31 +02:00
type Cmd struct {
name string
impl any
2025-08-26 14:12:18 +02:00
group bool
2025-08-18 14:24:31 +02:00
subcommands []Cmd
isDefault bool
minPositional int
maxPositional int
shortForms []string
2025-09-05 03:19:00 +02:00
helpFor *Cmd
2025-08-26 03:21:35 +02:00
version string
2025-08-18 14:24:31 +02:00
}
2026-01-07 17:07:46 +01:00
// 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
2026-01-07 22:21:54 +01:00
// for the options is accepted from both command line flags or environment variables, and, if defined, from
2026-01-07 17:07:46 +01:00
// 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,
2026-01-07 22:21:54 +01:00
// this results in ambiguity, e.g. Foo.BarBaz and Foo.Bar.Baz. In such cases, the types of the ambiguous fields
2026-01-07 17:07:46 +01:00
// 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
2026-01-07 22:21:54 +01:00
// the name of the executing binary, and are expected in lower or upper snake case. E.g. for a field InputValue:
2026-01-07 17:07:46 +01:00
// 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
2026-01-07 22:21:54 +01:00
// present, io.Reader is populated by os.Stdin and io.Writer is populated by os.Stdout.
2026-01-07 17:07:46 +01:00
//
// 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
2026-01-07 22:21:54 +01:00
// value is a primitive value, it will be printed onto os.Stdout using the stdlib fmt package. Complex values
2026-01-07 17:07:46 +01:00
// 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
2026-01-07 22:21:54 +01:00
// under every command in the command tree, if a subcommand has not already taken the name help. The help
2026-01-07 17:07:46 +01:00
// 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.
//
2026-01-07 22:21:54 +01:00
// When executing a command, there are two distinct stages of validation. The first one validates that the
2026-01-07 17:07:46 +01:00
// 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.
2025-08-18 14:24:31 +02:00
func Command(name string, impl any, subcmds ...Cmd) Cmd {
2025-08-26 14:12:18 +02:00
return Cmd{name: name, impl: impl, subcommands: subcmds}
}
2026-01-07 17:07:46 +01:00
// 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.
2025-08-26 14:12:18 +02:00
func Group(name string, subcmds ...Cmd) Cmd {
return Cmd{name: name, group: true, subcommands: subcmds}
2025-08-18 14:24:31 +02:00
}
2026-01-07 17:07:46 +01:00
// Default sets a subcommand as the default, to be used in a group.
2025-08-18 14:24:31 +02:00
func Default(cmd Cmd) Cmd {
cmd.isDefault = true
return cmd
}
2026-01-07 17:07:46 +01:00
// Args can be used to specify the minimum and the maximum number of positional arguments when the
// implementation function has variadic parameters.
2025-08-18 14:24:31 +02:00
func Args(cmd Cmd, min, max int) Cmd {
cmd.minPositional = min
cmd.maxPositional = max
return cmd
}
2026-01-07 17:07:46 +01:00
// ShortForm can be used to define short-form flags for command line options. E.g:
2026-01-07 22:21:54 +01:00
// ShortForm(cmd, "f", "foo", "b", "bar", "z", "baz"). In which case the resulting command can be called as:
2026-01-07 17:07:46 +01:00
// 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.
2025-08-26 14:12:18 +02:00
func ShortForm(cmd Cmd, f ...string) Cmd {
2025-09-01 02:07:48 +02:00
if len(f)%2 != 0 {
2025-12-30 16:52:10 +01:00
f = f[:len(f)-1]
2025-09-01 02:07:48 +02:00
}
2025-08-24 01:45:25 +02:00
cmd.shortForms = append(cmd.shortForms, f...)
for i := range cmd.subcommands {
2025-08-26 14:12:18 +02:00
cmd.subcommands[i] = ShortForm(
2025-08-24 01:45:25 +02:00
cmd.subcommands[i],
f...,
)
}
2025-08-18 14:24:31 +02:00
return cmd
}
2026-01-07 17:07:46 +01:00
// Version inserts a subcommand that, when called, displays the provided version.
2025-08-26 03:21:35 +02:00
func Version(cmd Cmd, version string) Cmd {
cmd.subcommands = append(
cmd.subcommands,
Cmd{name: "version", version: version},
)
return cmd
}
2026-01-07 17:07:46 +01:00
// 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().
2025-09-05 03:19:00 +02:00
func ConfigFile(name string) Config {
return Config{
file: func(Cmd) *file { return fileReader(name) },
}
}
2026-01-07 17:07:46 +01:00
// Etc defines an optional system wide configuration file found at /etc/<name of the binary>/config.
2025-08-24 01:45:25 +02:00
func Etc() Config {
return OptionalConfig(Config{
2025-08-24 04:46:54 +02:00
file: func(cmd Cmd) *file {
2025-08-24 01:45:25 +02:00
return fileReader(path.Join("/etc", cmd.name, "config"))
},
})
}
2026-01-07 17:07:46 +01:00
// UserConfig defines an optional user specific config file found at ~/.<name of the binary>/config or
// ~/.config/<name of the binary>/config.
2025-08-24 01:45:25 +02:00
func UserConfig() Config {
2025-08-26 14:12:18 +02:00
return OptionalConfig(
MergeConfig(
Config{
file: func(cmd Cmd) *file {
return fileReader(path.Join(os.Getenv("HOME"), fmt.Sprintf(".%s", cmd.name), "config"))
},
2025-08-24 04:46:54 +02:00
},
2025-08-26 14:12:18 +02:00
Config{
file: func(cmd Cmd) *file {
return fileReader(path.Join(os.Getenv("HOME"), ".config", cmd.name, "config"))
},
2025-08-24 04:46:54 +02:00
},
2025-08-26 14:12:18 +02:00
),
)
2025-08-24 01:45:25 +02:00
}
2026-01-07 17:07:46 +01:00
// 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.
2025-08-24 01:45:25 +02:00
func ConfigFromOption() Config {
return Config{fromOption: true}
}
2026-01-07 17:07:46 +01:00
// SystemConfig defines a typical set of optional configuration files, merging the Etc(), UserConfig() and
// ConfigFromOption() definitions.
2025-08-24 01:45:25 +02:00
func SystemConfig() Config {
return MergeConfig(Etc(), UserConfig(), ConfigFromOption())
}
2026-01-07 17:07:46 +01:00
// 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
2026-01-07 22:21:54 +01:00
// to be present during the execution of the command.
2026-01-07 17:07:46 +01:00
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.
2025-08-24 01:45:25 +02:00
func Exec(impl any, conf ...Config) {
exec(os.Stdin, os.Stdout, os.Stderr, os.Exit, wrap(impl), MergeConfig(conf...), os.Environ(), os.Args)
2025-08-18 14:24:31 +02:00
}