This commit is contained in:
lilleman
2026-06-08 23:35:51 +02:00
parent a765230d3b
commit acf7571334
6 changed files with 228 additions and 12 deletions
+1
View File
@@ -1,2 +1,3 @@
# Coverage output # Coverage output
*.out *.out
todo.md
+29 -1
View File
@@ -11,10 +11,14 @@ lacked the locale coverage and format control we needed.
tags (`sv_SE`, never `sv`). tags (`sv_SE`, never `sv`).
- **Data lives in JSON** — all source data is recursive JSON on disk, read when - **Data lives in JSON** — all source data is recursive JSON on disk, read when
you create a faker and then served from memory. Add or change locales without you create a faker and then served from memory. Add or change locales without
touching the library. touching the library. Behavior belongs in data too: the engine grows a
built-in function only for what data can't express (a checksum, a time-based
id), never for what character classes and choices already do.
- **Composable** — templates nest without limit: weighted choices, character - **Composable** — templates nest without limit: weighted choices, character
classes and sub-templates combine to model any format. classes and sub-templates combine to model any format.
- **Reproducible** — seed a faker and it emits the same sequence every time. - **Reproducible** — seed a faker and it emits the same sequence every time.
Every built-in draws only from that seed — no wall-clock, no `crypto/rand`
so determinism holds end to end.
- **Zero dependencies** — standard library only. - **Zero dependencies** — standard library only.
## CLI ## CLI
@@ -182,6 +186,29 @@ fast instead of silently skewing output.
This yields e.g. `bar foo baz`. `repeat` must be a positive integer and This yields e.g. `bar foo baz`. `repeat` must be a positive integer and
`separator` a string, both checked at `New`. `separator` a string, both checked at `New`.
**Functions.** A `{name()}` token calls a built-in function instead of rendering
a field. `{luhn()}` appends a Luhn check digit over the digits emitted **so far**
in the current format (non-digits skipped but kept); unknown functions or wrong
argument counts are rejected at `New`. This is what makes a generated Swedish
personnummer valid — its last digit is a Luhn checksum over the nine before it:
```json
{ "format": "00{mmdd}-000{luhn()}", "mmdd": [ ] }
```
renders e.g. `811218-987`, then `{luhn()}` appends `6``811218-9876`. Place it
after its payload (it reads what is to its left). The buffer it reads is
per-expansion, so nesting keeps fixed parts out of the sum — e.g. a 12-digit
form prefixes the century outside the checksummed core:
```json
{ "format": "{century}{core}", "century": ["19", "20"],
"core": { "format": "00{mmdd}-000{luhn()}", "mmdd": [ ] } }
```
A function must be deterministic in the seeded rng (no wall-clock), so a seeded
faker stays reproducible.
**Format string.** Every character is literal except: **Format string.** Every character is literal except:
| Token | Expands to | | Token | Expands to |
@@ -192,6 +219,7 @@ This yields e.g. `bar foo baz`. `repeat` must be a positive integer and
| `a` | letter az | | `a` | letter az |
| `#` | escape — the next char is literal (`#0``0`, `##``#`) | | `#` | escape — the next char is literal (`#0``0`, `##``#`) |
| `{name}` | render the sibling field `name` | | `{name}` | render the sibling field `name` |
| `{name()}` | call a built-in function (see **Functions**) |
`{a|b}` renders one of the sibling fields `a` or `b`, chosen at random. `{a|b}` renders one of the sibling fields `a` or `b`, chosen at random.
+56
View File
@@ -2,7 +2,9 @@ package fakes
import ( import (
"regexp" "regexp"
"strings"
"testing" "testing"
"time"
) )
// TestShippedDataCategories asserts every shipped locale emits only well-formed // TestShippedDataCategories asserts every shipped locale emits only well-formed
@@ -63,3 +65,57 @@ func TestShippedDataCategories(t *testing.T) {
} }
} }
} }
// TestSwedishPersonnummer checks the two rules the shape regex can't: the date
// is a real calendar date (so month-length variants never emit e.g. Apr 31 or
// Feb 30) and the trailing digit is a valid Luhn checksum over the other nine.
func TestSwedishPersonnummer(t *testing.T) {
sv := newFakes(t, "locales/sv_SE", WithSeed(1))
sawLongMonthEnd := false
for i := 0; i < 2000; i++ {
v := fake(t, sv, "ssn")
d := digitsOnly(v)
if len(d) != 10 {
t.Fatalf("ssn %q has %d digits, want 10", v, len(d))
}
if _, err := time.Parse("060102", d[:6]); err != nil { // 2-digit year, real-date check
t.Fatalf("ssn %q is not a valid calendar date: %v", v, err)
}
if !luhnValid(d) {
t.Fatalf("ssn %q fails the Luhn check", v)
}
if d[4:6] == "31" {
sawLongMonthEnd = true
}
}
if !sawLongMonthEnd {
t.Fatal("never generated a 31st — 31-day months are not reaching their last day")
}
}
func digitsOnly(s string) string {
var b strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
// luhnValid verifies a full number (payload + trailing check digit). It doubles
// from the second-from-right, independent of the generator's own Luhn code.
func luhnValid(s string) bool {
sum, double := 0, false
for i := len(s) - 1; i >= 0; i-- {
n := int(s[i] - '0')
if double {
if n *= 2; n > 9 {
n -= 9
}
}
double = !double
sum += n
}
return sum%10 == 0
}
+21 -4
View File
@@ -1,8 +1,25 @@
[ [
{ {
"format": "{yy}{month}{day}-0000", "format": "00{mmdd}-000{luhn()}",
"yy": [{ "format": "00" }], "mmdd": [
"month": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"], {
"day": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28"] "format": "{m}{d}",
"weight": 7,
"m": ["01", "03", "05", "07", "08", "10", "12"],
"d": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31"]
},
{
"format": "{m}{d}",
"weight": 4,
"m": ["04", "06", "09", "11"],
"d": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]
},
{
"format": "{m}{d}",
"weight": 1,
"m": ["02"],
"d": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28"]
}
]
} }
] ]
+97 -4
View File
@@ -122,9 +122,10 @@ func pick(r rng, c *choice) node {
// expand renders a format string. Character classes: '0' digit 0-9, '1' digit // expand renders a format string. Character classes: '0' digit 0-9, '1' digit
// 1-9, 'A' letter A-Z, 'a' letter a-z. '#' escapes the next character to a // 1-9, 'A' letter A-Z, 'a' letter a-z. '#' escapes the next character to a
// literal ("#0" -> "0", "##" -> "#"). "{token}" is substituted via resolve; // literal ("#0" -> "0", "##" -> "#"). A "{name(args)}" token calls a builtin;
// every other rune is literal. checkTokens validated the braces and tokens at // any other "{token}" is substituted via resolve; every other rune is literal.
// compile time, so this scan cannot fail. // checkTokens validated the braces and tokens at compile time, so this scan
// cannot fail.
func expand(r rng, format string, fields map[string]node) string { func expand(r rng, format string, fields map[string]node) string {
var b strings.Builder var b strings.Builder
rs := []rune(format) rs := []rune(format)
@@ -149,7 +150,12 @@ func expand(r rng, format string, fields map[string]node) string {
for rs[end] != '}' { // checkTokens guarantees a closing '}' for rs[end] != '}' { // checkTokens guarantees a closing '}'
end++ end++
} }
b.WriteString(resolve(r, string(rs[i+1:end]), fields)) body := string(rs[i+1 : end])
if name, args, ok := funcCall(body); ok {
b.WriteString(builtins[name].call(r, b.String(), args)) // b.String() is the output so far
} else {
b.WriteString(resolve(r, body, fields))
}
i = end i = end
default: default:
b.WriteRune(c) b.WriteRune(c)
@@ -165,6 +171,87 @@ func resolve(r rng, token string, fields map[string]node) string {
return render(r, fields[names[r.IntN(len(names))]]) return render(r, fields[names[r.IntN(len(names))]])
} }
// builtin is a format-string function invoked as {name(args)}. It receives the
// rng, the output emitted so far in the current expansion (for derivations such
// as a checksum over preceding digits), and its args. A builtin must be pure
// over those inputs — no wall-clock, no crypto/rand — so seeding stays
// reproducible; a time-based id derives its time from the rng.
type builtin struct {
arity int
call func(r rng, emitted string, args []string) string
}
var builtins = map[string]builtin{
// luhn emits a Luhn check digit over the digits emitted so far. Put it after
// its payload (its input is everything to its left); prepend fixed parts,
// e.g. a century, in an enclosing template so they stay out of the sum.
"luhn": {0, func(_ rng, emitted string, _ []string) string {
return string(rune('0' + luhnCheck(emitted)))
}},
}
// funcCall splits a "{token}" body shaped name(args) into its parts; ok is false
// for a plain field or alternation body. A '(' without a trailing ')' yields
// ok=false; checkFunc reports it as malformed at compile time.
func funcCall(body string) (name string, args []string, ok bool) {
lp := strings.IndexByte(body, '(')
if lp < 0 || !strings.HasSuffix(body, ")") {
return "", nil, false
}
return body[:lp], splitArgs(body[lp+1 : len(body)-1]), true
}
// splitArgs parses a function arg list: comma-separated, trimmed; empty -> none.
func splitArgs(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
args := strings.Split(s, ",")
for i := range args {
args[i] = strings.TrimSpace(args[i])
}
return args
}
// checkFunc validates a function token at compile time: well-formed, naming a
// known builtin, with the arg count that builtin takes.
func checkFunc(body string) error {
name, args, ok := funcCall(body)
if !ok {
return fmt.Errorf("malformed function token {%s}", body)
}
b, known := builtins[name]
if !known {
return fmt.Errorf("token {%s}: unknown function %q", body, name)
}
if len(args) != b.arity {
return fmt.Errorf("token {%s}: %s takes %d args, got %d", body, name, b.arity, len(args))
}
return nil
}
// luhnCheck returns the Luhn check digit (0-9) over the digits of s; non-digit
// runes are skipped. Doubling runs from the rightmost digit, so the result is
// correct whatever the payload length.
func luhnCheck(s string) int {
sum, double := 0, true
for i := len(s) - 1; i >= 0; i-- {
c := s[i]
if c < '0' || c > '9' {
continue
}
d := int(c - '0')
if double {
if d *= 2; d > 9 {
d -= 9
}
}
double = !double
sum += d
}
return (10 - sum%10) % 10
}
// compile converts parsed JSON into a node tree, validating structure up front. // compile converts parsed JSON into a node tree, validating structure up front.
func compile(v any) (node, error) { func compile(v any) (node, error) {
switch v := v.(type) { switch v := v.(type) {
@@ -262,11 +349,17 @@ func checkTokens(format string, fields map[string]node) error {
return fmt.Errorf("unterminated '{' in %q", format) return fmt.Errorf("unterminated '{' in %q", format)
} }
body := string(rs[i+1 : end]) body := string(rs[i+1 : end])
if strings.IndexByte(body, '(') >= 0 { // a function token, not a field
if err := checkFunc(body); err != nil {
return err
}
} else {
for _, name := range strings.Split(body, "|") { for _, name := range strings.Split(body, "|") {
if _, ok := fields[name]; !ok { if _, ok := fields[name]; !ok {
return fmt.Errorf("token {%s}: no field %q", body, name) return fmt.Errorf("token {%s}: no field %q", body, name)
} }
} }
}
i = end i = end
} }
} }
+21
View File
@@ -129,6 +129,24 @@ func TestRepeatRendersFormatNTimes(t *testing.T) {
} }
} }
func TestFunctionTokenLuhn(t *testing.T) {
// {luhn()} appends a Luhn check digit over the digits emitted so far in the
// current expansion (non-digits skipped but kept). It reads the output
// buffer, so a value is never re-rendered. Bodies are escaped to fix input.
f := engine(1)
cases := map[string]string{
`{"format":"#8#1#1#2#1#8#9#8#7{luhn()}"}`: "8112189876", // personnummer body
`{"format":"#7#9#9#2#7#3#9#8#7#1{luhn()}"}`: "79927398713", // classic Luhn vector
`{"format":"#8#1#1#2#1#8-#9#8#7{luhn()}"}`: "811218-9876", // '-' skipped, kept
`{"format":"{n}{luhn()}","n":["811218987"]}`: "8112189876", // over a rendered token
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
t.Fatalf("%s = %q, want %q", tmpl, got, want)
}
}
}
func TestRecursionHasNoDepthLimit(t *testing.T) { func TestRecursionHasNoDepthLimit(t *testing.T) {
// Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep. // Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep.
tmpl := `"deep"` tmpl := `"deep"`
@@ -162,6 +180,9 @@ func TestCompileErrors(t *testing.T) {
`{"format":"x","repeat":1.5}`, // non-integer repeat `{"format":"x","repeat":1.5}`, // non-integer repeat
`{"format":"x","repeat":"two"}`, // non-numeric repeat `{"format":"x","repeat":"two"}`, // non-numeric repeat
`{"format":"x","repeat":2,"separator":5}`, // non-string separator `{"format":"x","repeat":2,"separator":5}`, // non-string separator
`{"format":"{nope()}"}`, // unknown function
`{"format":"{luhn(x)}"}`, // function given args it takes none of
`{"format":"{luhn(}"}`, // malformed function token
} { } {
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)