Pin the builtin registry shape and grow as a lower bound, and correct stale comments

This commit is contained in:
Mikael Göransson
2026-08-27 23:48:35 +02:00
committed by lilleman-tw
parent ab9846c2ff
commit 37759f773f
6 changed files with 71 additions and 22 deletions
-4
View File
@@ -33,7 +33,6 @@ func BenchmarkCreditcard(b *testing.B) { benchPath(b, "data/misc", "creditcard")
func BenchmarkSSN(b *testing.B) { benchPath(b, "data/sv_SE", "ssn") }
func BenchmarkUUIDv7(b *testing.B) { benchPath(b, "data/misc", "uuid") }
// tmpData writes one category JSON into a fresh dir and returns the dir.
func tmpData(b *testing.B, name, body string) string {
b.Helper()
dir := b.TempDir()
@@ -43,19 +42,16 @@ func tmpData(b *testing.B, name, body string) string {
return dir
}
// BenchmarkCalc exercises the per-render expression re-parse.
func BenchmarkCalc(b *testing.B) {
dir := tmpData(b, "inv", `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99"],"qty":["3"]}`)
benchPath(b, dir, "inv")
}
// BenchmarkLongLiteral exercises the per-rune literal write path.
func BenchmarkLongLiteral(b *testing.B) {
dir := tmpData(b, "sql", `{"format":"INSERT INTO customers (id, name, city) V#ALUES (#1#2#3, '{word}', '{word}');","word":["alpha","beta","gamma","delta"]}`)
benchPath(b, dir, "sql")
}
// BenchmarkRepeat exercises repeat + separator.
func BenchmarkRepeat(b *testing.B) {
dir := tmpData(b, "many", `{"format":"{word}","repeat":20,"separator":", ","word":["alpha","beta","gamma","delta"]}`)
benchPath(b, dir, "many")
+17
View File
@@ -181,3 +181,20 @@ func ibanValid(s string) bool {
}
return rem == 1
}
// TestRegistryShapes pins the builtin contract bind relies on: exactly one of prep
// or call, and args parsed at compile only behind a check. A registry entry with
// neither would compile and then panic inside a render.
func TestRegistryShapes(t *testing.T) {
for name, b := range builtins {
switch {
case b.prep == nil && b.call == nil:
t.Errorf("builtin %q has neither prep nor call: bind would return a nil-func closure", name)
case b.prep != nil && b.call != nil:
t.Errorf("builtin %q has both prep and call; call is dead", name)
}
if b.prep != nil && b.arity != 0 && b.check == nil {
t.Errorf("builtin %q parses args in prep with no check", name)
}
}
}
+4 -4
View File
@@ -11,12 +11,12 @@ 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 call read the sibling fields (to
// {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 re-parsed each render, mirroring how expand re-scans the format; checkCalc
// proves it parses (and names real fields) at New, so render never fails.
// 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.
type calcNode interface {
+1 -1
View File
@@ -138,7 +138,7 @@ 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.fields)
t.ops, t.grow = compileOps(format)
return t, nil
}
+12 -13
View File
@@ -20,7 +20,7 @@ type ftoken struct {
}
// eachToken scans a format string once and calls fn for each unit, the single
// source of truth for how '#' escapes and {…} braces are read — expand,
// source of truth for how '#' escapes and {…} braces are read — compileOps,
// checkTokens, fieldTokens and refTokens all drive off it so the grammar can't
// drift between the validator and the renderer. '#' escapes the next char to a
// literal ("#0" -> '0', "##" -> '#'); a '{' must reach a '}' (else an error); an
@@ -66,7 +66,9 @@ func eachToken(format string, fn func(ftoken) error) error {
// seq is the one exception: it advances per-session counter state, which is itself
// deterministic (1, 2, 3 …). arity is the exact arg count, or -1 for variadic
// (then check does all the validation). The optional check validates args at
// compile time (their values, beyond the count). The registry lives in builtins.go.
// compile time (their values, beyond the count). A builtin supplies exactly one of
// prep (args parsed once, at compile) or call (args parsed per render). The registry
// lives in builtins.go.
type builtin struct {
arity int
// prep parses validated args once, at compile time, into the closure expand calls.
@@ -162,12 +164,12 @@ func fieldTokens(format string) []string {
return names
}
// op is one compiled unit of a format string: a literal run, a class char, a field
// alternation, or a builtin already bound to its args. compile builds these so
// render never re-scans the format.
// 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
// op is one compiled unit of a format string: a literal run, a class char, a field
// 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'
@@ -176,14 +178,16 @@ type op struct {
call callFn
}
// compileOps turns a validated format string into ops, and returns the smallest
// output it can produce (literals plus one byte per class char) to size the buffer.
func compileOps(format string, fields map[string]node) ([]op, int) {
// 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) {
var ops []op
var lit strings.Builder
grow := 0
flush := func() {
if lit.Len() > 0 {
grow += lit.Len()
ops = append(ops, op{kind: 'l', lit: lit.String()})
lit.Reset()
}
@@ -207,11 +211,6 @@ func compileOps(format string, fields map[string]node) ([]op, int) {
return nil
})
flush()
for _, o := range ops {
if o.kind == 'l' {
grow += len(o.lit)
}
}
return ops, grow
}
+37
View File
@@ -226,3 +226,40 @@ func TestFakePathNavigation(t *testing.T) {
t.Error("Fake(missing) = nil error, want unknown-category error")
}
}
// TestGrowIsALowerBound pins the property that makes the pre-sized render buffer
// safe: grow must never exceed what expand emits. render multiplies it by repeat,
// so an over-estimate would amplify up to a million-fold.
func TestGrowIsALowerBound(t *testing.T) {
f := engine(3)
for _, format := range []string{
"",
"plain literal",
"00-11-AA-aa",
"#0#1#A#a## literal",
"Ö dag åäö 日本語",
"{x}{x}{x}",
"{hex(8)}-{int(10,99)}-{nanoid(5)}",
"9{d}{luhn()}",
"{a|b} and {a|b}",
} {
src := `{"format":` + quote(format) + `,"x":["1"],"a":["A"],"b":["B"],"d":["012345678901234"]}`
tmpl, ok := compiled(t, src).(*template)
if !ok {
t.Fatalf("format %q did not compile to a template", format)
}
for i := 0; i < 50; i++ {
if got := len(expand(f.rand, tmpl)); got < tmpl.grow {
t.Errorf("format %q: expand emitted %d bytes, below grow %d", format, got, tmpl.grow)
}
}
}
}
func quote(s string) string {
b, err := json.Marshal(s)
if err != nil {
panic(err)
}
return string(b)
}