338 lines
9.6 KiB
Go
338 lines
9.6 KiB
Go
package commandkit
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
|
|
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
|
)
|
|
|
|
// Command is an opaque command declaration. Use NewCommand or Group to create
|
|
// declarations and WithChildren to compose them.
|
|
type Command struct {
|
|
name string
|
|
summary string
|
|
description string
|
|
children []Command
|
|
payload commandPayload
|
|
}
|
|
|
|
// Supplied carries a value and whether it was explicitly provided.
|
|
type Supplied[T any] struct {
|
|
// Value is the associated value.
|
|
Value T
|
|
// Set reports whether a caller explicitly supplied the value.
|
|
Set bool
|
|
}
|
|
|
|
// FlagMeta describes a command flag.
|
|
type FlagMeta struct {
|
|
// Name is the flag name without leading dashes.
|
|
Name string
|
|
// Short is an optional one-byte spelling.
|
|
Short string
|
|
// Usage briefly describes the flag.
|
|
Usage string
|
|
// Required reports whether execution requires the flag to be supplied.
|
|
Required bool
|
|
}
|
|
|
|
// ArgumentMeta describes a positional command argument.
|
|
type ArgumentMeta struct {
|
|
// Name is the argument name shown in command help.
|
|
Name string
|
|
// Usage briefly describes the argument.
|
|
Usage string
|
|
}
|
|
|
|
// Flag represents a typed binding for a command flag.
|
|
type Flag[I any] struct {
|
|
binding flagBinding[I]
|
|
}
|
|
|
|
type flagBinding[I any] struct {
|
|
meta FlagMeta
|
|
defaultText string
|
|
repeated bool
|
|
boolean bool
|
|
prepare func([]string) (func(*I), error)
|
|
clear func(*I)
|
|
valid bool
|
|
}
|
|
|
|
// Argument represents a typed binding for a positional command argument.
|
|
type Argument[I any] struct {
|
|
binding argumentBinding[I]
|
|
}
|
|
|
|
type argumentBinding[I any] struct {
|
|
meta ArgumentMeta
|
|
min int
|
|
max int
|
|
prepare func([]string) (func(*I), error)
|
|
valid bool
|
|
}
|
|
|
|
// CommandSpec declares one command using typed input and handler callbacks.
|
|
type CommandSpec[I any] struct {
|
|
// Name is one command path component.
|
|
Name string
|
|
// Summary is a short description of the command.
|
|
Summary string
|
|
// Description provides additional command detail.
|
|
Description string
|
|
// NewInput creates an input value for execution. A nil factory uses a fresh
|
|
// zero value.
|
|
NewInput func() I
|
|
// Flags declares the command's local flags.
|
|
Flags []Flag[I]
|
|
// Arguments declares the command's positional inputs.
|
|
Arguments []Argument[I]
|
|
// CheckInput validates declared combinations of parsed inputs.
|
|
CheckInput func(I) error
|
|
// Run handles an invocation. It may be nil for a command used as a group.
|
|
Run func(Invocation, I) Outcome
|
|
}
|
|
|
|
type commandPayload interface {
|
|
validate() error
|
|
hasRun() bool
|
|
bridgeFlags() []internalcobra.Flag
|
|
bridgeArguments() []internalcobra.Argument
|
|
prepare([][]string, []string, bool) (func() any, error)
|
|
checkInput(any) error
|
|
run(Invocation, any) Outcome
|
|
}
|
|
|
|
type typedCommand[I any] struct {
|
|
spec CommandSpec[I]
|
|
}
|
|
|
|
func (c typedCommand[I]) validate() error {
|
|
return validateBindings(c.spec.Flags, c.spec.Arguments)
|
|
}
|
|
|
|
func (c typedCommand[I]) hasRun() bool {
|
|
return c.spec.Run != nil
|
|
}
|
|
|
|
func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag {
|
|
flags := make([]internalcobra.Flag, len(c.spec.Flags))
|
|
for index, flag := range c.spec.Flags {
|
|
binding := flag.binding
|
|
flags[index] = internalcobra.Flag{
|
|
Name: binding.meta.Name,
|
|
Short: binding.meta.Short,
|
|
Usage: binding.meta.Usage,
|
|
Default: binding.defaultText,
|
|
Boolean: binding.boolean,
|
|
Repeated: binding.repeated,
|
|
Required: binding.meta.Required,
|
|
}
|
|
}
|
|
return flags
|
|
}
|
|
|
|
func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument {
|
|
arguments := make([]internalcobra.Argument, len(c.spec.Arguments))
|
|
for index, argument := range c.spec.Arguments {
|
|
binding := argument.binding
|
|
arguments[index] = internalcobra.Argument{
|
|
Name: binding.meta.Name,
|
|
Usage: binding.meta.Usage,
|
|
Min: binding.min,
|
|
Max: binding.max,
|
|
}
|
|
}
|
|
return arguments
|
|
}
|
|
|
|
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func() any, error) {
|
|
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func() any {
|
|
input := prepared.build()
|
|
return &input
|
|
}, nil
|
|
}
|
|
|
|
func (c typedCommand[I]) run(invocation Invocation, input any) Outcome {
|
|
return c.spec.Run(invocation, *input.(*I))
|
|
}
|
|
|
|
func (c typedCommand[I]) checkInput(input any) error {
|
|
if c.spec.CheckInput == nil {
|
|
return nil
|
|
}
|
|
return c.spec.CheckInput(*input.(*I))
|
|
}
|
|
|
|
// NewCommand validates and creates a typed command declaration. It retains
|
|
// callbacks without invoking them and copies the declarative flag and argument
|
|
// slices.
|
|
func NewCommand[I any](spec CommandSpec[I]) (Command, error) {
|
|
if err := validateCommandName(spec.Name); err != nil {
|
|
return Command{}, err
|
|
}
|
|
copySpec := spec
|
|
copySpec.Flags = append([]Flag[I](nil), spec.Flags...)
|
|
copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...)
|
|
payload := typedCommand[I]{spec: copySpec}
|
|
if err := payload.validate(); err != nil {
|
|
return Command{}, fmt.Errorf("command %q: %w", spec.Name, err)
|
|
}
|
|
return Command{
|
|
name: spec.Name,
|
|
summary: spec.Summary,
|
|
description: spec.Description,
|
|
payload: payload,
|
|
}, nil
|
|
}
|
|
|
|
// Group creates a non-runnable command group with the supplied children.
|
|
func Group(name, summary string, children ...Command) (Command, error) {
|
|
if err := validateCommandName(name); err != nil {
|
|
return Command{}, err
|
|
}
|
|
group := Command{name: name, summary: summary, children: cloneCommands(children)}
|
|
if err := validateCommandDeclaration(group); err != nil {
|
|
return Command{}, err
|
|
}
|
|
return group, nil
|
|
}
|
|
|
|
// WithChildren returns a copy of parent with the supplied relative child
|
|
// declarations. The parent's optional handler and input bindings are retained.
|
|
func WithChildren(parent Command, children ...Command) (Command, error) {
|
|
if err := validateCommandDeclaration(parent); err != nil {
|
|
return Command{}, fmt.Errorf("parent command: %w", err)
|
|
}
|
|
combined := make([]Command, 0, len(parent.children)+len(children))
|
|
combined = append(combined, parent.children...)
|
|
combined = append(combined, children...)
|
|
result := parent
|
|
result.children = cloneCommands(combined)
|
|
if err := validateCommandDeclaration(result); err != nil {
|
|
return Command{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func cloneCommands(commands []Command) []Command {
|
|
if len(commands) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make([]Command, len(commands))
|
|
for i, command := range commands {
|
|
cloned[i] = cloneCommand(command)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneCommand(command Command) Command {
|
|
command.children = cloneCommands(command.children)
|
|
return command
|
|
}
|
|
|
|
func validateCommandDeclaration(command Command) error {
|
|
if err := validateCommandName(command.name); err != nil {
|
|
return err
|
|
}
|
|
if command.payload != nil {
|
|
if err := command.payload.validate(); err != nil {
|
|
return fmt.Errorf("command %q: %w", command.name, err)
|
|
}
|
|
}
|
|
|
|
children := make(map[string]struct{}, len(command.children))
|
|
for _, child := range command.children {
|
|
if err := validateCommandDeclaration(child); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := children[child.name]; exists {
|
|
return fmt.Errorf("duplicate child command %q under %q", child.name, command.name)
|
|
}
|
|
children[child.name] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCommandName(name string) error {
|
|
fields := strings.Fields(name)
|
|
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
|
|
return fmt.Errorf("command name %q must be one non-option token", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
|
flagNames := make(map[string]struct{}, len(flags))
|
|
shortNames := make(map[string]struct{}, len(flags))
|
|
for _, flag := range flags {
|
|
binding := flag.binding
|
|
if !validFlagName(binding.meta.Name) {
|
|
return fmt.Errorf("flag name %q must use lowercase hyphen-separated words", binding.meta.Name)
|
|
}
|
|
if binding.meta.Name == "help" {
|
|
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
|
|
}
|
|
if _, exists := flagNames[binding.meta.Name]; exists {
|
|
return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
|
|
}
|
|
flagNames[binding.meta.Name] = struct{}{}
|
|
if binding.meta.Short != "" {
|
|
if len(binding.meta.Short) != 1 || binding.meta.Short == "-" {
|
|
return fmt.Errorf("short spelling %q must be one byte", binding.meta.Short)
|
|
}
|
|
if binding.meta.Short == "h" {
|
|
return fmt.Errorf("short spelling %q is reserved for help", binding.meta.Short)
|
|
}
|
|
if _, exists := shortNames[binding.meta.Short]; exists {
|
|
return fmt.Errorf("duplicate short spelling %q", binding.meta.Short)
|
|
}
|
|
shortNames[binding.meta.Short] = struct{}{}
|
|
}
|
|
if !binding.valid || binding.prepare == nil {
|
|
return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name)
|
|
}
|
|
}
|
|
|
|
for _, argument := range arguments {
|
|
binding := argument.binding
|
|
if binding.meta.Name == "" {
|
|
return fmt.Errorf("argument name must not be empty")
|
|
}
|
|
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
|
|
return fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
|
|
}
|
|
if !binding.valid || binding.prepare == nil {
|
|
return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validFlagName(name string) bool {
|
|
if name == "" || name[0] == '-' || name[len(name)-1] == '-' {
|
|
return false
|
|
}
|
|
segmentHasCharacter := false
|
|
for _, character := range name {
|
|
if character == '-' {
|
|
if !segmentHasCharacter {
|
|
return false
|
|
}
|
|
segmentHasCharacter = false
|
|
continue
|
|
}
|
|
if !unicode.IsLower(character) && !unicode.IsDigit(character) {
|
|
return false
|
|
}
|
|
segmentHasCharacter = true
|
|
}
|
|
return segmentHasCharacter
|
|
}
|