From f63b096525537c041dcac7f767053589b5f67daa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 11 Oct 2026 01:18:07 +0000 Subject: [PATCH] Unify typed binding preparation --- binding.go | 79 ++++++++++++++++++---------------- binding_test.go | 65 ++++++++++++++++++++++------ command.go | 26 +++++++---- docs/roadmap/implementation.md | 13 ++++-- registry_test.go | 12 ++++-- 5 files changed, 130 insertions(+), 65 deletions(-) diff --git a/binding.go b/binding.go index f05329d..6992094 100644 --- a/binding.go +++ b/binding.go @@ -13,24 +13,24 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, if defaultValue != nil { fallback = *defaultValue } - binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback)} + binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback)} if parse == nil || set == nil { return Flag[I]{binding: binding} } binding.valid = true binding.clear = func(input *I) { set(input, Supplied[V]{}) } - binding.prepare = func(values []string) (func(*I), error) { + binding.prepare = func(values []string) (preparedValue[I], error) { value := fallback supplied := len(values) > 0 for _, raw := range values { parsed, err := parse(raw) if err != nil { - return nil, err + return preparedValue[I]{}, err } value = parsed } result := Supplied[V]{Value: value, Set: supplied} - return func(input *I) { set(input, result) }, nil + return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil } return Flag[I]{binding: binding} } @@ -38,24 +38,24 @@ func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, // 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} + binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true} if set == nil { return Flag[I]{binding: binding} } binding.valid = true binding.clear = func(input *I) { set(input, Supplied[bool]{}) } - binding.prepare = func(values []string) (func(*I), error) { + binding.prepare = func(values []string) (preparedValue[I], error) { value := defaultValue supplied := len(values) > 0 for _, raw := range values { parsed, err := strconv.ParseBool(raw) if err != nil { - return nil, err + return preparedValue[I]{}, err } value = parsed } result := Supplied[bool]{Value: value, Set: supplied} - return func(input *I) { set(input, result) }, nil + return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil } return Flag[I]{binding: binding} } @@ -64,13 +64,13 @@ func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[boo // 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} + binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback), repeated: true} if parse == nil || set == nil { return Flag[I]{binding: binding} } binding.valid = true binding.clear = func(input *I) { set(input, Supplied[[]V]{}) } - binding.prepare = func(values []string) (func(*I), error) { + binding.prepare = func(values []string) (preparedValue[I], error) { parsedValues := append([]V(nil), fallback...) supplied := len(values) > 0 if supplied { @@ -78,15 +78,15 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er for _, raw := range values { parsed, err := parse(raw) if err != nil { - return nil, err + return preparedValue[I]{}, err } parsedValues = append(parsedValues, parsed) } } - return func(input *I) { + return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: supplied} set(input, result) - }, nil + }}, nil } return Flag[I]{binding: binding} } @@ -98,26 +98,26 @@ func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string if required { min = 1 } - binding := argumentBinding[I]{meta: meta, min: min, max: 1} + binding := argumentBinding[I]{identity: newBindingIdentity(), 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) { + binding.prepare = func(values []string) (preparedValue[I], error) { if len(values) > 1 { - return nil, fmt.Errorf("received %d values; maximum is 1", len(values)) + return preparedValue[I]{}, 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 + return preparedValue[I]{}, err } value = parsed } result := Supplied[V]{Value: value, Set: supplied} - return func(input *I) { set(input, result) }, nil + return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil } return Argument[I]{binding: binding} } @@ -125,34 +125,34 @@ func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string // 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} + binding := argumentBinding[I]{identity: newBindingIdentity(), 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) { + binding.prepare = func(values []string) (preparedValue[I], error) { if max >= 0 && len(values) > max { - return nil, fmt.Errorf("received %d values; maximum is %d", len(values), max) + return preparedValue[I]{}, 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 + return preparedValue[I]{}, err } parsedValues = append(parsedValues, parsed) } - return func(input *I) { + return preparedValue[I]{identity: binding.identity, supplied: len(values) > 0, apply: func(input *I) { result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: len(values) > 0} set(input, result) - }, nil + }}, nil } return Argument[I]{binding: binding} } type preparedInput[I any] struct { newInput func() I - setters []func(*I) + values []preparedValue[I] } func (p preparedInput[I]) build() I { @@ -160,8 +160,8 @@ func (p preparedInput[I]) build() I { if p.newInput != nil { input = p.newInput() } - for _, setter := range p.setters { - setter(&input) + for _, value := range p.values { + value.apply(&input) } return input } @@ -173,33 +173,40 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands [] 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)) + preparedFlags := make([]preparedValue[I], len(spec.Flags)) + missingRequired := "" 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]) + value, err := binding.prepare(flagValues[i]) if err != nil { return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err) } - setters = append(setters, setter) + preparedFlags[i] = value + if enforceRequired && binding.meta.Required && !value.supplied && missingRequired == "" { + missingRequired = binding.meta.Name + } + } + if missingRequired != "" { + return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired) } argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired) if err != nil { return preparedInput[I]{}, err } + preparedValues := append([]preparedValue[I](nil), preparedFlags...) for i, argument := range spec.Arguments { binding := argument.binding - setter, err := binding.prepare(argumentValues[i]) + value, err := binding.prepare(argumentValues[i]) if err != nil { return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err) } - setters = append(setters, setter) + preparedValues = append(preparedValues, value) } - return preparedInput[I]{newInput: spec.NewInput, setters: setters}, nil + return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil } +func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} } + func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) { for _, argument := range definitions { binding := argument.binding diff --git a/binding_test.go b/binding_test.go index 19a38d4..a1d1ab5 100644 --- a/binding_test.go +++ b/binding_test.go @@ -104,8 +104,7 @@ func TestBoolFlagRejectsExplicitEmptyValue(t *testing.T) { 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 }) + flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(input *bindingInput, value Supplied[[]string]) { input.tags = value }) defaults[0] = "changed after declaration" applyDefault, err := flag.binding.prepare(nil) @@ -113,25 +112,25 @@ func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) { t.Fatal(err) } first := &bindingInput{} - applyDefault(first) + applyDefault.apply(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) + applyDefault.apply(second) + if !reflect.DeepEqual(first.tags.Value, []string{"base"}) || first.tags.Set { + t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", first.tags) } - 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) + first.tags.Value[0] = "changed one input" + if !reflect.DeepEqual(second.tags.Value, []string{"base"}) { + t.Fatalf("repeat default collection was reused: %#v", second.tags.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) + third := &bindingInput{} + applyValues.apply(third) + if !reflect.DeepEqual(third.tags.Value, []string{"a,b", "c"}) || !third.tags.Set { + t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", third.tags) } } @@ -149,6 +148,46 @@ func TestSingletonParsesEveryOccurrenceAndPreservesCallerCause(t *testing.T) { } } +func TestPreparedScalarConvertsEveryOccurrenceOnceBeforeInputCreation(t *testing.T) { + parseCalls := 0 + var received Supplied[string] + flag := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) { + parseCalls++ + return value, nil + }, func(input *bindingInput, value Supplied[string]) { input.message = value }) + prepared, err := flag.binding.prepare([]string{"earlier", "last"}) + if err != nil { + t.Fatal(err) + } + if parseCalls != 2 || prepared.identity != flag.binding.identity || !prepared.supplied { + t.Fatalf("prepared scalar = %#v, parse calls = %d", prepared, parseCalls) + } + copy := flag + lookalike := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*bindingInput, Supplied[string]) {}) + if copy.binding.identity != flag.binding.identity || lookalike.binding.identity == flag.binding.identity { + t.Fatal("binding identity was not preserved by copying or distinct across constructors") + } + input := &bindingInput{} + prepared.apply(input) + received = input.message + if parseCalls != 2 || received.Value != "last" || !received.Set { + t.Fatalf("applied value = %#v, parse calls = %d", received, parseCalls) + } +} + +func TestInvalidBindingConstructionIsReportedAtInstallation(t *testing.T) { + badFlag := ValueFlag[struct{}, string](FlagMeta{Name: "broken"}, nil, nil, func(*struct{}, Supplied[string]) {}) + badArgument := RepeatArgument[struct{}, string](ArgumentMeta{Name: "items"}, 2, 1, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[[]string]) {}) + command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{badFlag}, Arguments: []Argument[struct{}]{badArgument}}) + app, err := New(Identity{Name: "example"}, command) + if err == nil { + t.Fatal("installation accepted nil parser wiring and invalid argument cardinality") + } + if app != nil { + t.Fatal("failed installation returned a usable app") + } +} + func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) { type operandsInput struct { prefix Supplied[[]string] diff --git a/command.go b/command.go index 0a80a3e..d55d159 100644 --- a/command.go +++ b/command.go @@ -58,12 +58,21 @@ 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 defaultText string repeated bool boolean bool - prepare func([]string) (func(*I), error) + prepare func([]string) (preparedValue[I], error) clear func(*I) valid bool } @@ -74,11 +83,12 @@ type Argument[I any] struct { } type argumentBinding[I any] struct { - meta ArgumentMeta - min int - max int - prepare func([]string) (func(*I), error) - valid bool + 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. @@ -319,7 +329,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error { } shortNames[binding.meta.Short] = struct{}{} } - if !binding.valid || binding.prepare == nil { + if binding.identity == nil || !binding.valid || binding.prepare == nil { return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name) } } @@ -332,7 +342,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error { 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.valid || binding.prepare == nil { + if binding.identity == nil || !binding.valid || binding.prepare == nil { return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name) } } diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 52ae0be..463fcae 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement the optional integration modules and captured registration API -Status: implementation in progress. Stages 1 and 2 are complete; subsequent work follows the ordered roadmap. +Status: implementation in progress. Stages 1 through 3 are complete; subsequent work follows the ordered roadmap. Execute the stages in order. Each stage is sized as a separate Luna coding-agent assignment. This document authorizes no publication or changes to sibling repositories. @@ -222,9 +222,14 @@ object deep copies. combined names, short names, duplicate bindings, and help conflicts. Avoid integration-specific reserved-flag lists. -**Completion evidence:** existing presence/repetition/help tests, adjusted to the -selected constructors, plus focused converter call counts and repeated-slice -independence. Do not add a new grammar restriction. +**Completion evidence — complete:** flags and operands now prepare the same +identity-bearing typed application value. Binding constructors retain invalid +wiring/cardinality for installation; copies preserve identity and independent +lookalikes do not. Conversion remains before input creation, every singleton +occurrence is parsed once, and repeated core slices are copied per application. +Existing presence, repetition, help, and operand tests pass with the captured +constructors, along with focused identity, converter-count, and independence +checks. No new grammar restriction was added. ## Stage 4: Implement captured flag application diff --git a/registry_test.go b/registry_test.go index 6c14d4c..acf9608 100644 --- a/registry_test.go +++ b/registry_test.go @@ -9,11 +9,12 @@ type registryInput struct{ value string } func TestCapturedDeclarationsRetainCallbacksWithoutCallingThem(t *testing.T) { var factoryCalls, prepareCalls, setterCalls, checkCalls, runCalls int + identity := newBindingIdentity() flag := Flag[registryInput]{binding: flagBinding[registryInput]{ - meta: FlagMeta{Name: "mode"}, valid: true, - prepare: func([]string) (func(*registryInput), error) { + identity: identity, meta: FlagMeta{Name: "mode"}, valid: true, + prepare: func([]string) (preparedValue[registryInput], error) { prepareCalls++ - return func(*registryInput) { setterCalls++ }, nil + return preparedValue[registryInput]{identity: identity, apply: func(*registryInput) { setterCalls++ }}, nil }, }} command := NewCommand(CommandSpec[registryInput]{ @@ -175,7 +176,10 @@ func TestNewRejectsEmptyIdentity(t *testing.T) { } func TestNewCommandCopiesBindingMetadata(t *testing.T) { - flags := []Flag[registryInput]{{binding: flagBinding[registryInput]{meta: FlagMeta{Name: "mode"}, valid: true, prepare: func([]string) (func(*registryInput), error) { return func(*registryInput) {}, nil }}}} + identity := newBindingIdentity() + flags := []Flag[registryInput]{{binding: flagBinding[registryInput]{identity: identity, meta: FlagMeta{Name: "mode"}, valid: true, prepare: func([]string) (preparedValue[registryInput], error) { + return preparedValue[registryInput]{identity: identity, apply: func(*registryInput) {}}, nil + }}}} command := NewCommand(CommandSpec[registryInput]{Name: "run", Flags: flags}) flags[0].binding.meta.Name = "changed" payload := command.record.payload.(typedCommand[registryInput])