Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64a0d29496 | |||
| 138bd21d83 | |||
| e31e77b8a3 | |||
| 4b3dd011fc | |||
| 21e7af6c1f | |||
| 8d03ce34bb | |||
| cf3229cd4d | |||
| 39d59aec23 | |||
| f949550515 | |||
| 027d5c33fc |
@@ -238,8 +238,9 @@ option stays unset. `New` rejects an option that cannot take effect — a
|
||||
`separator` without a `repeat` above 1, a `weight` outside a choice — and a name
|
||||
using a character the grammars reserve: `.` separates the segments of a path, `|`
|
||||
the arms of a token, `(` opens a function call and `}` ends the token, so a name
|
||||
carrying one is a name no format could ever spell. That holds for a category, a
|
||||
folder and a field alike.
|
||||
carrying one is a name no format could ever spell. An empty name goes the same
|
||||
way — it is no path segment at all, so `List` never offers it. That holds for a
|
||||
category, a folder and a field alike.
|
||||
|
||||
**Functions.** A `{name()}` token calls a built-in function instead of rendering
|
||||
a field. `{luhn()}` appends a Luhn check digit over the digits emitted **so far**
|
||||
@@ -365,9 +366,12 @@ the reference sits, including in a field the format renders.
|
||||
|
||||
A `{calc()}` operand is held on its own terms too, so the same fence guards it:
|
||||
`{..cat.net} x 2 = {calc(net * 2, 2)}` names one field two ways and is a load
|
||||
error, with no path token anywhere. What an operand's draw fixes is the value it
|
||||
renders, so a reference reaching *through* it is caught as well, while a sibling
|
||||
its format never renders is free — that sibling is no part of the value.
|
||||
error, with no path token anywhere. A calc renders its operand whole, so the hold
|
||||
pins what that render settled, following the operand's plain `{field}` tokens —
|
||||
`{net.v}` and `{..cat.net.v}` are rejected alike. It stops at a `{..path}`, where
|
||||
the operand's own value ends and a shared source begins: two names drawing from
|
||||
one referenced category are two draws, as `{word} {word}` is, so two dice over
|
||||
one `{..die}` are fine.
|
||||
|
||||
```text
|
||||
token {p} renders a level that {p.first} reads a path into; name the fields you want instead
|
||||
|
||||
+71
-13
@@ -148,25 +148,77 @@ func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) {
|
||||
// shown is not the value computed. Two names that merely draw from one source are
|
||||
// two draws, as {word} {word} is — the rule everywhere else in the engine.
|
||||
func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
||||
rejected := map[string]map[string]string{
|
||||
rejected := map[string]struct {
|
||||
files map[string]string
|
||||
want string // the route the error names, spelled as the author wrote it
|
||||
}{
|
||||
"a reference beside the operand": {
|
||||
"cat": `{"format":"{..cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
|
||||
map[string]string{
|
||||
"cat": `{"format":"{..cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
|
||||
},
|
||||
`{..cat.net} renders "net"`,
|
||||
},
|
||||
"a reference one level down": {
|
||||
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
|
||||
`"q":{"format":"{..cat.net}"}}`,
|
||||
map[string]string{
|
||||
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
|
||||
`"q":{"format":"{..cat.net}"}}`,
|
||||
},
|
||||
`{q} renders "net"`,
|
||||
},
|
||||
// A reference ending at the operand binds the choice itself, not a variant,
|
||||
// so wrapping the operand in one changes nothing.
|
||||
"a reference to an operand wrapped in a choice": {
|
||||
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":[{"format":"{v}","v":["1","2"]}],` +
|
||||
`"q":{"format":"{..cat.n}"}}`,
|
||||
map[string]string{
|
||||
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":[{"format":"{v}","v":["1","2"]}],` +
|
||||
`"q":{"format":"{..cat.n}"}}`,
|
||||
},
|
||||
`{q} renders "n"`,
|
||||
},
|
||||
// The reaching route can be another operand, which is not a token — so the
|
||||
// error must name it as one rather than invent a {b} the format never wrote.
|
||||
"an operand reaching another operand": {
|
||||
map[string]string{
|
||||
"cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` +
|
||||
`"b":{"format":"{..cat.a}"}}`,
|
||||
},
|
||||
`calc operand "b" renders "a"`,
|
||||
},
|
||||
// Reaching *into* the operand is the same shape as naming it: the draw the
|
||||
// calc holds settled that value too. The token spelling {net.v} is already
|
||||
// rejected, so the reference spelling has to be.
|
||||
"a reference into the operand": {
|
||||
map[string]string{
|
||||
"cat": `{"format":"{calc(net * 2, 2)}|{..cat.net.v}",` +
|
||||
`"net":{"format":"{v}","v":["10.00","20.00"]}}`,
|
||||
},
|
||||
`{..cat.net.v} renders "net"`,
|
||||
},
|
||||
"a reference into the operand one level down": {
|
||||
map[string]string{
|
||||
"cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` +
|
||||
`"q":{"format":"{..cat.net.v}"}}`,
|
||||
},
|
||||
`{q} renders "net"`,
|
||||
},
|
||||
// Each head is walked against its own seen set. Sharing one across heads
|
||||
// would let the first head's walk mark the only route to the second's draw,
|
||||
// and this violation sits on the later head in sorted order.
|
||||
"a violation on the second of two held heads": {
|
||||
map[string]string{
|
||||
"cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` +
|
||||
`"b":{"format":"{y}","y":["3","4"]},"w":{"format":"{..cat.b}"}}`,
|
||||
},
|
||||
`{w} renders "b"`,
|
||||
},
|
||||
}
|
||||
for name, files := range rejected {
|
||||
_, err := New([]string{writeData(t, files)})
|
||||
for name, c := range rejected {
|
||||
_, err := New([]string{writeData(t, c.files)})
|
||||
if err == nil || !strings.Contains(err.Error(), "a {calc()} also reads") {
|
||||
t.Errorf("%s: New = %v, want the second route to the operand rejected", name, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: New = %v, want it to name %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +252,11 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
||||
"a literal operand named twice": {
|
||||
"cat": `{"format":"{calc(n * 2, 0)} {..cat.n}","n":"5"}`,
|
||||
},
|
||||
// One node reached twice while walking the operand: the walk must not
|
||||
// revisit it, and the repeat is not a second route to anything.
|
||||
"an operand that renders one field twice": {
|
||||
"cat": `{"format":"{calc(v * 2, 0)}","v":{"format":"{a}{a}","a":["1","2"]}}`,
|
||||
},
|
||||
}
|
||||
for name, files := range accepted {
|
||||
if _, err := New([]string{writeData(t, files)}); err != nil {
|
||||
@@ -599,12 +656,13 @@ func TestDeepPathsUnderOneHeadStayIndependentWhereTheyDiverge(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEmptyPathSegmentIsRejected(t *testing.T) {
|
||||
// "{a.}", "{.b}" and "{a..b}" are unfinished paths. A field really named ""
|
||||
// would otherwise make them resolve, which reads as a typo that worked.
|
||||
// "{a.}", "{.b}" and "{a..b}" are unfinished paths, and the segment naming
|
||||
// nothing is reported as that rather than as a missing field. An empty name is
|
||||
// rejected where it is authored, so no data can make these resolve.
|
||||
rejected := map[string]string{
|
||||
"trailing dot": `{"format":"[{a.}]","a":{"format":"x","":["V"]}}`,
|
||||
"leading dot": `{"format":"[{.b}]","":{"format":"{b}","b":["V"]}}`,
|
||||
"double dot": `{"format":"[{a..b}]","a":{"format":"x","":{"format":"{b}","b":["V"]}}}`,
|
||||
"trailing dot": `{"format":"[{a.}]","a":{"format":"x"}}`,
|
||||
"leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":["V"]}}`,
|
||||
"double dot": `{"format":"[{a..b}]","a":{"format":"x"}}`,
|
||||
}
|
||||
for name, file := range rejected {
|
||||
_, err := New([]string{writeData(t, map[string]string{"cat": file})})
|
||||
|
||||
+10
-3
@@ -48,9 +48,7 @@ var builtins = map[string]builtin{
|
||||
return func(s *session, _ string, _ []string) string { return strconv.Itoa(lo + s.IntN(span)) }
|
||||
}},
|
||||
"float": {arity: 3, check: floatArgs, prep: func(a []string) callFn {
|
||||
lo, _ := strconv.ParseFloat(a[0], 64)
|
||||
hi, _ := strconv.ParseFloat(a[1], 64)
|
||||
dp := atoi(a[2])
|
||||
lo, hi, dp := atof(a[0]), atof(a[1]), atoi(a[2])
|
||||
return func(s *session, _ string, _ []string) string {
|
||||
return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', dp, 64)
|
||||
}
|
||||
@@ -104,6 +102,15 @@ func atoi(s string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// atof is atoi for a float arg, and reports an unvalidated one the same way.
|
||||
func atof(s string) float64 {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("fakes: builtin arg %q reached prep unvalidated: %v", s, err))
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func randBytes(r rng, n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
|
||||
@@ -193,3 +193,27 @@ func TestRegistryShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestArgGuardsPanic pins the guards that report a builtin arg its check should
|
||||
// have rejected. No data reaches them — checkFunc runs a builtin's check before
|
||||
// compileOps ever calls prep — so they are exercised directly.
|
||||
func TestArgGuardsPanic(t *testing.T) {
|
||||
for name, call := range map[string]func(){
|
||||
"atoi on an unvalidated arg": func() { atoi("nope") },
|
||||
"atof on an unvalidated arg": func() { atof("nope") },
|
||||
} {
|
||||
mustPanic(t, name, call)
|
||||
}
|
||||
}
|
||||
|
||||
// mustPanic fails unless call panics, which is what separates a reported
|
||||
// invariant break from a silently wrong value.
|
||||
func mustPanic(t *testing.T, name string, call func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic, want the invariant reported", name)
|
||||
}
|
||||
}()
|
||||
call()
|
||||
}
|
||||
|
||||
@@ -103,6 +103,19 @@ func TestCalcTokenOperandsReadsOnlyACalc(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcGuardsPanic pins the guards on calc's own invariants: checkCalc parsed
|
||||
// the expression before prep sees it, and indexVars places every name calcVars
|
||||
// read, so a break in either is reported rather than computed around.
|
||||
func TestCalcGuardsPanic(t *testing.T) {
|
||||
for name, call := range map[string]func(){
|
||||
"prep on an expression that does not parse": func() { calcPrep([]string{"1 +"}) },
|
||||
"an operand name never placed": func() { calcVar("n").eval(nil) },
|
||||
"indexVars on a name it did not read": func() { indexVars(calcVar("n"), map[string]int{}) },
|
||||
} {
|
||||
mustPanic(t, name, call)
|
||||
}
|
||||
}
|
||||
|
||||
// --- one draw, one value: a calc operand reads the expansion's draw ---
|
||||
|
||||
// TestCalcOperandReadsTheExpansionsDraw pins the one-draw rule for a calc operand.
|
||||
|
||||
+14
-1
@@ -120,6 +120,13 @@ func TestNewErrors(t *testing.T) {
|
||||
map[string]string{"a|b": `["1"]`},
|
||||
`category "a|b" contains "|"`,
|
||||
},
|
||||
// An empty name is not a path segment, so List never offered it — while a
|
||||
// bare {}, a trailing dot in Fake("a.") and a {..a.} reference all reached
|
||||
// it. The engine accepted spellings it would never advertise.
|
||||
"empty field name": {
|
||||
map[string]string{"a": `{"format":"[{}]","":"VALUE"}`},
|
||||
`field "" is empty`,
|
||||
},
|
||||
"folder name with a paren": {
|
||||
map[string]string{"a(b/cat": `["1"]`},
|
||||
`folder "a(b" contains "("`,
|
||||
@@ -149,9 +156,15 @@ func TestNewErrors(t *testing.T) {
|
||||
map[string]string{"a": `{"format":"{..|..}"}`},
|
||||
"reference has no path",
|
||||
},
|
||||
// No field can be named "", so the token is told that rather than sent to
|
||||
// name one — the fix "no field" points at is itself a load error.
|
||||
"repeated empty arm": {
|
||||
map[string]string{"a": `{"format":"{|}"}`},
|
||||
`no field ""`,
|
||||
"a name is never empty",
|
||||
},
|
||||
"bare empty token": {
|
||||
map[string]string{"a": `{"format":"[{}]","x":["1"]}`},
|
||||
"a name is never empty",
|
||||
},
|
||||
}
|
||||
for name, c := range rejected {
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Fakes generates fake data from a loaded namespace tree. Create one with [New].
|
||||
@@ -93,14 +92,6 @@ func (f *Fakes) List() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// addressable reports whether a name can be one segment of a dot path. A dot would
|
||||
// split it into two segments and an empty name into none, so a path through such a
|
||||
// name cannot be spelled — List must not offer one, and it must not count towards
|
||||
// what a choice's variants share.
|
||||
func addressable(name string) bool {
|
||||
return name != "" && !strings.Contains(name, ".")
|
||||
}
|
||||
|
||||
// paths lists the dot paths addressable from n, relative to it, where "" is n
|
||||
// itself. A group has no value of its own, so it contributes only its children's.
|
||||
func paths(n node) []string {
|
||||
@@ -108,9 +99,6 @@ func paths(n node) []string {
|
||||
case *group:
|
||||
var out []string
|
||||
for _, name := range sortedNames(n.children) {
|
||||
if !addressable(name) {
|
||||
continue
|
||||
}
|
||||
for _, p := range paths(n.children[name]) {
|
||||
out = append(out, join(name, p))
|
||||
}
|
||||
@@ -119,7 +107,7 @@ func paths(n node) []string {
|
||||
case *template:
|
||||
out := []string{""}
|
||||
for _, name := range sortedNames(n.fields) {
|
||||
if isRef(name) || !addressable(name) { // a binding, or unreachable by path
|
||||
if isRef(name) { // a binding, not a path segment
|
||||
continue
|
||||
}
|
||||
for _, p := range paths(n.fields[name]) {
|
||||
|
||||
@@ -266,6 +266,9 @@ var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
|
||||
// category or folder and a field go through it, so there is one answer to what a
|
||||
// name may contain.
|
||||
func checkName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name)
|
||||
}
|
||||
if i := strings.IndexAny(name, reservedInName); i >= 0 {
|
||||
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path and {token} grammars reserve",
|
||||
name, name[i:i+1], reservedList)
|
||||
|
||||
+45
-21
@@ -65,10 +65,10 @@ func checkBoundLevelsHeld(root map[string]node) error {
|
||||
if isPath {
|
||||
cover(t.fields[head], held)
|
||||
} else {
|
||||
coverRendered(t.fields[head], held)
|
||||
operandDraw(t.fields[head], held)
|
||||
}
|
||||
if len(held) == 0 {
|
||||
continue // a fixed string, which cannot disagree with itself
|
||||
continue // an early out: a literal head holds nothing to reach
|
||||
}
|
||||
// One seen set across the edges: a node that cannot reach the level
|
||||
// cannot reach it by another route either, so it is walked once here.
|
||||
@@ -79,9 +79,9 @@ func checkBoundLevelsHeld(root map[string]node) error {
|
||||
}
|
||||
if renders(e.to, held, seen) {
|
||||
if isPath {
|
||||
return fmt.Errorf("%s: {%s} renders %q, which {%s} reads a path into; name the fields you want instead", path, e.label, head, reader)
|
||||
return fmt.Errorf("%s: %s renders %q, which {%s} reads a path into; name the fields you want instead", path, e.reached(), head, reader)
|
||||
}
|
||||
return fmt.Errorf("%s: {%s} renders %q, which a {calc()} also reads; reach it one way so it is drawn once", path, e.label, head)
|
||||
return fmt.Errorf("%s: %s renders %q, which a {calc()} also reads; reach it one way so it is drawn once", path, e.reached(), head)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,14 +103,17 @@ func cover(n node, into map[node]bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// coverRendered collects what one held draw of a calc operand answers for: what
|
||||
// rendering it reaches, following the same edges expand does. An operand is only
|
||||
// ever rendered — checkNoOverlap rejects a path into one — so its draw fixes that
|
||||
// value and nothing else. That is narrower than containment where a sibling the
|
||||
// operand's format never renders cannot disagree with it, and wider where the
|
||||
// operand renders through a reference, which is part of the value it produces.
|
||||
// Literals are left out for the reason cover leaves them out.
|
||||
func coverRendered(n node, into map[node]bool) {
|
||||
// operandDraw collects what one held draw of a {calc()} operand answers for: the
|
||||
// operand and what rendering it settles inside itself. A calc renders its operand
|
||||
// whole, so that draw fixes every value the render produced, and a second route to
|
||||
// any of them disagrees with it.
|
||||
//
|
||||
// The walk stops at a {..path} edge, which is where the operand's own value ends
|
||||
// and a shared source begins: two names referencing one category are two draws, the
|
||||
// same rule {word} {word} follows. cover stops there too, by way of named, so both
|
||||
// halves of the fence end at the same boundary. A literal is left out for the
|
||||
// reason cover leaves one out.
|
||||
func operandDraw(n node, into map[node]bool) {
|
||||
if _, fixed := n.(literal); fixed {
|
||||
return
|
||||
}
|
||||
@@ -119,7 +122,10 @@ func coverRendered(n node, into map[node]bool) {
|
||||
}
|
||||
into[n] = true
|
||||
for _, e := range renderEdges(n) {
|
||||
coverRendered(e.to, into)
|
||||
if isRef(e.label) {
|
||||
continue
|
||||
}
|
||||
operandDraw(e.to, into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,11 +273,23 @@ func refTokens(format string) []string {
|
||||
return refs
|
||||
}
|
||||
|
||||
// renderEdge is a child a node renders into, labelled by the token that reaches it
|
||||
// (a field name, reference, or choice index) for a readable cycle report.
|
||||
// renderEdge is a child a node renders into, labelled by what reaches it (a field
|
||||
// name, reference, or choice index) for a readable cycle report. operand marks a
|
||||
// label that is a {calc()} operand name rather than a token, so an error can name
|
||||
// it the way the author wrote it.
|
||||
type renderEdge struct {
|
||||
to node
|
||||
label string
|
||||
to node
|
||||
label string
|
||||
operand bool
|
||||
}
|
||||
|
||||
// reached names an edge as the author spelled it, the vocabulary boundReaders uses
|
||||
// for the sibling fence.
|
||||
func (e renderEdge) reached() string {
|
||||
if e.operand {
|
||||
return fmt.Sprintf("calc operand %q", e.label)
|
||||
}
|
||||
return "{" + e.label + "}"
|
||||
}
|
||||
|
||||
// renderEdges lists the children rendering n recurses into, mirroring expand: a
|
||||
@@ -282,21 +300,27 @@ func renderEdges(n node) []renderEdge {
|
||||
case *choice:
|
||||
es := make([]renderEdge, len(n.items))
|
||||
for i, it := range n.items {
|
||||
es[i] = renderEdge{it, fmt.Sprintf("[%d]", i)}
|
||||
es[i] = renderEdge{to: it, label: fmt.Sprintf("[%d]", i)}
|
||||
}
|
||||
return es
|
||||
case *template:
|
||||
var es []renderEdge
|
||||
for _, name := range append(fieldTokens(n.format), calcOperands(n.format)...) {
|
||||
add := func(name string, operand bool) {
|
||||
a := splitArm(name)
|
||||
c, ok := n.fields[a.key]
|
||||
if !ok {
|
||||
continue
|
||||
return
|
||||
}
|
||||
for _, leaf := range pathLeaves(c, a.tail) {
|
||||
es = append(es, renderEdge{leaf, name})
|
||||
es = append(es, renderEdge{leaf, name, operand})
|
||||
}
|
||||
}
|
||||
for _, name := range fieldTokens(n.format) {
|
||||
add(name, false)
|
||||
}
|
||||
for _, name := range calcOperands(n.format) {
|
||||
add(name, true)
|
||||
}
|
||||
return es
|
||||
default:
|
||||
return nil
|
||||
|
||||
@@ -156,7 +156,8 @@ func expand(s *session, t *template) string {
|
||||
case 'f':
|
||||
b.WriteString(readField(s, t, held, o.arms[s.IntN(len(o.arms))]))
|
||||
case 'b':
|
||||
// Read in the order calcVars fixed, which is what op.operands holds.
|
||||
// Read before the call, so the value a calc computes is the value the
|
||||
// format showed. calcVars fixed the order op.operands holds.
|
||||
var operands []string
|
||||
if len(o.operands) > 0 {
|
||||
operands = make([]string, len(o.operands))
|
||||
|
||||
@@ -147,6 +147,9 @@ func checkTokens(format string, fields map[string]node) error {
|
||||
}
|
||||
head, ok := fields[a.key]
|
||||
if !ok {
|
||||
if a.key == "" {
|
||||
return fmt.Errorf("token {%s}: a name is never empty, so this token can name no field", t.body)
|
||||
}
|
||||
if isOption(a.key) {
|
||||
return fmt.Errorf("token {%s}: %q is an option and can never be a field", t.body, a.key)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user