Add ID/checksum/numeric builtins and a misc data folder (uuid v4, mac, creditcard)

This commit is contained in:
lilleman
2026-06-09 01:25:41 +02:00
parent c0eac6538d
commit ae41e6bb97
9 changed files with 468 additions and 36 deletions
+39 -5
View File
@@ -140,9 +140,10 @@ A `*Fakes` is **not** safe for concurrent use — create one per goroutine.
## Data ## Data
The library ships a ready-to-use set under [`data/`](data), organised by locale The library ships a ready-to-use set under [`data/`](data): one folder per locale
(`en_US`, `sv_SE`). Point either tool at the whole tree, a single locale, a (`en_US`, `sv_SE`) plus a locale-neutral `misc` folder. Point either tool at the
copy, or your own directory — anywhere on disk; no naming rules. 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 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 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`, `person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`,
`uuid`, `version`, `word`. `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 ## Data format
Each JSON file in a data directory is a **category** named after the file 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 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 27 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 **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
@@ -331,9 +364,10 @@ 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
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), 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 To add a category, drop a JSON file into a data directory; to add a locale, add
+242
View File
@@ -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)
}
+132
View File
@@ -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
}
+5
View File
@@ -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 }
]
+6
View File
@@ -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"]
}
]
+7
View File
@@ -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"]
}
]
+20
View File
@@ -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 // 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 // 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. // Feb 30) and the trailing digit is a valid Luhn checksum over the other nine.
+9 -32
View File
@@ -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 // 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 // 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 // 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 { type builtin struct {
arity int arity int
check func(args []string) error
call func(r rng, emitted string, args []string) string 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 // 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 // for a plain field or alternation body. A '(' without a trailing ')' yields
// ok=false; checkFunc reports it as malformed at compile time. // ok=false; checkFunc reports it as malformed at compile time.
@@ -239,31 +233,14 @@ func checkFunc(body string) error {
if len(args) != b.arity { if len(args) != b.arity {
return fmt.Errorf("token {%s}: %s takes %d args, got %d", body, name, b.arity, len(args)) return fmt.Errorf("token {%s}: %s takes %d args, got %d", body, name, b.arity, len(args))
} }
if b.check != nil {
if err := b.check(args); err != nil {
return fmt.Errorf("token {%s}: %w", body, err)
}
}
return nil 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) {
+9
View File
@@ -183,6 +183,15 @@ func TestCompileErrors(t *testing.T) {
`{"format":"{nope()}"}`, // unknown function `{"format":"{nope()}"}`, // unknown function
`{"format":"{luhn(x)}"}`, // function given args it takes none of `{"format":"{luhn(x)}"}`, // function given args it takes none of
`{"format":"{luhn(}"}`, // malformed function token `{"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 { 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)