diff --git a/binding.go b/binding.go new file mode 100644 index 0000000..a74492f --- /dev/null +++ b/binding.go @@ -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 +} diff --git a/binding_test.go b/binding_test.go new file mode 100644 index 0000000..6fcb66f --- /dev/null +++ b/binding_test.go @@ -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) + } +} diff --git a/command.go b/command.go index ffe1e0c..c247a15 100644 --- a/command.go +++ b/command.go @@ -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 +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 83f2cde..8e76923 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/registry_test.go b/registry_test.go index 3be94e6..4867e5f 100644 --- a/registry_test.go +++ b/registry_test.go @@ -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 {