From ae41e6bb972f52653bd7b427bdbc65e04bec83e0 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 9 Jun 2026 01:25:41 +0200 Subject: [PATCH] Add ID/checksum/numeric builtins and a misc data folder (uuid v4, mac, creditcard) --- README.md | 44 ++++++- builtins.go | 242 ++++++++++++++++++++++++++++++++++++++ builtins_test.go | 132 +++++++++++++++++++++ data/misc/creditcard.json | 5 + data/misc/mac.json | 6 + data/misc/uuid.json | 7 ++ data_test.go | 20 ++++ template.go | 39 ++---- template_test.go | 9 ++ 9 files changed, 468 insertions(+), 36 deletions(-) create mode 100644 builtins.go create mode 100644 builtins_test.go create mode 100644 data/misc/creditcard.json create mode 100644 data/misc/mac.json create mode 100644 data/misc/uuid.json diff --git a/README.md b/README.md index ac52840..2e9fbff 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,10 @@ A `*Fakes` is **not** safe for concurrent use — create one per goroutine. ## Data -The library ships a ready-to-use set under [`data/`](data), organised by locale -(`en_US`, `sv_SE`). Point either tool at the whole tree, a single locale, a -copy, or your own directory — anywhere on disk; no naming rules. +The library ships a ready-to-use set under [`data/`](data): one folder per locale +(`en_US`, `sv_SE`) plus a locale-neutral `misc` folder. Point either tool at the +whole tree, a single folder, a copy, or your own directory — anywhere on disk; no +naming rules. A directory is just a namespace. Each JSON file is a category named after the file; each subdirectory is a dot-path segment — folders nest exactly like JSON @@ -163,6 +164,11 @@ Swedish personnummer): `address`, `color`, `company`, `date`, `email`, `ip`, `person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`, `uuid`, `version`, `word`. +`data/misc` carries locale-neutral categories: `uuid` (a proper random v4), +`mac`, and `creditcard` (per-network numbers ending in a valid `{luhn()}` digit). +A time-ordered v7 UUID can't be expressed as data, so it's the `{uuid()}` builtin +instead (see **Functions**). + ## Data format Each JSON file in a data directory is a **category** named after the file @@ -225,7 +231,34 @@ form prefixes the century outside the checksummed core: ``` A function must be deterministic in the seeded rng (no wall-clock), so a seeded -faker stays reproducible. +faker stays 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 two kinds. **Derivations** read the digits emitted so far, so put them +after their payload; **generators** read only the rng, so they stand alone. +Arguments are validated at `New` (a bad count, range, or country fails fast). + +| Function | Kind | Emits | +|----------|------|-------| +| `{luhn()}` | derivation | Luhn check digit (mod-10) over preceding digits | +| `{mod11()}` | derivation | weighted mod-11 check char (weights 2–7 from the right); `X` when it would be 10 | +| `{ean()}` | derivation | EAN-13 / UPC-A / ISBN-13 / GTIN check digit | +| `{uuid()}` | generator | UUID v7 (v4 ships as data — see [Data](#data)) | +| `{ulid()}` | generator | ULID, 26-char Crockford base32 | +| `{objectid()}` | generator | MongoDB ObjectID, 24 hex chars | +| `{nanoid(n)}` | generator | URL-safe Nano ID, `n` chars | +| `{hex(n)}` | generator | `n` lowercase hex digits | +| `{base64(n)}` | generator | `n` random bytes, base64 | +| `{int(min,max)}` | generator | uniform integer in `[min, max]` | +| `{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) | + +`{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: +an IBAN's check digits sit *before* the account number, which a left-to-right +reader can't reach, so it emits the whole value (a generic numeric BBAN — valid +length and checksum, not real bank routing). **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 @@ -331,9 +364,10 @@ 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 data.go data loading: folders/files -> namespace tree, multi-path merge cmd/fakes/ the `fakes` CLI (New + Fake over stdout) -data/ shipped data (JSON), organised by locale +data/ shipped data (JSON): locale folders + a misc folder ``` To add a category, drop a JSON file into a data directory; to add a locale, add diff --git a/builtins.go b/builtins.go new file mode 100644 index 0000000..006dcbc --- /dev/null +++ b/builtins.go @@ -0,0 +1,242 @@ +package fakes + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" +) + +// builtins is the registry of {name(args)} functions. Two kinds: derivations read +// the digits emitted so far in the current expansion (luhn, mod11, ean — place +// them after their payload); generators read only the rng (uuid, ulid, ...). All +// must stay pure over (rng, emitted, args) so a seeded faker is reproducible — a +// time-based id (uuid v7, ulid, objectid) draws its timestamp from the rng, not +// the wall clock. Add a builtin only for what data can't express: a random v4 +// UUID already ships as data (data/misc/uuid.json), so the builtin is v7. +var builtins = map[string]builtin{ + "luhn": {arity: 0, call: func(_ rng, e string, _ []string) string { return string(rune('0' + luhnCheck(e))) }}, + "mod11": {arity: 0, call: func(_ rng, e string, _ []string) string { return mod11Check(e) }}, + "ean": {arity: 0, call: func(_ rng, e string, _ []string) string { return eanCheck(e) }}, + "uuid": {arity: 0, call: func(r rng, _ string, _ []string) string { return uuidV7(r) }}, + "ulid": {arity: 0, call: func(r rng, _ string, _ []string) string { return ulid(r) }}, + "objectid": {arity: 0, call: func(r rng, _ string, _ []string) string { return randHex(r, 24) }}, + "nanoid": {arity: 1, check: posIntArg, call: func(r rng, _ string, a []string) string { return nanoid(r, atoi(a[0])) }}, + "hex": {arity: 1, check: posIntArg, call: func(r rng, _ string, a []string) string { return randHex(r, atoi(a[0])) }}, + "base64": {arity: 1, check: posIntArg, call: func(r rng, _ string, a []string) string { + return base64.StdEncoding.EncodeToString(randBytes(r, atoi(a[0]))) + }}, + "int": {arity: 2, check: intRangeArgs, call: func(r rng, _ string, a []string) string { + return strconv.Itoa(atoi(a[0]) + r.IntN(atoi(a[1])-atoi(a[0])+1)) + }}, + "float": {arity: 3, check: floatArgs, call: floatCall}, + "iban": {arity: 1, check: ibanArg, call: func(r rng, _ string, a []string) string { return iban(r, a[0]) }}, +} + +const hexDigits = "0123456789abcdef" + +// atoi parses an arg already validated by a builtin's check, so it cannot fail. +func atoi(s string) int { n, _ := strconv.Atoi(s); return n } + +func randBytes(r rng, n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(r.IntN(256)) + } + return b +} + +func randHex(r rng, n int) string { + b := make([]byte, n) + for i := range b { + b[i] = hexDigits[r.IntN(16)] + } + return string(b) +} + +func posIntArg(a []string) error { + if n, err := strconv.Atoi(a[0]); err != nil || n < 1 { + return fmt.Errorf("count %q must be a positive integer", a[0]) + } + return nil +} + +func intRangeArgs(a []string) error { + lo, e1 := strconv.Atoi(a[0]) + hi, e2 := strconv.Atoi(a[1]) + if e1 != nil || e2 != nil { + return fmt.Errorf("int(min,max) needs integer args, got %q,%q", a[0], a[1]) + } + if lo > hi { + return fmt.Errorf("int(min,max): min %d > max %d", lo, hi) + } + return nil +} + +func floatArgs(a []string) error { + lo, e1 := strconv.ParseFloat(a[0], 64) + hi, e2 := strconv.ParseFloat(a[1], 64) + dp, e3 := strconv.Atoi(a[2]) + if e1 != nil || e2 != nil || e3 != nil { + return fmt.Errorf("float(min,max,dp) needs numeric args, got %q,%q,%q", a[0], a[1], a[2]) + } + if lo > hi { + return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi) + } + if dp < 0 { + return fmt.Errorf("float(min,max,dp): decimals %d < 0", dp) + } + return nil +} + +func floatCall(r rng, _ string, a []string) string { + lo, _ := strconv.ParseFloat(a[0], 64) + hi, _ := strconv.ParseFloat(a[1], 64) + return strconv.FormatFloat(lo+r.Float64()*(hi-lo), 'f', atoi(a[2]), 64) +} + +// nanoidAlphabet is the 64-char URL-safe set Nano IDs use (order is irrelevant +// to the uniform pick). +const nanoidAlphabet = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func nanoid(r rng, n int) string { + b := make([]byte, n) + for i := range b { + b[i] = nanoidAlphabet[r.IntN(len(nanoidAlphabet))] + } + return string(b) +} + +// uuidV7 builds an RFC 9562 v7 UUID. The 48-bit timestamp field is drawn from +// the rng (not the clock) to stay reproducible, then the version (7) and variant +// (10) bits are forced; the rest is random. +func uuidV7(r rng) string { + b := randBytes(r, 16) + b[6] = b[6]&0x0f | 0x70 + b[8] = b[8]&0x3f | 0x80 + var sb strings.Builder + for i, x := range b { + if i == 4 || i == 6 || i == 8 || i == 10 { + sb.WriteByte('-') + } + sb.WriteByte(hexDigits[x>>4]) + sb.WriteByte(hexDigits[x&0x0f]) + } + return sb.String() +} + +// crockford is the ULID/Crockford base32 alphabet (no I, L, O, U). +const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// ulid builds a 26-char ULID: 128 random bits (timestamp from the rng) encoded +// big-endian into base32, the 130-bit stream left-padded with two zero bits. +func ulid(r rng) string { + b := randBytes(r, 16) + out := make([]byte, 26) + for i := range out { + v := 0 + for j := 0; j < 5; j++ { // 5 bits per char; the two leading pad bits are zero + real := 5*i + j - 2 + bit := 0 + if real >= 0 { + bit = int(b[real/8]>>(7-uint(real%8))) & 1 + } + v = v<<1 | bit + } + out[i] = crockford[v] + } + return string(out) +} + +// 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 +} + +// mod11Check returns the weighted mod-11 check character over the digits of s +// (weights 2..7 cycling from the right). A would-be value of 10 emits 'X', as in +// ISBN-10 / ISO 7064; non-digits are skipped. +func mod11Check(s string) string { + sum, w := 0, 2 + for i := len(s) - 1; i >= 0; i-- { + c := s[i] + if c < '0' || c > '9' { + continue + } + sum += int(c-'0') * w + if w++; w > 7 { + w = 2 + } + } + if chk := (11 - sum%11) % 11; chk != 10 { + return string(rune('0' + chk)) + } + return "X" +} + +// eanCheck returns the EAN-13 / UPC-A / ISBN-13 / GTIN check digit over the +// digits of s: weights 3 and 1 alternating from the rightmost digit, mod 10. +func eanCheck(s string) string { + sum, w := 0, 3 + for i := len(s) - 1; i >= 0; i-- { + c := s[i] + if c < '0' || c > '9' { + continue + } + sum += int(c-'0') * w + w = 4 - w // 3 <-> 1 + } + return string(rune('0' + (10-sum%10)%10)) +} + +// ibanLen maps a supported country code to the full IBAN length. The check digits +// sit between the country code and the BBAN, so — unlike luhn/ean — iban can't be +// a left-to-right derivation; it generates the whole value instead. +var ibanLen = map[string]int{"BE": 16, "DE": 22, "DK": 18, "ES": 24, "FI": 18, "NO": 15, "SE": 24} + +func ibanArg(a []string) error { + if _, ok := ibanLen[a[0]]; !ok { + return fmt.Errorf("iban(%q): unsupported country code", a[0]) + } + return nil +} + +// iban generates a structurally valid IBAN for cc: a numeric BBAN of the right +// length, then mod-97 check digits. Real bank/branch structure isn't modelled — +// the result passes length and checksum validation, which is what fake data needs. +func iban(r rng, cc string) string { + bban := make([]byte, ibanLen[cc]-4) + for i := range bban { + bban[i] = byte('0' + r.IntN(10)) + } + rem := 0 + feed := func(d int) { rem = (rem*10 + d) % 97 } + for _, c := range bban { + feed(int(c - '0')) + } + for i := 0; i < len(cc); i++ { // letters A-Z -> 10..35, fed as two digits + v := int(cc[i]-'A') + 10 + feed(v / 10) + feed(v % 10) + } + feed(0) + feed(0) + return fmt.Sprintf("%s%02d%s", cc, 98-rem, bban) +} diff --git a/builtins_test.go b/builtins_test.go new file mode 100644 index 0000000..969dbd8 --- /dev/null +++ b/builtins_test.go @@ -0,0 +1,132 @@ +package fakes + +import ( + "encoding/base64" + "regexp" + "strconv" + "testing" +) + +func TestBuiltinIDGenerators(t *testing.T) { + cases := []struct { + name string + tmpl string + re *regexp.Regexp + }{ + {"uuid v7", `{"format":"{uuid()}"}`, regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)}, + {"ulid", `{"format":"{ulid()}"}`, regexp.MustCompile(`^[0-7][0-9A-HJKMNP-TV-Z]{25}$`)}, + {"objectid", `{"format":"{objectid()}"}`, regexp.MustCompile(`^[0-9a-f]{24}$`)}, + {"nanoid", `{"format":"{nanoid(21)}"}`, regexp.MustCompile(`^[A-Za-z0-9_-]{21}$`)}, + {"hex", `{"format":"{hex(16)}"}`, regexp.MustCompile(`^[0-9a-f]{16}$`)}, + } + f := engine(1) + for _, c := range cases { + seen := map[string]bool{} + for i := 0; i < 200; i++ { + got := mustRender(t, f, c.tmpl) + if !c.re.MatchString(got) { + t.Fatalf("%s = %q, want %s", c.name, got, c.re) + } + seen[got] = true + } + if len(seen) < 190 { + t.Fatalf("%s not varied: %d uniques in 200", c.name, len(seen)) + } + } +} + +// TestBuiltinGeneratorsReproducible pins the determinism guardrail: every +// generator draws only from the seeded rng, so same seed -> same value. +func TestBuiltinGeneratorsReproducible(t *testing.T) { + for _, tmpl := range []string{ + `{"format":"{uuid()}"}`, `{"format":"{ulid()}"}`, `{"format":"{objectid()}"}`, + `{"format":"{nanoid(12)}"}`, `{"format":"{int(1,1000000)}"}`, + `{"format":"{float(0,1,6)}"}`, `{"format":"{base64(12)}"}`, `{"format":"{iban(SE)}"}`, + } { + if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b { + t.Fatalf("%s not reproducible: %q != %q", tmpl, a, b) + } + } +} + +func TestBuiltinIntInclusiveRange(t *testing.T) { + f := engine(1) + lo, hi := false, false + for i := 0; i < 1000; i++ { + n, err := strconv.Atoi(mustRender(t, f, `{"format":"{int(3,7)}"}`)) + if err != nil || n < 3 || n > 7 { + t.Fatalf("int(3,7) = %d (err %v), out of range", n, err) + } + lo, hi = lo || n == 3, hi || n == 7 + } + if !lo || !hi { + t.Fatalf("int(3,7) never hit a bound: lo=%v hi=%v (bounds must be inclusive)", lo, hi) + } +} + +func TestBuiltinFloat(t *testing.T) { + f, re := engine(1), regexp.MustCompile(`^[12]\.\d{3}$`) + for i := 0; i < 200; i++ { + if got := mustRender(t, f, `{"format":"{float(1,2,3)}"}`); !re.MatchString(got) { + t.Fatalf("float(1,2,3) = %q, want d.ddd in [1,2]", got) + } + } +} + +func TestBuiltinBase64(t *testing.T) { + got := mustRender(t, engine(1), `{"format":"{base64(9)}"}`) + if b, err := base64.StdEncoding.DecodeString(got); err != nil || len(b) != 9 { + t.Fatalf("base64(9) = %q decodes to %d bytes (err %v), want 9", got, len(b), err) + } +} + +// TestBuiltinChecksums fixes the payload with escapes and compares to a +// hand-computed check, like the luhn test. mod-11 emits X when it would be 10. +func TestBuiltinChecksums(t *testing.T) { + cases := map[string]string{ + `{"format":"#1#2#3#4#5#6#7#8{mod11()}"}`: "123456785", // weights 2..7 from the right + `{"format":"#6{mod11()}"}`: "6X", // remainder 10 -> X + `{"format":"#4#0#0#6#3#8#1#3#3#3#9#3{ean()}"}`: "4006381333931", // EAN-13 (= ISBN-13) check digit + } + f := engine(1) + for tmpl, want := range cases { + if got := mustRender(t, f, tmpl); got != want { + t.Fatalf("%s = %q, want %q", tmpl, got, want) + } + } +} + +func TestBuiltinIBAN(t *testing.T) { + wantLen := map[string]int{"SE": 24, "DE": 22, "NO": 15} + f := engine(1) + for cc, n := range wantLen { + tmpl := `{"format":"{iban(` + cc + `)}"}` + for i := 0; i < 100; i++ { + got := mustRender(t, f, tmpl) + if got[:2] != cc || len(got) != n { + t.Fatalf("iban(%s) = %q, want %d chars prefixed %s", cc, got, n, cc) + } + if !ibanValid(got) { + t.Fatalf("iban(%s) = %q fails the mod-97 check", cc, got) + } + } + } +} + +// ibanValid checks the IBAN mod-97 rule independently of the generator: move the +// first four chars to the end, map letters A-Z to 10-35, the number mod 97 == 1. +func ibanValid(s string) bool { + r := s[4:] + s[:4] + rem := 0 + for i := 0; i < len(r); i++ { + switch c := r[i]; { + case c >= '0' && c <= '9': + rem = (rem*10 + int(c-'0')) % 97 + case c >= 'A' && c <= 'Z': + rem = (rem*100 + int(c-'A') + 10) % 97 + default: + return false + } + } + return rem == 1 +} diff --git a/data/misc/creditcard.json b/data/misc/creditcard.json new file mode 100644 index 0000000..a9d7447 --- /dev/null +++ b/data/misc/creditcard.json @@ -0,0 +1,5 @@ +[ + { "format": "4{d}{luhn()}", "d": { "format": "0", "repeat": 14 }, "weight": 4 }, + { "format": "5{m}{d}{luhn()}", "m": ["1", "2", "3", "4", "5"], "d": { "format": "0", "repeat": 13 }, "weight": 3 }, + { "format": "3{m}{d}{luhn()}", "m": ["4", "7"], "d": { "format": "0", "repeat": 12 }, "weight": 1 } +] diff --git a/data/misc/mac.json b/data/misc/mac.json new file mode 100644 index 0000000..318a66f --- /dev/null +++ b/data/misc/mac.json @@ -0,0 +1,6 @@ +[ + { + "format": "{x}{x}:{x}{x}:{x}{x}:{x}{x}:{x}{x}:{x}{x}", + "x": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"] + } +] diff --git a/data/misc/uuid.json b/data/misc/uuid.json new file mode 100644 index 0000000..3508c93 --- /dev/null +++ b/data/misc/uuid.json @@ -0,0 +1,7 @@ +[ + { + "format": "{x}{x}{x}{x}{x}{x}{x}{x}-{x}{x}{x}{x}-4{x}{x}{x}-{y}{x}{x}{x}-{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}", + "x": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"], + "y": ["8", "9", "a", "b"] + } +] diff --git a/data_test.go b/data_test.go index d392ad3..ec5fbd8 100644 --- a/data_test.go +++ b/data_test.go @@ -66,6 +66,26 @@ func TestShippedDataCategories(t *testing.T) { } } +// TestShippedMiscCategories covers the locale-neutral data/misc folder: a proper +// v4 UUID (version/variant nibbles fixed), a MAC address, and credit-card numbers +// whose trailing {luhn()} check passes. +func TestShippedMiscCategories(t *testing.T) { + f := newFakes(t, "data/misc", WithSeed(1)) + v4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + mac := regexp.MustCompile(`^([0-9a-f]{2}:){5}[0-9a-f]{2}$`) + for i := 0; i < 200; i++ { + if v := fake(t, f, "uuid"); !v4.MatchString(v) { + t.Fatalf("misc uuid %q is not a valid v4", v) + } + if v := fake(t, f, "mac"); !mac.MatchString(v) { + t.Fatalf("misc mac %q is not a MAC address", v) + } + if v := fake(t, f, "creditcard"); len(v) < 15 || len(v) > 16 || !luhnValid(v) { + t.Fatalf("creditcard %q is not a 15-16 digit Luhn-valid number", v) + } + } +} + // 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. diff --git a/template.go b/template.go index 7491fbf..7449085 100644 --- a/template.go +++ b/template.go @@ -187,21 +187,15 @@ func resolve(r rng, token string, fields map[string]node) string { // 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. +// reproducible; a time-based id derives its time from the rng. The optional +// check validates args at compile time (their values, beyond the arity count). +// The registry lives in builtins.go. type builtin struct { arity int + check func(args []string) error 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. @@ -239,29 +233,12 @@ func checkFunc(body string) error { 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 + if b.check != nil { + if err := b.check(args); err != nil { + return fmt.Errorf("token {%s}: %w", body, err) } - d := int(c - '0') - if double { - if d *= 2; d > 9 { - d -= 9 - } - } - double = !double - sum += d } - return (10 - sum%10) % 10 + return nil } // compile converts parsed JSON into a node tree, validating structure up front. diff --git a/template_test.go b/template_test.go index 5f754c0..fd384ff 100644 --- a/template_test.go +++ b/template_test.go @@ -183,6 +183,15 @@ func TestCompileErrors(t *testing.T) { `{"format":"{nope()}"}`, // unknown function `{"format":"{luhn(x)}"}`, // function given args it takes none of `{"format":"{luhn(}"}`, // malformed function token + `{"format":"{int(1)}"}`, // wrong arity + `{"format":"{int(a,b)}"}`, // non-integer args + `{"format":"{int(5,1)}"}`, // min > max + `{"format":"{hex(0)}"}`, // count must be positive + `{"format":"{nanoid(-1)}"}`, // negative count + `{"format":"{base64(0)}"}`, // count must be positive + `{"format":"{float(1,2)}"}`, // wrong arity + `{"format":"{float(1,2,-1)}"}`, // negative decimals + `{"format":"{iban(US)}"}`, // unsupported country } { if _, err := compile(parse(t, bad)); err == nil { t.Errorf("compile(%s) = nil error, want error", bad)