Add {calc()}: arithmetic over number literals and sibling fields

This commit is contained in:
lilleman
2026-06-09 07:59:29 +02:00
parent eaf99d4542
commit cee2f35a2c
5 changed files with 361 additions and 5 deletions
+23 -3
View File
@@ -244,10 +244,11 @@ reproducible. A time-based id (UUID v7, ULID) therefore draws its timestamp from
the rng, not the clock — the result is a valid, reproducible value, not a real the rng, not the clock — the result is a valid, reproducible value, not a real
point in time. point in time.
There are three kinds. **Derivations** read the digits emitted so far, so put them There are four kinds. **Derivations** read the digits emitted so far, so put them
after their payload; **generators** read only the rng, so they stand alone; one after their payload; **generators** read only the rng, so they stand alone; one
**session counter** (`seq`) advances state held on the faker. Arguments are **session counter** (`seq`) advances state held on the faker; and one
validated at `New` (a bad count, range, or country fails fast). **computation** (`calc`) evaluates arithmetic over sibling fields. Arguments are
validated at `New` (a bad count, range, country, or expression fails fast).
| Function | Kind | Emits | | Function | Kind | Emits |
|----------|------|-------| |----------|------|-------|
@@ -264,6 +265,7 @@ validated at `New` (a bad count, range, or country fails fast).
| `{float(min,max,dp)}` | generator | number in `[min, max]` with `dp` decimals | | `{float(min,max,dp)}` | generator | number in `[min, max]` with `dp` decimals |
| `{iban(CC)}` | generator | a length- and mod-97-valid IBAN for country `CC` (BE, DE, DK, ES, FI, NO, SE) | | `{iban(CC)}` | generator | a length- and mod-97-valid IBAN for country `CC` (BE, DE, DK, ES, FI, NO, SE) |
| `{seq()}`, `{seq(name)}` | session counter | next integer (from 1) in this faker's sequence; `name` selects an independent counter | | `{seq()}`, `{seq(name)}` | session counter | next integer (from 1) in this faker's sequence; `name` selects an independent counter |
| `{calc(expr)}`, `{calc(expr,dp)}` | computation | value of an arithmetic expression over number literals and sibling fields; `dp` rounds |
`{ean()}` is also the ISBN-13 check (an ISBN-13 *is* an EAN-13 — build the 978/979 `{ean()}` is also the ISBN-13 check (an ISBN-13 *is* an EAN-13 — build the 978/979
prefix in data and call `{ean()}`). `{iban()}` is a generator, not a derivation: prefix in data and call `{ean()}`). `{iban()}` is a generator, not a derivation:
@@ -275,6 +277,23 @@ length and checksum, not real bank routing).
and resets when you build a new faker — `seq` is reproducible by being ordered, and resets when you build a new faker — `seq` is reproducible by being ordered,
not random. It's the natural fit for a primary-key column in the SQL example above. not random. It's the natural fit for a primary-key column in the SQL example above.
**Computation.** `{calc(expr)}` evaluates an arithmetic expression — `+ - * /`,
parentheses and unary minus, the usual precedence — and emits the result. Operands
are number literals and **sibling field names**, each rendered then read as a
number; an optional second arg rounds to that many decimals (`{calc(net * qty, 2)}`),
otherwise the value prints in minimal form. A hyphen is always subtraction, so a
hyphenated field name can't be an operand. The expression is checked at `New`
(parse, and that every name is a real field):
```json
{ "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99"], "qty": ["3"] }
```
renders `19.99 x 3 = 59.97`. Each name is rendered where it appears, so a field
shown *and* used in a `calc` is drawn twice — keep a shared operand in a
single-value field if the two must agree. A field that doesn't render to a number
yields `NaN`, which prints rather than failing the render.
**References.** A `{..path}` token renders a node from the **data root** instead **References.** A `{..path}` token renders a node from the **data root** instead
of a sibling field — the dot path is the one `Fake` takes, resolved across every of a sibling field — the dot path is the one `Fake` takes, resolved across every
loaded directory. One category can borrow another, even across folders or layered loaded directory. One category can borrow another, even across folders or layered
@@ -380,6 +399,7 @@ docker compose run --rm test # latest
fakes.go Fakes, New, options, seeding fakes.go Fakes, New, options, seeding
template.go Fake, the recursive renderer (choices, format strings, paths) template.go Fake, the recursive renderer (choices, format strings, paths)
builtins.go the {name()} function registry and its implementations builtins.go the {name()} function registry and its implementations
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
data.go data loading: folders/files -> namespace tree, multi-path merge data.go data loading: folders/files -> namespace tree, multi-path merge
cmd/fakes/ the `fakes` CLI (New + Fake over stdout) cmd/fakes/ the `fakes` CLI (New + Fake over stdout)
data/ shipped data (JSON): locale folders + a misc folder data/ shipped data (JSON): locale folders + a misc folder
+236
View File
@@ -0,0 +1,236 @@
package fakes
import (
"fmt"
"math"
"strconv"
"strings"
"unicode"
)
// 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. Unlike a builtin it reads the field
// environment, so expand and checkTokens route it here rather than the builtins
// registry. The value prints in minimal decimal form, or rounded to dp decimals
// 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.
// calcNode is a parsed expression node.
type calcNode interface {
eval(s *session, fields map[string]node) float64
}
type calcNum float64 // a number literal
type calcVar string // a sibling-field name
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 calcVar) eval(s *session, fields map[string]node) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(render(s, fields[string(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) }
func (n calcBin) eval(s *session, fields map[string]node) float64 {
l, r := n.l.eval(s, fields), n.r.eval(s, fields)
switch n.op {
case '+':
return l + r
case '-':
return l - r
case '*':
return l * r
default: // '/'
return l / r
}
}
// checkCalc validates a calc token at compile time: a parseable expression whose
// operands all name existing fields, and an optional non-negative integer dp.
func checkCalc(args []string, fields map[string]node) error {
if len(args) < 1 || len(args) > 2 {
return fmt.Errorf("calc takes an expression and an optional decimals count, got %d args", len(args))
}
expr, err := parseCalc(args[0])
if err != nil {
return fmt.Errorf("calc(%q): %w", args[0], err)
}
for _, name := range calcVars(expr) {
if _, ok := fields[name]; !ok {
return fmt.Errorf("calc(%q): no field %q", args[0], name)
}
}
if len(args) == 2 {
if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 {
return fmt.Errorf("calc decimals %q must be a non-negative integer", args[1])
}
}
return nil
}
// calcEval renders a calc token. checkCalc proved the expression parses and the
// decimals arg is valid, so neither step here can fail. dp -1 prints the minimal
// form; a given dp rounds to that many places.
func calcEval(s *session, args []string, fields map[string]node) string {
expr, _ := parseCalc(args[0])
dp := -1
if len(args) == 2 {
dp = atoi(args[1])
}
return strconv.FormatFloat(expr.eval(s, fields), 'f', dp, 64)
}
// calcVars lists the field names an expression references, for the existence
// check in checkCalc.
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
}
}
// calcParser is a recursive-descent parser over the expression runes, threading
// expr -> term -> factor for the standard * / before + - precedence.
type calcParser struct {
rs []rune
pos int
}
// parseCalc parses a whole expression, requiring it to consume all input.
func parseCalc(expr string) (calcNode, error) {
p := &calcParser{rs: []rune(expr)}
if p.space(); p.pos >= len(p.rs) {
return nil, fmt.Errorf("empty expression")
}
n, err := p.expr()
if err != nil {
return nil, err
}
if p.space(); p.pos != len(p.rs) {
return nil, fmt.Errorf("unexpected %q", string(p.rs[p.pos:]))
}
return n, nil
}
func (p *calcParser) space() {
for p.pos < len(p.rs) && unicode.IsSpace(p.rs[p.pos]) {
p.pos++
}
}
func (p *calcParser) expr() (calcNode, error) { return p.binary(p.term, '+', '-') }
func (p *calcParser) term() (calcNode, error) { return p.binary(p.factor, '*', '/') }
// binary parses a left-associative run of next() operands joined by the given
// operators, the one shape expr and term share.
func (p *calcParser) binary(next func() (calcNode, error), ops ...byte) (calcNode, error) {
n, err := next()
if err != nil {
return nil, err
}
for {
p.space()
if p.pos >= len(p.rs) || !contains(ops, byte(p.rs[p.pos])) {
return n, nil
}
op := byte(p.rs[p.pos])
p.pos++
r, err := next()
if err != nil {
return nil, err
}
n = calcBin{op, n, r}
}
}
func (p *calcParser) factor() (calcNode, error) {
p.space()
if p.pos >= len(p.rs) {
return nil, fmt.Errorf("unexpected end of expression")
}
switch c := p.rs[p.pos]; {
case c == '-':
p.pos++
x, err := p.factor()
if err != nil {
return nil, err
}
return calcNeg{x}, nil
case c == '(':
p.pos++
n, err := p.expr()
if err != nil {
return nil, err
}
if p.space(); p.pos >= len(p.rs) || p.rs[p.pos] != ')' {
return nil, fmt.Errorf("missing ')'")
}
p.pos++
return n, nil
case c == '.' || c >= '0' && c <= '9':
return p.number()
case c == '_' || unicode.IsLetter(c):
return p.ident()
default:
return nil, fmt.Errorf("unexpected %q", string(c))
}
}
func (p *calcParser) number() (calcNode, error) {
start, dot := p.pos, false
for p.pos < len(p.rs) {
if c := p.rs[p.pos]; c >= '0' && c <= '9' {
p.pos++
} else if c == '.' && !dot {
dot, p.pos = true, p.pos+1
} else {
break
}
}
v, err := strconv.ParseFloat(string(p.rs[start:p.pos]), 64)
if err != nil {
return nil, fmt.Errorf("bad number %q", string(p.rs[start:p.pos]))
}
return calcNum(v), nil
}
// ident reads a field name: a letter or '_', then letters, digits or '_'. A '-'
// is always the minus operator, so a hyphenated field name can't be an operand.
func (p *calcParser) ident() (calcNode, error) {
start := p.pos
for p.pos < len(p.rs) {
if c := p.rs[p.pos]; c == '_' || unicode.IsLetter(c) || unicode.IsDigit(c) {
p.pos++
} else {
break
}
}
return calcVar(string(p.rs[start:p.pos])), nil
}
func contains(bs []byte, b byte) bool {
for _, x := range bs {
if x == b {
return true
}
}
return false
}
+84
View File
@@ -0,0 +1,84 @@
package fakes
import "testing"
// TestCalcArithmetic pins the operators, precedence, parentheses and unary minus
// over number literals.
func TestCalcArithmetic(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"format":"{calc(2 + 3)}"}`: "5",
`{"format":"{calc(2 * 3 + 4)}"}`: "10", // * binds tighter than +
`{"format":"{calc(2 + 3 * 4)}"}`: "14",
`{"format":"{calc((2 + 3) * 4)}"}`: "20", // parentheses override
`{"format":"{calc(10 / 4)}"}`: "2.5",
`{"format":"{calc(-2 + 5)}"}`: "3", // unary minus
`{"format":"{calc(2 - -3)}"}`: "5",
`{"format":"{calc(1.5 * 2)}"}`: "3", // whole result drops the decimals
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
t.Errorf("%s = %q, want %q", tmpl, got, want)
}
}
}
// TestCalcAuto pins the default (no-dp) rendering: minimal decimal form, no
// scientific notation, whole numbers without a fraction.
func TestCalcAuto(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"format":"{calc(10 / 3)}"}`: "3.3333333333333335",
`{"format":"{calc(6 / 2)}"}`: "3",
`{"format":"{calc(1 / 4)}"}`: "0.25",
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
t.Errorf("%s = %q, want %q", tmpl, got, want)
}
}
}
// TestCalcDecimals pins the optional decimals arg: rounds to dp places, dp 0
// drops the fraction.
func TestCalcDecimals(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"format":"{calc(10 / 3, 2)}"}`: "3.33",
`{"format":"{calc(10 / 3, 0)}"}`: "3",
`{"format":"{calc(2 * 3, 2)}"}`: "6.00",
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
t.Errorf("%s = %q, want %q", tmpl, got, want)
}
}
}
// TestCalcFields pins that bare names resolve to sibling fields, rendered then
// parsed as numbers.
func TestCalcFields(t *testing.T) {
if got := mustRender(t, engine(1), `{"format":"{calc(price * qty, 2)}","price":["19.99"],"qty":["3"]}`); got != "59.97" {
t.Fatalf("calc over fields = %q, want 59.97", got)
}
// A field that is itself a template renders before parsing.
if got := mustRender(t, engine(1), `{"format":"{calc(a - b)}","a":[{"format":"{n}","n":["10"]}],"b":["3"]}`); got != "7" {
t.Fatalf("calc(a - b) = %q, want 7", got)
}
}
// TestCalcNonNumericIsNaN pins the never-fail rule: a field that doesn't render
// to a number becomes NaN, which propagates and prints visibly.
func TestCalcNonNumericIsNaN(t *testing.T) {
if got := mustRender(t, engine(1), `{"format":"{calc(x * 2)}","x":["abc"]}`); got != "NaN" {
t.Fatalf("calc over non-numeric field = %q, want NaN", got)
}
}
// TestCalcReproducible pins that a calc over a random operand stays seed-stable.
func TestCalcReproducible(t *testing.T) {
tmpl := `{"format":"{calc(q * 2 + 1)}","q":[{"format":"{int(1,1000000)}"}]}`
if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b {
t.Fatalf("calc not reproducible: %q != %q", a, b)
}
}
+9 -1
View File
@@ -163,7 +163,11 @@ func expand(s *session, format string, fields map[string]node) string {
} }
body := string(rs[i+1 : end]) body := string(rs[i+1 : end])
if name, args, ok := funcCall(body); ok { if name, args, ok := funcCall(body); ok {
if name == "calc" { // calc reads sibling fields, not just (rng, emitted)
b.WriteString(calcEval(s, args, fields))
} else {
b.WriteString(builtins[name].call(s, b.String(), args)) // b.String() is the output so far b.WriteString(builtins[name].call(s, b.String(), args)) // b.String() is the output so far
}
} else { } else {
b.WriteString(resolve(s, body, fields)) b.WriteString(resolve(s, body, fields))
} }
@@ -342,7 +346,11 @@ func checkTokens(format string, fields map[string]node) error {
} }
body := string(rs[i+1 : end]) body := string(rs[i+1 : end])
if strings.IndexByte(body, '(') >= 0 { // a function token, not a field if strings.IndexByte(body, '(') >= 0 { // a function token, not a field
if err := checkFunc(body); err != nil { if name, args, ok := funcCall(body); ok && name == "calc" {
if err := checkCalc(args, fields); err != nil {
return fmt.Errorf("token {%s}: %w", body, err)
}
} else if err := checkFunc(body); err != nil {
return err return err
} }
} else { } else {
+8
View File
@@ -193,6 +193,14 @@ func TestCompileErrors(t *testing.T) {
`{"format":"{float(1,2,-1)}"}`, // negative decimals `{"format":"{float(1,2,-1)}"}`, // negative decimals
`{"format":"{iban(US)}"}`, // unsupported country `{"format":"{iban(US)}"}`, // unsupported country
`{"format":"{seq(a,b)}"}`, // seq takes at most one name `{"format":"{seq(a,b)}"}`, // seq takes at most one name
`{"format":"{calc()}"}`, // calc needs an expression
`{"format":"{calc(1 +)}"}`, // dangling operator
`{"format":"{calc((1 + 2)}"}`, // unbalanced parenthesis
`{"format":"{calc(1 2)}"}`, // two operands, no operator
`{"format":"{calc(price)}"}`, // operand names no field
`{"format":"{calc(1, 2, 3)}"}`, // too many args
`{"format":"{calc(1, x)}"}`, // decimals arg not an integer
`{"format":"{calc(1, -1)}"}`, // decimals negative
} { } {
if _, err := compile(parse(t, bad)); err == nil { if _, err := compile(parse(t, bad)); err == nil {
t.Errorf("compile(%s) = nil error, want error", bad) t.Errorf("compile(%s) = nil error, want error", bad)