From 362c48482529607214e1ece597704d5c717cff9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20G=C3=B6ransson?= Date: Fri, 28 Aug 2026 00:15:02 +0200 Subject: [PATCH] Advertise only spellable paths, and name what a choice's variants share --- README.md | 10 +++++----- edge_test.go | 5 +++++ fakes.go | 55 ++++++++++++++++++++++++++++++++++------------------ node.go | 6 +++++- render.go | 28 +++++++++++++------------- 5 files changed, 66 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index c115648..cca0e5b 100644 --- a/README.md +++ b/README.md @@ -141,8 +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). It is exactly the -set `Fake` accepts. +dotted fields and folder segments (what the CLI's `-list` prints). Every path it +lists renders. A `*Fakes` is **not** safe for concurrent use — create one per goroutine. @@ -364,9 +364,9 @@ 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. A path may continue -*through* a choice only where every variant carries the rest of it — `misc`'s -`currency` has 16 variants and every one carries `symbol`, so `currency.symbol` -resolves — which keeps a path from rendering on one call and failing on the next. +*through* a choice only where every variant carries the rest of it — every +`currency` variant carries `symbol`, so `currency.symbol` resolves — which keeps a +path from rendering on one call and failing on the next. ### Performance diff --git a/edge_test.go b/edge_test.go index 871702f..f1d097b 100644 --- a/edge_test.go +++ b/edge_test.go @@ -97,6 +97,7 @@ func TestPathThroughChoice(t *testing.T) { dir := writeData(t, map[string]string{ "every": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`, "notall": `[{"format":"{f}","f":["1"]},["plain"]]`, + "some": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"],"extra":["x"]}]`, }) f := newFakes(t, dir, WithSeed(1)) for i := 0; i < 200; i++ { @@ -104,6 +105,10 @@ func TestPathThroughChoice(t *testing.T) { t.Fatalf("every.f = %q, want 1 or 2", got) } } + // A path only some variants carry is reported against what all of them carry. + if _, err := f.Fake("some.extra"); err == nil || !strings.Contains(err.Error(), "all carry [f]") { + t.Errorf("Fake(some.extra) = %v, want it to name what every variant carries", err) + } var first string for i := 0; i < 200; i++ { _, err := f.Fake("notall.f") diff --git a/fakes.go b/fakes.go index abdb618..e40a2b1 100644 --- a/fakes.go +++ b/fakes.go @@ -21,8 +21,8 @@ import ( "encoding/binary" "fmt" "math/rand/v2" - "slices" "sort" + "strings" ) // Fakes generates fake data from a loaded namespace tree. Create one with [New]. @@ -90,7 +90,15 @@ func (f *Fakes) List() []string { } } sort.Strings(out) - return slices.Compact(out) + return out +} + +// addressable reports whether a name can be one segment of a dot path. A dot would +// split it into two segments and an empty name into none, so a path through such a +// name cannot be spelled — List must not offer one, and it must not count towards +// what a choice's variants share. +func addressable(name string) bool { + return name != "" && !strings.Contains(name, ".") } // paths lists the dot paths addressable from n, relative to it, where "" is n @@ -100,6 +108,9 @@ func paths(n node) []string { case *group: var out []string for _, name := range sortedNames(n.children) { + if !addressable(name) { + continue + } for _, p := range paths(n.children[name]) { out = append(out, join(name, p)) } @@ -108,7 +119,7 @@ func paths(n node) []string { case *template: out := []string{""} for _, name := range sortedNames(n.fields) { - if isRef(name) { // a bound {..path} reference, not an authored field + if isRef(name) || !addressable(name) { // a binding, or unreachable by path continue } for _, p := range paths(n.fields[name]) { @@ -132,29 +143,35 @@ func paths(n node) []string { } // sharedPaths is the sub-paths every item carries — the only ones a path may step -// through a multi-variant choice to reach. An item is counted once per path, since -// one item offering a path twice does not make it shared. +// through a multi-variant choice to reach. It intersects, bailing as soon as the set +// is empty, which is immediate for a choice of plain strings. func sharedPaths(items []node) map[string]bool { - count := map[string]int{} - for _, it := range items { - seen := map[string]bool{} - for _, p := range paths(it) { - if p == "" || seen[p] { - continue - } - seen[p] = true - count[p]++ + shared := subPaths(items[0]) + for _, it := range items[1:] { + if len(shared) == 0 { + return nil } - } - shared := map[string]bool{} - for p, n := range count { - if n == len(items) { - shared[p] = true + next := subPaths(it) + for p := range shared { + if !next[p] { + delete(shared, p) + } } } return shared } +// subPaths is paths(n) as a set, without the empty path that means n itself. +func subPaths(n node) map[string]bool { + out := map[string]bool{} + for _, p := range paths(n) { + if p != "" { + out[p] = true + } + } + return out +} + func join(prefix, name string) string { switch { case prefix == "": diff --git a/node.go b/node.go index d721291..7911ed9 100644 --- a/node.go +++ b/node.go @@ -91,7 +91,11 @@ func compileChoice(items []any) (node, error) { } c.cum = cum } - c.shared = sharedPaths(c.items) + if len(c.items) > 1 { + // Safe to precompute: a choice's items come from one file, so no group can + // appear inside one, and neither mergeChildren nor linkRefs can reach in. + c.shared = sharedPaths(c.items) + } return c, nil } diff --git a/render.go b/render.go index 29fabd1..de0446e 100644 --- a/render.go +++ b/render.go @@ -2,7 +2,6 @@ package fakes import ( "fmt" - "slices" "sort" "strings" ) @@ -52,9 +51,10 @@ func descend(s *session, n node, segments []string) (node, error) { } return descend(s, child, segments[1:]) case *choice: - want := strings.Join(segments, ".") - if !n.shared[want] { - return nil, unreachableInChoice(n, want) + if len(n.items) > 1 { + if want := strings.Join(segments, "."); !n.shared[want] { + return nil, unreachableInChoice(n, want) + } } return descend(s, pick(s, n), segments) case literal: @@ -64,17 +64,19 @@ func descend(s *session, n node, segments []string) (node, error) { } } -// unreachableInChoice names the first variant that cannot address want. It walks -// the variants, which only a failing path does. +// unreachableInChoice reports that a path cannot step through this choice, listing +// what every variant does carry. It reads the precomputed set, so a failing path +// costs no more than a rendering one. func unreachableInChoice(c *choice, want string) error { - if len(c.items) > 1 { - for i, item := range c.items { - if !slices.Contains(paths(item), want) { - return fmt.Errorf("variant %d of a %d-way choice cannot reach %q", i+1, len(c.items), want) - } - } + if len(c.shared) == 0 { + return fmt.Errorf("no variant of this %d-way choice carries %q", len(c.items), want) } - return fmt.Errorf("cannot reach %q", want) + offered := make([]string, 0, len(c.shared)) + for p := range c.shared { + offered = append(offered, p) + } + sort.Strings(offered) + return fmt.Errorf("not every variant of this %d-way choice carries %q; all carry %v", len(c.items), want, offered) } // render evaluates a compiled node to a string. compile validates every node up