diff --git a/.gitignore b/.gitignore index c3e7912..bb3531d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ # Coverage output *.out +todo.md \ No newline at end of file diff --git a/README.md b/README.md index d3c6f34..cc89e05 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,14 @@ lacked the locale coverage and format control we needed. tags (`sv_SE`, never `sv`). - **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 - 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 classes and sub-templates combine to model any format. - **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. ## 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 `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: | Token | Expands to | @@ -192,6 +219,7 @@ This yields e.g. `bar foo baz`. `repeat` must be a positive integer and | `a` | letter a–z | | `#` | escape — the next char is literal (`#0` → `0`, `##` → `#`) | | `{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. diff --git a/data_test.go b/data_test.go index cdd38c0..0ef735d 100644 --- a/data_test.go +++ b/data_test.go @@ -2,7 +2,9 @@ package fakes import ( "regexp" + "strings" "testing" + "time" ) // 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 +} diff --git a/locales/sv_SE/ssn.json b/locales/sv_SE/ssn.json index 7329e21..3dbc854 100644 --- a/locales/sv_SE/ssn.json +++ b/locales/sv_SE/ssn.json @@ -1,8 +1,25 @@ [ { - "format": "{yy}{month}{day}-0000", - "yy": [{ "format": "00" }], - "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": "00{mmdd}-000{luhn()}", + "mmdd": [ + { + "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"] + } + ] } ] diff --git a/template.go b/template.go index 074d483..949edb5 100644 --- a/template.go +++ b/template.go @@ -122,9 +122,10 @@ func pick(r rng, c *choice) node { // 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 -// literal ("#0" -> "0", "##" -> "#"). "{token}" is substituted via resolve; -// every other rune is literal. checkTokens validated the braces and tokens at -// compile time, so this scan cannot fail. +// literal ("#0" -> "0", "##" -> "#"). A "{name(args)}" token calls a builtin; +// any other "{token}" is substituted via resolve; every other rune is literal. +// 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 { var b strings.Builder 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 '}' 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 default: 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))]]) } +// 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. func compile(v any) (node, error) { switch v := v.(type) { @@ -262,9 +349,15 @@ func checkTokens(format string, fields map[string]node) error { return fmt.Errorf("unterminated '{' in %q", format) } body := string(rs[i+1 : end]) - for _, name := range strings.Split(body, "|") { - if _, ok := fields[name]; !ok { - return fmt.Errorf("token {%s}: no field %q", body, name) + 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, "|") { + if _, ok := fields[name]; !ok { + return fmt.Errorf("token {%s}: no field %q", body, name) + } } } i = end diff --git a/template_test.go b/template_test.go index 202074b..5f754c0 100644 --- a/template_test.go +++ b/template_test.go @@ -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) { // Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep. tmpl := `"deep"` @@ -162,6 +180,9 @@ func TestCompileErrors(t *testing.T) { `{"format":"x","repeat":1.5}`, // non-integer repeat `{"format":"x","repeat":"two"}`, // non-numeric repeat `{"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 { t.Errorf("compile(%s) = nil error, want error", bad)