Add typed command input bindings

This commit is contained in:
2026-10-07 13:22:57 +00:00
parent f99be3257c
commit 82128463cd
5 changed files with 513 additions and 13 deletions

257
binding.go Normal file
View File

@@ -0,0 +1,257 @@
package commandkit
import (
"fmt"
"strconv"
)
// ValueFlag declares a singleton flag with a typed parser, optional default,
// and setter. Every supplied occurrence is parsed; the last parsed value is
// applied with Set=true.
func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] {
var fallback V
if defaultValue != nil {
fallback = *defaultValue
}
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback)}
if parse == nil || set == nil {
return Flag[I]{binding: binding}
}
binding.valid = true
binding.prepare = func(values []string) (func(*I), error) {
value := fallback
supplied := len(values) > 0
for _, raw := range values {
parsed, err := parse(raw)
if err != nil {
return nil, err
}
value = parsed
}
result := Supplied[V]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil
}
return Flag[I]{binding: binding}
}
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
// "true"; explicit values are converted with strconv.ParseBool.
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
if set == nil {
return Flag[I]{binding: binding}
}
binding.valid = true
binding.prepare = func(values []string) (func(*I), error) {
value := defaultValue
supplied := len(values) > 0
for _, raw := range values {
parsed, err := strconv.ParseBool(raw)
if err != nil {
return nil, err
}
value = parsed
}
result := Supplied[bool]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil
}
return Flag[I]{binding: binding}
}
// RepeatFlag declares a repeatable flag. Supplied occurrences replace defaults
// and are parsed in argv order, with each occurrence treated as one value.
func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, error), set func(*I, Supplied[[]V])) Flag[I] {
fallback := append([]V(nil), defaults...)
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback), repeated: true}
if parse == nil || set == nil {
return Flag[I]{binding: binding}
}
binding.valid = true
binding.prepare = func(values []string) (func(*I), error) {
parsedValues := append([]V(nil), fallback...)
supplied := len(values) > 0
if supplied {
parsedValues = make([]V, 0, len(values))
for _, raw := range values {
parsed, err := parse(raw)
if err != nil {
return nil, err
}
parsedValues = append(parsedValues, parsed)
}
}
return func(input *I) {
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: supplied}
set(input, result)
}, nil
}
return Flag[I]{binding: binding}
}
// ValueArgument declares an optional or required singleton positional
// argument.
func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string) (V, error), set func(*I, Supplied[V])) Argument[I] {
min := 0
if required {
min = 1
}
binding := argumentBinding[I]{meta: meta, min: min, max: 1}
if parse == nil || set == nil {
return Argument[I]{binding: binding}
}
binding.valid = true
binding.prepare = func(values []string) (func(*I), error) {
if len(values) > 1 {
return nil, fmt.Errorf("received %d values; maximum is 1", len(values))
}
var value V
supplied := len(values) == 1
if supplied {
parsed, err := parse(values[0])
if err != nil {
return nil, err
}
value = parsed
}
result := Supplied[V]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil
}
return Argument[I]{binding: binding}
}
// RepeatArgument declares a positional argument that accepts a bounded number
// of values. A maximum of -1 means unbounded.
func RepeatArgument[I, V any](meta ArgumentMeta, min, max int, parse func(string) (V, error), set func(*I, Supplied[[]V])) Argument[I] {
binding := argumentBinding[I]{meta: meta, min: min, max: max}
if parse == nil || set == nil {
return Argument[I]{binding: binding}
}
binding.valid = true
binding.prepare = func(values []string) (func(*I), error) {
if max >= 0 && len(values) > max {
return nil, fmt.Errorf("received %d values; maximum is %d", len(values), max)
}
parsedValues := make([]V, 0, len(values))
for _, raw := range values {
parsed, err := parse(raw)
if err != nil {
return nil, err
}
parsedValues = append(parsedValues, parsed)
}
return func(input *I) {
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: len(values) > 0}
set(input, result)
}, nil
}
return Argument[I]{binding: binding}
}
type preparedInput[I any] struct {
newInput func() I
setters []func(*I)
}
func (p preparedInput[I]) build() I {
var input I
if p.newInput != nil {
input = p.newInput()
}
for _, setter := range p.setters {
setter(&input)
}
return input
}
// prepareInput parses all supplied bindings into typed closures before it
// creates an input value. When enforceRequired is false, missing required flags
// and operands are allowed so metadata help can validate supplied syntax.
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
if len(flagValues) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
}
setters := make([]func(*I), 0, len(spec.Flags)+len(spec.Arguments))
for i, flag := range spec.Flags {
binding := flag.binding
if enforceRequired && binding.meta.Required && len(flagValues[i]) == 0 {
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", binding.meta.Name)
}
setter, err := binding.prepare(flagValues[i])
if err != nil {
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
}
setters = append(setters, setter)
}
argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired)
if err != nil {
return preparedInput[I]{}, err
}
for i, argument := range spec.Arguments {
binding := argument.binding
setter, err := binding.prepare(argumentValues[i])
if err != nil {
return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err)
}
setters = append(setters, setter)
}
return preparedInput[I]{newInput: spec.NewInput, setters: setters}, nil
}
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
for _, argument := range definitions {
binding := argument.binding
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
}
}
if enforceRequired {
minimum := 0
for _, argument := range definitions {
if argument.binding.min > len(operands)-minimum {
return nil, fmt.Errorf("not enough positional arguments")
}
minimum += argument.binding.min
}
}
suffixMinimum := make([]int, len(definitions)+1)
for i := len(definitions) - 1; i >= 0; i-- {
minimum := definitions[i].binding.min
remaining := len(operands) - suffixMinimum[i+1]
if minimum > remaining {
suffixMinimum[i] = len(operands)
} else {
suffixMinimum[i] = suffixMinimum[i+1] + minimum
}
}
allocated := make([][]string, len(definitions))
position := 0
for i, argument := range definitions {
binding := argument.binding
remaining := len(operands) - position
available := remaining - suffixMinimum[i+1]
if available < 0 {
available = 0
}
count := available
if binding.max >= 0 && count > binding.max {
count = binding.max
}
if !enforceRequired && count < binding.min && remaining > 0 {
count = remaining
if binding.max >= 0 && count > binding.max {
count = binding.max
}
}
if enforceRequired && count < binding.min {
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name)
}
allocated[i] = operands[position : position+count]
position += count
}
if position < len(operands) {
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position)
}
return allocated, nil
}

