Files
commandkit/command.go

504 lines
16 KiB
Go

package commandkit
import (
"errors"
"fmt"
"strings"
"unicode"
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
)
// Command is an opaque, reusable declaration captured by NewCommand, Group,
// RootGroup, or an integration. Capture copies declaration slices but retains
// callback and caller-owned object references. Installation validates the
// declaration before changing an App registry.
type Command struct{ record *commandRecord }
// Declaration is a captured command or application routing rule accepted by
// New and App.Register. Only CommandKit declaration types implement it.
type Declaration interface {
commandkitDeclaration()
}
func (Command) commandkitDeclaration() {}
// RoutingRule is an opaque, reusable command or flag alias declaration.
type RoutingRule struct{ record *routingRuleRecord }
func (RoutingRule) commandkitDeclaration() {}
type routingRuleKind uint8
const (
commandAliasRule routingRuleKind = iota + 1
globalFlagAliasRule
scopedFlagAliasRule
)
type capturedFlagSpelling struct {
name string
short bool
}
type routingRuleRecord struct {
kind routingRuleKind
sourcePath commandpath.Path
targetPath commandpath.Path
scopePath commandpath.Path
sourceFlag capturedFlagSpelling
targetFlag capturedFlagSpelling
sourceText string
targetText string
scopeText string
issues []error
}
type commandRecord struct {
name string
summary string
description string
children []Command
payload commandPayload
issues []error
rootOnly bool
absent bool
invalid bool
}
// Supplied carries a value and whether it was explicitly provided. Defaults can
// have Set=false; an explicitly supplied empty, false, or zero value has Set=true.
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. Required applies when execution prepares
// the selected command; help reports the metadata without requiring the value.
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. Its name and usage are
// used in generated help.
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. Copies retain binding
// identity; separately constructed flags are distinct even when their metadata
// and callbacks are equivalent.
type Flag[I any] struct {
binding flagBinding[I]
}
type bindingIdentity struct{ marker byte }
type preparedValue[I any] struct {
identity *bindingIdentity
supplied bool
apply func(*I)
}
type flagBinding[I any] struct {
identity *bindingIdentity
meta FlagMeta
deferredRequired bool
defaultText string
repeated bool
boolean bool
prepare func([]string) (preparedValue[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 {
identity *bindingIdentity
meta ArgumentMeta
min int
max int
prepare func([]string) (preparedValue[I], error)
valid bool
}
// CommandSpec declares one command using typed input and handler callbacks.
// Capture copies its Flags, Arguments, and Children slices. NewInput runs only
// when execution reaches input construction; a nil factory yields a fresh zero
// value. CheckInput runs after supplied values are converted and applied, before
// Run. Run may be nil for a parent that only groups child commands.
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
// Children are relative child declarations captured with this command.
Children []Command
// 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() []commandmeta.Flag
bridgeArguments() []commandmeta.Argument
prepare([][]string, []string, bool) (func(*execution) Outcome, error)
}
type typedCommand[I any] struct {
spec CommandSpec[I]
privateRun func(commandExecution, I) Outcome
}
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 || c.privateRun != nil
}
func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
flags := make([]commandmeta.Flag, len(c.spec.Flags))
for index, flag := range c.spec.Flags {
binding := flag.binding
flags[index] = commandmeta.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() []commandmeta.Argument {
arguments := make([]commandmeta.Argument, len(c.spec.Arguments))
for index, argument := range c.spec.Arguments {
binding := argument.binding
arguments[index] = commandmeta.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(*execution) Outcome, error) {
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
if err != nil {
return nil, err
}
return func(current *execution) Outcome {
input := prepared.build()
if c.spec.CheckInput != nil {
if err := c.spec.CheckInput(input); err != nil {
return classifyInputError(err)
}
}
if c.privateRun != nil {
return c.privateRun(current.commandContext(), input)
}
return c.spec.Run(current.invocation(), input)
}, nil
}
// NewCommand captures a typed command declaration without constructing its
// input or running callbacks. Construction issues are retained and reported
// when the declaration is installed. The returned declaration can be reused.
func NewCommand[I any](spec CommandSpec[I]) Command {
return captureCommand(spec, nil)
}
func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution, I) Outcome) Command {
copySpec := spec
copySpec.Flags = append([]Flag[I](nil), spec.Flags...)
copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...)
copySpec.Children = append([]Command(nil), spec.Children...)
payload := typedCommand[I]{spec: copySpec, privateRun: privateRun}
record := &commandRecord{
name: spec.Name, summary: spec.Summary, description: spec.Description,
children: copySpec.Children, payload: payload,
}
if err := payload.validate(); err != nil {
record.issues = append(record.issues, fmt.Errorf("command %q: %w", spec.Name, err))
}
captureComposition(record)
return Command{record: record}
}
// Group captures a non-runnable command group and a copy of its relative child
// slice. Child declarations remain reusable and are not recursively copied.
func Group(name, summary string, children ...Command) Command {
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
captureComposition(record)
return Command{record: record}
}
// RootGroup captures a group that may only be installed at the application root.
// Installation rejects it below any other command, including through wrappers.
func RootGroup(name, summary string, children ...Command) Command {
command := Group(name, summary, children...)
command.record.rootOnly = true
return command
}
// WithError returns a new declaration that also retains a non-nil err for
// installation. Earlier declaration and child errors remain available, and
// wrapped error identity is preserved. A nil err returns command unchanged.
func WithError(command Command, err error) Command {
if err == nil {
return command
}
var record commandRecord
if command.record == nil {
record.invalid = true
} else {
record = *command.record
record.issues = append([]error(nil), record.issues...)
}
record.issues = append(record.issues, err)
return Command{record: &record}
}
// NoCommand explicitly represents an absent optional declaration. Installation
// skips it, but reports errors attached to it; a zero Command is invalid and is
// not an absence marker.
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
// Alias captures a command-path alias from path to target. Paths are
// whitespace-separated sequences of command names; construction issues are
// retained for New or App.Register to report.
func Alias(path, target string) RoutingRule {
record := &routingRuleRecord{
kind: commandAliasRule,
sourceText: path,
targetText: target,
}
record.sourcePath = captureRoutingPath("source", path, &record.issues)
record.targetPath = captureRoutingPath("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAlias captures an application-wide flag spelling alias. Spellings must
// be one long flag name or one individual short spelling.
func FlagAlias(spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: globalFlagAliasRule,
sourceText: spelling,
targetText: target,
}
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
// path.
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: scopedFlagAliasRule,
sourceText: spelling,
targetText: target,
scopeText: commandPath,
}
record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues)
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
func captureRoutingPath(label, value string, issues *[]error) commandpath.Path {
path, err := commandpath.ParseDeclaration(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err))
}
return path
}
func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling {
spelling, err := parseFlagSpelling(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err))
}
return spelling
}
func parseFlagSpelling(value string) (capturedFlagSpelling, error) {
if strings.HasPrefix(value, "--") {
name := value[2:]
if validFlagName(name) {
return capturedFlagSpelling{name: name}, nil
}
}
if len(value) == 2 && value[0] == '-' && value[1] != '-' {
return capturedFlagSpelling{name: value[1:], short: true}, nil
}
return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling")
}
func captureComposition(record *commandRecord) {
if err := validateCommandName(record.name); err != nil {
record.issues = append(record.issues, err)
}
seen := make(map[string]struct{}, len(record.children))
for _, child := range record.children {
if child.record == nil {
record.issues = append(record.issues, fmt.Errorf("command %q has an invalid child declaration", record.name))
continue
}
if child.record.absent {
continue
}
name := child.record.name
if _, exists := seen[name]; exists {
record.issues = append(record.issues, fmt.Errorf("duplicate child command %q under %q", name, record.name))
}
seen[name] = struct{}{}
}
}
func validateCommandDeclaration(command Command) error {
return validateCommandPlacement(command)
}
func validateCommandPlacement(command Command) error {
var issues []error
collectDeclarationIssues(command, nil, true, &issues)
return errors.Join(issues...)
}
func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
if command.record == nil {
*issues = append(*issues, errors.New("invalid zero command declaration"))
return
}
record := command.record
path := append(append([]string(nil), parents...), record.name)
label := commandpath.FromTokens(path).String()
for _, issue := range record.issues {
if label != "" {
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
} else {
*issues = append(*issues, issue)
}
}
if record.rootOnly && !root {
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
}
if record.invalid || record.absent {
return
}
for _, child := range record.children {
if child.record == nil {
continue
}
collectDeclarationIssues(child, path, false, issues)
}
}
func validateCommandName(name string) error {
return commandpath.ValidateComponent(name)
}
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.identity == nil || !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.identity == nil || !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
}