From 964f04c7c89b6c301fdb1e2f0ea9e5e01097924b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 21:55:46 +0200 Subject: [PATCH] Answer stability round 1 and product-owner round 1: List advertises no path into a level carrying a repeat, a refusal that says to read the level whole, and plainer walk-position docs --- CHANGELOG.md | 7 +++--- README.md | 3 ++- fejkdata.go | 66 ++++++++++++++++++++++++++----------------------- node.go | 2 +- path.go | 13 ++++++---- render_test.go | 2 +- shipped_test.go | 2 +- 7 files changed, 52 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd43358..7bb2597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,10 @@ replacement, and each removed path, column or flag. `{/city[Oslo]..country.name}` renders `Norway`, and `.` after it steps down again, drawing afresh. `New` refuses a `..` naming anything but the parent table, and a selector after a `..`. -- A path given to `Fake` or the CLI may not read into a level carrying a - `repeat`, as a reference may not: `Fake("x.a")` where `x` carries `"repeat": 3` fails, - naming the level `x`. +- A path, given to `Fake` or the CLI or referenced in a template, may not read into a + level carrying a `repeat`, and `List` advertises none that does: where `x` carries + `"repeat": 3`, `Fake("x.a")` fails naming the level `x`, and `Fake("x")` renders it + whole. - Every `{…}` draws afresh, and a name is the one way to keep a pick: `{p.a} {p.b}`, `{w} {uppercase(w)}` and `{/person.first} {/person.last}` are two draws each, which `{p as q}{q.a} {q.b}` and `{/person as n}{n.first} {n.last}` make one. diff --git a/README.md b/README.md index df9ea93..619f6ac 100644 --- a/README.md +++ b/README.md @@ -795,7 +795,8 @@ token {p.postal-code}: not every variant of this 2-way choice carries "postal-co ``` The sub-fields stay addressable — `Fake("address.place.locality")` renders, and -`List` advertises it. A path may not read into a level carrying a `repeat`. +`List` advertises it. A path may not read into a level carrying a `repeat`, and `List` +advertises none that does; read that level whole. ### Performance diff --git a/fejkdata.go b/fejkdata.go index 491a515..b160124 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -113,67 +113,71 @@ func (c config) load() (folder, error) { } // List returns the sorted dotted paths Fake renders: each category and every field, -// column and linked table below it, a direct descent at a time. A choice consumes no -// segment, so a path continues through one only where every variant carries it. +// column and linked table below it, a direct descent at a time, stopping at a level +// carrying a repeat. A choice consumes no segment, so a path continues through one +// only where every variant carries it. func (f *Generator) List() []string { f.mu.Lock() defer f.mu.Unlock() - out := paths(&f.root) + out := paths(&f.root, false) sort.Strings(out) return out } // paths lists the dot paths addressable from n, relative to it, where "" is n // itself. A folder has no value of its own, so it contributes only its children's. -func paths(n node) []string { +// intoRepeats lists the fields under a level carrying a repeat too, which a path +// reaches only to be refused. +func paths(n node, intoRepeats bool) []string { switch n := n.(type) { case *folder: var out []string for _, name := range sortedNames(n.children) { - for _, p := range paths(n.children[name]) { - out = append(out, join(name, p)) - } + out = appendUnder(out, name, paths(n.children[name], intoRepeats)) } for _, name := range sortedNames(n.unloaded) { - for _, p := range n.unloaded[name].paths { - out = append(out, join(name, p)) - } + out = appendUnder(out, name, n.unloaded[name].paths) } return out case *template: out := []string{""} + if n.repeat > 1 && !intoRepeats { + return out + } for _, name := range sortedNames(n.fields) { - for _, p := range paths(n.fields[name]) { - out = append(out, join(name, p)) - } + out = appendUnder(out, name, paths(n.fields[name], intoRepeats)) } return out - case *nullItem: - return []string{""} case *table: - return tablePaths(n) - case *tableColumn, *tableRow: + return tablePaths(n, intoRepeats) + case *tableColumn, *tableRow, *nullItem: return []string{""} case *choice: - out := []string{""} - for p := range n.shared { - out = append(out, p) + shared := n.shared + if !intoRepeats { + shared = sharedPaths(n.items, false) } - return out + return append([]string{""}, sortedNames(shared)...) default: panic(invariant.Broken("paths has no case for node %T", n)) } } +// appendUnder appends each of ps to out under name. +func appendUnder(out []string, name string, ps []string) []string { + for _, p := range ps { + out = append(out, join(name, p)) + } + return out +} + // tablePaths is a table's columns, then each table linked to it under its name: the // direct descents, a step at a time. -func tablePaths(t *table) []string { +func tablePaths(t *table, intoRepeats bool) []string { out := append([]string{""}, t.rows.Header()...) sort.Strings(out[1:]) for _, c := range t.rows.Children() { - for _, p := range paths(c.Owner()) { - out = append(out, join(c.Segment(), p)) - } + out = appendUnder(out, c.Segment(), paths(c.Owner(), intoRepeats)) } return out } @@ -181,13 +185,13 @@ func tablePaths(t *table) []string { // sharedPaths is the sub-paths every item carries — the only ones a path may step // through a 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 { - shared := subPaths(items[0]) +func sharedPaths(items []node, intoRepeats bool) map[string]bool { + shared := subPaths(items[0], intoRepeats) for _, it := range items[1:] { if len(shared) == 0 { return nil } - next := subPaths(it) + next := subPaths(it, intoRepeats) for p := range shared { if !next[p] { delete(shared, p) @@ -197,10 +201,10 @@ func sharedPaths(items []node) map[string]bool { return shared } -// subPaths is paths(n) as a set, without the empty path that means n itself. -func subPaths(n node) map[string]bool { +// subPaths is paths(n, intoRepeats) as a set, without the empty path that means n itself. +func subPaths(n node, intoRepeats bool) map[string]bool { out := map[string]bool{} - for _, p := range paths(n) { + for _, p := range paths(n, intoRepeats) { if p != "" { out[p] = true } diff --git a/node.go b/node.go index 422e2fa..edf134e 100644 --- a/node.go +++ b/node.go @@ -266,7 +266,7 @@ func compileChoice(items []any, pos position) (node, error) { } c.cum = cum } - c.shared = sharedPaths(c.items) + c.shared = sharedPaths(c.items, true) return c, nil } diff --git a/path.go b/path.go index 6c90adf..1667f71 100644 --- a/path.go +++ b/path.go @@ -142,9 +142,12 @@ func (w *pathCheck) walk(n node, tail []string) (node, error) { return at.n, nil } +func repeatLevelError(level string) error { + return fmt.Errorf("the level %q carries a repeat, so a path cannot read one draw of it; read %q whole", level, level) +} + // pathPos is where a walk stands: the node reached, the tail left to walk from it, and whether -// the step into it came from a row of an ancestor table, so a table there reads a row even -// where the tail is empty. +// it descended there, as route reads it. type pathPos struct { n node tail []string @@ -154,8 +157,8 @@ type pathPos struct { func (p pathPos) more() bool { return len(p.tail) > 0 || p.descended } // takeStep moves at past the step its tail starts with, from its node, which is no choice, and -// appends what it takes to steps: a field, or the route a table passes. whole is the path at's -// tail ends, and level names its head in errors. +// appends what it takes to steps: a field, or the route a table passes. whole is the full path, +// at.tail its unwalked end, and level names its head in errors. func takeStep(at *pathPos, whole []string, level string, steps []pathStep, pins *pinSet) ([]pathStep, error) { i := len(whole) - len(at.tail) switch x := at.n.(type) { @@ -168,7 +171,7 @@ func takeStep(at *pathPos, whole []string, level string, steps []pathStep, pins return routeSteps(steps, pins, x, r, i) case *template: if x.repeat > 1 && !grammar.IsSelector(at.tail[0]) { - return steps, fmt.Errorf("the level %q carries a repeat, which a path reading one draw of it cannot apply", join(level, strings.Join(whole[:i], "."))) + return steps, repeatLevelError(join(level, strings.Join(whole[:i], "."))) } } next, err := stepInto(at.n, at.tail[0]) diff --git a/render_test.go b/render_test.go index b61e041..92bf76c 100644 --- a/render_test.go +++ b/render_test.go @@ -109,7 +109,7 @@ func TestNodeSwitchesHandleEveryKind(t *testing.T) { for name, call := range map[string]func(node){ "columnItems": func(n node) { columnItems(n) }, "contained": func(n node) { contained(n) }, - "paths": func(n node) { paths(n) }, + "paths": func(n node) { paths(n, false) }, "prove": func(n node) { (&valueProof{}).prove(n) }, "render": func(n node) { var frames frameStack diff --git a/shipped_test.go b/shipped_test.go index 7e19bfc..ed7daf8 100644 --- a/shipped_test.go +++ b/shipped_test.go @@ -72,7 +72,7 @@ func shippedIndexSource(whole *Generator) ([]byte, error) { if t, isTable := s.n.(*table); isTable && t.rows.Options().Parent != "" { fmt.Fprintf(&b, "parent: %q, ", t.rows.Options().Parent) } - ps := paths(s.n) + ps := paths(s.n, false) sort.Strings(ps) fmt.Fprintf(&b, "paths: %#v},\n", ps) }