302 lines
6.4 KiB
Go
302 lines
6.4 KiB
Go
package wand
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"slices"
|
|
)
|
|
|
|
var commandNameExpression = regexp.MustCompile("^[a-zA-Z_][a-zA-Z_0-9]*$")
|
|
|
|
func command(name string, impl any, subcmds ...Cmd) Cmd {
|
|
return Cmd{
|
|
name: name,
|
|
impl: impl,
|
|
subcommands: subcmds,
|
|
}
|
|
}
|
|
|
|
func wrap(impl any) Cmd {
|
|
cmd, ok := impl.(Cmd)
|
|
if ok {
|
|
return cmd
|
|
}
|
|
|
|
return Command("", impl)
|
|
}
|
|
|
|
func validateFields(f []field, conf Config) error {
|
|
hasConfigFromOption := hasConfigFromOption(conf)
|
|
mf := make(map[string]field)
|
|
for _, fi := range f {
|
|
if ef, ok := mf[fi.name]; ok && !compatibleTypes(fi.typ, ef.typ) {
|
|
return fmt.Errorf("duplicate fields with different types: %s", fi.name)
|
|
}
|
|
|
|
if hasConfigFromOption && fi.name == "config" {
|
|
return errors.New("option reserved for config file shadowed by struct field")
|
|
}
|
|
|
|
mf[fi.name] = fi
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateParameter(visited map[reflect.Type]bool, t reflect.Type) error {
|
|
switch t.Kind() {
|
|
case reflect.Bool,
|
|
reflect.Int,
|
|
reflect.Int8,
|
|
reflect.Int16,
|
|
reflect.Int32,
|
|
reflect.Int64,
|
|
reflect.Uint,
|
|
reflect.Uint8,
|
|
reflect.Uint16,
|
|
reflect.Uint32,
|
|
reflect.Uint64,
|
|
reflect.Float32,
|
|
reflect.Float64,
|
|
reflect.String:
|
|
return nil
|
|
case reflect.Pointer,
|
|
reflect.Slice:
|
|
if visited[t] {
|
|
return fmt.Errorf("circular type definitions not supported: %s", t.Name())
|
|
}
|
|
|
|
if visited == nil {
|
|
visited = make(map[reflect.Type]bool)
|
|
}
|
|
|
|
visited[t] = true
|
|
t = unpack(t)
|
|
return validateParameter(visited, t)
|
|
case reflect.Interface:
|
|
if t.NumMethod() > 0 {
|
|
return errors.New("non-empty interface parameter")
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported parameter type: %v", t)
|
|
}
|
|
}
|
|
|
|
func validatePositional(t reflect.Type, min, max int) error {
|
|
p := positionalParameters(t)
|
|
ior, iow := ioParameters(p)
|
|
if len(ior) > 1 || len(iow) > 1 {
|
|
return errors.New("only zero or one reader and zero or one writer parameters is supported")
|
|
}
|
|
|
|
for i, pi := range p {
|
|
if slices.Contains(ior, i) || slices.Contains(iow, i) {
|
|
continue
|
|
}
|
|
|
|
if err := validateParameter(nil, pi); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
last := t.NumIn() - 1
|
|
lastVariadic := t.IsVariadic() &&
|
|
!isStruct(t.In(last)) &&
|
|
!slices.Contains(ior, last) &&
|
|
!slices.Contains(iow, last)
|
|
fixedPositional := len(p) - len(ior) - len(iow)
|
|
if lastVariadic {
|
|
fixedPositional--
|
|
}
|
|
|
|
if min > 0 && min < fixedPositional {
|
|
return fmt.Errorf(
|
|
"minimum positional defined as %d but the implementation expects minimum %d fixed parameters",
|
|
min,
|
|
fixedPositional,
|
|
)
|
|
}
|
|
|
|
if min > 0 && min > fixedPositional && !lastVariadic {
|
|
return fmt.Errorf(
|
|
"minimum positional defined as %d but the implementation has only %d fixed parameters and no variadic parameter",
|
|
min,
|
|
fixedPositional,
|
|
)
|
|
}
|
|
|
|
if max > 0 && max < fixedPositional {
|
|
return fmt.Errorf(
|
|
"maximum positional defined as %d but the implementation expects minimum %d fixed parameters",
|
|
max,
|
|
fixedPositional,
|
|
)
|
|
}
|
|
|
|
if min > 0 && max > 0 && min > max {
|
|
return fmt.Errorf(
|
|
"minimum positional defined as larger then the maxmimum positional: %d > %d",
|
|
min,
|
|
max,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateImpl(cmd Cmd, conf Config) error {
|
|
v := reflect.ValueOf(cmd.impl)
|
|
v = unpack(v)
|
|
t := v.Type()
|
|
if t.Kind() != reflect.Func {
|
|
return errors.New("command implementation not a function")
|
|
}
|
|
|
|
s := structParameters(t)
|
|
f, err := fieldsChecked(nil, s...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateFields(f, conf); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validatePositional(t, cmd.minPositional, cmd.maxPositional); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateShortForms(cmd Cmd, assignedShortForms map[string]string) error {
|
|
mf := mapFields(cmd.impl)
|
|
if len(cmd.shortForms)%2 != 0 {
|
|
return fmt.Errorf(
|
|
"undefined option short form: %s", cmd.shortForms[len(cmd.shortForms)-1],
|
|
)
|
|
}
|
|
|
|
for i := 0; i < len(cmd.shortForms); i += 2 {
|
|
fn := cmd.shortForms[i]
|
|
sf := cmd.shortForms[i+1]
|
|
if len(sf) != 1 && (sf[0] < 'a' || sf[0] > 'z') {
|
|
return fmt.Errorf("invalid short form: %s", sf)
|
|
}
|
|
|
|
if _, ok := mf[sf]; ok {
|
|
return fmt.Errorf("short form shadowing field name: %s", sf)
|
|
}
|
|
|
|
if _, ok := mf[fn]; !ok {
|
|
continue
|
|
}
|
|
|
|
if lf, ok := assignedShortForms[sf]; ok && lf != fn {
|
|
return fmt.Errorf("ambigous short form: %s", sf)
|
|
}
|
|
|
|
assignedShortForms[sf] = fn
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateCommandTree(cmd Cmd, conf Config, assignedShortForms map[string]string, root bool) error {
|
|
if cmd.isHelp {
|
|
return nil
|
|
}
|
|
|
|
if cmd.version != "" {
|
|
return nil
|
|
}
|
|
|
|
if !root && !commandNameExpression.MatchString(cmd.name) {
|
|
return fmt.Errorf("command name is not a valid symbol: '%s'", cmd.name)
|
|
}
|
|
|
|
if cmd.impl != nil {
|
|
if err := validateImpl(cmd, conf); err != nil {
|
|
return fmt.Errorf("%s: %w", cmd.name, err)
|
|
}
|
|
}
|
|
|
|
if cmd.impl == nil && len(cmd.subcommands) == 0 {
|
|
return fmt.Errorf("empty command category: %s", cmd.name)
|
|
}
|
|
|
|
if cmd.impl != nil {
|
|
if err := validateShortForms(cmd, assignedShortForms); err != nil {
|
|
return fmt.Errorf("%s: %w", cmd.name, err)
|
|
}
|
|
}
|
|
|
|
var hasDefault bool
|
|
names := make(map[string]bool)
|
|
for _, s := range cmd.subcommands {
|
|
if s.name == "" {
|
|
return fmt.Errorf("unnamed subcommand of: %s", cmd.name)
|
|
}
|
|
|
|
if names[s.name] {
|
|
return fmt.Errorf("subcommand name conflict: %s", s.name)
|
|
}
|
|
|
|
names[s.name] = true
|
|
if err := validateCommandTree(s, conf, assignedShortForms, false); err != nil {
|
|
return fmt.Errorf("%s: %w", s.name, err)
|
|
}
|
|
|
|
if s.isDefault && cmd.impl != nil {
|
|
return fmt.Errorf(
|
|
"default subcommand defined for a command with explicit implementation: %s, %s",
|
|
cmd.name,
|
|
s.name,
|
|
)
|
|
}
|
|
|
|
if s.isDefault && hasDefault {
|
|
return fmt.Errorf("multiple default subcommands for: %s", cmd.name)
|
|
}
|
|
|
|
if s.isDefault {
|
|
hasDefault = true
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func allShortForms(cmd Cmd) []string {
|
|
var sf []string
|
|
for _, sc := range cmd.subcommands {
|
|
sf = append(sf, allShortForms(sc)...)
|
|
}
|
|
|
|
for i := 0; i < len(cmd.shortForms); i += 2 {
|
|
sf = append(sf, cmd.shortForms[i])
|
|
}
|
|
|
|
return sf
|
|
}
|
|
|
|
func validateCommand(cmd Cmd, conf Config) error {
|
|
assignedShortForms := make(map[string]string)
|
|
if err := validateCommandTree(cmd, conf, assignedShortForms, true); err != nil {
|
|
return err
|
|
}
|
|
|
|
asf := allShortForms(cmd)
|
|
for _, sf := range asf {
|
|
if _, ok := assignedShortForms[sf]; !ok {
|
|
return fmt.Errorf("unassigned option short form: %s", sf)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|