From 12100239f5f0cd164fc4231f4b189509b391d429 Mon Sep 17 00:00:00 2001 From: M Date: Fri, 28 Aug 2026 15:03:23 +0200 Subject: [PATCH] Draw a dotted token's head once per expansion --- edge_test.go | 38 ++++++++++--------------- node.go | 39 +++++++++++++++++++++++++- render.go | 47 +++++++++++++++++++++++++++---- template.go | 78 ++++++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 158 insertions(+), 44 deletions(-) diff --git a/edge_test.go b/edge_test.go index e98f24b..9b46e83 100644 --- a/edge_test.go +++ b/edge_test.go @@ -196,35 +196,27 @@ func TestPathThroughChoice(t *testing.T) { } } -// TestPathKeyIsUnambiguous pins the one shape that can collide: a field literally -// named "a.b" in one variant and a field "a" holding "b" in another both spell the -// dot path "a.b". Neither variant may inherit the other's, so the path must fail -// the same way every call and must not be advertised. +// TestPathKeyIsUnambiguous pins that the one shape which could collide cannot be +// written: a field literally named "a.b" and a field "a" holding "b" would both +// spell "a.b", so a dotted field name is rejected at New and a dot means a path +// wherever it appears. func TestPathKeyIsUnambiguous(t *testing.T) { dir := writeData(t, map[string]string{ "cat": `[{"format":"{a.b}","a.b":["1"]},{"format":"{a}","a":{"format":"{b}","b":["2"]}}]`, }) - f := newFakes(t, dir, WithSeed(1)) - if slices.Contains(f.List(), "cat.a.b") { - t.Errorf("List() advertises cat.a.b, which only one variant carries") + _, err := New([]string{dir}) + if err == nil || !strings.Contains(err.Error(), `field "a.b" contains a dot`) { + t.Fatalf("New = %v, want the dotted field name rejected", err) } - var first string - for i := 0; i < 200; i++ { - _, err := f.Fake("cat.a.b") - if err == nil { - t.Fatal("Fake(cat.a.b) = nil error, want the same failure every call") - } - if i == 0 { - first = err.Error() - } else if err.Error() != first { - t.Fatalf("cat.a.b error varies:\n %s\n %s", first, err.Error()) - } + // The same data without the dotted key is fine, and the path resolves. + f := newFakes(t, writeData(t, map[string]string{ + "cat": `{"format":"{a.b}","a":{"format":"{b}","b":["2"]}}`, + }), WithSeed(1)) + if !slices.Contains(f.List(), "cat.a.b") { + t.Error("List() omits cat.a.b, which the data carries") } - // Each variant still renders through its own token. - for i := 0; i < 50; i++ { - if got := fake(t, f, "cat"); got != "1" && got != "2" { - t.Fatalf("cat = %q, want 1 or 2", got) - } + if got := fake(t, f, "cat"); got != "2" { + t.Fatalf("cat = %q, want 2", got) } } diff --git a/node.go b/node.go index f7ac73e..85edeba 100644 --- a/node.go +++ b/node.go @@ -46,6 +46,10 @@ type template struct { separator string ops []op // format compiled once (see compileOps); what expand walks grow int // minimum output size, to size the render buffer + // bound is the fields the format addresses by dotted path, each drawn once per + // expansion so two tokens read one row (see boundHeads and expand). nil when + // the format takes no path. + bound map[string]bool } func (*template) isNode() {} @@ -144,6 +148,9 @@ func compileTemplate(m map[string]any) (node, error) { if isRef(k) { return nil, fmt.Errorf("field %q starts with %q, which is reserved for {..path} bindings", k, refPrefix) } + if strings.Contains(k, ".") { + return nil, fmt.Errorf("field %q contains a dot, which separates the segments of a path into a field", k) + } n, err := compile(m[k]) if err != nil { return nil, fmt.Errorf("field %q: %w", k, err) @@ -153,10 +160,40 @@ func compileTemplate(m map[string]any) (node, error) { if err := checkTokens(format, t.fields); err != nil { return nil, err } - t.ops, t.grow = compileOps(format) + t.ops, t.grow, t.bound = compileOps(format) return t, nil } +// checkPath is the static twin of descend: it reports whether a dotted tail can +// address a node whichever way the draw goes. A multi-variant choice must carry the +// whole remaining path in the set every variant shares, so a path that validates +// here resolves on every render, and a typo is a New-time error. +func checkPath(n node, tail []string) error { + if len(tail) == 0 { + return nil + } + switch n := n.(type) { + case *template: + child, ok := n.fields[tail[0]] + if !ok { + return fmt.Errorf("no field %q", tail[0]) + } + return checkPath(child, tail[1:]) + case *choice: + if len(n.items) > 1 { + if want := strings.Join(tail, "."); !n.shared[want] { + return unreachableInChoice(n, want) + } + return nil // every variant carries it, so any draw resolves + } + return checkPath(n.items[0], tail) + case literal: + return fmt.Errorf("a plain string has no field %q", tail[0]) + default: + return fmt.Errorf("cannot descend into %T at %q", n, tail[0]) + } +} + // repeatOf reads a template's "repeat" (default 1): how many times its format // is rendered and concatenated. A present one must be a positive integer. func repeatOf(m map[string]any) (int, error) { diff --git a/render.go b/render.go index 316afb0..841ea2f 100644 --- a/render.go +++ b/render.go @@ -137,6 +137,12 @@ func classChar(s *session, c rune) byte { func expand(s *session, t *template) string { var b strings.Builder b.Grow(t.grow) + // One draw per bound head, held for this expansion only: a nested template and + // each repeat iteration get their own, since each is its own expansion. + var bound map[string]node + if len(t.bound) > 0 { + bound = make(map[string]node, len(t.bound)) + } for i := range t.ops { o := &t.ops[i] switch o.kind { @@ -145,7 +151,7 @@ func expand(s *session, t *template) string { case 'c': b.WriteByte(classChar(s, o.r)) case 'f': - b.WriteString(resolve(s, o.names, t.fields)) + b.WriteString(resolve(s, o.arms, t, bound)) case 'b': b.WriteString(o.call(s, b.String(), t.fields)) // b.String() is the output so far } @@ -153,9 +159,38 @@ func expand(s *session, t *template) string { return b.String() } -// resolve renders one field alternation: the '|' arms, one picked at random. A -// name is a sibling field or a {..path} reference, which linkRefs bound into -// fields too; checkTokens and linkRefs guarantee both exist. -func resolve(s *session, names []string, fields map[string]node) string { - return render(s, fields[names[s.IntN(len(names))]]) +// resolve renders one field alternation: the '|' alternatives, one picked at +// random. An arm's key is a sibling field or a {..path} reference, which linkRefs +// bound into fields too. A head the format addresses by dotted path is drawn once +// and held in bound, so {place.postal-code} and {place.locality} read one row; +// the arm's tail then walks into that draw. checkTokens, checkPath and linkRefs +// prove every step, so this cannot fail. +func resolve(s *session, arms []arm, t *template, bound map[string]node) string { + a := arms[s.IntN(len(arms))] + n := t.fields[a.key] + if t.bound[a.key] { + held, drew := bound[a.key] + if !drew { + held = drawn(s, n) + bound[a.key] = held + } + n = held + } + if len(a.tail) > 0 { + var err error + if n, err = descend(s, n, a.tail); err != nil { + panic(fmt.Sprintf("fakes: %s: %v", strings.Join(append([]string{a.key}, a.tail...), "."), err)) + } + } + return render(s, n) +} + +// drawn resolves a choice to one variant, so a bound head is a concrete node the +// rest of the expansion shares. Nested choices unwrap too: a draw is one value, not +// another set to pick from. +func drawn(s *session, n node) node { + for c, ok := n.(*choice); ok; c, ok = n.(*choice) { + n = pick(s, c) + } + return n } diff --git a/template.go b/template.go index 41cb788..7aafd23 100644 --- a/template.go +++ b/template.go @@ -142,11 +142,16 @@ func checkTokens(format string, fields map[string]node) error { } continue // a root reference; its target is checked at New (see linkRefs) } - if _, ok := fields[name]; !ok { - if isOption(name) { - return fmt.Errorf("token {%s}: %q is an option and can never be a field", t.body, name) + a := splitArm(name) + head, ok := fields[a.key] + if !ok { + if isOption(a.key) { + return fmt.Errorf("token {%s}: %q is an option and can never be a field", t.body, a.key) } - return fmt.Errorf("token {%s}: no field %q", t.body, name) + return fmt.Errorf("token {%s}: no field %q", t.body, a.key) + } + if err := checkPath(head, a.tail); err != nil { + return fmt.Errorf("token {%s}: field %q: %w", t.body, a.key, err) } } return nil @@ -167,6 +172,38 @@ func fieldTokens(format string) []string { return names } +// arm is one alternative of a {a|b} token, split into the key naming the node in +// a template's fields (a sibling field, or the whole "..path" string a reference is +// bound under) and the tail of a dotted path into it. A non-empty tail is what makes +// the arm a bound draw: its head is drawn once per expansion (see boundHeads). +type arm struct { + key string + tail []string +} + +// splitArm splits one token alternative into key and tail. A reference keeps its +// dots — linkRefs binds it whole — so only a sibling name reads as a path. +func splitArm(name string) arm { + if isRef(name) { + return arm{key: name} + } + head, tail, dotted := strings.Cut(name, ".") + if !dotted { + return arm{key: name} + } + return arm{key: head, tail: strings.Split(tail, ".")} +} + +// splitArms splits a token body's '|' alternatives. +func splitArms(body string) []arm { + parts := strings.Split(body, "|") + arms := make([]arm, len(parts)) + for i, p := range parts { + arms[i] = splitArm(p) + } + return arms +} + // callFn is a builtin bound to one call site: its args already parsed. type callFn func(s *session, emitted string, fields map[string]node) string @@ -174,19 +211,23 @@ type callFn func(s *session, emitted string, fields map[string]node) string // alternation, or a builtin already bound to its args. compile builds these so // render never re-scans the format. type op struct { - kind byte // 'l' literal run, 'c' class char, 'f' field alternation, 'b' builtin - lit string // kind 'l' - r rune // kind 'c' - names []string // kind 'f': the '|' arms, split once - call callFn + kind byte // 'l' literal run, 'c' class char, 'f' field alternation, 'b' builtin + lit string // kind 'l' + r rune // kind 'c' + arms []arm // kind 'f': the '|' alternatives, split into key and path once + call callFn } // compileOps turns a format string into ops, and returns the smallest output it can -// produce (literals plus one byte per class char) to size the render buffer. Call -// checkTokens first: it is what proves the scan and every token are valid. -func compileOps(format string) ([]op, int) { +// produce (literals plus one byte per class char) to size the render buffer, plus +// the fields the format addresses by dotted path — each drawn once per expansion, +// so every token reading one sees the same row (see expand). bound is nil when the +// format takes no path, so data that uses none carries no render-time cost. +// Call checkTokens first: it is what proves the scan and every token are valid. +func compileOps(format string) ([]op, int, map[string]bool) { var ops []op var lit strings.Builder + var bound map[string]bool grow := 0 flush := func() { if lit.Len() > 0 { @@ -208,13 +249,22 @@ func compileOps(format string) ([]op, int) { if name, args, ok := funcCall(t.body); ok { ops = append(ops, op{kind: 'b', call: builtins[name].bind(args)}) } else { - ops = append(ops, op{kind: 'f', names: strings.Split(t.body, "|")}) + arms := splitArms(t.body) + for _, a := range arms { + if len(a.tail) > 0 { + if bound == nil { + bound = map[string]bool{} + } + bound[a.key] = true + } + } + ops = append(ops, op{kind: 'f', arms: arms}) } } return nil }) flush() - return ops, grow + return ops, grow, bound } // bind returns the closure for one call site, parsing args once via prep if present.