Files
commandkit/command.go

266 lines
7.8 KiB
Go

package commandkit
import (
"fmt"
"strings"
)
// 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
prepare func([]string) (func(*I), error)
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
newInput() any
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]) newInput() any {
var input I
if c.spec.NewInput != nil {
input = c.spec.NewInput()
}
return &input
}
func (c typedCommand[I]) run(invocation Invocation, input any) Outcome {
return c.spec.Run(invocation, *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 binding.meta.Name == "" || strings.ContainsAny(binding.meta.Name, " \t\r\n") || strings.HasPrefix(binding.meta.Name, "-") {
return fmt.Errorf("flag name %q must be a nonempty token without leading dashes", 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
}