Capture immutable command declarations
This commit is contained in:
172
command.go
172
command.go
@@ -1,6 +1,7 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -8,14 +9,20 @@ import (
|
||||
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 {
|
||||
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
||||
// RootGroup, or an integration.
|
||||
type Command struct{ record *commandRecord }
|
||||
|
||||
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.
|
||||
@@ -82,6 +89,8 @@ type CommandSpec[I any] struct {
|
||||
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
|
||||
@@ -170,94 +179,109 @@ func (c typedCommand[I]) checkInput(input any) error {
|
||||
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
|
||||
}
|
||||
// NewCommand captures a typed command declaration. Construction issues are
|
||||
// retained and reported when the declaration is installed.
|
||||
func NewCommand[I any](spec CommandSpec[I]) 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}
|
||||
record := &commandRecord{
|
||||
name: spec.Name, summary: spec.Summary, description: spec.Description,
|
||||
children: copySpec.Children, payload: payload,
|
||||
}
|
||||
if err := payload.validate(); err != nil {
|
||||
return Command{}, fmt.Errorf("command %q: %w", spec.Name, err)
|
||||
record.issues = append(record.issues, fmt.Errorf("command %q: %w", spec.Name, err))
|
||||
}
|
||||
return Command{
|
||||
name: spec.Name,
|
||||
summary: spec.Summary,
|
||||
description: spec.Description,
|
||||
payload: payload,
|
||||
}, nil
|
||||
captureComposition(record)
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Group captures a non-runnable command group and its relative children.
|
||||
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}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// RootGroup captures a group that may only be installed at the application root.
|
||||
func RootGroup(name, summary string, children ...Command) Command {
|
||||
command := Group(name, summary, children...)
|
||||
command.record.rootOnly = true
|
||||
return command
|
||||
}
|
||||
|
||||
func validateCommandDeclaration(command Command) error {
|
||||
if err := validateCommandName(command.name); err != nil {
|
||||
return err
|
||||
// WithError returns a declaration that also retains err for installation.
|
||||
func WithError(command Command, err error) Command {
|
||||
if err == nil {
|
||||
return command
|
||||
}
|
||||
if command.payload != nil {
|
||||
if err := command.payload.validate(); err != nil {
|
||||
return fmt.Errorf("command %q: %w", command.name, err)
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
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{}{}
|
||||
// NoCommand explicitly represents an absent optional declaration.
|
||||
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
||||
|
||||
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 {
|
||||
var issues []error
|
||||
collectDeclarationIssues(command, nil, &issues)
|
||||
return errors.Join(issues...)
|
||||
}
|
||||
|
||||
func collectDeclarationIssues(command Command, parents []string, 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 := strings.Join(path, " ")
|
||||
for _, issue := range record.issues {
|
||||
if label != "" {
|
||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||
} else {
|
||||
*issues = append(*issues, issue)
|
||||
}
|
||||
}
|
||||
if record.invalid || record.absent {
|
||||
return
|
||||
}
|
||||
for _, child := range record.children {
|
||||
if child.record == nil {
|
||||
continue
|
||||
}
|
||||
collectDeclarationIssues(child, path, issues)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCommandName(name string) error {
|
||||
|
||||
Reference in New Issue
Block a user