From af5b8c9abd5a390d544cceb1d17bfe5e9ff066b8 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 27 Aug 2026 23:07:03 +0200 Subject: [PATCH] Require every choice variant to carry a path, and list only what Fake accepts --- README.md | 8 +++-- fakes.go | 92 +++++++++++++++++++++++++++++++++++++------------------ render.go | 25 ++++++++++++--- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e2b75cd..aa3ed00 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,8 @@ av == bv // true ``` `f.List()` returns the sorted paths the loaded data offers — the categories, their -dotted fields and folder segments (what the CLI's `-list` prints). +dotted fields and folder segments (what the CLI's `-list` prints). It is exactly the +set `Fake` accepts. A `*Fakes` is **not** safe for concurrent use — create one per goroutine. @@ -362,7 +363,10 @@ no tokens at all, use a bare string node (`"100 Main St"`), emitted verbatim. This yields e.g. `Anna Eriksson`, `Erik Berg`, or rarely `dr Astrid von Flemming`. Any field is reachable by dotted path — `Fake("person.last")` renders just a -surname; choices along the path are resolved at random. +surname; choices along the path are resolved at random. A path may continue +*through* a choice only where every variant carries the rest of it (so +`currency.symbol` works across all 16 currency variants), which keeps a path from +rendering on one call and failing on the next. ### Performance diff --git a/fakes.go b/fakes.go index 2df1b57..cebb08f 100644 --- a/fakes.go +++ b/fakes.go @@ -76,39 +76,73 @@ func New(paths []string, opts ...Option) (*Fakes, error) { return &Fakes{rand: newRand(c.seed, c.seeded), categories: cats}, nil } -// List returns the sorted dotted paths Fake can render: every category, the -// dotted fields within a template, and folder segments — descending transparently -// through single-variant choices the way a reference does. A multi-variant choice -// is one path (its pick is random); its items are not separately addressable. It's -// the discoverable map of what a loaded data set offers, powering the CLI's -list. +// List returns the sorted dotted paths Fake can render: every category, the dotted +// fields within a template, and folder segments — descending transparently through +// single-variant choices the way a reference does. A choice consumes no segment, so +// a path continues through a multi-variant one only where every variant carries it, +// which is the rule Fake applies too: List is the set of paths Fake accepts. func (f *Fakes) List() []string { var out []string - var walk func(prefix string, n node) - walk = func(prefix string, n node) { - switch n := n.(type) { - case *group: - for name, c := range n.children { - walk(join(prefix, name), c) - } - case *choice: - if len(n.items) == 1 { // a single-variant choice is a transparent wrapper - walk(prefix, n.items[0]) - return - } - out = append(out, prefix) - case *template: - out = append(out, prefix) - for name, c := range n.fields { - if isRef(name) { // a bound {..path} reference, not an authored field - continue - } - walk(join(prefix, name), c) - } - case literal: - out = append(out, prefix) + for _, name := range sortedNames(f.categories) { + for _, p := range paths(f.categories[name]) { + out = append(out, join(name, p)) + } + } + sort.Strings(out) + return out +} + +// paths lists the dot paths addressable from n, relative to it, where "" is n +// itself. A group has no value of its own, so it contributes only its children's. +func paths(n node) []string { + switch n := n.(type) { + case *group: + var out []string + for _, name := range sortedNames(n.children) { + for _, p := range paths(n.children[name]) { + out = append(out, join(name, p)) + } + } + return out + case *template: + out := []string{""} + for _, name := range sortedNames(n.fields) { + if isRef(name) { // a bound {..path} reference, not an authored field + continue + } + for _, p := range paths(n.fields[name]) { + out = append(out, join(name, p)) + } + } + return out + case *choice: + if len(n.items) == 1 { + return paths(n.items[0]) + } + return append([]string{""}, sharedPaths(n.items)...) + case literal: + return []string{""} + } + return nil +} + +// sharedPaths is the sub-paths every item carries — the only ones a path may step +// through a multi-variant choice to reach. +func sharedPaths(items []node) []string { + count := map[string]int{} + for _, it := range items { + for _, p := range paths(it) { + if p != "" { + count[p]++ + } + } + } + var out []string + for p, n := range count { + if n == len(items) { + out = append(out, p) } } - walk("", &group{children: f.categories}) sort.Strings(out) return out } diff --git a/render.go b/render.go index 7b10568..dd555ec 100644 --- a/render.go +++ b/render.go @@ -28,9 +28,11 @@ func (f *Fakes) Fake(path string) (string, error) { return render(f.rand, n), nil } -// descend walks named fields, resolving choices it meets along the way. It is -// the one render-side step that can fail, because the path comes from the -// caller and may name a field that does not exist. +// descend walks named fields to the node a path names. It is the one render-side +// step that can fail, because the path comes from the caller and may name a field +// that does not exist. A choice consumes no segment, so every variant must carry +// the rest of the path before one is picked at random — a path that resolves at +// all resolves on every call. A nil session validates without picking. func descend(s *session, n node, segments []string) (node, error) { if len(segments) == 0 { return n, nil @@ -49,7 +51,22 @@ func descend(s *session, n node, segments []string) (node, error) { } return descend(s, child, segments[1:]) case *choice: - return descend(s, pick(s, n), segments) // a choice consumes no path segment + for i, item := range n.items { + _, err := descend(nil, item, segments) + if err == nil { + continue + } + if len(n.items) == 1 { // a single-variant choice is a transparent wrapper + return nil, err + } + return nil, fmt.Errorf("variant %d of a %d-way choice: %w", i+1, len(n.items), err) + } + if s == nil { + return n, nil + } + return descend(s, pick(s, n), segments) + case literal: + return nil, fmt.Errorf("a plain string has no field %q", segments[0]) default: return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0]) }