Unify typed binding preparation

This commit is contained in:
2026-10-11 01:18:07 +00:00
parent de9bfdcc4c
commit f63b096525
5 changed files with 130 additions and 65 deletions

View File

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

View File

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

View File

@@ -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)
}
}

View File

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

View File

@@ -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])