Draw a dotted token's head once per expansion

This commit is contained in:
M
2026-08-28 15:03:23 +02:00
committed by lilleman-tw
parent 5263c866c4
commit 12100239f5
4 changed files with 158 additions and 44 deletions
+15 -23
View File
@@ -196,35 +196,27 @@ func TestPathThroughChoice(t *testing.T) {
} }
} }
// TestPathKeyIsUnambiguous pins the one shape that can collide: a field literally // TestPathKeyIsUnambiguous pins that the one shape which could collide cannot be
// named "a.b" in one variant and a field "a" holding "b" in another both spell the // written: a field literally named "a.b" and a field "a" holding "b" would both
// dot path "a.b". Neither variant may inherit the other's, so the path must fail // spell "a.b", so a dotted field name is rejected at New and a dot means a path
// the same way every call and must not be advertised. // wherever it appears.
func TestPathKeyIsUnambiguous(t *testing.T) { func TestPathKeyIsUnambiguous(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"cat": `[{"format":"{a.b}","a.b":["1"]},{"format":"{a}","a":{"format":"{b}","b":["2"]}}]`, "cat": `[{"format":"{a.b}","a.b":["1"]},{"format":"{a}","a":{"format":"{b}","b":["2"]}}]`,
}) })
f := newFakes(t, dir, WithSeed(1)) _, err := New([]string{dir})
if slices.Contains(f.List(), "cat.a.b") { if err == nil || !strings.Contains(err.Error(), `field "a.b" contains a dot`) {
t.Errorf("List() advertises cat.a.b, which only one variant carries") t.Fatalf("New = %v, want the dotted field name rejected", err)
} }
var first string // The same data without the dotted key is fine, and the path resolves.
for i := 0; i < 200; i++ { f := newFakes(t, writeData(t, map[string]string{
_, err := f.Fake("cat.a.b") "cat": `{"format":"{a.b}","a":{"format":"{b}","b":["2"]}}`,
if err == nil { }), WithSeed(1))
t.Fatal("Fake(cat.a.b) = nil error, want the same failure every call") if !slices.Contains(f.List(), "cat.a.b") {
} t.Error("List() omits cat.a.b, which the data carries")
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())
}
} }
// Each variant still renders through its own token. if got := fake(t, f, "cat"); got != "2" {
for i := 0; i < 50; i++ { t.Fatalf("cat = %q, want 2", got)
if got := fake(t, f, "cat"); got != "1" && got != "2" {
t.Fatalf("cat = %q, want 1 or 2", got)
}
} }
} }
+38 -1
View File
@@ -46,6 +46,10 @@ type template struct {
separator string separator string
ops []op // format compiled once (see compileOps); what expand walks ops []op // format compiled once (see compileOps); what expand walks
grow int // minimum output size, to size the render buffer 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() {} func (*template) isNode() {}
@@ -144,6 +148,9 @@ func compileTemplate(m map[string]any) (node, error) {
if isRef(k) { if isRef(k) {
return nil, fmt.Errorf("field %q starts with %q, which is reserved for {..path} bindings", k, refPrefix) 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]) n, err := compile(m[k])
if err != nil { if err != nil {
return nil, fmt.Errorf("field %q: %w", k, err) 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 { if err := checkTokens(format, t.fields); err != nil {
return nil, err return nil, err
} }
t.ops, t.grow = compileOps(format) t.ops, t.grow, t.bound = compileOps(format)
return t, nil 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 // 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. // is rendered and concatenated. A present one must be a positive integer.
func repeatOf(m map[string]any) (int, error) { func repeatOf(m map[string]any) (int, error) {
+41 -6
View File
@@ -137,6 +137,12 @@ func classChar(s *session, c rune) byte {
func expand(s *session, t *template) string { func expand(s *session, t *template) string {
var b strings.Builder var b strings.Builder
b.Grow(t.grow) 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 { for i := range t.ops {
o := &t.ops[i] o := &t.ops[i]
switch o.kind { switch o.kind {
@@ -145,7 +151,7 @@ func expand(s *session, t *template) string {
case 'c': case 'c':
b.WriteByte(classChar(s, o.r)) b.WriteByte(classChar(s, o.r))
case 'f': case 'f':
b.WriteString(resolve(s, o.names, t.fields)) b.WriteString(resolve(s, o.arms, t, bound))
case 'b': case 'b':
b.WriteString(o.call(s, b.String(), t.fields)) // b.String() is the output so far 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() return b.String()
} }
// resolve renders one field alternation: the '|' arms, one picked at random. A // resolve renders one field alternation: the '|' alternatives, one picked at
// name is a sibling field or a {..path} reference, which linkRefs bound into // random. An arm's key is a sibling field or a {..path} reference, which linkRefs
// fields too; checkTokens and linkRefs guarantee both exist. // bound into fields too. A head the format addresses by dotted path is drawn once
func resolve(s *session, names []string, fields map[string]node) string { // and held in bound, so {place.postal-code} and {place.locality} read one row;
return render(s, fields[names[s.IntN(len(names))]]) // 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
} }
+64 -14
View File
@@ -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) continue // a root reference; its target is checked at New (see linkRefs)
} }
if _, ok := fields[name]; !ok { a := splitArm(name)
if isOption(name) { head, ok := fields[a.key]
return fmt.Errorf("token {%s}: %q is an option and can never be a field", t.body, name) 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 return nil
@@ -167,6 +172,38 @@ func fieldTokens(format string) []string {
return names 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. // 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 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 // alternation, or a builtin already bound to its args. compile builds these so
// render never re-scans the format. // render never re-scans the format.
type op struct { type op struct {
kind byte // 'l' literal run, 'c' class char, 'f' field alternation, 'b' builtin kind byte // 'l' literal run, 'c' class char, 'f' field alternation, 'b' builtin
lit string // kind 'l' lit string // kind 'l'
r rune // kind 'c' r rune // kind 'c'
names []string // kind 'f': the '|' arms, split once arms []arm // kind 'f': the '|' alternatives, split into key and path once
call callFn call callFn
} }
// compileOps turns a format string into ops, and returns the smallest output it can // 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 // produce (literals plus one byte per class char) to size the render buffer, plus
// checkTokens first: it is what proves the scan and every token are valid. // the fields the format addresses by dotted path — each drawn once per expansion,
func compileOps(format string) ([]op, int) { // 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 ops []op
var lit strings.Builder var lit strings.Builder
var bound map[string]bool
grow := 0 grow := 0
flush := func() { flush := func() {
if lit.Len() > 0 { if lit.Len() > 0 {
@@ -208,13 +249,22 @@ func compileOps(format string) ([]op, int) {
if name, args, ok := funcCall(t.body); ok { if name, args, ok := funcCall(t.body); ok {
ops = append(ops, op{kind: 'b', call: builtins[name].bind(args)}) ops = append(ops, op{kind: 'b', call: builtins[name].bind(args)})
} else { } 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 return nil
}) })
flush() flush()
return ops, grow return ops, grow, bound
} }
// bind returns the closure for one call site, parsing args once via prep if present. // bind returns the closure for one call site, parsing args once via prep if present.