diff --git a/README.md b/README.md index 0bf7ba1..0198688 100644 --- a/README.md +++ b/README.md @@ -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 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 -**session counter** (`seq`) advances state held on the faker. Arguments are -validated at `New` (a bad count, range, or country fails fast). +**session counter** (`seq`) advances state held on the faker; and one +**computation** (`calc`) evaluates arithmetic over sibling fields. Arguments are +validated at `New` (a bad count, range, country, or expression fails fast). | 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 | | `{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 | +| `{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 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, 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 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 @@ -380,6 +399,7 @@ docker compose run --rm test # latest fakes.go Fakes, New, options, seeding template.go Fake, the recursive renderer (choices, format strings, paths) 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 cmd/fakes/ the `fakes` CLI (New + Fake over stdout) data/ shipped data (JSON): locale folders + a misc folder diff --git a/calc.go b/calc.go new file mode 100644 index 0000000..3a115e3 --- /dev/null +++ b/calc.go @@ -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 +} diff --git a/calc_test.go b/calc_test.go new file mode 100644 index 0000000..f62d48e --- /dev/null +++ b/calc_test.go @@ -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) + } +} diff --git a/template.go b/template.go index ed608b1..fa59c96 100644 --- a/template.go +++ b/template.go @@ -163,7 +163,11 @@ func expand(s *session, format string, fields map[string]node) string { } body := string(rs[i+1 : end]) if name, args, ok := funcCall(body); ok { - b.WriteString(builtins[name].call(s, b.String(), args)) // b.String() is the output so far + 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 + } } else { 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]) 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 } } else { diff --git a/template_test.go b/template_test.go index 159cae3..f356af3 100644 --- a/template_test.go +++ b/template_test.go @@ -193,6 +193,14 @@ func TestCompileErrors(t *testing.T) { `{"format":"{float(1,2,-1)}"}`, // negative decimals `{"format":"{iban(US)}"}`, // unsupported country `{"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 { t.Errorf("compile(%s) = nil error, want error", bad)