diff --git a/x/config/cli/profile.go b/x/config/cli/profile.go index b908e20169..5b3575db21 100644 --- a/x/config/cli/profile.go +++ b/x/config/cli/profile.go @@ -3,10 +3,12 @@ package cli import ( "errors" "fmt" + "iter" "maps" "reflect" "slices" "strings" + "unicode" "github.com/huandu/go-clone" "github.com/spf13/cobra" @@ -49,7 +51,7 @@ func ProfileWithSelector[T, S any, K comparable]( scope := selector.goPath[:len(selector.goPath)-1] for _, other := range entry.profileSelectors { - if scopesOverlap(other.goPath[:len(other.goPath)-1], scope) { + if scopesNest(other.goPath[:len(other.goPath)-1], scope) { return nil, fmt.Errorf("profiles for %s and %s overlap: each rebuilds its whole section, so one would drop the other's values", other.key, selector.key) } } @@ -106,15 +108,10 @@ func checkProfileKeys[T any, S any, K comparable]( m := readField(vBuilt, leaf.goPath[len(scope):]) for _, el := range leaf.entries { - err := visitEntries(el, leaf.key, m, 0, func(name string, v reflect.Value, isMap bool) error { - if !isMap { - return nil + for name, v := range entryMaps(el, leaf.key, m) { + if err := foldCollision(k, name, v); err != nil { + return err } - - return foldCollision(k, name, v) - }) - if err != nil { - return err } } } @@ -131,22 +128,52 @@ func foldCollision(profile any, key string, m reflect.Value) error { } slices.Sort(texts) - for i, text := range texts { - for _, other := range texts[i+1:] { - if strings.EqualFold(text, other) { - return fmt.Errorf("profile %v gives %s the keys %q and %q, which differ only in case, so an env var for either is ambiguous", profile, key, text, other) - } + seen := map[string]string{} + for _, text := range texts { + folded := foldText(text) + if other, dup := seen[folded]; dup { + return fmt.Errorf("profile %v gives %s the keys %q and %q, which differ only in case, so an env var for either is ambiguous", profile, key, other, text) } + + seen[folded] = text } return nil } -// visitEntries calls visit for m and each map below it on leaf's path, then for each value leaf sets, named as their -// flags would be, such as section.chains.mainnet.labels. -func visitEntries(leaf *entryLeaf, name string, m reflect.Value, depth int, visit func(name string, v reflect.Value, isMap bool) error) error { - if err := visit(name, m, true); err != nil { - return err +// Maps each rune to the smallest in its case orbit, so two texts fold alike exactly when strings.EqualFold, which env +// vars match keys with, holds. Full case folding, as in golang.org/x/text/cases, would also join keys such as "ß" and +// "ss", which strings.EqualFold keeps apart. +func foldText(s string) string { + return strings.Map(func(r rune) rune { + least := r + for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) { + least = min(least, f) + } + + return least + }, s) +} + +// entryMaps yields m and each map below it on leaf's path, named as their flags would be, such as +// section.chains.mainnet.labels. +func entryMaps(leaf *entryLeaf, name string, m reflect.Value) iter.Seq2[string, reflect.Value] { + return func(yield func(string, reflect.Value) bool) { + walkEntries(leaf, name, m, 0, yield, func(string, reflect.Value) bool { return true }) + } +} + +// entryValues yields each value leaf sets inside m, named as entryMaps names maps. +func entryValues(leaf *entryLeaf, name string, m reflect.Value) iter.Seq2[string, reflect.Value] { + return func(yield func(string, reflect.Value) bool) { + walkEntries(leaf, name, m, 0, func(string, reflect.Value) bool { return true }, yield) + } +} + +// Keys are sorted, so the order is deterministic. +func walkEntries(leaf *entryLeaf, name string, m reflect.Value, depth int, yieldMap, yieldValue func(string, reflect.Value) bool) bool { + if !yieldMap(name, m) { + return false } parts := strings.Split(leaf.key, "."+cliKeyPlaceholder) @@ -154,19 +181,16 @@ func visitEntries(leaf *entryLeaf, name string, m reflect.Value, depth int, visi for _, key := range slices.SortedFunc(slices.Values(m.MapKeys()), byText) { inner := name + "." + textOf(key) + parts[depth+1] v := readField(m.MapIndex(key), leaf.paths[depth]) - var err error if depth == len(leaf.keyTypes)-1 { - err = visit(inner, v, false) - } else { - err = visitEntries(leaf, inner, v, depth+1, visit) - } - - if err != nil { - return err + if !yieldValue(inner, v) { + return false + } + } else if !walkEntries(leaf, inner, v, depth+1, yieldMap, yieldValue) { + return false } } - return nil + return true } // A required field left zero is omitted from help, as a source must set it. @@ -204,13 +228,11 @@ func describeProfiles[T any, K comparable, S any]( continue } - _ = visitEntries(el, leaf.key, derefOrZero(v), 0, func(name string, v reflect.Value, isMap bool) error { - if text, ok := displayText(v); ok && !isMap { + for name, v := range entryValues(el, leaf.key, derefOrZero(v)) { + if text, ok := displayText(v); ok { _, _ = fmt.Fprintf(&out, " %s = %s\n", name, text) } - - return nil - }) + } } } } @@ -228,27 +250,24 @@ func locateProfile[T, S any, K comparable]( allocateField(v, k.goPath) } - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("profile selector panicked: %v", r) - } - }() + sType, kType := reflect.TypeFor[S](), reflect.TypeFor[K]() + if holdsIdentityKind(kType) { + return leafKey{}, fmt.Errorf("profile key type %s compares by identity, so no value a source sets would match a profile", kType) + } - if holdsIdentityKind(reflect.TypeFor[K]()) { - return leafKey{}, fmt.Errorf("profile key type %s compares by identity, so no value a source sets would match a profile", reflect.TypeFor[K]()) + wantSection, wantKey, err := callSelector(scratch, section, key) + if err != nil { + return leafKey{}, err } - s := section(scratch) - wantKey := reflect.ValueOf(key(s)).Pointer() - wantSection := reflect.ValueOf(s).Pointer() for _, k := range entry.leaves { parent := lookupField(v, k.goPath[:len(k.goPath)-1]) // Types are checked as a struct shares its first field's address. - if parent.Type() != reflect.TypeFor[S]() || parent.Addr().Pointer() != wantSection { + if parent.Type() != sType || parent.Addr().Pointer() != wantSection { continue } - if k.flag != nil && k.goType == reflect.TypeFor[K]() && + if k.flag != nil && k.goType == kType && lookupField(parent, k.goPath[len(k.goPath)-1:]).Addr().Pointer() == wantKey { return k, nil } @@ -257,16 +276,29 @@ func locateProfile[T, S any, K comparable]( return leafKey{}, errors.New("profile key must return a top-level field of its section bound as a flag, such as &c.ID") } +// The section and key functions are the caller's, and can panic on anything, such as an index out of range. +func callSelector[T, S any, K comparable](scratch *T, section func(*T) *S, key func(*S) *K) (wantSection, wantKey uintptr, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("profile selector panicked: %v", r) + } + }() + + s := section(scratch) + return reflect.ValueOf(s).Pointer(), reflect.ValueOf(key(s)).Pointer(), nil +} + +// A scope is the Go path to a profile's section, such as [Chain] for &c.Chain.ID. func inScope(k leafKey, scope []string) bool { - return len(k.goPath) > len(scope) && slices.Equal(k.goPath[:len(scope)], scope) + return len(k.goPath) > len(scope) && hasPrefix(k.goPath, scope) } -func scopesOverlap(a, b []string) bool { - if len(a) > len(b) { - a, b = b, a - } +func scopesNest(a, b []string) bool { + return hasPrefix(a, b) || hasPrefix(b, a) +} - return slices.Equal(a, b[:len(a)]) +func hasPrefix(path, prefix []string) bool { + return len(path) >= len(prefix) && slices.Equal(path[:len(prefix)], prefix) } // Decoding overwrites the section, so its defaults are copied first, deeply. Each use copies them again, so that a