Capture immutable command declarations

This commit is contained in:
2026-10-11 01:15:13 +00:00
parent 476e98a463
commit de9bfdcc4c
16 changed files with 368 additions and 474 deletions

View File

@@ -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 {