diff --git a/bench_test.go b/bench_test.go index d73b681..2c1ed5d 100644 --- a/bench_test.go +++ b/bench_test.go @@ -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") diff --git a/builtins_test.go b/builtins_test.go index 41b3f2d..9e95ea9 100644 --- a/builtins_test.go +++ b/builtins_test.go @@ -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) + } + } +} diff --git a/calc.go b/calc.go index 1f47069..324c824 100644 --- a/calc.go +++ b/calc.go @@ -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 { diff --git a/node.go b/node.go index 81a0752..59d4eec 100644 --- a/node.go +++ b/node.go @@ -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 } diff --git a/template.go b/template.go index fe37218..14e5ede 100644 --- a/template.go +++ b/template.go @@ -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 } diff --git a/template_test.go b/template_test.go index f356af3..3d94595 100644 --- a/template_test.go +++ b/template_test.go @@ -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) +}