Draw a dotted token's head once per expansion
This commit is contained in:
+15
-23
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+64
-14
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user