208
binding_test.go Normal file
View File

@@ -0,0 +1,208 @@
package commandkit
import (
"errors"
"reflect"
"strconv"
"testing"
)
type bindingInput struct {
message Supplied[string]
count Supplied[int]
enabled Supplied[bool]
tags Supplied[[]string]
runtime string
}
func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
defaultMessage := "fallback"
factoryCalls := 0
spec := CommandSpec[bindingInput]{
Name: "run",
NewInput: func() bindingInput {
factoryCalls++
return bindingInput{runtime: "caller state"}
},
Flags: []Flag[bindingInput]{
ValueFlag(FlagMeta{Name: "message"}, &defaultMessage, func(value string) (string, error) { return value, nil }, func(input *bindingInput, value Supplied[string]) { input.message = value }),
ValueFlag(FlagMeta{Name: "count"}, (*int)(nil), strconv.Atoi, func(input *bindingInput, value Supplied[int]) { input.count = value }),
BoolFlag(FlagMeta{Name: "enabled"}, true, func(input *bindingInput, value Supplied[bool]) { input.enabled = value }),
},
}
if _, err := NewCommand(spec); err != nil {
t.Fatal(err)
}
defaultMessage = "changed after declaration"
prepared, err := prepareInput(spec, [][]string{nil, nil, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
if factoryCalls != 0 {
t.Fatal("preparing typed values invoked NewInput")
}
input := prepared.build()
if factoryCalls != 1 {
t.Fatalf("NewInput calls = %d, want 1", factoryCalls)
}
if input.message.Value != "fallback" || input.message.Set {
t.Fatalf("omitted message = %#v, want fallback with Set=false", input.message)
}
if input.count.Value != 0 || input.count.Set {
t.Fatalf("omitted count = %#v, want zero with Set=false", input.count)
}
if !input.enabled.Value || input.enabled.Set {
t.Fatalf("omitted enabled = %#v, want true default with Set=false", input.enabled)
}
if input.runtime != "caller state" {
t.Fatalf("NewInput runtime value = %q, want caller value", input.runtime)
}
prepared, err = prepareInput(spec, [][]string{{"", "last"}, {"0"}, {"true", "false"}}, nil, true)
if err != nil {
t.Fatal(err)
}
input = prepared.build()
if input.message.Value != "last" || !input.message.Set {
t.Fatalf("message = %#v, want last supplied value", input.message)
}
if input.count.Value != 0 || !input.count.Set {
t.Fatalf("count = %#v, want explicit zero", input.count)
}
if input.enabled.Value || !input.enabled.Set {
t.Fatalf("enabled = %#v, want explicit false", input.enabled)
}
prepared, err = prepareInput(spec, [][]string{{""}, nil, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
input = prepared.build()
if input.message.Value != "" || !input.message.Set {
t.Fatalf("message = %#v, want explicitly supplied empty string", input.message)
}
prepared, err = prepareInput(spec, [][]string{nil, nil, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
input = prepared.build()
if input.message.Value != "fallback" || input.message.Set || input.count.Set || input.enabled.Set {
t.Fatalf("omitted values after a supplied invocation = %#v", input)
}
}
func TestBoolFlagRejectsExplicitEmptyValue(t *testing.T) {
flag := BoolFlag(FlagMeta{Name: "enabled"}, false, func(*bool, Supplied[bool]) {})
_, err := flag.binding.prepare([]string{""})
if err == nil {
t.Fatal("BoolFlag accepted an explicitly empty value")
}
if _, err := flag.binding.prepare([]string{"true"}); err != nil {
t.Fatalf("BoolFlag rejected the presence value: %v", err)
}
}
func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) {
defaults := []string{"base"}
var captured Supplied[[]string]
flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(_ *bindingInput, value Supplied[[]string]) { captured = value })
defaults[0] = "changed after declaration"
applyDefault, err := flag.binding.prepare(nil)
if err != nil {
t.Fatal(err)
}
first := &bindingInput{}
applyDefault(first)
second := &bindingInput{}
applyDefault(second)
if !reflect.DeepEqual(captured.Value, []string{"base"}) || captured.Set {
t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", captured)
}
captured.Value[0] = "changed one input"
applyDefault(second)
if !reflect.DeepEqual(captured.Value, []string{"base"}) {
t.Fatalf("repeat default collection was reused: %#v", captured.Value)
}
applyValues, err := flag.binding.prepare([]string{"a,b", "c"})
if err != nil {
t.Fatal(err)
}
applyValues(&bindingInput{})
if !reflect.DeepEqual(captured.Value, []string{"a,b", "c"}) || !captured.Set {
t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", captured)
}
}
func TestSingletonParsesEveryOccurrenceAndPreservesCallerCause(t *testing.T) {
parseFailure := errors.New("bad number")
flag := ValueFlag[struct{}, int](FlagMeta{Name: "count"}, nil, func(value string) (int, error) {
if value == "bad" {
return 0, parseFailure
}
return strconv.Atoi(value)
}, func(*struct{}, Supplied[int]) {})
_, err := flag.binding.prepare([]string{"bad", "9"})
if !errors.Is(err, parseFailure) {
t.Fatalf("parse error = %v, want caller cause", err)
}
}
func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
type operandsInput struct {
prefix Supplied[[]string]
id Supplied[string]
suffix Supplied[[]string]
}
spec := CommandSpec[operandsInput]{
Name: "restore",
Arguments: []Argument[operandsInput]{
RepeatArgument(ArgumentMeta{Name: "prefix"}, 0, 2, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.prefix = value }),
ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[string]) { input.id = value }),
RepeatArgument(ArgumentMeta{Name: "suffix"}, 1, -1, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.suffix = value }),
},
}
if _, err := NewCommand(spec); err != nil {
t.Fatal(err)
}
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
if !reflect.DeepEqual(input.prefix.Value, []string{"a", "b"}) || !input.prefix.Set {
t.Fatalf("prefix = %#v", input.prefix)
}
if input.id.Value != "id-1" || !input.id.Set {
t.Fatalf("id = %#v", input.id)
}
if !reflect.DeepEqual(input.suffix.Value, []string{"x", "y"}) || !input.suffix.Set {
t.Fatalf("suffix = %#v", input.suffix)
}
}
func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing.T) {
factoryCalls := 0
spec := CommandSpec[struct{}]{
Name: "show",
NewInput: func() struct{} {
factoryCalls++
return struct{}{}
},
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
}
if _, err := NewCommand(spec); err != nil {
t.Fatal(err)
}
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil {
t.Fatal("strict preparation accepted missing requirements")
}
if _, err := prepareInput(spec, [][]string{nil}, nil, false); err != nil {
t.Fatalf("help preparation rejected omitted execution inputs: %v", err)
}
if factoryCalls != 0 {
t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
}
}

