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 }