// 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//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 ~/./config or // ~/.config//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) }