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
|
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user