diff --git a/README.md b/README.md index 2797826..474dedb 100644 --- a/README.md +++ b/README.md @@ -366,11 +366,11 @@ 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. A calc renders its operand whole, so the hold -pins every value that render settled: naming the operand and reaching into it -both conflict, and `{net.v}` and `{..cat.net.v}` are rejected alike. The hold -ends 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. +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 diff --git a/bound_test.go b/bound_test.go index 72aa629..6b0468b 100644 --- a/bound_test.go +++ b/bound_test.go @@ -200,6 +200,16 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { }, `{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, c := range rejected { _, err := New([]string{writeData(t, c.files)}) @@ -242,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 { diff --git a/reference.go b/reference.go index 474c14a..f5094c7 100644 --- a/reference.go +++ b/reference.go @@ -68,7 +68,7 @@ func checkBoundLevelsHeld(root map[string]node) error { 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. @@ -111,9 +111,8 @@ func cover(n node, into map[node]bool) { // 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. Containment would be wrong here — -// it reaches a sibling the operand never renders, which is no part of its value. -// A literal is left out for the reason cover leaves one out. +// 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