View File

@@ -3,6 +3,7 @@ package commandkit
import (
"fmt"
"strings"
"unicode"
)
// Command is an opaque command declaration. Use NewCommand or Group to create
@@ -49,9 +50,12 @@ type Flag[I any] struct {
}
type flagBinding[I any] struct {
meta FlagMeta
prepare func([]string) (func(*I), error)
valid bool
meta FlagMeta
defaultText string
repeated bool
boolean bool
prepare func([]string) (func(*I), error)
valid bool
}
// Argument represents a typed binding for a positional command argument.
@@ -91,7 +95,7 @@ type CommandSpec[I any] struct {
type commandPayload interface {
validate() error
hasRun() bool
newInput() any
prepare([][]string, []string, bool) (func() any, error)
run(Invocation, any) Outcome
}
@@ -107,12 +111,15 @@ 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()
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 &input
return func() any {
input := prepared.build()
return &input
}, nil
}
func (c typedCommand[I]) run(invocation Invocation, input any) Outcome {
@@ -222,8 +229,8 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
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 !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)
@@ -263,3 +270,24 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
}
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
}

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan
Status: implementation underway; Stages 1-2 complete and Stages 3-14 pending.
Status: implementation underway; Stages 1-3 complete and Stages 4-14 pending.
Date: 2026-10-07.
@@ -239,7 +239,7 @@ version reservations, and atomic per-App registration. Verified with `go test ./
## Stage 3: implement typed bindings and input assembly
**Status:** pending. **Prerequisite:** Stage 2.
**Status:** complete. **Prerequisite:** Stage 2.
Add root factories in `binding.go` using these signatures:
@@ -288,6 +288,10 @@ last-wins, repeated order and comma-containing strings, defaults, positional
allocation, caller parse errors, and fresh collections/input state. Adapter-level
integration of these cases belongs to Stage 4/5; avoid duplicating every case.
**Completion evidence:** binding behavior tests and `go test ./...` pass. Input
factories remain deferred until callers build a prepared input; help-mode
preparation parses supplied values without enforcing omitted required values.
## Stage 4: implement the internal Cobra adapter
**Status:** pending. **Prerequisite:** Stage 3.

View File

@@ -186,6 +186,9 @@ func TestNewRejectsEmptyIdentityAndInvalidBindingMetadata(t *testing.T) {
{validFlag("one", "x"), validFlag("one", "y")},
{validFlag("one", "x"), validFlag("two", "x")},
{validFlag("help", "")},
{validFlag("SomeFlag", "")},
{validFlag("some_flag", "")},
{validFlag("some--flag", "")},
{{binding: flagBinding[registryInput]{meta: FlagMeta{Name: "missing"}}}},
} {
if _, err := NewCommand(CommandSpec[registryInput]{Name: "flags", Flags: flags}); err == nil {