Hold a calc operand's draw for the expansion

A field the format rendered and a calc read was drawn twice, so the operand
shown could differ from the operand computed — the same disagreement the
dotted-path rule already fences, in its plainest spelling. The README
carried it as a caveat, which is what a rule like this exists to remove.

A calc operand now joins the names an expansion holds, so it is drawn once
and every later read of it — the calc, and any bare token spelling it —
sees that draw. The hold stays per expansion: each repeat iteration and each
nested template draws its own.

expand reads a calc's operands before the call and hands over their values,
so a builtin takes (emitted, operands) rather than the sibling fields, and
the evaluator indexes that slice instead of walking the node tree. That is
what keeps the draws a local. Handing them to a builtin instead lets them
escape through an indirect call, which put two maps on the heap for every
held format, calc or not: BenchmarkBound went 56 B/5 allocs -> 744 B/10.

Measured against main: Bound 359 -> 349 ns at 56 B/5 allocs, unchanged;
Calc 416 -> 577 ns and one more alloc, which is what the correlation costs.

Validation is unchanged: t.bound still carries only the levels a dotted
token reads, so the overlap fences fence exactly what they did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
M
2026-08-30 23:09:58 +02:00
committed by lilleman-tw
parent 4f87a4dd9e
commit 9ed0d3bf55
5 changed files with 155 additions and 86 deletions
+68 -32
View File
@@ -9,42 +9,48 @@ import (
)
// calc is the {calc(expr[, dp])} token: an arithmetic expression over number
// literals and sibling-field names (rendered, then parsed as numbers), with
// + - * /, unary minus and parentheses. It is a registry builtin like any other
// {name(args)} function — the one whose check and prep read the sibling fields (to
// render its operands). The value prints in minimal decimal form, or rounded to dp
// when given. A field that doesn't render to a number becomes NaN, which
// propagates and prints as "NaN" — visible, never a render error. The expression is
// parsed once at New into an AST every render shares and none mutates, so render
// cannot fail and must not carry per-render state.
// literals and sibling-field names, with + - * /, unary minus and parentheses. It is
// a registry builtin like any other {name(args)} function — the one that names
// operands, which expand reads for it (once per expansion, so the value computed is
// the value the format showed) and hands over as strings. The value prints in
// minimal decimal form, or rounded to dp when given. An operand that isn't a number
// becomes NaN, which propagates and prints as "NaN" — visible, never a render error.
// The expression is parsed once at New into an AST every render shares and none
// mutates, so render cannot fail and must not carry per-render state.
// calcNode is a parsed expression node.
// calcNode is a parsed expression node. It evaluates over the operand values expand
// read, so the evaluator touches neither the rng nor the node tree.
type calcNode interface {
eval(s *session, fields map[string]node) float64
eval(operands []string) float64
}
type calcNum float64 // a number literal
type calcVar string // a sibling-field name
type calcVar string // a sibling-field name, before indexVars places it
type calcIdx int // an operand, by its position in the values expand read
type calcNeg struct{ x calcNode }
type calcBin struct { // a + - * / b
op byte
l, r calcNode
}
func (n calcNum) eval(*session, map[string]node) float64 { return float64(n) }
func (n calcNum) eval([]string) float64 { return float64(n) }
func (n calcVar) eval(s *session, fields map[string]node) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(render(s, fields[string(n)])), 64)
func (n calcIdx) eval(operands []string) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(operands[n]), 64)
if err != nil {
return math.NaN() // a non-numeric operand stays visible, never an error
}
return v
}
func (n calcNeg) eval(s *session, fields map[string]node) float64 { return -n.x.eval(s, fields) }
// eval on an unplaced name cannot happen: calcPrep runs indexVars over every
// expression it compiles, so only a calcIdx reaches a render.
func (n calcVar) eval([]string) float64 { return math.NaN() }
func (n calcBin) eval(s *session, fields map[string]node) float64 {
l, r := n.l.eval(s, fields), n.r.eval(s, fields)
func (n calcNeg) eval(operands []string) float64 { return -n.x.eval(operands) }
func (n calcBin) eval(operands []string) float64 {
l, r := n.l.eval(operands), n.r.eval(operands)
switch n.op {
case '+':
return l + r
@@ -82,19 +88,39 @@ func checkCalc(fields map[string]node, args []string) error {
return nil
}
// calcPrep parses the expression and decimals once, at compile time. checkCalc
// proved both valid, so neither step here can fail; dp -1 prints the minimal form.
// calcPrep parses the expression and decimals once, at compile time, and places each
// operand name at the position expand will read it into. checkCalc proved both args
// valid, so no step here can fail; dp -1 prints the minimal form.
func calcPrep(args []string) callFn {
expr, _ := parseCalc(args[0])
at := make(map[string]int)
for i, name := range calcVars(expr) {
at[name] = i
}
placed := indexVars(expr, at)
dp := -1
if len(args) == 2 {
dp = atoi(args[1])
}
return func(s *session, _ string, fields map[string]node) string {
return strconv.FormatFloat(expr.eval(s, fields), 'f', dp, 64)
return func(_ *session, _ string, operands []string) string {
return strconv.FormatFloat(placed.eval(operands), 'f', dp, 64)
}
}
// indexVars replaces each operand name with its position in the values expand reads.
// Both sides take that order from calcVars, so they cannot drift.
func indexVars(n calcNode, at map[string]int) calcNode {
switch n := n.(type) {
case calcVar:
return calcIdx(at[string(n)])
case calcNeg:
return calcNeg{indexVars(n.x, at)}
case calcBin:
return calcBin{n.op, indexVars(n.l, at), indexVars(n.r, at)}
}
return n
}
// calcOperands lists the sibling-field names every {calc(...)} token in a format
// reads, so cycle detection sees the field edges calc renders through (a function
// token otherwise carries no field edge).
@@ -124,19 +150,29 @@ func calcTokenOperands(body string) []string {
return calcVars(expr)
}
// calcVars lists the field names an expression references, for the existence
// check in checkCalc.
// calcVars lists the distinct field names an expression reads, in the order it first
// names each. That order is the contract between expand, which reads the operands
// into a slice, and indexVars, which places each name at its position in it.
func calcVars(n calcNode) []string {
switch n := n.(type) {
case calcVar:
return []string{string(n)}
case calcNeg:
return calcVars(n.x)
case calcBin:
return append(calcVars(n.l), calcVars(n.r)...)
default:
return nil
var out []string
seen := map[string]bool{}
var walk func(calcNode)
walk = func(n calcNode) {
switch n := n.(type) {
case calcVar:
if name := string(n); !seen[name] {
seen[name] = true
out = append(out, name)
}
case calcNeg:
walk(n.x)
case calcBin:
walk(n.l)
walk(n.r)
}
}
walk(n)
return out
}
// calcParser is a recursive-descent parser over the expression runes, threading