Stuff
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
# Coverage output
|
||||
*.out
|
||||
todo.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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+21
-4
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+97
-4
@@ -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,11 +349,17 @@ func checkTokens(format string, fields map[string]node) error {
|
||||
return fmt.Errorf("unterminated '{' in %q", format)
|
||||
}
|
||||
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, "|") {
|
||||
if _, ok := fields[name]; !ok {
|
||||
return fmt.Errorf("token {%s}: no field %q", body, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
i = end
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user