2025-08-18 14:24:31 +02:00
|
|
|
package wand
|
|
|
|
|
|
|
|
|
|
import (
|
2025-09-01 04:10:35 +02:00
|
|
|
"code.squareroundforest.org/arpio/bind"
|
2025-08-18 14:24:31 +02:00
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"reflect"
|
2025-08-26 03:21:35 +02:00
|
|
|
"regexp"
|
2025-09-05 03:19:00 +02:00
|
|
|
"strings"
|
2025-08-18 14:24:31 +02:00
|
|
|
)
|
|
|
|
|
|
2025-09-06 02:46:28 +02:00
|
|
|
var commandNameExpression = regexp.MustCompile("^[a-zA-Z_][a-zA-Z_0-9-]*$")
|
2025-08-26 03:21:35 +02:00
|
|
|
|
2025-08-18 14:24:31 +02:00
|
|
|
func wrap(impl any) Cmd {
|
|
|
|
|
cmd, ok := impl.(Cmd)
|
|
|
|
|
if ok {
|
|
|
|
|
return cmd
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Command("", impl)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-04 01:09:24 +02:00
|
|
|
func validateFields(f map[string][]bind.Field, conf Config) error {
|
2025-08-24 01:45:25 +02:00
|
|
|
hasConfigFromOption := hasConfigFromOption(conf)
|
2025-09-04 01:09:24 +02:00
|
|
|
for name, ff := range f {
|
|
|
|
|
var t []bind.FieldType
|
|
|
|
|
for _, fi := range ff {
|
|
|
|
|
t = append(t, fi.Type())
|
2025-08-18 14:24:31 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-04 01:09:24 +02:00
|
|
|
if !compatibleTypes(t...) {
|
|
|
|
|
return fmt.Errorf("duplicate fields with different types: %s", name)
|
2025-08-24 01:45:25 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-04 01:09:24 +02:00
|
|
|
if hasConfigFromOption && name == "config" {
|
|
|
|
|
return errors.New("option reserved for config file shadowed by struct field")
|
|
|
|
|
}
|
2025-08-18 14:24:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
func validatePositional(p []reflect.Type, variadic bool, min, max int) error {
|
|
|
|
|
fixedPositional := len(p)
|
|
|
|
|
if variadic {
|
2025-08-18 14:24:31 +02:00
|
|
|
fixedPositional--
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if min > 0 && min < fixedPositional {
|
|
|
|
|
return fmt.Errorf(
|
2025-09-01 02:07:48 +02:00
|
|
|
"minimum positional arguments defined as %d but the implementation expects minimum %d fixed parameters",
|
2025-08-18 14:24:31 +02:00
|
|
|
min,
|
|
|
|
|
fixedPositional,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
if min > 0 && min > fixedPositional && !variadic {
|
2025-08-18 14:24:31 +02:00
|
|
|
return fmt.Errorf(
|
2025-09-01 02:07:48 +02:00
|
|
|
"minimum positional arguments defined as %d but the implementation has only %d fixed parameters and no variadic parameter",
|
2025-08-18 14:24:31 +02:00
|
|
|
min,
|
|
|
|
|
fixedPositional,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if max > 0 && max < fixedPositional {
|
|
|
|
|
return fmt.Errorf(
|
2025-09-01 02:07:48 +02:00
|
|
|
"maximum positional arguments defined as %d but the implementation expects minimum %d fixed parameters",
|
2025-08-18 14:24:31 +02:00
|
|
|
max,
|
|
|
|
|
fixedPositional,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if min > 0 && max > 0 && min > max {
|
|
|
|
|
return fmt.Errorf(
|
2025-09-01 02:07:48 +02:00
|
|
|
"minimum positional arguments defined as larger then the maxmimum positional: %d > %d",
|
2025-08-18 14:24:31 +02:00
|
|
|
min,
|
|
|
|
|
max,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
func validateIOParameters(ior, iow []reflect.Type) error {
|
|
|
|
|
if len(ior) > 1 || len(iow) > 1 {
|
|
|
|
|
return errors.New("only zero or one reader and zero or one writer parameter is supported")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-24 01:45:25 +02:00
|
|
|
func validateImpl(cmd Cmd, conf Config) error {
|
2025-09-01 02:07:48 +02:00
|
|
|
if !isFunc(cmd.impl) {
|
|
|
|
|
return errors.New("command implementation must be a function or a pointer to a function")
|
2025-08-18 14:24:31 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
p := parameters(cmd.impl)
|
|
|
|
|
for _, pi := range p {
|
|
|
|
|
if !isReader(pi) && !isWriter(pi) && !bindable(pi) {
|
|
|
|
|
return fmt.Errorf("unsupported parameter type: %s", pi.Name())
|
|
|
|
|
}
|
2025-08-24 01:45:25 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-04 01:09:24 +02:00
|
|
|
f := mapFields(cmd.impl)
|
2025-08-24 01:45:25 +02:00
|
|
|
if err := validateFields(f, conf); err != nil {
|
2025-08-18 14:24:31 +02:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
pos, variadic := positional(cmd.impl)
|
|
|
|
|
if err := validatePositional(pos, variadic, cmd.minPositional, cmd.maxPositional); err != nil {
|
2025-08-18 14:24:31 +02:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
ior, iow := ioParameters(cmd.impl)
|
|
|
|
|
if err := validateIOParameters(ior, iow); err != nil {
|
|
|
|
|
return err
|
2025-08-18 14:24:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
func validateCommandTree(cmd Cmd, conf Config) error {
|
2025-08-26 03:21:35 +02:00
|
|
|
if cmd.version != "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-26 14:12:18 +02:00
|
|
|
if cmd.impl == nil && !cmd.group {
|
|
|
|
|
return fmt.Errorf("command does not have an implementation: %s", cmd.name)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-18 14:24:31 +02:00
|
|
|
if cmd.impl == nil && len(cmd.subcommands) == 0 {
|
|
|
|
|
return fmt.Errorf("empty command category: %s", cmd.name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cmd.impl != nil {
|
2025-09-01 02:07:48 +02:00
|
|
|
if err := validateImpl(cmd, conf); err != nil {
|
2025-08-18 14:24:31 +02:00
|
|
|
return fmt.Errorf("%s: %w", cmd.name, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var hasDefault bool
|
|
|
|
|
names := make(map[string]bool)
|
|
|
|
|
for _, s := range cmd.subcommands {
|
2025-09-01 02:07:48 +02:00
|
|
|
if !commandNameExpression.MatchString(s.name) {
|
|
|
|
|
return fmt.Errorf("command name is not a valid symbol: '%s'", cmd.name)
|
2025-08-18 14:24:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if names[s.name] {
|
|
|
|
|
return fmt.Errorf("subcommand name conflict: %s", s.name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
names[s.name] = true
|
2025-08-24 01:45:25 +02:00
|
|
|
if s.isDefault && cmd.impl != nil {
|
|
|
|
|
return fmt.Errorf(
|
2025-09-01 02:07:48 +02:00
|
|
|
"default subcommand defined for a command that has an explicit implementation: %s, %s",
|
2025-08-24 01:45:25 +02:00
|
|
|
cmd.name,
|
|
|
|
|
s.name,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-18 14:24:31 +02:00
|
|
|
if s.isDefault && hasDefault {
|
|
|
|
|
return fmt.Errorf("multiple default subcommands for: %s", cmd.name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if s.isDefault {
|
|
|
|
|
hasDefault = true
|
|
|
|
|
}
|
2025-09-01 02:07:48 +02:00
|
|
|
|
|
|
|
|
if err := validateCommandTree(s, conf); err != nil {
|
2025-09-06 02:46:28 +02:00
|
|
|
return fmt.Errorf("%s: %w", cmd.name, err)
|
2025-09-01 02:07:48 +02:00
|
|
|
}
|
2025-08-18 14:24:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2025-08-24 01:45:25 +02:00
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
func checkShortFormDefinition(existing map[string]string, short, long string) error {
|
|
|
|
|
e, ok := existing[short]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if e == long {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Errorf(
|
|
|
|
|
"using the same short form for different options is not allowed: %s->%s, %s->%s",
|
|
|
|
|
short, long, short, e,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func collectMappedShortForms(to, from map[string]string) (map[string]string, error) {
|
|
|
|
|
for s, l := range from {
|
|
|
|
|
if err := checkShortFormDefinition(to, s, l); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if to == nil {
|
|
|
|
|
to = make(map[string]string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
to[s] = l
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return to, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateShortFormsTree(cmd Cmd) (map[string]string, map[string]string, error) {
|
|
|
|
|
var mapped, unmapped map[string]string
|
2025-08-24 01:45:25 +02:00
|
|
|
for _, sc := range cmd.subcommands {
|
2025-09-01 02:07:48 +02:00
|
|
|
m, um, err := validateShortFormsTree(sc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if mapped, err = collectMappedShortForms(mapped, m); err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if unmapped, err = collectMappedShortForms(unmapped, um); err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mf := mapFields(cmd.impl)
|
2025-09-05 03:19:00 +02:00
|
|
|
_, helpDefined := mf["help"]
|
2025-12-10 20:31:10 +01:00
|
|
|
l2s := longFormsToShort(cmd.shortForms)
|
|
|
|
|
for l, ss := range l2s {
|
|
|
|
|
for _, s := range ss {
|
|
|
|
|
if s == "h" && l == "help" && !helpDefined {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-09-05 03:19:00 +02:00
|
|
|
|
2025-12-10 20:31:10 +01:00
|
|
|
r := []rune(s)
|
|
|
|
|
if len(r) != 1 || r[0] < 'a' || r[0] > 'z' {
|
|
|
|
|
return nil, nil, fmt.Errorf("invalid short form: %s", s)
|
|
|
|
|
}
|
2025-09-01 02:07:48 +02:00
|
|
|
|
2025-12-10 20:31:10 +01:00
|
|
|
if err := checkShortFormDefinition(mapped, s, l); err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
2025-09-01 02:07:48 +02:00
|
|
|
|
2025-12-10 20:31:10 +01:00
|
|
|
if err := checkShortFormDefinition(unmapped, s, l); err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, hasField := mf[l]
|
|
|
|
|
_, isMapped := mapped[s]
|
|
|
|
|
if !hasField && !isMapped {
|
|
|
|
|
if unmapped == nil {
|
|
|
|
|
unmapped = make(map[string]string)
|
|
|
|
|
}
|
2025-09-01 02:07:48 +02:00
|
|
|
|
2025-12-10 20:31:10 +01:00
|
|
|
unmapped[s] = l
|
|
|
|
|
continue
|
2025-09-01 02:07:48 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-10 20:31:10 +01:00
|
|
|
if mapped == nil {
|
|
|
|
|
mapped = make(map[string]string)
|
|
|
|
|
}
|
2025-09-01 02:07:48 +02:00
|
|
|
|
2025-12-10 20:31:10 +01:00
|
|
|
delete(unmapped, s)
|
|
|
|
|
mapped[s] = l
|
2025-09-01 02:07:48 +02:00
|
|
|
}
|
2025-08-24 01:45:25 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
return mapped, unmapped, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateShortForms(cmd Cmd) error {
|
|
|
|
|
_, um, err := validateShortFormsTree(cmd)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(um) != 0 {
|
2025-09-05 03:19:00 +02:00
|
|
|
var umn []string
|
|
|
|
|
for n := range um {
|
|
|
|
|
umn = append(umn, n)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Errorf("unmapped short forms: %s", strings.Join(umn, ", "))
|
2025-09-01 02:07:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
2025-08-24 01:45:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateCommand(cmd Cmd, conf Config) error {
|
2025-09-01 02:07:48 +02:00
|
|
|
if err := validateCommandTree(cmd, conf); err != nil {
|
2025-08-24 01:45:25 +02:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
if err := validateShortForms(cmd); err != nil {
|
|
|
|
|
return err
|
2025-08-24 01:45:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2025-09-01 02:07:48 +02:00
|
|
|
|
|
|
|
|
func boolOptions(cmd Cmd) []string {
|
|
|
|
|
f := fields(cmd.impl)
|
|
|
|
|
b := boolFields(f)
|
|
|
|
|
|
|
|
|
|
var n []string
|
|
|
|
|
for _, fi := range b {
|
|
|
|
|
n = append(n, fi.Name())
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-06 02:46:28 +02:00
|
|
|
var hasHelp bool
|
|
|
|
|
for _, fi := range f {
|
|
|
|
|
if fi.Name() == "help" {
|
|
|
|
|
hasHelp = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !hasHelp {
|
|
|
|
|
n = append(n, "help")
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
var sf []string
|
2025-12-10 20:31:10 +01:00
|
|
|
l2s := longFormsToShort(cmd.shortForms)
|
2025-09-01 02:07:48 +02:00
|
|
|
for _, ni := range n {
|
2025-12-10 20:31:10 +01:00
|
|
|
sf = append(sf, l2s[ni]...)
|
2025-09-06 02:46:28 +02:00
|
|
|
}
|
|
|
|
|
|
2025-09-01 02:07:48 +02:00
|
|
|
return append(n, sf...)
|
|
|
|
|
}
|