Compare commits

...

10 Commits

69 changed files with 1969 additions and 1568 deletions
+1
View File
@@ -1,3 +1,4 @@
.claude
*.out
__pycache__/
todo.md
+9
View File
@@ -0,0 +1,9 @@
# Rules
- Go runs only through `docker compose run --rm <test|vet|fmt|build>`; the merge gate is `docker build .`.
- Tests first, in their own commit; the implementation follows in the next. A re-pin of seeded output is its own commit.
- One-line commit messages: no ticket prefix, no repo name, no authorship trailers.
- Hard tabs. No comment by default; delete a restatement, a rationale, history, or a file preamble.
- One spelling per result: reject the other at `New`, and let the error name the spelling to use.
- A standing choice a reader would relitigate goes under Decisions in the README, not in a comment.
- A README example is a `json` block that loads and renders as a category; `readme_test.go` runs every one.
+248 -388
View File
@@ -1,69 +1,65 @@
# fejkdata
A Go library and CLI for generating locale-aware fake data from JSON templates.
Forked from [github.com/Timewave-AB/fakes](https://github.com/Timewave-AB/fakes).
A Go library and CLI for generating fake data, built for
**internationalization**. It exists because the existing Go fake libraries
lacked the locale coverage and format control we needed.
## Goals
- **Standards first** — formats, names and structures follow international and
local standards first and foremost.
- **Locale-aware** — names, addresses, postal codes and phone numbers follow
per-locale data and formats. The shipped data is organised by full locale tag
(`sv_SE`), but the engine treats folders as plain namespaces — name yours
anything.
- **Data lives in JSON** — all source data is recursive JSON on disk, read when
you create a generator and then served from memory. Add or change data without
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 generator and it emits the same sequence every time,
for a given version of the data: changing how a value is composed shifts the
stream for that value and for everything drawn after it in the same generator.
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.
1. **Valid by construction** — every value passes the check its real consumer
applies; facts that belong together come from one draw, within a value and
across categories.
2. **Text means what it says** — a format renders as written; only `{…}` varies,
random characters included (`{digits(3)}`). One spelling per result; the wrong
one is a load error naming the right one.
3. **Every mistake is a load error** — `New` rejects; `Fake` on a loaded generator
fails only for an unknown path.
4. **Zero to a value in one command** — `go install`, then `fejkdata sv_SE.person`:
no checkout, no flag. Flags are GNU-form (`--seed 42`, `-n 3`) in any position;
the first custom template needs no escape and no option.
5. **Data lives in JSON** — a builtin only for what data can't express.
6. **Reproducible** — seed in, same stream out; no builtin reads a clock.
7. **Zero dependencies** — standard library only.
8. **Docs index the grammar** — every syntax feature is a heading; every example
runs under test and shows its output; a rule is stated once.
## CLI
Install the `fejkdata` command and give it a path — it prints one value to stdout.
The shipped data set is built in; each dot segment descends one level: folders,
then the category (a JSON file), then fields inside it.
```sh
go install gitea.larvit.se/larvit/fejkdata/cmd/fejkdata@latest
fejkdata sv_SE.person # Sara Eriksson
fejkdata sv_SE.person.last # Eriksson (dotted path into a category)
fejkdata --seed 42 sv_SE.address
fejkdata --repeat 3 sv_SE.person # three values, one per line
fejkdata sv_SE.person.last # Eriksson
fejkdata --seed 42 sv_SE.address # the same address every run
fejkdata -n 3 --separator ', ' sv_SE.word # nät, barn, sol
fejkdata --list # every path the data offers
fejkdata --data-path ./mydata sv_SE.word # layer a dir over the shipped data; last wins a clash
fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data
fejkdata --no-shipped-data -d ./mydata --list # only your data
```
Flags are GNU-style: `--name value` or `--name=value`, short aliases `-d`, `-n`,
`-s`, `-h`, in any position; `--` ends the flags. `--data-path` is repeatable
(last wins a name clash) and `--no-shipped-data` leaves the built-in set out.
`--repeat N` renders the path N times — each an independent draw — joined by
`--separator` (default a newline, so values land one per line). `--list` prints
every path you can ask for; `--version` prints the build version.
A path names a category, or a field inside one: each dot segment descends one
level — folders, then the category (a JSON file), then fields.
Without installing, run it from a checkout with `go run ./cmd/fejkdata …`. Exit
codes: `0` success (including `--list`, `--version`, `--help`), `1` runtime error
(missing dir, unknown path), `2` misuse.
| Flag | |
|------|--|
| `-d`, `--data-path D` | a directory to layer over the shipped data; repeatable, the last wins a name clash |
| `--no-shipped-data` | load only the `--data-path` directories |
| `-s`, `--seed N` | reproducible output |
| `-n`, `--repeat N` | render the path N times, each an independent draw |
| `--separator S` | between repeated values (default a newline) |
| `--list` | print every path, then exit |
| `--version`, `-h`, `--help` | print, then exit |
### Generating a file from a custom template
`--name value` and `--name=value` both work (see [Decisions](#decisions)); flags
go anywhere, `--` ends them. Exit codes: `0` success, `1` runtime error (missing
dir, unknown path), `2` misuse. From a checkout: `go run ./cmd/fejkdata …`.
A category is just a JSON file in a data directory, so you can drop in your
own and render it — no code change. Save this as `mydata/sql.json`:
### Your own data
A category is a JSON file in a directory. Save this as `mydata/sql.json`:
```json
{
"format": "INSERT INTO users V#ALUES({sql-username});",
"format": "INSERT INTO users VALUES({sql-username});",
"sql-username": {
"format": "'{username}'",
"repeat": 3,
@@ -73,410 +69,283 @@ own and render it — no code change. Save this as `mydata/sql.json`:
}
```
`sql-username` renders `'{username}'` `repeat` times and joins the results with
the `),(` separator; the outer `V#ALUES(…)` wraps that into one valid row list.
(`#A` escapes the literal `A`, which a format string would otherwise read as a
letter token — see [Data format](#data-format).)
```sh
fejkdata --seed 1 --data-path ./mydata sql
# INSERT INTO users VALUES('zoom'),('wahoo'),('blip');
```
Raise the template's `repeat` for more rows per statement; use the CLI's
`--repeat` for more statements — together they build a whole seed file:
```sh
fejkdata --repeat 100 --data-path ./mydata sql > seed.sql
```
## Library
```sh
go get gitea.larvit.se/larvit/fejkdata # requires Go 1.22+ (for math/rand/v2)
go get gitea.larvit.se/larvit/fejkdata # Go 1.22+
```
`New` loads the shipped data set, plus any `WithDataPath` directories layered over
it; generate values by path with `Fake`. Each dot segment descends one level:
folders, then the category (a JSON file), then fields inside it.
```go
package main
import (
"fmt"
"log"
"gitea.larvit.se/larvit/fejkdata"
)
func main() {
f, err := fejkdata.New()
f, err := fejkdata.New(fejkdata.WithSeed(42))
if err != nil {
log.Fatal(err)
}
for _, path := range []string{"sv_SE.person", "sv_SE.address", "sv_SE.phone", "sv_SE.address.locality"} {
v, err := f.Fake(path)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%-24s %s\n", path, v)
}
}
v, err := f.Fake("sv_SE.address") // "Kungsvägen 68\n379 17 Stockholm"
paths := f.List() // every path Fake accepts, sorted
```
```
sv_SE.person Sara Eriksson
sv_SE.address Kungsvägen 68
379 17 Stockholm
sv_SE.phone 072-402 91 67
sv_SE.address.locality Linköping
```
Options: `WithSeed(n)` for a reproducible sequence — same seed + data yields an
identical sequence, handy for stable tests; `WithDataPath(dir)` layers a directory
(repeat it to layer several, last wins a clash); `WithDataFS(fsys)` layers an
`fs.FS`, such as your own `embed.FS`; `WithoutShippedData()` loads only what you
give.
```go
a, _ := fejkdata.New(fejkdata.WithSeed(42))
b, _ := fejkdata.New(fejkdata.WithSeed(42))
av, _ := a.Fake("sv_SE.person")
bv, _ := b.Fake("sv_SE.person")
av == bv // true
```
`f.List()` returns the sorted paths the loaded data offers — the categories, their
dotted fields and folder segments (what the CLI's `--list` prints). Every path it
lists renders.
| Option | |
|--------|--|
| `WithSeed(n)` | same seed, same data: identical sequence |
| `WithDataPath(dir)` | layer a directory; repeat to layer several, the last wins a clash |
| `WithDataFS(fsys)` | layer an `fs.FS`, such as your own `embed.FS` |
| `WithoutShippedData()` | load only what you give |
A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only
when drawn from one goroutine.
when drawn from one goroutine. Changing how a value is composed shifts the seeded
stream for that value and everything drawn after it.
## Data
The shipped set under [`data/`](data) — one folder per locale (`en_US`, `sv_SE`)
plus a locale-neutral `misc` folder — is embedded in the library and the CLI, so
both work with no data on disk. Layer your own directories over it; a category or
folder name must not use `.`, `|`, `(` or `}` (see [Data format](#data-format)),
and dot-prefixed entries are skipped, so a data directory can also be a checkout.
plus a locale-neutral `misc` folder — is embedded, so the CLI and the library
work with no data on disk. A directory is a namespace: each JSON file is a
category named after the file, each subdirectory a dot-path segment, so
`mydata/sv_SE/person.json` is `sv_SE.person` and replaces the shipped one.
Sources merge in order; matching folders combine, any other clash is won by the
last loaded. Names may not use `.`, `|`, `(`, `{` or `}`; dot-prefixed entries
are skipped, so a data directory can also be a checkout.
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
objects do. So `mydata/sv_SE/person.json` is `Fake("sv_SE.person")`.
Sources merge in order: matching folders combine by their children, and any other
clash is won by the last one loaded. That lets you override a shipped category
without copying the rest:
```go
fejkdata.New(fejkdata.WithDataPath("./mydata")) // mydata/sv_SE/person.json replaces sv_SE.person
```
Each shipped locale carries these categories, formatted per locale (e.g. `date`
is `MM/DD/YYYY` in `en_US`, `YYYY-MM-DD` in `sv_SE`; `ssn` is a US SSN vs a
Swedish personnummer): `address`, `color`, `company`, `date`, `email`, `ip`,
Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`,
`person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`,
`version`, `word`.
`data/misc` carries locale-neutral categories — universal data that isn't tied to
a language or region:
- ids & networking: `uuid` (a proper random v4), `mac`, `objectid` (24 hex chars,
MongoDB ObjectID-shaped — the leading bytes are random, not a real timestamp),
`creditcard` (per-network numbers ending in a valid `{luhn()}` digit)
- reference codes: `currency` (ISO 4217), `country` (ISO 3166), `language` (ISO
639), `timezone` (IANA)
- web & systems: `mimetype`, `httpstatus`, `useragent`
- misc: `coordinate` (a lat/long point), `emoji`, `car`
Many carry dotted sub-fields — `currency.symbol`, `country.alpha2`,
`mimetype.ext`, `httpstatus.code`, `car.maker`. A time-ordered v7 UUID can't be
expressed as data, so it's the `{uuid()}` builtin instead (see **Functions**).
`version` and `word`, formatted per locale. `misc` carries `car`, `coordinate`,
`country` (ISO 3166), `creditcard` (Luhn-valid), `currency` (ISO 4217), `emoji`,
`httpstatus`, `language` (ISO 639), `mac`, `mimetype`, `objectid`, `timezone`
(IANA), `useragent` and `uuid` (v4). Many carry sub-fields — `misc.currency.symbol`,
`misc.country.alpha2`, `misc.httpstatus.code` — which `--list` shows.
## Data format
Each JSON file in a data directory is a **category** named after the file
(`address.json` → `address`), rendered by `Fake("address")`. Drop in a new file
or folder — no code change, no recompile.
Every value is a **node**, nestable without limit:
Every value is a **node**, one of three shapes, nestable without limit:
| Node | JSON | Meaning |
| Node | JSON | Renders |
|------|------|---------|
| literal | `"Malmö"` | emitted verbatim — never formatted |
| choice | `["a", "b", …]` | one element, picked at random |
| template | `{"format": "…", …}` | a format string plus the named sub-nodes it references |
| string | `"Malmö"` | its text, with any `{…}` tokens expanded |
| choice | `["a", "b", …]` | one item, picked at random |
| template | `{"format": "…", …}` | its format, with `{name}` tokens rendering the named fields |
**Weight.** A template node may carry a `weight` (default `1`) to skew its odds
within a choice:
### Format string
Every character is literal except a `{…}` token:
| Token | Renders |
|-------|---------|
| `{name}` | the sibling field `name` |
| `{name.field}` | `field` of one draw of `name` ([Correlated fields](#correlated-fields)) |
| `{a\|b}` | one of the named fields, even odds |
| `{fn(args)}` | a builtin ([Functions](#functions)) |
| `{..path}` | a node reached from the data root ([References](#references)) |
| `{{`, `}}` | a literal `{` or `}` |
```json
"100 Main St, Apt {int(1,9)}{upper(1)} — tel {digits(3)}-{digits(4)}"
```
Renders e.g. `100 Main St, Apt 4B — tel 555-0199`. Random characters come from
the sample functions, so a phone pattern is `070-{digits(3)} {digits(2)} {digits(2)}`.
A lone `}` is a load error naming `}}`; the arms of `{a|b}` must differ, a
repeated arm being a second spelling of [weight](#weight).
### Weight
An item of a choice may carry a `weight` (default `1`) to skew its odds:
```json
[
{ "format": "#070-000 00 00", "weight": 10 },
{ "format": "#01-000 00 00" },
{ "format": "#010-000 00 00" }
{ "format": "070-{digits(3)} {digits(2)} {digits(2)}", "weight": 10 },
"08-{digits(3)} {digits(2)} {digits(2)}",
{ "format": "010-{digits(3)} {digits(2)} {digits(2)}", "weight": 2 }
]
```
Only template (object) nodes carry `weight` — a bare string or nested array in a
choice always counts as `1`. Weights are checked when you create the generator: a
negative, non-numeric, or all-zero set is rejected at `New`, so a typo fails
fast instead of silently skewing output.
Renders `070-412 38 91` ten times as often as `08-…`. A string item is weighted by
writing it as `{ "format": "AB", "weight": 3 }`. Rejected at load: a weight that
is negative, non-numeric, `1` (the default) or outside a choice, a set summing to
zero, and a repeated item — `["a", "a", "b"]` is `[{ "format": "a", "weight": 2 }, "b"]`,
and the error says so.
**Repeat.** A template node may carry a `repeat` (default `1`) to render its
`format` that many times — each render an independent pick — joined by
`separator` (default `""`):
### Repeat
A template may carry `repeat` (an integer above `1`) to render its format that
many times — each an independent draw — joined by `separator` (default `""`):
```json
{ "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] }
```
This yields e.g. `bar foo baz`. `repeat` must be a positive integer and
`separator` a string, both checked at `New`.
Renders e.g. `bar foo baz`. A `separator` without a `repeat` is rejected.
`format`, `weight`, `repeat` and `separator` are the only options; **any other key
is a field**. So write `seperator` and you get a field by that name while the
option stays unset. `New` rejects an option that cannot take effect — a
`separator` without a `repeat` above 1, a `weight` outside a choice — and a name
using a character the grammars reserve: `.` separates the segments of a path, `|`
the arms of a token, `(` opens a function call and `}` ends the token, so a name
carrying one is a name no format could ever spell. An empty name goes the same
way — it is no path segment at all, so `List` never offers it. That holds for a
category, a folder and a field alike.
### Options and fields
**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:
`format`, `weight`, `repeat` and `separator` are the only options; **any other
key is a field** (see [Decisions](#decisions)). An object that does nothing a
string can't — only a `format` — is rejected naming the string, as is a one-item
choice naming its item.
```json
{ "format": "00{mmdd}-000{luhn()}", "mmdd": [ … ] }
```
### Functions
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:
A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad
count, range, country or expression fails fast, and a length, count or decimal
place beyond a sane maximum is rejected, so a fat-fingered `hex(2000000000)`
never tries to allocate gigabytes. Every builtin draws only from the seed — a
time-based id takes its timestamp from the rng, not the clock — so seeded output
stays reproducible.
| Function | Kind | Renders |
|----------|------|---------|
| `{luhn()}` | derivation | Luhn check digit over the digits emitted so far in this expansion |
| `{mod11()}` | derivation | weighted mod-11 check char (weights 2–7 from the right); `X` for 10 |
| `{ean()}` | derivation | EAN-13 / UPC-A / ISBN-13 / GTIN check digit |
| `{digits(n)}` | sample | `n` digits 0–9 |
| `{upper(n)}`, `{lower(n)}` | sample | `n` letters A–Z, a–z |
| `{int(min,max)}` | sample | uniform integer in `[min, max]` |
| `{float(min,max,dp)}` | sample | number in `[min, max]` with `dp` decimals |
| `{hex(n)}` | sample | `n` lowercase hex digits |
| `{base64(n)}` | sample | `n` random bytes, base64 |
| `{uuid()}` | sample | UUID v7 (`misc.uuid` ships v4 as data) |
| `{ulid()}` | sample | ULID, 26 Crockford base32 chars |
| `{nanoid(n)}` | sample | URL-safe Nano ID, `n` chars |
| `{iban(CC)}` | sample | length- and mod-97-valid IBAN for BE, DE, DK, ES, FI, NO or SE |
| `{seq()}`, `{seq(name)}` | counter | next integer from 1 in this generator; `name` selects an independent counter |
| `{calc(expr)}`, `{calc(expr,dp)}` | computation | an arithmetic expression over sibling fields ([Computation](#computation)) |
| `{lowercase(x)}`, `{uppercase(x)}`, `{ascii(x)}` | transform | a field's value rewritten ([Transforms](#transforms)) |
A derivation reads what is to its left, so place it after its payload; the
buffer is per expansion, so a nested template keeps fixed parts out of the sum. A
Swedish personnummer is a Luhn checksum over the nine digits before it:
```json
{ "format": "{century}{core}", "century": ["19", "20"],
"core": { "format": "00{mmdd}-000{luhn()}", "mmdd": [ … ] } }
"core": { "format": "{digits(2)}{mmdd}-{digits(3)}{luhn()}", "mmdd": ["0115", "0704", "1218"] } }
```
A function must be deterministic (no wall-clock), so a seeded generator 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.
Renders e.g. `19811218-9876`. `{seq()}` spans `Fake` calls and `repeat`, resets
with a new generator, and is the natural primary key for the SQL example above.
There are four kinds. **Derivations** read the digits emitted so far, so put them
after their payload; **samples** read only the rng, so they stand alone; one
**session counter** (`seq`) advances state held on the generator; and one
**computation** (`calc`) evaluates arithmetic over sibling fields. Arguments are
validated at `New` (a bad count, range, country, or expression fails fast); a
length, count or decimal place beyond a sane maximum is rejected there too, so a
fat-fingered `hex(2000000000)` can't try to allocate gigabytes at render.
### Computation
| 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()}` | sample | UUID v7 (v4 ships as data — see [Data](#data)) |
| `{ulid()}` | sample | ULID, 26-char Crockford base32 |
| `{nanoid(n)}` | sample | URL-safe Nano ID, `n` chars |
| `{hex(n)}` | sample | `n` lowercase hex digits |
| `{base64(n)}` | sample | `n` random bytes, base64 |
| `{int(min,max)}` | sample | uniform integer in `[min, max]` |
| `{float(min,max,dp)}` | sample | number in `[min, max]` with `dp` decimals |
| `{iban(CC)}` | sample | a length- and mod-97-valid IBAN for country `CC` (BE, DE, DK, ES, FI, NO, SE) |
| `{seq()}`, `{seq(name)}` | session counter | next integer (from 1) in this generator's sequence; `name` selects an independent counter |
| `{calc(expr)}`, `{calc(expr,dp)}` | computation | value of an arithmetic expression over number literals and sibling fields; `dp` rounds |
`{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 sample, 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).
`{seq()}`'s counter lives on the generator, so it spans `Fake` calls (and `repeat`)
and resets when you build a new generator — `seq` is reproducible by being ordered,
not random. It's the natural fit for a primary-key column in the SQL example above.
**Computation.** `{calc(expr)}` evaluates an arithmetic expression — `+ - * /`,
parentheses and unary minus, the usual precedence — and emits the result. Operands
are number literals and **sibling field names**, each rendered then read as a
number; an optional second arg rounds to that many decimals (`{calc(net * qty, 2)}`),
otherwise the value prints in minimal form. A hyphen is always subtraction, so a
hyphenated field name can't be an operand. The expression is checked at `New`
(parse, and that every name is a real field):
`{calc(expr)}` evaluates `+ - * /`, parentheses and unary minus over number
literals and sibling field names, each rendered then read as a number; a second
argument rounds to that many decimals. A hyphen is always subtraction, so a
hyphenated field can't be an operand.
```json
{ "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99"], "qty": ["3"] }
{ "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] }
```
renders `19.99 x 3 = 59.97`. A field a `calc` reads is drawn **once per
expansion** and held, so the operand shown is the operand computed — give `net`
three prices and the line still multiplies the one it printed. The hold covers
every reading of that name in the format, so `{w} {w} {calc(w)}` is one value
three times; a name no `calc` reads is unaffected, and `{word} {word}` still draws
twice. A field that doesn't render to a number yields `NaN`, and a division by
zero yields `Inf`; both print rather than failing the render.
Renders e.g. `19.99 x 3 = 59.97`: an operand is drawn once per expansion, so the
operand shown is the operand computed. A non-numeric operand yields `NaN` and a
division by zero `Inf`; both print rather than fail.
**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
loaded directory. One category can borrow another, even across folders or layered
data dirs:
### Transforms
`{lowercase(x)}`, `{uppercase(x)}` and `{ascii(x)}` rewrite the value of `x` — a
field, a path or a `..path` — and nest. `ascii` folds Latin letters (`Åsa Öberg`
→ `Asa Oberg`) and drops any other non-ASCII rune. Like a calc operand, `x` is
drawn once per expansion, so an email built from a name matches the name beside it:
```json
{ "format": "Hej, {..en_US.person}!" }
```
renders e.g. `Hej, Pat Smith!`. References are bound when you create the generator, so
a path that is unknown, names a folder, or steps through a multi-variant choice
fails at `New`. A reference that leads back to its own value (directly, mutually,
or through a chain) is a cycle that would never finish rendering, so it too is
rejected at `New`.
**Correlated fields.** A `{name.field}` token addresses a **path** into a sibling,
and a sibling addressed that way is drawn **once per expansion** — so several
tokens read one row. That is how two facts that belong together, such as a
locality and the postal code that really covers it, stay together:
```json
{ "format": "{street} {number}\n{place.postal-code} {place.locality}",
"place": [
{ "format": "{locality}", "locality": "Stockholm", "postal-code": { "format": "#100 00" }, "weight": 975 },
{ "format": "{locality}", "locality": "Tranås", "postal-code": { "format": "#5#7#3 00" }, "weight": 18 }
{ "format": "{p.first} {p.last} <{lowercase(ascii(p.first))}.{lowercase(ascii(p.last))}@example.com>",
"p": [
{ "format": "{first} {last}", "first": "Åsa", "last": "Öberg" },
{ "format": "{first} {last}", "first": "Bo", "last": "Ek" }
] }
```
renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm postal code
beside Tranås. Each row carries its own `weight`, so how often a place appears is data
too. The rule holds both ways: two tails of one head come from the same row, and
one path read twice reads one value (`{p.first} … {p.first}@…` gives one name).
Renders `Åsa Öberg <asa.oberg@example.com>` or `Bo Ek <bo.ek@example.com>`, never
a mix.
**One draw, one spelling.** The hold above is what a dotted token reads, and a
`{calc()}` operand reads its sibling the same way (see **Computation**). A format
may not both *render* a level and *read a path into* it — `{p}` beside
`{p.first}`, or `{place}` beside `{place.locality}`.
Rendering a level expands it afresh while a path reads the level's held draw, so
the two would disagree; naming the fields you want is the one spelling that
always agrees, and the other is a load error. This covers every way a level can
be rendered: a token, a `{calc()}` operand, and a `{..path}` reference — wherever
the reference sits, including in a field the format renders.
### References
A `{calc()}` operand is held on its own terms too, so the same fence guards it:
`{..cat.net} x 2 = {calc(net * 2, 2)}` names one field two ways and is a load
error, with no path token anywhere. A calc renders its operand whole, so the hold
pins what that render settled, following the operand's plain `{field}` tokens —
`{net.v}` and `{..cat.net.v}` are rejected alike. It stops at a `{..path}`, where
the operand's own value ends and a shared source begins: two names drawing from
one referenced category are two draws, as `{word} {word}` is, so two dice over
one `{..die}` are fine.
`{..path}` renders a node from the **data root** — the path `Fake` takes, across
every loaded source — so one category borrows another, even across folders or
layered directories:
```json
"Hej, {..en_US.person}!"
```
Renders e.g. `Hej, Pat Smith!`. A reference into a category is held like a
[correlated](#correlated-fields) path — `{..sv_SE.person.first} {..sv_SE.person.last}`
name one person, `{lowercase(..sv_SE.person.first)}` reads that same draw — while
a bare `{..misc.uuid} {..misc.uuid}` is two draws. Rejected at `New`: a path that
is unknown, names a folder, or reads a field not every variant of a choice
carries, and a reference that leads back to its own value, directly, mutually or
through a chain.
### Correlated fields
`{name.field}` reads a path into a sibling, and a sibling read that way is drawn
**once per expansion**, so several tokens read one row — a locality and the
postal code that really covers it:
```json
{ "format": "{street} {int(1,99)}\n{place.postal-code} {place.locality}",
"street": ["Kungsgatan", "Storgatan"],
"place": [
{ "format": "{locality}", "locality": "Stockholm", "postal-code": "1{digits(2)} {digits(2)}", "weight": 975 },
{ "format": "{locality}", "locality": "Tranås", "postal-code": "573 {digits(2)}", "weight": 18 }
] }
```
Renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm code beside
Tranås; each row's `weight` says how often it appears. A path is held at every
level it passes through (`{p.geo.town.name} {p.geo.town.zip}` share the town),
one path read twice reads one value, and a field no path addresses is drawn each
time (`{word} {word}` differs). The hold lasts one expansion: each `repeat`
iteration and each nested template draws again. Every variant of a choice on the
path must carry the rest of it, so a row missing a field is named at load:
```text
token {place.postal-code}: field "place": not every variant of this 2-way choice carries "postal-code"; all carry [locality]
```
The sub-fields stay addressable — `Fake("address.place.locality")` renders, and
`List` advertises it. A path may not read into a level carrying a `repeat`.
### One draw, one spelling
A format may not both **render** a level and **read a path into** it — `{p}`
beside `{p.first}`, `{..cat.net}` beside `{calc(net * 2)}`, or `{q}` beside
`{p.first}` where `q` renders `{..cat.p.last}` — because the render draws afresh
while the path reads the held draw, and the two would disagree. Wherever the
second route sits, it is a load error naming the spelling to use:
```text
token {p} renders a level that {p.first} reads a path into; name the fields you want instead
```
For the same reason a path may not read into a level carrying a `repeat`: it
reads one draw, so the repeat could never apply.
A path is held at **every level it passes through**, not just the first, so the
facts can nest as deeply as they belong:
```json
{ "format": "{p.geo.town.name} {p.geo.town.zip}, {p.geo.region}", "p": [ … ] }
```
renders `Kiruna 98100, Norrbotten` — the town, the zip that covers it and the
region it sits in all come from one draw. Two paths part company exactly where
they diverge: `{p.a.v}` and `{p.b.v}` share the row and nothing below it.
The binding lasts for one expansion, so each `repeat` iteration draws again and a
nested template keeps its own. A field no dotted token addresses is unaffected:
`{word} {word}` still draws twice.
`New` checks a path the way `Fake` resolves one: every variant of a multi-variant
choice must carry the whole path, so a row missing a field is named at load:
```text
token {place.postal-code}: field "place": not every variant of this 2-way choice
carries "postal-code"; all carry [locality]
```
The sub-fields stay addressable on their own — `Fake("address.place.locality")`
renders, and `List` advertises it.
**Format string.** Every character is literal except:
| Token | Expands to |
|-------|-----------|
| `0` | digit 0–9 |
| `1` | digit 1–9 |
| `A` | letter A–Z |
| `a` | letter a–z |
| `#` | escape — the next char is literal (`#0` → `0`, `##` → `#`) |
| `{name}` | render the sibling field `name` |
| `{name.field}` | render `field` of one draw of the sibling `name` (see **Correlated fields**) |
| `{name()}` | call a built-in function (see **Functions**) |
| `{..path}` | render the node at a dot path from the data root (see **References**) |
`{a|b}` renders one of the sibling fields `a` or `b`, chosen at random; an arm
may be a `{..path}` reference too (`{name|..en_US.person}`). The arms are picked
evenly and must differ — `{a|a|b}` would skew the odds, which is what `weight` is
for, so a repeated arm is a load error.
Inside a `format`, `0 1 A a` are **always** character classes — so a fixed `0`,
`1`, `A` or `a` must be escaped (`#1`, `#A`) or it becomes random. A format of
`100 Main St` renders e.g. `506 Mdin St` — the `1`, `0`, `0` and `a` were random,
the `M`, `in` and `St` were not. A half-fixed string is the trap: `555-0000`
keeps `555-` and randomises the last four digits. For a value with no
tokens at all, use a bare string node (`"100 Main St"`), emitted verbatim.
**Putting it together** (`person.json`):
```json
[
{
"format": "{prefix}{femalefirst|malefirst} {last}",
"femalefirst": ["Anna", "Astrid", "Elin"],
"malefirst": ["Anders", "Erik", "Gustav"],
"last": [
{ "format": "{first}sson", "first": ["Ander", "Erik", "Karl"] },
["Berg", "von Flemming"]
],
"prefix": [
"",
{ "format": "{string} ", "string": ["dr", "prof"], "weight": 0.05 }
]
}
]
```
This yields e.g. `Anna Eriksson`, `Erik Berg`, or rarely `dr Astrid von Flemming`.
Any field is reachable by dotted path — `Fake("person.last")` renders just a
surname; choices along the path are resolved at random. A path may continue
*through* a choice only where every variant carries the rest of it — every
`currency` variant carries `symbol`, so `currency.symbol` resolves — which keeps a
path from rendering on one call and failing on the next.
### Performance
Each file is parsed, validated and weight-indexed once, in `New`. After that a
`Fake` call costs about what its output costs — it scans the chosen format and
renders nested tokens, independent of how large your lists are:
Each file is parsed, validated and weight-indexed once, in `New`. A `Fake` call
then costs about what its output costs: an unweighted pick is O(1) whatever the
list's length, a weighted one O(log n), and long formats, deep nesting and many
tokens add cost in proportion to the output.
- Picking from a list is **O(1)** whatever its length — a 10-name list and a
100 000-name list cost the same.
- Giving entries a `weight` makes that list's pick **O(log n)** instead (a
search over cumulative weights). Still tiny, but an unweighted list is the
cheapest — only add `weight` where you actually want skew.
- Long `format` strings, deep nesting and many `{tokens}` add cost in
proportion to the output produced.
## Decisions
- **Options and fields share one namespace.** `format`, `weight`, `repeat` and
`separator` are reserved; every other key is a field. Nesting fields under a
key, or prefixing options, would tax every template to guard against a
misspelt option.
- **`{a|b}` stays beside nested choices.** `[[…], […]]` picks the same way, but
its arms are anonymous; `{femalefirst|malefirst}` keeps `person.femalefirst`
addressable.
- **`--name value` and `--name=value` both work.** GNU getopt_long convention,
which every shell user expects. A single-dash long flag is rejected naming the
double-dash spelling.
- **The shipped data is embedded, not discovered.** A directory a machine happens
to have would make `--seed 42` machine-dependent. Data still lives in `data/`
as JSON; `--data-path` layers over it.
- **Samples say what they emit, transforms what they do.** `{upper(2)}` is two
letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on
whether the argument looks like a number.
## Development
@@ -484,7 +353,7 @@ Everything runs in Docker — **no local tooling beyond Docker is needed**.
Source is bind-mounted; build caches persist in the `gocache` volume.
```sh
docker compose run --rm test # run tests
docker compose run --rm test # go test -race
docker compose run --rm cover # tests with coverage
docker compose run --rm bench # benchmarks
docker compose run --rm build # compile the library
@@ -506,14 +375,7 @@ gate — vet, format check and tests — so run it locally before pushing:
```sh
docker build . # latest
docker build --build-arg GO_VERSION=1.22.12 . # lowest supported
```
Tests run against the latest Go by default. Set `GO_VERSION` to check the lowest
supported version too:
```sh
GO_VERSION=1.22.12 docker compose run --rm test # lowest supported
docker compose run --rm test # latest
GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the image build
```
## Layout
@@ -521,19 +383,17 @@ docker compose run --rm test # latest
```
fejkdata.go Generator, New, options, the embedded data set, List
node.go the node model and JSON -> node compilation
render.go Fake and the recursive renderer (choices, format strings, paths, bound draws)
template.go the {token} grammar: scanning, function and path tokens, validation
render.go Fake and the recursive renderer (choices, format strings, paths, held draws)
template.go the {token} grammar: scanning, arms, operands, validation
reference.go {..path} binding across the tree, the render graph, and the walks over it
builtins.go the {name()} function registry and its implementations
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
cmd/fejkdata/ the `fejkdata` CLI (New + Fake/List over stdout)
cmd/fejkdata/ the fejkdata CLI
data/ shipped data (JSON), embedded at build: locale folders + a misc folder
format-migration/ converters from the pre-release grammar; delete before the first tag
```
To add a category, drop a JSON file into a data directory; to add a locale, add
a subdirectory of JSON files.
## License
MIT — see [LICENSE](LICENSE).
+5 -10
View File
@@ -43,12 +43,12 @@ func tmpData(b *testing.B, name, body string) string {
}
func BenchmarkCalc(b *testing.B) {
dir := tmpData(b, "inv", `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99"],"qty":["3"]}`)
dir := tmpData(b, "inv", `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":"19.99","qty":"3"}`)
benchPath(b, dir, "inv")
}
func BenchmarkLongLiteral(b *testing.B) {
dir := tmpData(b, "sql", `{"format":"INSERT INTO customers (id, name, city) V#ALUES (#1#2#3, '{word}', '{word}');","word":["alpha","beta","gamma","delta"]}`)
dir := tmpData(b, "sql", `{"format":"INSERT INTO customers (id, name, city) VALUES (123, '{word}', '{word}');","word":["alpha","beta","gamma","delta"]}`)
benchPath(b, dir, "sql")
}
@@ -61,24 +61,19 @@ func BenchmarkRepeat(b *testing.B) {
// what a correlated pair costs against BenchmarkUnbound, the same output drawn from
// two independent fields.
func BenchmarkBound(b *testing.B) {
dir := tmpData(b, "addr", `{"format":"{place.postal-code} {place.locality}","place":[
{"format":"{locality}","locality":"Stockholm","postal-code":{"format":"#100 00"}},
{"format":"{locality}","locality":"Tranås","postal-code":{"format":"#5#7#3 00"}}]}`)
dir := tmpData(b, "addr", `{"format":"{place.postal-code} {place.locality}","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`)
benchPath(b, dir, "addr")
}
func BenchmarkUnbound(b *testing.B) {
dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}",
"postal-code":[{"format":"#100 00"},{"format":"#5#7#3 00"}],
"locality":["Stockholm","Tranås"]}`)
dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}","postal-code":["1{digits(2)} {digits(2)}","573 {digits(2)}"],"locality":["Stockholm","Tranås"]}`)
benchPath(b, dir, "addr")
}
// BenchmarkBoundDeep reads two paths through an intermediate level, the shape the
// depth question is about.
func BenchmarkBoundDeep(b *testing.B) {
dir := tmpData(b, "addr", `{"format":"{p.addr.city} {p.addr.zip}","p":[
{"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":{"format":"#100 00"}}}]}`)
dir := tmpData(b, "addr", `{"format":"{p.addr.city} {p.addr.zip}","p":{"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":"1{digits(2)} {digits(2)}"}}}`)
benchPath(b, dir, "addr")
}
+45 -59
View File
@@ -15,10 +15,7 @@ import (
// swedishPlaces is a two-variant sibling whose variants pair a locality with the
// postal-code prefix that really belongs to it.
const swedishPlaces = `{"format":"%s","place":[
{"format":"{locality}","locality":"Stockholm","postal-code":{"format":"#100 00"}},
{"format":"{locality}","locality":"Tranås","postal-code":{"format":"#5#7#3 00"}}
]}`
const swedishPlaces = `{"format":"%s","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`
// agree reports whether a rendered "postcode locality" pair is a real pairing.
var agree = regexp.MustCompile(`^(1[0-9]{2} [0-9]{2} Stockholm|573 [0-9]{2} Tranås)$`)
@@ -48,10 +45,10 @@ func TestRenderingALevelAndReadingIntoItIsRejected(t *testing.T) {
// other rendered — so the pair is a load error rather than a shape whose two
// halves can disagree.
rejected := map[string]string{
"bare head beside a path": `{"format":"{p} + {p.first}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`,
"prefix path beside a path": `{"format":"{p.addr}|{p.addr.city}","p":[{"format":"{addr}","addr":[` +
`{"format":"{city}","city":["Kiruna","Boden"]},{"format":"{city}","city":["Malmö","Lund"]}]}]}`,
"either order": `{"format":"{p.first} + {p}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`,
"bare head beside a path": `{"format":"{p} + {p.first}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
"prefix path beside a path": `{"format":"{p.addr}|{p.addr.city}","p":{"format":"{addr}","addr":[` +
`{"format":"{city}","city":["Kiruna","Boden"]},{"format":"{city}","city":["Malmö","Lund"]}]}}`,
"either order": `{"format":"{p.first} + {p}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
}
for name, file := range rejected {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
@@ -65,7 +62,7 @@ func TestRenderingALevelAndReadingIntoItIsRejected(t *testing.T) {
}
// The same path twice is one spelling, so it stays legal.
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"{p.first} + {p.first}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`,
"cat": `{"format":"{p.first} + {p.first}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
}))); err != nil {
t.Errorf("New = %v, want one path read twice accepted", err)
}
@@ -84,7 +81,7 @@ func TestCalcOperandNamingABoundLevelIsRejected(t *testing.T) {
}
// Arithmetic over fields no path names is untouched.
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99"],"qty":["3"]}`,
"cat": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":"19.99","qty":"3"}`,
}))); err != nil {
t.Errorf("New = %v, want plain arithmetic accepted", err)
}
@@ -95,13 +92,13 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
// afresh beside the path that reads its held draw — the same overlap by
// another spelling.
rejected := map[string]string{
"reference names the head": `{"format":"{p.first}|{..cat.p}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`,
"reference names the head": `{"format":"{p.first}|{..cat.p}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
"reference names the leaf": `{"format":"{p.addr}|{..cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`,
// The reference need not sit in the format that binds: any field it renders
// reaches the level just the same, however deep.
"reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` +
`{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` +
`"inner":{"format":"{..cat.p}"}}`,
`"inner":"{..cat.p}"}`,
}
for name, file := range rejected {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
@@ -111,7 +108,7 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
}
// A reference to anything this format does not bind is untouched.
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"{p.first} {..surname}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`,
"cat": `{"format":"{p.first} {..surname}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
"surname": `["Eriksson","Lindqvist"]`,
}))); err != nil {
t.Errorf("New = %v, want a reference outside the bound level accepted", err)
@@ -123,7 +120,7 @@ func TestCycleReachedOnlyByAPathTokenIsRejected(t *testing.T) {
// cycle walk has to follow it there. A head whose own format names nothing
// would otherwise hide the cycle until render, where it is fatal.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"a": `{"format":"{p.x}","p":{"format":"static","x":{"format":"{..a}"}}}`,
"a": `{"format":"{p.x}","p":{"format":"static","x":"{..a}"}}`,
})))
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
@@ -134,7 +131,7 @@ func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) {
// {p.a} renders q, so it is a route to the level {q.x} holds — even though p's
// own format names nothing.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":{"format":"{..thing.q}"}},` +
"thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{..thing.q}"},` +
`"q":{"format":"{x}","x":["1","2"]}}`,
})))
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
@@ -161,7 +158,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a reference one level down": {
map[string]string{
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
`"q":{"format":"{..cat.net}"}}`,
`"q":"{..cat.net}"}`,
},
`{q} renders "net"`,
},
@@ -169,8 +166,8 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
// so wrapping the operand in one changes nothing.
"a reference to an operand wrapped in a choice": {
map[string]string{
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":[{"format":"{v}","v":["1","2"]}],` +
`"q":{"format":"{..cat.n}"}}`,
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` +
`"q":"{..cat.n}"}`,
},
`{q} renders "n"`,
},
@@ -179,7 +176,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"an operand reaching another operand": {
map[string]string{
"cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` +
`"b":{"format":"{..cat.a}"}}`,
`"b":"{..cat.a}"}`,
},
`calc operand "b" renders "a"`,
},
@@ -196,7 +193,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a reference into the operand one level down": {
map[string]string{
"cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` +
`"q":{"format":"{..cat.net.v}"}}`,
`"q":"{..cat.net.v}"}`,
},
`{q} renders "net"`,
},
@@ -206,7 +203,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a violation on the second of two held heads": {
map[string]string{
"cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` +
`"b":{"format":"{y}","y":["3","4"]},"w":{"format":"{..cat.b}"}}`,
`"b":{"format":"{y}","y":["3","4"]},"w":"{..cat.b}"}`,
},
`{w} renders "b"`,
},
@@ -233,20 +230,20 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a reference to a sibling the operand never renders": {
"cat": `{"format":"{calc(net * 2, 2)} {unit}",` +
`"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` +
`"unit":{"format":"{..cat.net.spare}"}}`,
`"unit":"{..cat.net.spare}"}`,
},
// Two operands drawing from one source are two names, so two draws: each is
// held under its own name and shown once, and neither can disagree.
"two operands sharing one source": {
"die": `["1","2","3","4","5","6"]`,
"cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":{"format":"{..die}"},` +
`"d2":{"format":"{..die}"}}`,
"cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{..die}",` +
`"d2":"{..die}"}`,
},
// Likewise a reference drawing from what the operand draws from: {..common}
// and net are two names, not two spellings of one field.
"a reference to what an operand renders through": {
"common": `["1","2"]`,
"cat": `{"format":"{calc(net * 2, 2)} {..common}","net":{"format":"{..common}"}}`,
"cat": `{"format":"{calc(net * 2, 2)} {..common}","net":"{..common}"}`,
},
// A fixed string cannot disagree with itself, so it needs no fence.
"a literal operand named twice": {
@@ -272,11 +269,11 @@ func TestAPathReachesEveryVariantItMightDraw(t *testing.T) {
// to a held level disagrees silently.
rejected := map[string]struct{ file, want string }{
"a cycle in a later variant": {
`{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":{"format":"{..cat}"}}]}`,
`{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat}"}]}`,
"reference cycle",
},
"a second route in a later variant": {
`{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":{"format":"{..cat.q}"}}],` +
`{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat.q}"}],` +
`"q":{"format":"{y}","y":["1","2"]}}`,
"reads a path into",
},
@@ -309,10 +306,10 @@ func TestALevelAPathNeverRendersIsAccepted(t *testing.T) {
func TestADeepDiamondChainLoads(t *testing.T) {
// A diamond chain is 2^n routes through n nodes. The search past a bound level
// must walk each node once, not once per route, or a deep chain never loads.
files := map[string]string{"l0": `["x"]`}
files := map[string]string{"l0": `"x"`}
for i := 1; i <= 30; i++ {
files[fmt.Sprintf("l%d", i)] = fmt.Sprintf(
`{"format":"{a}{b}","a":{"format":"{..l%d}"},"b":{"format":"{..l%d}"}}`, i-1, i-1)
`{"format":"{a}{b}","a":"{..l%d}","b":"{..l%d}"}`, i-1, i-1)
}
files["thing"] = `{"format":"{p.first} {..l30}","p":{"format":"x","first":["A","B"]}}`
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil {
@@ -325,9 +322,9 @@ func TestASharedNodeIsWalkedOnce(t *testing.T) {
// The search past a bound level must take that in its stride rather than walk
// the shared node once per route.
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"{p.first}|{q}","p":[{"format":"{first}","first":["Anna","Bo"]}],` +
`"q":{"format":"{a}{b}","a":{"format":"{..shared}"},"b":{"format":"{..shared}"}}}`,
"shared": `["x"]`,
"cat": `{"format":"{p.first}|{q}","p":{"format":"{first}","first":["Anna","Bo"]},` +
`"q":{"format":"{a}{b}","a":"{..shared}","b":"{..shared}"}}`,
"shared": `"x"`,
})), WithSeed(1))
if err != nil {
t.Fatalf("New = %v, want a shared node accepted", err)
@@ -342,7 +339,7 @@ func TestTheEarlierReaderIsNamed(t *testing.T) {
// first is the one reported, so the error points at the same place a reader
// looking at the format would start.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"{calc(p * 1)} {p} {p.first}","p":[{"format":"{first}","first":["1","2"]}]}`,
"cat": `{"format":"{calc(p * 1)} {p} {p.first}","p":{"format":"{first}","first":["1","2"]}}`,
})))
if err == nil || !strings.Contains(err.Error(), `calc operand "p"`) {
t.Fatalf("New = %v, want the calc operand named, being written first", err)
@@ -368,7 +365,7 @@ func TestNestedChoiceDrawsOneVariant(t *testing.T) {
f := engine(21)
seen := map[string]bool{}
for i := 0; i < 200; i++ {
seen[mustRender(t, f, `{"format":"{p.x}","p":[[{"format":"{x}","x":"1"}],[{"format":"{x}","x":"2"}]]}`)] = true
seen[mustRender(t, f, `{"format":"{p.x}","p":[{"format":"{x}","x":"1"},{"format":"{x}","x":"2"}]}`)] = true
}
if !seen["1"] || !seen["2"] || len(seen) != 2 {
t.Fatalf("200 draws produced %v, want 1 and 2", seen)
@@ -379,7 +376,7 @@ func TestRepeatingLevelIsNamedInTheError(t *testing.T) {
// The level carrying the repeat is the one to fix, so the error names it
// rather than the head the path started from.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"[{p.a.b}]","p":{"format":"{a}","a":{"format":"{b}","repeat":3,"separator":",","b":["z"]}}}`,
"cat": `{"format":"[{p.a.b}]","p":{"format":"{a}","a":{"format":"{b}","repeat":3,"separator":",","b":"z"}}}`,
})))
if err == nil || !strings.Contains(err.Error(), `"p.a"`) {
t.Fatalf("New = %v, want it to name the level p.a that carries the repeat", err)
@@ -391,7 +388,7 @@ func TestPathIntoARepeatingLevelBehindAChoiceIsRejected(t *testing.T) {
// reach inside one — otherwise the direct spelling is a load error and the same
// mistake behind a choice silently drops the repeat.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"[{p.a}]","p":[{"format":"{a}","repeat":3,"separator":",","a":["x"]},{"format":"{a}","a":["y"]}]}`,
"cat": `{"format":"[{p.a}]","p":[{"format":"{a}","repeat":3,"separator":",","a":"x"},{"format":"{a}","a":"y"}]}`,
})))
if err == nil || !strings.Contains(err.Error(), "repeat") {
t.Fatalf("New = %v, want the repeat behind a choice rejected", err)
@@ -402,7 +399,7 @@ func TestPathIntoARepeatingLevelIsRejected(t *testing.T) {
// A path reads one level's draw, so it can never apply that level's repeat.
// The engine rejects options that cannot take effect, and this is one.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"[{p.a}]","p":{"format":"{a}","repeat":3,"separator":",","a":["z"]}}`,
"cat": `{"format":"[{p.a}]","p":{"format":"{a}","repeat":3,"separator":",","a":"z"}}`,
})))
if err == nil || !strings.Contains(err.Error(), "repeat") {
t.Fatalf("New = %v, want a path into a repeating level rejected", err)
@@ -511,7 +508,7 @@ func TestDottedTokenErrors(t *testing.T) {
want string
}{
"unknown head": {
`{"format":"{nope.x}","place":[{"format":"{v}","v":"A"}]}`,
`{"format":"{nope.x}","place":{"format":"{v}","v":"A"}}`,
`no field "nope"`,
},
"unknown tail": {
@@ -519,7 +516,7 @@ func TestDottedTokenErrors(t *testing.T) {
`"nope"`,
},
"tail only some variants carry": {
`{"format":"{place.locality}","place":[{"format":"{locality}","locality":"A"},{"format":"x"}]}`,
`{"format":"{place.locality}","place":[{"format":"{locality}","locality":"A"},"x"]}`,
`locality`,
},
"path into a literal": {
@@ -527,7 +524,7 @@ func TestDottedTokenErrors(t *testing.T) {
`"y"`,
},
"dotted field key": {
`{"format":"[{a.b}]","a.b":["V"]}`,
`{"format":"[{a.b}]","a.b":"V"}`,
`contains "."`,
},
}
@@ -548,8 +545,7 @@ func TestSamePathReadTwiceReadsOneValue(t *testing.T) {
// what a name shown in a display form and again in an address needs.
f := engine(7)
for i := 0; i < 300; i++ {
got := mustRender(t, f, `{"format":"{p.first}|{p.first}",
"p":[{"format":"{first}","first":["Anna","Astrid","Elin","Karin"]}]}`)
got := mustRender(t, f, `{"format":"{p.first}|{p.first}","p":{"format":"{first}","first":["Anna","Astrid","Elin","Karin"]}}`)
parts := strings.Split(got, "|")
if parts[0] != parts[1] {
t.Fatalf("draw %d = %q, want one value read twice", i, got)
@@ -571,9 +567,7 @@ func TestDifferentTailsUnderOneHeadShareTheRow(t *testing.T) {
// deepPlaces nests the correlated facts a level down: the row holds an addr, and
// the addr holds the city and the zip that belongs to it.
const deepPlaces = `{"format":"%s","p":[{"format":"{addr}","addr":[
{"format":"{city}","city":"Stockholm","zip":"11111"},
{"format":"{city}","city":"Kiruna","zip":"98100"}]}]}`
const deepPlaces = `{"format":"%s","p":{"format":"{addr}","addr":[{"format":"{city}","city":"Stockholm","zip":"11111"},{"format":"{city}","city":"Kiruna","zip":"98100"}]}}`
func TestPathHoldsItsDrawAtEveryLevel(t *testing.T) {
// A choice below the head is drawn once too, so two paths sharing a prefix
@@ -595,13 +589,7 @@ func TestPathHoldsItsDrawAtEveryLevel(t *testing.T) {
func TestPathHoldsItsDrawThreeLevelsDown(t *testing.T) {
// The rule does not run out at two: every level a path passes through is held.
f := engine(12)
tmpl := `{"format":"{p.geo.town.name}/{p.geo.town.zip} {p.geo.region}","p":[{"format":"{geo}","geo":[
{"format":"{town}","region":"Norrbotten","town":[
{"format":"{name}","name":"Kiruna","zip":"98100"},
{"format":"{name}","name":"Luleå","zip":"97200"}]},
{"format":"{town}","region":"Skåne","town":[
{"format":"{name}","name":"Malmö","zip":"21100"},
{"format":"{name}","name":"Lund","zip":"22100"}]}]}]}`
tmpl := `{"format":"{p.geo.town.name}/{p.geo.town.zip} {p.geo.region}","p":{"format":"{geo}","geo":[{"format":"{town}","region":"Norrbotten","town":[{"format":"{name}","name":"Kiruna","zip":"98100"},{"format":"{name}","name":"Luleå","zip":"97200"}]},{"format":"{town}","region":"Skåne","town":[{"format":"{name}","name":"Malmö","zip":"21100"},{"format":"{name}","name":"Lund","zip":"22100"}]}]}}`
want := map[string]bool{
"Kiruna/98100 Norrbotten": true, "Luleå/97200 Norrbotten": true,
"Malmö/21100 Skåne": true, "Lund/22100 Skåne": true,
@@ -641,9 +629,7 @@ func TestDeepPathsUnderOneHeadStayIndependentWhereTheyDiverge(t *testing.T) {
// Two paths share only what they actually share: a common prefix is one draw,
// and each keeps its own draw past the point they part.
f := engine(14)
tmpl := `{"format":"{p.a.v}{p.b.v}","p":[{"format":"x",
"a":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}],
"b":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}]}]}`
tmpl := `{"format":"{p.a.v}{p.b.v}","p":{"format":"x","a":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}],"b":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}]}}`
seen := map[string]bool{}
for i := 0; i < 400; i++ {
seen[mustRender(t, f, tmpl)] = true
@@ -660,9 +646,9 @@ func TestEmptyPathSegmentIsRejected(t *testing.T) {
// nothing is reported as that rather than as a missing field. An empty name is
// rejected where it is authored, so no data can make these resolve.
rejected := map[string]string{
"trailing dot": `{"format":"[{a.}]","a":{"format":"x"}}`,
"leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":["V"]}}`,
"double dot": `{"format":"[{a..b}]","a":{"format":"x"}}`,
"trailing dot": `{"format":"[{a.}]","a":"x"}`,
"leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":"V"}}`,
"double dot": `{"format":"[{a..b}]","a":"x"}`,
}
for name, file := range rejected {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
@@ -681,7 +667,7 @@ func TestCycleThroughAPathTokenIsRejected(t *testing.T) {
// must be caught at New. Reaching render would be fatal: the recursion never
// terminates, and a stack overflow cannot be recovered.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"a": `{"format":"{p.x}","p":{"format":"{x}","x":{"format":"{..a}"}}}`,
"a": `{"format":"{p.x}","p":{"format":"{x}","x":"{..a}"}}`,
})))
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
+119 -33
View File
@@ -6,6 +6,7 @@ import (
"math"
"strconv"
"strings"
"unicode"
)
// maxLen caps sample output lengths (hex, nanoid, base64) and maxDecimals caps
@@ -16,27 +17,21 @@ const (
maxDecimals = 1024
)
// builtins is the registry of {name(args)} functions. Derivations read the
// digits emitted so far in the current expansion (luhn, mod11, ean — place them
// after their payload); samples read only the rng (uuid, ulid, ...). All
// must stay pure over (rng, emitted, args) so seeded output is reproducible — a
// time-based id (uuid v7, ulid) draws its timestamp from the rng, not the wall
// clock. Add a builtin only for what data can't express: a random v4 UUID and a
// 24-hex ObjectID both ship as data, so the uuid builtin is v7.
// builtins is the registry of {name(args)} functions. Derivations read the digits
// emitted so far in the current expansion (place them after their payload);
// samples read only the rng. A time-based id (uuid v7, ulid) draws its timestamp
// from the rng, not the wall clock, so seeded output stays reproducible.
var builtins = map[string]builtin{
"luhn": {arity: 0, prep: derive(func(e string) string { return string(rune('0' + luhnCheck(e))) })},
"mod11": {arity: 0, prep: derive(mod11Check)},
"ean": {arity: 0, prep: derive(eanCheck)},
"uuid": {arity: 0, prep: sample(uuidV7)},
"ulid": {arity: 0, prep: sample(ulid)},
"nanoid": {arity: 1, check: posIntArg, prep: func(a []string) callFn {
n := atoi(a[0])
return func(s *session, _ string, _ []string) string { return nanoid(s, n) }
}},
"hex": {arity: 1, check: posIntArg, prep: func(a []string) callFn {
n := atoi(a[0])
return func(s *session, _ string, _ []string) string { return randHex(s, n) }
}},
"nanoid": {arity: 1, check: posIntArg, prep: chars(nanoidAlphabet)},
"hex": {arity: 1, check: posIntArg, prep: chars(hexDigits)},
"digits": {arity: 1, check: posIntArg, prep: chars("0123456789")},
"upper": {arity: 1, check: posIntArg, prep: chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")},
"lower": {arity: 1, check: posIntArg, prep: chars("abcdefghijklmnopqrstuvwxyz")},
"base64": {arity: 1, check: posIntArg, prep: func(a []string) callFn {
n := atoi(a[0])
return func(s *session, _ string, _ []string) string {
@@ -57,9 +52,10 @@ var builtins = map[string]builtin{
cc := a[0]
return func(s *session, _ string, _ []string) string { return iban(s, cc) }
}},
// calc is the one builtin that names operands (expand reads them for it);
// every other ignores them. 1 or 2 args: the expression and an optional decimals.
"calc": {arity: -1, check: checkCalc, prep: calcPrep},
"calc": {arity: -1, check: checkCalc, prep: calcPrep, operands: calcOperands},
"lowercase": {arity: 1, check: transformArg, prep: transformPrep(strings.ToLower), operands: transformOperand},
"uppercase": {arity: 1, check: transformArg, prep: transformPrep(strings.ToUpper), operands: transformOperand},
"ascii": {arity: 1, check: transformArg, prep: transformPrep(asciiFold), operands: transformOperand},
// seq is the one stateful builtin: a per-session counter from 1, advancing on
// each call. An optional name selects an independent counter; no name uses the
// default one. Deterministic by construction, so seeded output stays stable.
@@ -74,9 +70,9 @@ var builtins = map[string]builtin{
}},
}
// derive and sample are the two argument-free builtin shapes the README names: a
// derivation reads the output emitted so far, a sample reads only the rng. Each
// lifts that one function into the prep every registry entry supplies.
// derive and sample are the two argument-free builtin shapes: a derivation reads
// the output emitted so far, a sample reads only the rng. chars is the shape of a
// sample of n characters drawn from an alphabet.
func derive(f func(emitted string) string) func([]string) callFn {
return func([]string) callFn {
return func(_ *session, emitted string, _ []string) string { return f(emitted) }
@@ -89,8 +85,107 @@ func sample(f func(rng) string) func([]string) callFn {
}
}
func chars(alphabet string) func([]string) callFn {
return func(a []string) callFn {
n := atoi(a[0])
return func(s *session, _ string, _ []string) string { return randChars(s, n, alphabet) }
}
}
const hexDigits = "0123456789abcdef"
// transforms are the builtins that rewrite one operand's value; they nest, so
// {lowercase(ascii(x))} folds then lowers.
var transforms = map[string]func(string) string{
"ascii": asciiFold,
"lowercase": strings.ToLower,
"uppercase": strings.ToUpper,
}
// unwrapTransform peels nested transform calls off an operand arg, returning the
// field it finally names and the transforms to apply, innermost last.
func unwrapTransform(arg string) (leaf string, chain []func(string) string, err error) {
for {
name, args, isCall := funcCall(arg)
if !isCall {
return arg, chain, nil
}
fn, isTransform := transforms[name]
if !isTransform {
return "", nil, fmt.Errorf("%s(%s) is not a transform, so it cannot be an operand", name, strings.Join(args, ","))
}
if len(args) != 1 {
return "", nil, fmt.Errorf("%s takes 1 arg, got %d", name, len(args))
}
chain = append(chain, fn)
arg = args[0]
}
}
func transformArg(fields map[string]node, a []string) error {
leaf, _, err := unwrapTransform(a[0])
if err != nil {
return err
}
if isRef(leaf) {
if leaf == refPrefix {
return fmt.Errorf("reference has no path")
}
return nil
}
return checkArm(leaf, fields)
}
func transformOperand(a []string) []string {
leaf, _, err := unwrapTransform(a[0])
if err != nil {
return nil
}
return []string{leaf}
}
func transformPrep(outer func(string) string) func([]string) callFn {
return func(a []string) callFn {
_, chain, err := unwrapTransform(a[0])
if err != nil {
panic(fmt.Sprintf("fejkdata: transform arg %q reached prep unvalidated: %v", a[0], err))
}
return func(_ *session, _ string, operands []string) string {
v := operands[0]
for i := len(chain) - 1; i >= 0; i-- {
v = chain[i](v)
}
return outer(v)
}
}
}
// asciiFolds maps the Latin letters with diacritics or ligatures to ASCII.
var asciiFolds = map[rune]string{
'À': "A", 'Á': "A", 'Â': "A", 'Ã': "A", 'Ä': "A", 'Å': "A", 'Æ': "AE", 'Ç': "C",
'È': "E", 'É': "E", 'Ê': "E", 'Ë': "E", 'Ì': "I", 'Í': "I", 'Î': "I", 'Ï': "I",
'Ð': "D", 'Ñ': "N", 'Ò': "O", 'Ó': "O", 'Ô': "O", 'Õ': "O", 'Ö': "O", 'Ø': "O",
'Ù': "U", 'Ú': "U", 'Û': "U", 'Ü': "U", 'Ý': "Y", 'Þ': "Th", 'ß': "ss", 'Œ': "OE",
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'æ': "ae", 'ç': "c",
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ì': "i", 'í': "i", 'î': "i", 'ï': "i",
'ð': "d", 'ñ': "n", 'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o",
'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ý': "y", 'þ': "th", 'ÿ': "y", 'œ': "oe",
}
// asciiFold rewrites s to ASCII: folded Latin letters stay, any other non-ASCII
// rune is dropped.
func asciiFold(s string) string {
var b strings.Builder
for _, r := range s {
if r < unicode.MaxASCII {
b.WriteRune(r)
} else {
b.WriteString(asciiFolds[r])
}
}
return b.String()
}
// atoi parses an arg a builtin's check already validated. It panics rather than
// returning zero, so a check that stops covering its own args is a stack trace and
// not a silently wrong length, range or decimal count.
@@ -119,10 +214,10 @@ func randBytes(r rng, n int) []byte {
return b
}
func randHex(r rng, n int) string {
func randChars(r rng, n int, alphabet string) string {
b := make([]byte, n)
for i := range b {
b[i] = hexDigits[r.IntN(16)]
b[i] = alphabet[r.IntN(len(alphabet))]
}
return string(b)
}
@@ -182,18 +277,9 @@ func seqArg(_ map[string]node, a []string) error {
return nil
}
// nanoidAlphabet is the 64-char URL-safe set Nano IDs use (order is irrelevant
// to the uniform pick).
// nanoidAlphabet is the 64-char URL-safe set Nano IDs use.
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.
+40 -27
View File
@@ -13,10 +13,10 @@ func TestBuiltinIDGenerators(t *testing.T) {
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}$`)},
{"nanoid", `{"format":"{nanoid(21)}"}`, regexp.MustCompile(`^[A-Za-z0-9_-]{21}$`)},
{"hex", `{"format":"{hex(16)}"}`, regexp.MustCompile(`^[0-9a-f]{16}$`)},
{"uuid v7", `"{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", `"{ulid()}"`, regexp.MustCompile(`^[0-7][0-9A-HJKMNP-TV-Z]{25}$`)},
{"nanoid", `"{nanoid(21)}"`, regexp.MustCompile(`^[A-Za-z0-9_-]{21}$`)},
{"hex", `"{hex(16)}"`, regexp.MustCompile(`^[0-9a-f]{16}$`)},
}
f := engine(1)
for _, c := range cases {
@@ -38,9 +38,9 @@ func TestBuiltinIDGenerators(t *testing.T) {
// sample draws only from the seeded rng, so same seed -> same value.
func TestBuiltinSamplesReproducible(t *testing.T) {
for _, tmpl := range []string{
`{"format":"{uuid()}"}`, `{"format":"{ulid()}"}`,
`{"format":"{nanoid(12)}"}`, `{"format":"{int(1,1000000)}"}`,
`{"format":"{float(0,1,6)}"}`, `{"format":"{base64(12)}"}`, `{"format":"{iban(SE)}"}`,
`"{uuid()}"`, `"{ulid()}"`,
`"{nanoid(12)}"`, `"{int(1,1000000)}"`,
`"{float(0,1,6)}"`, `"{base64(12)}"`, `"{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)
@@ -52,7 +52,7 @@ 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)}"}`))
n, err := strconv.Atoi(mustRender(t, f, `"{int(3,7)}"`))
if err != nil || n < 3 || n > 7 {
t.Fatalf("int(3,7) = %d (err %v), out of range", n, err)
}
@@ -66,14 +66,14 @@ func TestBuiltinIntInclusiveRange(t *testing.T) {
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) {
if got := mustRender(t, f, `"{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)}"}`)
got := mustRender(t, engine(1), `"{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)
}
@@ -83,9 +83,9 @@ func TestBuiltinBase64(t *testing.T) {
// 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
`"12345678{mod11()}"`: "123456785", // weights 2..7 from the right
`"6{mod11()}"`: "6X", // remainder 10 -> X
`"400638133393{ean()}"`: "4006381333931", // EAN-13 (= ISBN-13) check digit
}
f := engine(1)
for tmpl, want := range cases {
@@ -101,14 +101,14 @@ func TestBuiltinChecksums(t *testing.T) {
func TestBuiltinSeqPerSession(t *testing.T) {
f := engine(1)
for i := 1; i <= 5; i++ {
if got := mustRender(t, f, `{"format":"{seq()}"}`); got != strconv.Itoa(i) {
if got := mustRender(t, f, `"{seq()}"`); got != strconv.Itoa(i) {
t.Fatalf("seq call %d = %q, want %d", i, got, i)
}
}
if got := mustRender(t, f, `{"format":"{seq(orders)}"}`); got != "1" {
if got := mustRender(t, f, `"{seq(orders)}"`); got != "1" {
t.Fatalf("named seq(orders) = %q, want its own count from 1", got)
}
if got := mustRender(t, f, `{"format":"{seq()}"}`); got != "6" {
if got := mustRender(t, f, `"{seq()}"`); got != "6" {
t.Fatalf("default seq after the named one = %q, want 6 (counters are independent)", got)
}
// repeat drives one counter forward across its renders...
@@ -116,7 +116,7 @@ func TestBuiltinSeqPerSession(t *testing.T) {
t.Fatalf("repeat seq = %q, want 1,2,3", got)
}
// ...and a fresh session restarts from 1.
if got := mustRender(t, engine(2), `{"format":"{seq()}"}`); got != "1" {
if got := mustRender(t, engine(2), `"{seq()}"`); got != "1" {
t.Fatalf("new session seq = %q, want 1", got)
}
}
@@ -126,14 +126,14 @@ func TestBuiltinSeqPerSession(t *testing.T) {
// at compile, not detonate when the template is drawn.
func TestBuiltinArgLimits(t *testing.T) {
bad := []string{
`{"format":"{int(-9223372036854775808,9223372036854775807)}"}`, // span overflows int -> IntN panic
`{"format":"{hex(2000000000)}"}`, // ~2 GB string
`{"format":"{nanoid(2000000000)}"}`,
`{"format":"{base64(2000000000)}"}`,
`{"format":"{float(-1e308,1e308,2)}"}`, // span is +Inf
`{"format":"{float(0,1,100000)}"}`, // decimals -> huge string
`{"format":"{calc(1+1,100000)}"}`, // calc decimals -> huge string
`{"format":"{x}","x":["1"],"repeat":2000000000}`, // repeat -> multi-GB join
`"{int(-9223372036854775808,9223372036854775807)}"`, // span overflows int -> IntN panic
`"{hex(2000000000)}"`, // ~2 GB string
`"{nanoid(2000000000)}"`,
`"{base64(2000000000)}"`,
`"{float(-1e308,1e308,2)}"`, // span is +Inf
`"{float(0,1,100000)}"`, // decimals -> huge string
`"{calc(1+1,100000)}"`, // calc decimals -> huge string
`{"format":"{x}","x":"1","repeat":2000000000}`, // repeat -> multi-GB join
}
for _, tmpl := range bad {
if _, err := compile(parse(t, tmpl)); err == nil {
@@ -141,7 +141,7 @@ func TestBuiltinArgLimits(t *testing.T) {
}
}
// Ordinary values still compile.
if _, err := compile(parse(t, `{"format":"{hex(16)} {int(-5,5)} {float(0,1,3)} {calc(1+1,2)}"}`)); err != nil {
if _, err := compile(parse(t, `"{hex(16)} {int(-5,5)} {float(0,1,3)} {calc(1+1,2)}"`)); err != nil {
t.Errorf("compile of normal args failed: %v", err)
}
}
@@ -150,7 +150,7 @@ 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 + `)}"}`
tmpl := `"{iban(` + cc + `)}"`
for i := 0; i < 100; i++ {
got := mustRender(t, f, tmpl)
if got[:2] != cc || len(got) != n {
@@ -217,3 +217,16 @@ func mustPanic(t *testing.T, name string, call func()) {
}()
call()
}
func TestClassBuiltinArgs(t *testing.T) {
for _, bad := range []string{`"{digits(0)}"`, `"{digits(2000000000)}"`, `"{upper(-1)}"`, `"{lower(x)}"`, `"{digits()}"`, `"{upper(1,2)}"`} {
if _, err := compile(parse(t, bad)); err == nil {
t.Errorf("compile(%s) = nil error, want the arg rejected", bad)
}
}
for _, ok := range []string{`"{digits(1048576)}"`, `"{upper(1)}"`, `"{lower(26)}"`} {
if _, err := compile(parse(t, ok)); err != nil {
t.Errorf("compile(%s) = %v", ok, err)
}
}
}
+4 -30
View File
@@ -8,16 +8,6 @@ import (
"unicode"
)
// calc is the {calc(expr[, dp])} token: an arithmetic expression over number
// literals and sibling-field names, with + - * /, unary minus and parentheses. It is
// a registry builtin like any other {name(args)} function — the one that names
// operands, which expand reads for it (once per expansion, so the value computed is
// the value the format showed) and hands over as strings. The value prints in
// minimal decimal form, or rounded to dp when given. An operand that isn't a number
// becomes NaN, which propagates and prints as "NaN" — visible, never a render error.
// The expression is parsed once at New into an AST every render shares and none
// mutates, so render cannot fail and must not carry per-render state.
// calcNode is a parsed expression node. It evaluates over the operand values expand
// read, so the evaluator touches neither the rng nor the node tree.
type calcNode interface {
@@ -132,26 +122,10 @@ func indexVars(n calcNode, at map[string]int) calcNode {
return n
}
// calcOperands lists the sibling-field names every {calc(...)} token in a format
// reads, so cycle detection sees the field edges calc renders through (a function
// token otherwise carries no field edge).
func calcOperands(format string) []string {
var names []string
_ = eachToken(format, func(t ftoken) error {
if t.kind == 'b' {
names = append(names, calcTokenOperands(t.body)...)
}
return nil
})
return names
}
// calcTokenOperands lists the sibling-field names one {token} body reads, empty
// for anything that is not a {calc(...)}. checkCalc reports an expression that does
// not parse, so one that does not simply names nothing here.
func calcTokenOperands(body string) []string {
name, args, ok := funcCall(body)
if !ok || name != "calc" || len(args) == 0 {
// calcOperands lists the sibling-field names a calc's args read. checkCalc reports
// an expression that does not parse, so one that does not simply names nothing.
func calcOperands(args []string) []string {
if len(args) == 0 {
return nil
}
expr, err := parseCalc(args[0])
+24 -24
View File
@@ -12,14 +12,14 @@ import (
func TestCalcArithmetic(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"format":"{calc(2 + 3)}"}`: "5",
`{"format":"{calc(2 * 3 + 4)}"}`: "10", // * binds tighter than +
`{"format":"{calc(2 + 3 * 4)}"}`: "14",
`{"format":"{calc((2 + 3) * 4)}"}`: "20", // parentheses override
`{"format":"{calc(10 / 4)}"}`: "2.5",
`{"format":"{calc(-2 + 5)}"}`: "3", // unary minus
`{"format":"{calc(2 - -3)}"}`: "5",
`{"format":"{calc(1.5 * 2)}"}`: "3", // whole result drops the decimals
`"{calc(2 + 3)}"`: "5",
`"{calc(2 * 3 + 4)}"`: "10", // * binds tighter than +
`"{calc(2 + 3 * 4)}"`: "14",
`"{calc((2 + 3) * 4)}"`: "20", // parentheses override
`"{calc(10 / 4)}"`: "2.5",
`"{calc(-2 + 5)}"`: "3", // unary minus
`"{calc(2 - -3)}"`: "5",
`"{calc(1.5 * 2)}"`: "3", // whole result drops the decimals
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
@@ -33,9 +33,9 @@ func TestCalcArithmetic(t *testing.T) {
func TestCalcAuto(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"format":"{calc(10 / 3)}"}`: "3.3333333333333335",
`{"format":"{calc(6 / 2)}"}`: "3",
`{"format":"{calc(1 / 4)}"}`: "0.25",
`"{calc(10 / 3)}"`: "3.3333333333333335",
`"{calc(6 / 2)}"`: "3",
`"{calc(1 / 4)}"`: "0.25",
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
@@ -49,9 +49,9 @@ func TestCalcAuto(t *testing.T) {
func TestCalcDecimals(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"format":"{calc(10 / 3, 2)}"}`: "3.33",
`{"format":"{calc(10 / 3, 0)}"}`: "3",
`{"format":"{calc(2 * 3, 2)}"}`: "6.00",
`"{calc(10 / 3, 2)}"`: "3.33",
`"{calc(10 / 3, 0)}"`: "3",
`"{calc(2 * 3, 2)}"`: "6.00",
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
@@ -63,11 +63,11 @@ func TestCalcDecimals(t *testing.T) {
// TestCalcFields pins that bare names resolve to sibling fields, rendered then
// parsed as numbers.
func TestCalcFields(t *testing.T) {
if got := mustRender(t, engine(1), `{"format":"{calc(price * qty, 2)}","price":["19.99"],"qty":["3"]}`); got != "59.97" {
if got := mustRender(t, engine(1), `{"format":"{calc(price * qty, 2)}","price":"19.99","qty":"3"}`); got != "59.97" {
t.Fatalf("calc over fields = %q, want 59.97", got)
}
// A field that is itself a template renders before parsing.
if got := mustRender(t, engine(1), `{"format":"{calc(a - b)}","a":[{"format":"{n}","n":["10"]}],"b":["3"]}`); got != "7" {
if got := mustRender(t, engine(1), `{"format":"{calc(a - b)}","a":{"format":"{n}","n":"10"},"b":"3"}`); got != "7" {
t.Fatalf("calc(a - b) = %q, want 7", got)
}
}
@@ -75,31 +75,31 @@ func TestCalcFields(t *testing.T) {
// TestCalcNonNumericIsNaN pins the never-fail rule: a field that doesn't render
// to a number becomes NaN, which propagates and prints visibly.
func TestCalcNonNumericIsNaN(t *testing.T) {
if got := mustRender(t, engine(1), `{"format":"{calc(x * 2)}","x":["abc"]}`); got != "NaN" {
if got := mustRender(t, engine(1), `{"format":"{calc(x * 2)}","x":"abc"}`); got != "NaN" {
t.Fatalf("calc over non-numeric field = %q, want NaN", got)
}
}
// TestCalcReproducible pins that a calc over a random operand stays seed-stable.
func TestCalcReproducible(t *testing.T) {
tmpl := `{"format":"{calc(q * 2 + 1)}","q":[{"format":"{int(1,1000000)}"}]}`
tmpl := `{"format":"{calc(q * 2 + 1)}","q":"{int(1,1000000)}"}`
if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b {
t.Fatalf("calc not reproducible: %q != %q", a, b)
}
}
// TestCalcTokenOperandsReadsOnlyACalc pins what the helper answers for a body that
// TestTokenOperandsReadsOnlyAnOperandBuiltin pins what the helper answers for a body that
// is not a calc, and for one whose expression does not parse: nothing, either way.
// checkCalc is what reports a bad expression, so the callers that run after it
// never meet one — but they must not have to depend on that order to be safe.
func TestCalcTokenOperandsReadsOnlyACalc(t *testing.T) {
func TestTokenOperandsReadsOnlyAnOperandBuiltin(t *testing.T) {
for _, body := range []string{"plain", "luhn()", "calc()", "calc(1 +)", "calc(()"} {
if got := calcTokenOperands(body); got != nil {
t.Errorf("calcTokenOperands(%q) = %v, want none", body, got)
if got := tokenOperands(body); got != nil {
t.Errorf("tokenOperands(%q) = %v, want none", body, got)
}
}
if got := calcTokenOperands("calc(net * qty)"); len(got) != 2 {
t.Errorf("calcTokenOperands(calc(net * qty)) = %v, want both operands", got)
if got := tokenOperands("calc(net * qty)"); len(got) != 2 {
t.Errorf("tokenOperands(calc(net * qty)) = %v, want both operands", got)
}
}
+7 -14
View File
@@ -1,24 +1,17 @@
[
{
"format": "{street-number} {street}\n{locality}, {region} {postal-code}",
"street": [
{
"street": {
"format": "{name} {suffix}",
"name": ["Adams", "Ashby", "Aspen", "Bay", "Birch", "Bridge", "Cedar", "Chestnut", "Church", "Clark", "Cypress", "Dogwood", "Elm", "Forest", "Franklin", "Garden", "Grove", "Hawthorn", "Hickory", "Highland", "Jackson", "Jefferson", "Juniper", "Lake", "Laurel", "Liberty", "Lincoln", "Madison", "Magnolia", "Maple", "Market", "Meadow", "Mill", "Oak", "Park", "Pine", "Poplar", "Prospect", "Ridge", "River", "Spruce", "Sunset", "Sycamore", "Union", "Walnut", "Washington", "Willow", "Wilson"],
"suffix": ["Avenue", "Boulevard", "Circle", "Court", "Drive", "Lane", "Place", "Road", "Street", "Terrace", "Trail", "Way"]
}
],
},
"street-number": [
{ "format": "10" },
{ "format": "100", "weight": 2 },
{ "format": "1000", "weight": 0.5 },
{ "format": "10100", "weight": 0.3 }
"{int(10,99)}",
{ "format": "{int(100,999)}", "weight": 2 },
{ "format": "{int(1000,9999)}", "weight": 0.5 },
{ "format": "{int(10,99)}{int(100,999)}", "weight": 0.3 }
],
"locality": ["Albany", "Atlanta", "Austin", "Baltimore", "Boston", "Charlotte", "Chicago", "Cincinnati", "Cleveland", "Columbus", "Dallas", "Denver", "Detroit", "El Paso", "Fort Worth", "Fresno", "Houston", "Indianapolis", "Jacksonville", "Kansas City", "Las Vegas", "Long Beach", "Los Angeles", "Memphis", "Mesa", "Miami", "Milwaukee", "Minneapolis", "Nashville", "New Orleans", "Oakland", "Oklahoma City", "Omaha", "Orlando", "Philadelphia", "Phoenix", "Pittsburgh", "Portland", "Raleigh", "Sacramento", "San Antonio", "San Diego", "San Jose", "Seattle", "St. Louis", "Tampa", "Tucson", "Tulsa"],
"region": ["AL", "AZ", "CA", "CO", "CT", "FL", "GA", "IL", "IN", "KY", "LA", "MA", "MD", "MI", "MN", "MO", "NC", "NJ", "NV", "NY", "OH", "OK", "OR", "PA", "TN", "TX", "VA", "WA", "WI"],
"postal-code": [
{ "format": "10000" },
{ "format": "10000-0000", "weight": 0.3 }
]
"postal-code": ["{int(10000,99999)}", { "format": "{int(10000,99999)}-{digits(4)}", "weight": 0.3 }]
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
[
{
"format": "##{x}{x}{x}{x}{x}{x}",
"format": "#{x}{x}{x}{x}{x}{x}",
"x": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"],
"weight": 2
},
+1 -3
View File
@@ -1,4 +1,3 @@
[
{
"format": "{base} {suffix}",
"base": [
@@ -9,6 +8,5 @@
},
["Atlas Industries", "Beacon Labs", "Cedar & Vine", "Compass Group", "Evergreen Trading", "Harbor Logistics", "Hudson Partners", "Liberty Holdings", "Meridian Capital", "Northgate Studios", "Pinnacle Systems", "Riverside", "Sequoia Foods", "Smith & Sons", "Stonebridge Ventures", "Sunrise Bakery", "Wexford Holdings"]
],
"suffix": ["Co.", "Corp.", "Group", "Inc.", "Inc.", "LLC", "LLC", "Ltd."]
"suffix": ["Co.", "Corp.", "Group", { "format": "Inc.", "weight": 2 }, { "format": "LLC", "weight": 2 }, "Ltd."]
}
]
+6 -8
View File
@@ -1,15 +1,13 @@
[
{
"format": "{month}/{day}/{year}",
"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"],
"year": [
{ "format": "#1970" },
{ "format": "#1980" },
{ "format": "#1990", "weight": 2 },
{ "format": "2#0#00", "weight": 3 },
{ "format": "2#0#10", "weight": 3 },
{ "format": "2#020", "weight": 2 }
"197{digits(1)}",
"198{digits(1)}",
{ "format": "199{digits(1)}", "weight": 2 },
{ "format": "200{digits(1)}", "weight": 3 },
{ "format": "201{digits(1)}", "weight": 3 },
{ "format": "202{digits(1)}", "weight": 2 }
]
}
]
+4 -7
View File
@@ -1,4 +1,3 @@
[
{
"format": "{local}@{domain}",
"local": [
@@ -7,23 +6,21 @@
"weight": 3,
"first": ["alex", "amelia", "andrew", "anna", "ava", "benjamin", "carter", "charlotte", "chloe", "chris", "daniel", "david", "dylan", "elijah", "ella", "emily", "emma", "ethan", "evelyn", "gabriel", "grace", "hannah", "harper", "henry", "isaac", "isabella", "jack", "jacob", "james", "john", "joseph", "julia", "leah", "liam", "linda", "lily", "logan", "lucas", "luke", "mary", "mason", "matthew", "maya", "mia", "michael", "natalie", "noah", "nora", "oliver", "olivia", "owen", "patricia", "robert", "ryan", "samuel", "sarah", "sofia", "sophia", "thomas", "victoria", "william", "zoe"],
"last": ["adams", "allen", "anderson", "bailey", "baker", "bennett", "brooks", "brown", "campbell", "carter", "clark", "collins", "cook", "cooper", "davis", "edwards", "evans", "fisher", "flores", "foster", "garcia", "gonzalez", "gray", "green", "hall", "harris", "hayes", "hernandez", "hill", "howard", "hughes", "jackson", "jenkins", "johnson", "jones", "kelly", "king", "lee", "lewis", "long", "lopez", "martin", "martinez", "miller", "mitchell", "moore", "morgan", "morris", "murphy", "nelson", "parker", "perez", "perry", "phillips", "powell", "price", "reed", "reyes", "rivera", "roberts", "robinson", "rodriguez", "rogers", "ross", "russell", "sanchez", "sanders", "scott", "smith", "stewart", "sullivan", "taylor", "thomas", "thompson", "torres", "turner", "walker", "ward", "watson", "white", "williams", "wilson", "wood", "wright", "young"],
"sep": [".", ".", "_", "-", ""],
"n": ["", "", "", "1", "7", "21", "42", "88", "99", "2024"]
"sep": [{ "format": ".", "weight": 2 }, "_", "-", ""],
"n": [{ "format": "", "weight": 3 }, "1", "7", "21", "42", "88", "99", "2024"]
},
{
"format": "{adj}{noun}{n}",
"weight": 2,
"adj": ["blue", "cool", "dark", "epic", "fast", "fluffy", "frosty", "funky", "fuzzy", "golden", "grumpy", "happy", "hyper", "jazzy", "lazy", "lucky", "mega", "mighty", "neon", "ninja", "quiet", "rad", "rusty", "salty", "shiny", "silent", "sleepy", "snappy", "sneaky", "spicy", "super", "swift", "turbo", "wild", "witty", "zany"],
"noun": ["badger", "banana", "comet", "dragon", "falcon", "ferret", "goblin", "hamster", "koala", "llama", "mango", "muffin", "narwhal", "ninja", "noodle", "otter", "panda", "penguin", "pickle", "pirate", "pixel", "potato", "pretzel", "raccoon", "raptor", "robot", "taco", "toast", "trunk", "turtle", "unicorn", "viking", "waffle", "walrus", "wizard", "yeti"],
"n": ["", "", "7", "13", "14", "42", "77", "99", "123", "420", "2024"]
"n": [{ "format": "", "weight": 2 }, "7", "13", "14", "42", "77", "99", "123", "420", "2024"]
},
{
"format": "{w}{n}",
"weight": 1,
"w": ["blooper", "blupp", "boop", "byteme", "coolcat", "derpina", "doomguy", "epicgamer", "fizzbuzz", "glitchlord", "glorptron", "gronkzilla", "kapowski", "lazerwolf", "megashark", "mooncat", "narfle", "nightowl", "noodlemaster", "oofster", "pixelfox", "pixelpusher", "plonkers", "retrowave", "snork", "splatmaster", "stardust", "superdog", "voidwalker", "vroom", "wahoo", "waooooh", "wootwoot", "yeetus", "zapper", "zoomzoom"],
"n": ["", "", "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
"n": [{ "format": "", "weight": 2 }, "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
}
],
"domain": ["example.com", "example.org", "example.net", "example.io", "example.co", "example.dev", "example.app", "mail.example.com", "mail.example.org", "webmail.example.net", "inbox.example.com", "post.example.org", "demo.example.net", "test.example.org", "dev.example.io"]
}
]
+7 -5
View File
@@ -2,9 +2,11 @@
{
"format": "{o}.{o}.{o}.{o}",
"weight": 8,
"o": [{ "format": "0", "weight": 3 }, { "format": "10", "weight": 4 }, { "format": "#100", "weight": 2 }]
},
{
"format": "{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}"
}
"o": [
{ "format": "{digits(1)}", "weight": 3 },
{ "format": "{int(10,99)}", "weight": 4 },
{ "format": "1{digits(2)}", "weight": 2 }
]
},
"{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}"
]
+1 -10
View File
@@ -1,16 +1,7 @@
[
{
"format": "{prefix}{femalefirst|malefirst} {last}",
"femalefirst": ["Abigail", "Addison", "Amelia", "Aria", "Aubrey", "Audrey", "Aurora", "Ava", "Bella", "Brooklyn", "Camila", "Caroline", "Charlotte", "Chloe", "Claire", "Eleanor", "Elizabeth", "Ella", "Ellie", "Emily", "Emma", "Evelyn", "Gianna", "Grace", "Hannah", "Harper", "Hazel", "Isabella", "Layla", "Leah", "Lillian", "Lily", "Lucy", "Luna", "Madison", "Maya", "Mia", "Mila", "Naomi", "Natalie", "Nora", "Olivia", "Paisley", "Penelope", "Riley", "Savannah", "Scarlett", "Sofia", "Sophia", "Stella", "Victoria", "Violet", "Zoe"],
"malefirst": ["Aiden", "Alexander", "Andrew", "Anthony", "Asher", "Benjamin", "Caleb", "Carter", "Charles", "Christopher", "Daniel", "David", "Dylan", "Elijah", "Ethan", "Ezra", "Gabriel", "Grayson", "Henry", "Isaac", "Jack", "Jackson", "Jacob", "James", "Jayden", "John", "Joseph", "Joshua", "Julian", "Levi", "Liam", "Lincoln", "Logan", "Lucas", "Luke", "Mason", "Mateo", "Matthew", "Michael", "Nathan", "Noah", "Oliver", "Owen", "Samuel", "Sebastian", "Theodore", "Thomas", "William", "Wyatt"],
"last": ["Adams", "Allen", "Anderson", "Bailey", "Baker", "Bell", "Bennett", "Brooks", "Brown", "Campbell", "Carter", "Clark", "Collins", "Cook", "Cooper", "Cox", "Davis", "Edwards", "Evans", "Flores", "Foster", "Garcia", "Gonzalez", "Gray", "Green", "Hall", "Harris", "Hayes", "Hernandez", "Hill", "Howard", "Hughes", "Jackson", "James", "Jenkins", "Johnson", "Jones", "Kelly", "King", "Lee", "Lewis", "Long", "Lopez", "Martin", "Martinez", "Miller", "Mitchell", "Moore", "Morgan", "Morris", "Murphy", "Nelson", "Parker", "Perez", "Perry", "Peterson", "Phillips", "Powell", "Price", "Ramirez", "Reed", "Richardson", "Rivera", "Roberts", "Robinson", "Rodriguez", "Rogers", "Ross", "Russell", "Sanchez", "Sanders", "Scott", "Smith", "Stewart", "Sullivan", "Taylor", "Thomas", "Thompson", "Torres", "Turner", "Walker", "Ward", "Watson", "White", "Williams", "Wilson", "Wood", "Wright", "Young"],
"prefix": [
"",
{
"format": "{title} ",
"title": ["Dr", "Miss", "Mr", "Mrs", "Ms", "Mx", "Prof"],
"weight": 0.1
"prefix": ["", { "format": "{title} ", "title": ["Dr", "Miss", "Mr", "Mrs", "Ms", "Mx", "Prof"], "weight": 0.1 }]
}
]
}
]
+4 -4
View File
@@ -3,13 +3,13 @@
"format": "({area}) {exch}-{line}",
"weight": 2,
"area": ["202", "212", "213", "305", "312", "404", "415", "469", "503", "512", "602", "617", "646", "702", "718", "720", "737", "773", "786", "808", "917"],
"exch": [{ "format": "100" }],
"line": [{ "format": "0000" }]
"exch": "{int(100,999)}",
"line": "{digits(4)}"
},
{
"format": "{area}-{exch}-{line}",
"area": ["202", "212", "213", "305", "312", "404", "415", "469", "503", "512", "602", "617", "646", "702", "718", "720", "737", "773", "786", "808", "917"],
"exch": [{ "format": "100" }],
"line": [{ "format": "0000" }]
"exch": "{int(100,999)}",
"line": "{digits(4)}"
}
]
+7 -14
View File
@@ -1,19 +1,12 @@
[
{
"format": "${amt}.{cents}",
"amt": [
{ "format": "1", "weight": 2 },
{ "format": "10", "weight": 4 },
{ "format": "100", "weight": 3 },
{ "format": "1#,000", "weight": 1 },
{ "format": "10#,000", "weight": 0.4 },
{ "format": "100#,000", "weight": 0.1 }
{ "format": "{int(1,9)}", "weight": 2 },
{ "format": "{int(10,99)}", "weight": 4 },
{ "format": "{int(100,999)}", "weight": 3 },
"{int(1,9)},{digits(3)}",
{ "format": "{int(10,99)},{digits(3)}", "weight": 0.4 },
{ "format": "{int(100,999)},{digits(3)}", "weight": 0.1 }
],
"cents": [
{ "format": "00", "weight": 3 },
"49",
"95",
"99"
]
"cents": [{ "format": "{digits(2)}", "weight": 3 }, "49", "95", "99"]
}
]
+2 -2
View File
@@ -7,7 +7,7 @@
"noun": ["anchor", "archer", "beacon", "bridge", "captain", "cavern", "cottage", "ember", "engine", "falcon", "ferry", "forest", "fox", "garden", "glacier", "harbor", "hermit", "hollow", "island", "lantern", "lighthouse", "mariner", "meadow", "mountain", "orchard", "otter", "pilgrim", "prairie", "quarry", "raven", "ridge", "river", "sailor", "scholar", "signal", "sparrow", "stranger", "summit", "thicket", "tower", "traveler", "valley", "voyager", "wanderer", "willow", "woodland"],
"verb": ["abandons", "anchors", "awakens", "befriends", "builds", "calms", "carries", "chases", "conceals", "crosses", "embraces", "follows", "gathers", "greets", "guards", "guides", "haunts", "heralds", "honors", "kindles", "leads", "mends", "mirrors", "outlasts", "ponders", "reveals", "salutes", "shapes", "shelters", "shields", "summons", "tends", "traces", "weathers"],
"prep": ["above", "across", "against", "alongside", "amid", "around", "beneath", "beside", "between", "beyond", "near", "over", "past", "through", "toward", "under", "within"],
"end": [".", ".", ".", ".", "!"]
"end": [{ "format": ".", "weight": 4 }, "!"]
},
{
"format": "{lead} {noun} {adv} {verb} {prep} {adj} {noun}{end}",
@@ -18,7 +18,7 @@
"verb": ["builds", "carries", "chases", "crosses", "follows", "gathers", "guards", "guides", "leads", "mirrors", "outlasts", "reveals", "shapes", "shelters", "summons", "tends", "traces", "weathers"],
"adj": ["ancient", "bright", "distant", "fierce", "fragile", "gentle", "golden", "hidden", "lonely", "quiet", "restless", "rugged", "silent", "stormy", "weary", "wild"],
"prep": ["across", "against", "alongside", "amid", "beneath", "between", "beyond", "near", "over", "past", "through", "toward", "within"],
"end": [".", ".", ".", "!"]
"end": [{ "format": ".", "weight": 3 }, "!"]
},
{
"format": "{q} {adj} {noun} {verb} {prep} {adj} {noun}?",
+1 -3
View File
@@ -1,3 +1 @@
[
{ "format": "100-00-0000" }
]
"{int(100,999)}-{digits(2)}-{digits(4)}"
+1 -3
View File
@@ -1,8 +1,6 @@
[
{
"format": "{hour}:{minute} {ampm}",
"hour": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
"minute": [{ "format": "{t}0", "t": ["0", "1", "2", "3", "4", "5"] }],
"minute": { "format": "{t}{digits(1)}", "t": ["0", "1", "2", "3", "4", "5"] },
"ampm": ["AM", "PM"]
}
]
+11 -6
View File
@@ -1,12 +1,17 @@
[
{
"format": "https://{host}{path}",
"host": ["example.com", "www.example.com", "example.org", "www.example.org", "example.net", "example.io", "example.co", "blog.example.com", "blog.example.net", "shop.example.com", "store.example.org", "app.example.io", "api.example.com", "docs.example.org", "news.example.net", "mail.example.com"],
"path": [
"",
"",
{ "format": "/{seg}", "seg": ["about", "account", "blog", "careers", "cart", "categories", "checkout", "contact", "dashboard", "downloads", "events", "faq", "features", "help", "home", "items", "login", "news", "order", "pricing", "privacy", "products", "profile", "register", "search", "settings", "signup", "support", "terms", "welcome"] },
{ "format": "/{seg}/{sub}", "weight": 0.5, "seg": ["blog", "docs", "help", "products", "category", "user"], "sub": ["getting-started", "overview", "guide", "2024", "latest", "popular", "archive", "details"] }
]
{ "format": "", "weight": 2 },
{
"format": "/{seg}",
"seg": ["about", "account", "blog", "careers", "cart", "categories", "checkout", "contact", "dashboard", "downloads", "events", "faq", "features", "help", "home", "items", "login", "news", "order", "pricing", "privacy", "products", "profile", "register", "search", "settings", "signup", "support", "terms", "welcome"]
},
{
"format": "/{seg}/{sub}",
"weight": 0.5,
"seg": ["blog", "docs", "help", "products", "category", "user"],
"sub": ["getting-started", "overview", "guide", "2024", "latest", "popular", "archive", "details"]
}
]
}
+5 -6
View File
@@ -4,21 +4,20 @@
"weight": 2,
"first": ["alex", "ash", "avery", "bailey", "blake", "cameron", "casey", "charlie", "chris", "dakota", "drew", "elliot", "emerson", "finley", "frankie", "harley", "hayden", "jamie", "jordan", "kai", "kendall", "lane", "logan", "morgan", "parker", "quinn", "reese", "riley", "rowan", "sam", "sawyer", "skyler", "spencer", "taylor"],
"last": ["adams", "brooks", "carter", "cole", "evans", "fisher", "fox", "grant", "gray", "hayes", "hunt", "lane", "lowe", "marsh", "mason", "morris", "north", "page", "quinn", "reed", "rhodes", "shaw", "stone", "vale", "wells", "west", "wolfe"],
"sep": ["", "", "", ".", "_"],
"n": ["", "", "", "", "1", "7", "12", "23", "42", "77", "99", "2024"]
"sep": [{ "format": "", "weight": 3 }, ".", "_"],
"n": [{ "format": "", "weight": 4 }, "1", "7", "12", "23", "42", "77", "99", "2024"]
},
{
"format": "{adj}{sep}{noun}{n}",
"weight": 2,
"adj": ["bluish", "brave", "chill", "cosmic", "cool", "dizzy", "electric", "epic", "fluffy", "frosty", "funky", "fuzzy", "golden", "groovy", "grumpy", "happy", "hyper", "jazzy", "lucky", "mega", "mighty", "neon", "nimble", "quirky", "rad", "rusty", "salty", "shiny", "silent", "sleepy", "snappy", "sneaky", "sparkly", "spicy", "sunny", "super", "swift", "turbo", "wild", "witty", "zany"],
"noun": ["badger", "biscuit", "cactus", "comet", "donut", "dragon", "falcon", "ferret", "gizmo", "goblin", "hamster", "koala", "llama", "mango", "muffin", "narwhal", "noodle", "otter", "panda", "penguin", "pickle", "pirate", "pixel", "potato", "pretzel", "raccoon", "raptor", "robot", "taco", "toast", "trunk", "turtle", "unicorn", "viking", "waffle", "walrus", "wizard", "yeti"],
"sep": ["", "", "", ".", "_"],
"n": ["", "", "7", "13", "42", "77", "99", "123", "420", "2024"]
"sep": [{ "format": "", "weight": 3 }, ".", "_"],
"n": [{ "format": "", "weight": 2 }, "7", "13", "42", "77", "99", "123", "420", "2024"]
},
{
"format": "{w}{n}",
"weight": 1,
"w": ["blooper", "blupp", "boop", "byteme", "coolcat", "derpina", "doomguy", "epicgamer", "fizzbuzz", "glitchlord", "glorptron", "gronkzilla", "kapowski", "lazerwolf", "megashark", "mooncat", "narfle", "nightowl", "noodlemaster", "oofster", "pixelfox", "pixelpusher", "plonkers", "retrowave", "snork", "splatmaster", "stardust", "superdog", "voidwalker", "vroom", "wahoo", "waooooh", "wootwoot", "yeetus", "zapper", "zoomzoom"],
"n": ["", "", "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
"n": [{ "format": "", "weight": 2 }, "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
}
]
+1 -3
View File
@@ -1,8 +1,6 @@
[
{
"format": "{pre}{n}.{n}.{n}{suffix}",
"n": [{ "format": "0", "weight": 3 }, { "format": "10" }],
"n": [{ "format": "{digits(1)}", "weight": 3 }, "{int(10,99)}"],
"pre": ["", { "format": "v", "weight": 0.4 }],
"suffix": ["", { "format": "-{tag}.{m}", "tag": ["alpha", "beta", "rc"], "m": ["1", "2", "3"], "weight": 0.3 }]
}
]
+8 -8
View File
@@ -1,10 +1,10 @@
[
{ "format": "{maker} {model}", "maker": ["BMW"], "model": ["3 Series", "5 Series", "X3", "X5"] },
{ "format": "{maker} {model}", "maker": ["Ford"], "model": ["Fiesta", "Focus", "Mustang", "Explorer"] },
{ "format": "{maker} {model}", "maker": ["Honda"], "model": ["Civic", "Accord", "CR-V", "Jazz"] },
{ "format": "{maker} {model}", "maker": ["Mercedes-Benz"], "model": ["A-Class", "C-Class", "E-Class", "GLC"] },
{ "format": "{maker} {model}", "maker": ["Tesla"], "model": ["Model 3", "Model S", "Model X", "Model Y"] },
{ "format": "{maker} {model}", "maker": ["Toyota"], "model": ["Corolla", "Camry", "RAV4", "Yaris"] },
{ "format": "{maker} {model}", "maker": ["Volkswagen"], "model": ["Golf", "Passat", "Polo", "Tiguan"] },
{ "format": "{maker} {model}", "maker": ["Volvo"], "model": ["XC40", "XC60", "XC90", "V60"] }
{ "format": "{maker} {model}", "maker": "BMW", "model": ["3 Series", "5 Series", "X3", "X5"] },
{ "format": "{maker} {model}", "maker": "Ford", "model": ["Fiesta", "Focus", "Mustang", "Explorer"] },
{ "format": "{maker} {model}", "maker": "Honda", "model": ["Civic", "Accord", "CR-V", "Jazz"] },
{ "format": "{maker} {model}", "maker": "Mercedes-Benz", "model": ["A-Class", "C-Class", "E-Class", "GLC"] },
{ "format": "{maker} {model}", "maker": "Tesla", "model": ["Model 3", "Model S", "Model X", "Model Y"] },
{ "format": "{maker} {model}", "maker": "Toyota", "model": ["Corolla", "Camry", "RAV4", "Yaris"] },
{ "format": "{maker} {model}", "maker": "Volkswagen", "model": ["Golf", "Passat", "Polo", "Tiguan"] },
{ "format": "{maker} {model}", "maker": "Volvo", "model": ["XC40", "XC60", "XC90", "V60"] }
]
+1 -7
View File
@@ -1,7 +1 @@
[
{
"format": "{lat}, {lon}",
"lat": { "format": "{float(-90,90,6)}" },
"lon": { "format": "{float(-180,180,6)}" }
}
]
{ "format": "{lat}, {lon}", "lat": "{float(-90,90,6)}", "lon": "{float(-180,180,6)}" }
+20 -20
View File
@@ -1,22 +1,22 @@
[
{ "format": "{name}", "name": ["Australia"], "alpha2": ["AU"], "alpha3": ["AUS"] },
{ "format": "{name}", "name": ["Brazil"], "alpha2": ["BR"], "alpha3": ["BRA"] },
{ "format": "{name}", "name": ["Canada"], "alpha2": ["CA"], "alpha3": ["CAN"] },
{ "format": "{name}", "name": ["China"], "alpha2": ["CN"], "alpha3": ["CHN"] },
{ "format": "{name}", "name": ["Denmark"], "alpha2": ["DK"], "alpha3": ["DNK"] },
{ "format": "{name}", "name": ["Finland"], "alpha2": ["FI"], "alpha3": ["FIN"] },
{ "format": "{name}", "name": ["France"], "alpha2": ["FR"], "alpha3": ["FRA"] },
{ "format": "{name}", "name": ["Germany"], "alpha2": ["DE"], "alpha3": ["DEU"] },
{ "format": "{name}", "name": ["India"], "alpha2": ["IN"], "alpha3": ["IND"] },
{ "format": "{name}", "name": ["Italy"], "alpha2": ["IT"], "alpha3": ["ITA"] },
{ "format": "{name}", "name": ["Japan"], "alpha2": ["JP"], "alpha3": ["JPN"] },
{ "format": "{name}", "name": ["Mexico"], "alpha2": ["MX"], "alpha3": ["MEX"] },
{ "format": "{name}", "name": ["Netherlands"], "alpha2": ["NL"], "alpha3": ["NLD"] },
{ "format": "{name}", "name": ["Norway"], "alpha2": ["NO"], "alpha3": ["NOR"] },
{ "format": "{name}", "name": ["Poland"], "alpha2": ["PL"], "alpha3": ["POL"] },
{ "format": "{name}", "name": ["Spain"], "alpha2": ["ES"], "alpha3": ["ESP"] },
{ "format": "{name}", "name": ["Sweden"], "alpha2": ["SE"], "alpha3": ["SWE"] },
{ "format": "{name}", "name": ["Switzerland"], "alpha2": ["CH"], "alpha3": ["CHE"] },
{ "format": "{name}", "name": ["United Kingdom"], "alpha2": ["GB"], "alpha3": ["GBR"] },
{ "format": "{name}", "name": ["United States"], "alpha2": ["US"], "alpha3": ["USA"] }
{ "format": "{name}", "name": "Australia", "alpha2": "AU", "alpha3": "AUS" },
{ "format": "{name}", "name": "Brazil", "alpha2": "BR", "alpha3": "BRA" },
{ "format": "{name}", "name": "Canada", "alpha2": "CA", "alpha3": "CAN" },
{ "format": "{name}", "name": "China", "alpha2": "CN", "alpha3": "CHN" },
{ "format": "{name}", "name": "Denmark", "alpha2": "DK", "alpha3": "DNK" },
{ "format": "{name}", "name": "Finland", "alpha2": "FI", "alpha3": "FIN" },
{ "format": "{name}", "name": "France", "alpha2": "FR", "alpha3": "FRA" },
{ "format": "{name}", "name": "Germany", "alpha2": "DE", "alpha3": "DEU" },
{ "format": "{name}", "name": "India", "alpha2": "IN", "alpha3": "IND" },
{ "format": "{name}", "name": "Italy", "alpha2": "IT", "alpha3": "ITA" },
{ "format": "{name}", "name": "Japan", "alpha2": "JP", "alpha3": "JPN" },
{ "format": "{name}", "name": "Mexico", "alpha2": "MX", "alpha3": "MEX" },
{ "format": "{name}", "name": "Netherlands", "alpha2": "NL", "alpha3": "NLD" },
{ "format": "{name}", "name": "Norway", "alpha2": "NO", "alpha3": "NOR" },
{ "format": "{name}", "name": "Poland", "alpha2": "PL", "alpha3": "POL" },
{ "format": "{name}", "name": "Spain", "alpha2": "ES", "alpha3": "ESP" },
{ "format": "{name}", "name": "Sweden", "alpha2": "SE", "alpha3": "SWE" },
{ "format": "{name}", "name": "Switzerland", "alpha2": "CH", "alpha3": "CHE" },
{ "format": "{name}", "name": "United Kingdom", "alpha2": "GB", "alpha3": "GBR" },
{ "format": "{name}", "name": "United States", "alpha2": "US", "alpha3": "USA" }
]
+16 -3
View File
@@ -1,5 +1,18 @@
[
{ "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 }
{
"format": "4{d}{luhn()}",
"d": { "format": "{digits(1)}", "repeat": 14 },
"weight": 4
},
{
"format": "5{m}{d}{luhn()}",
"m": ["1", "2", "3", "4", "5"],
"d": { "format": "{digits(1)}", "repeat": 13 },
"weight": 3
},
{
"format": "3{m}{d}{luhn()}",
"m": ["4", "7"],
"d": { "format": "{digits(1)}", "repeat": 12 }
}
]
+16 -16
View File
@@ -1,18 +1,18 @@
[
{ "format": "{code}", "code": ["AUD"], "name": ["Australian Dollar"], "symbol": ["$"] },
{ "format": "{code}", "code": ["BRL"], "name": ["Brazilian Real"], "symbol": ["R$"] },
{ "format": "{code}", "code": ["CAD"], "name": ["Canadian Dollar"], "symbol": ["$"] },
{ "format": "{code}", "code": ["CHF"], "name": ["Swiss Franc"], "symbol": ["CHF"] },
{ "format": "{code}", "code": ["CNY"], "name": ["Chinese Yuan"], "symbol": ["¥"] },
{ "format": "{code}", "code": ["DKK"], "name": ["Danish Krone"], "symbol": ["kr"] },
{ "format": "{code}", "code": ["EUR"], "name": ["Euro"], "symbol": ["€"] },
{ "format": "{code}", "code": ["GBP"], "name": ["Pound Sterling"], "symbol": ["£"] },
{ "format": "{code}", "code": ["INR"], "name": ["Indian Rupee"], "symbol": ["₹"] },
{ "format": "{code}", "code": ["JPY"], "name": ["Japanese Yen"], "symbol": ["¥"] },
{ "format": "{code}", "code": ["MXN"], "name": ["Mexican Peso"], "symbol": ["$"] },
{ "format": "{code}", "code": ["NOK"], "name": ["Norwegian Krone"], "symbol": ["kr"] },
{ "format": "{code}", "code": ["PLN"], "name": ["Polish Zloty"], "symbol": ["zł"] },
{ "format": "{code}", "code": ["SEK"], "name": ["Swedish Krona"], "symbol": ["kr"] },
{ "format": "{code}", "code": ["USD"], "name": ["US Dollar"], "symbol": ["$"] },
{ "format": "{code}", "code": ["ZAR"], "name": ["South African Rand"], "symbol": ["R"] }
{ "format": "{code}", "code": "AUD", "name": "Australian Dollar", "symbol": "$" },
{ "format": "{code}", "code": "BRL", "name": "Brazilian Real", "symbol": "R$" },
{ "format": "{code}", "code": "CAD", "name": "Canadian Dollar", "symbol": "$" },
{ "format": "{code}", "code": "CHF", "name": "Swiss Franc", "symbol": "CHF" },
{ "format": "{code}", "code": "CNY", "name": "Chinese Yuan", "symbol": "¥" },
{ "format": "{code}", "code": "DKK", "name": "Danish Krone", "symbol": "kr" },
{ "format": "{code}", "code": "EUR", "name": "Euro", "symbol": "€" },
{ "format": "{code}", "code": "GBP", "name": "Pound Sterling", "symbol": "£" },
{ "format": "{code}", "code": "INR", "name": "Indian Rupee", "symbol": "₹" },
{ "format": "{code}", "code": "JPY", "name": "Japanese Yen", "symbol": "¥" },
{ "format": "{code}", "code": "MXN", "name": "Mexican Peso", "symbol": "$" },
{ "format": "{code}", "code": "NOK", "name": "Norwegian Krone", "symbol": "kr" },
{ "format": "{code}", "code": "PLN", "name": "Polish Zloty", "symbol": "zł" },
{ "format": "{code}", "code": "SEK", "name": "Swedish Krona", "symbol": "kr" },
{ "format": "{code}", "code": "USD", "name": "US Dollar", "symbol": "$" },
{ "format": "{code}", "code": "ZAR", "name": "South African Rand", "symbol": "R" }
]
+1 -6
View File
@@ -1,6 +1 @@
[
"😀", "😂", "😍", "🤔", "😎", "😭", "😡", "👍", "👎", "🙏",
"👏", "🙌", "💪", "🔥", "✨", "🎉", "❤️", "💔", "💯", "👀",
"🚀", "⭐", "🌈", "☀️", "🌙", "⚡", "❄️", "🍕", "🍔", "🍺",
"☕", "🎂", "🐶", "🐱", "🦊", "🐢", "🦄", "🌸", "🌍", "💡"
]
["😀", "😂", "😍", "🤔", "😎", "😭", "😡", "👍", "👎", "🙏", "👏", "🙌", "💪", "🔥", "✨", "🎉", "❤️", "💔", "💯", "👀", "🚀", "⭐", "🌈", "☀️", "🌙", "⚡", "❄️", "🍕", "🍔", "🍺", "☕", "🎂", "🐶", "🐱", "🦊", "🐢", "🦄", "🌸", "🌍", "💡"]
+16 -16
View File
@@ -1,18 +1,18 @@
[
{ "format": "{code} {reason}", "code": ["200"], "reason": ["OK"] },
{ "format": "{code} {reason}", "code": ["201"], "reason": ["Created"] },
{ "format": "{code} {reason}", "code": ["204"], "reason": ["No Content"] },
{ "format": "{code} {reason}", "code": ["301"], "reason": ["Moved Permanently"] },
{ "format": "{code} {reason}", "code": ["302"], "reason": ["Found"] },
{ "format": "{code} {reason}", "code": ["304"], "reason": ["Not Modified"] },
{ "format": "{code} {reason}", "code": ["400"], "reason": ["Bad Request"] },
{ "format": "{code} {reason}", "code": ["401"], "reason": ["Unauthorized"] },
{ "format": "{code} {reason}", "code": ["403"], "reason": ["Forbidden"] },
{ "format": "{code} {reason}", "code": ["404"], "reason": ["Not Found"] },
{ "format": "{code} {reason}", "code": ["409"], "reason": ["Conflict"] },
{ "format": "{code} {reason}", "code": ["422"], "reason": ["Unprocessable Entity"] },
{ "format": "{code} {reason}", "code": ["429"], "reason": ["Too Many Requests"] },
{ "format": "{code} {reason}", "code": ["500"], "reason": ["Internal Server Error"] },
{ "format": "{code} {reason}", "code": ["502"], "reason": ["Bad Gateway"] },
{ "format": "{code} {reason}", "code": ["503"], "reason": ["Service Unavailable"] }
{ "format": "{code} {reason}", "code": "200", "reason": "OK" },
{ "format": "{code} {reason}", "code": "201", "reason": "Created" },
{ "format": "{code} {reason}", "code": "204", "reason": "No Content" },
{ "format": "{code} {reason}", "code": "301", "reason": "Moved Permanently" },
{ "format": "{code} {reason}", "code": "302", "reason": "Found" },
{ "format": "{code} {reason}", "code": "304", "reason": "Not Modified" },
{ "format": "{code} {reason}", "code": "400", "reason": "Bad Request" },
{ "format": "{code} {reason}", "code": "401", "reason": "Unauthorized" },
{ "format": "{code} {reason}", "code": "403", "reason": "Forbidden" },
{ "format": "{code} {reason}", "code": "404", "reason": "Not Found" },
{ "format": "{code} {reason}", "code": "409", "reason": "Conflict" },
{ "format": "{code} {reason}", "code": "422", "reason": "Unprocessable Entity" },
{ "format": "{code} {reason}", "code": "429", "reason": "Too Many Requests" },
{ "format": "{code} {reason}", "code": "500", "reason": "Internal Server Error" },
{ "format": "{code} {reason}", "code": "502", "reason": "Bad Gateway" },
{ "format": "{code} {reason}", "code": "503", "reason": "Service Unavailable" }
]
+16 -16
View File
@@ -1,18 +1,18 @@
[
{ "format": "{name}", "name": ["Arabic"], "code": ["ar"] },
{ "format": "{name}", "name": ["Chinese"], "code": ["zh"] },
{ "format": "{name}", "name": ["Danish"], "code": ["da"] },
{ "format": "{name}", "name": ["Dutch"], "code": ["nl"] },
{ "format": "{name}", "name": ["English"], "code": ["en"] },
{ "format": "{name}", "name": ["Finnish"], "code": ["fi"] },
{ "format": "{name}", "name": ["French"], "code": ["fr"] },
{ "format": "{name}", "name": ["German"], "code": ["de"] },
{ "format": "{name}", "name": ["Italian"], "code": ["it"] },
{ "format": "{name}", "name": ["Japanese"], "code": ["ja"] },
{ "format": "{name}", "name": ["Norwegian"], "code": ["no"] },
{ "format": "{name}", "name": ["Polish"], "code": ["pl"] },
{ "format": "{name}", "name": ["Portuguese"], "code": ["pt"] },
{ "format": "{name}", "name": ["Russian"], "code": ["ru"] },
{ "format": "{name}", "name": ["Spanish"], "code": ["es"] },
{ "format": "{name}", "name": ["Swedish"], "code": ["sv"] }
{ "format": "{name}", "name": "Arabic", "code": "ar" },
{ "format": "{name}", "name": "Chinese", "code": "zh" },
{ "format": "{name}", "name": "Danish", "code": "da" },
{ "format": "{name}", "name": "Dutch", "code": "nl" },
{ "format": "{name}", "name": "English", "code": "en" },
{ "format": "{name}", "name": "Finnish", "code": "fi" },
{ "format": "{name}", "name": "French", "code": "fr" },
{ "format": "{name}", "name": "German", "code": "de" },
{ "format": "{name}", "name": "Italian", "code": "it" },
{ "format": "{name}", "name": "Japanese", "code": "ja" },
{ "format": "{name}", "name": "Norwegian", "code": "no" },
{ "format": "{name}", "name": "Polish", "code": "pl" },
{ "format": "{name}", "name": "Portuguese", "code": "pt" },
{ "format": "{name}", "name": "Russian", "code": "ru" },
{ "format": "{name}", "name": "Spanish", "code": "es" },
{ "format": "{name}", "name": "Swedish", "code": "sv" }
]
+1 -3
View File
@@ -1,3 +1 @@
[
{ "format": "{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}" }
]
"{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}:{hex(2)}"
+15 -15
View File
@@ -1,17 +1,17 @@
[
{ "format": "{type}", "type": ["application/gzip"], "ext": [".gz"] },
{ "format": "{type}", "type": ["application/json"], "ext": [".json"] },
{ "format": "{type}", "type": ["application/pdf"], "ext": [".pdf"] },
{ "format": "{type}", "type": ["application/xml"], "ext": [".xml"] },
{ "format": "{type}", "type": ["application/zip"], "ext": [".zip"] },
{ "format": "{type}", "type": ["audio/mpeg"], "ext": [".mp3"] },
{ "format": "{type}", "type": ["image/gif"], "ext": [".gif"] },
{ "format": "{type}", "type": ["image/jpeg"], "ext": [".jpg"] },
{ "format": "{type}", "type": ["image/png"], "ext": [".png"] },
{ "format": "{type}", "type": ["image/svg+xml"], "ext": [".svg"] },
{ "format": "{type}", "type": ["image/webp"], "ext": [".webp"] },
{ "format": "{type}", "type": ["text/csv"], "ext": [".csv"] },
{ "format": "{type}", "type": ["text/html"], "ext": [".html"] },
{ "format": "{type}", "type": ["text/plain"], "ext": [".txt"] },
{ "format": "{type}", "type": ["video/mp4"], "ext": [".mp4"] }
{ "format": "{type}", "type": "application/gzip", "ext": ".gz" },
{ "format": "{type}", "type": "application/json", "ext": ".json" },
{ "format": "{type}", "type": "application/pdf", "ext": ".pdf" },
{ "format": "{type}", "type": "application/xml", "ext": ".xml" },
{ "format": "{type}", "type": "application/zip", "ext": ".zip" },
{ "format": "{type}", "type": "audio/mpeg", "ext": ".mp3" },
{ "format": "{type}", "type": "image/gif", "ext": ".gif" },
{ "format": "{type}", "type": "image/jpeg", "ext": ".jpg" },
{ "format": "{type}", "type": "image/png", "ext": ".png" },
{ "format": "{type}", "type": "image/svg+xml", "ext": ".svg" },
{ "format": "{type}", "type": "image/webp", "ext": ".webp" },
{ "format": "{type}", "type": "text/csv", "ext": ".csv" },
{ "format": "{type}", "type": "text/html", "ext": ".html" },
{ "format": "{type}", "type": "text/plain", "ext": ".txt" },
{ "format": "{type}", "type": "video/mp4", "ext": ".mp4" }
]
+1 -3
View File
@@ -1,3 +1 @@
[
{ "format": "{hex(24)}" }
]
"{hex(24)}"
+1 -29
View File
@@ -1,29 +1 @@
[
"UTC",
"Africa/Cairo",
"Africa/Johannesburg",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Mexico_City",
"America/New_York",
"America/Sao_Paulo",
"America/Toronto",
"Asia/Dubai",
"Asia/Hong_Kong",
"Asia/Kolkata",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Berlin",
"Europe/London",
"Europe/Madrid",
"Europe/Moscow",
"Europe/Oslo",
"Europe/Paris",
"Europe/Stockholm",
"Europe/Warsaw",
"Pacific/Auckland"
]
["UTC", "Africa/Cairo", "Africa/Johannesburg", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Mexico_City", "America/New_York", "America/Sao_Paulo", "America/Toronto", "Asia/Dubai", "Asia/Hong_Kong", "Asia/Kolkata", "Asia/Shanghai", "Asia/Singapore", "Asia/Tokyo", "Australia/Sydney", "Europe/Amsterdam", "Europe/Berlin", "Europe/London", "Europe/Madrid", "Europe/Moscow", "Europe/Oslo", "Europe/Paris", "Europe/Stockholm", "Europe/Warsaw", "Pacific/Auckland"]
+1 -10
View File
@@ -1,10 +1 @@
[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"
]
["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"]
+1 -6
View File
@@ -1,6 +1 @@
[
{
"format": "{hex(8)}-{hex(4)}-4{hex(3)}-{variant}{hex(3)}-{hex(12)}",
"variant": ["8", "9", "a", "b"]
}
]
{ "format": "{hex(8)}-{hex(4)}-4{hex(3)}-{variant}{hex(3)}-{hex(12)}", "variant": ["8", "9", "a", "b"] }
+7 -11
View File
@@ -1,4 +1,3 @@
[
{
"format": "{street} {street-number}\n{postal-code} {locality}",
"street": [
@@ -10,16 +9,13 @@
["Avenyn", "Birger Jarlsgatan", "Promenaden", "Staby", "Sveavägen", "Vintjärn"]
],
"street-number": [
{ "format": "1" },
{ "format": "10" },
{ "format": "100", "weight": 0.2 },
{ "format": "1A", "weight": 0.2 },
{ "format": "10A", "weight": 0.1 },
{ "format": "100A", "weight": 0.05 }
],
"postal-code": [
{ "format": "100 10" }
"{int(1,9)}",
"{int(10,99)}",
{ "format": "{int(100,999)}", "weight": 0.2 },
{ "format": "{int(1,9)}{upper(1)}", "weight": 0.2 },
{ "format": "{int(10,99)}{upper(1)}", "weight": 0.1 },
{ "format": "{int(100,999)}{upper(1)}", "weight": 0.05 }
],
"postal-code": "{int(100,999)} {int(10,99)}",
"locality": ["Alingsås", "Alvesta", "Ängelholm", "Arboga", "Arvika", "Avesta", "Boden", "Bollnäs", "Borås", "Borlänge", "Enköping", "Eskilstuna", "Eslöv", "Fagersta", "Falkenberg", "Falköping", "Falun", "Finspång", "Gällivare", "Gävle", "Göteborg", "Halmstad", "Haparanda", "Härnösand", "Hässleholm", "Helsingborg", "Huddinge", "Hudiksvall", "Jönköping", "Kalmar", "Karlshamn", "Karlskoga", "Karlskrona", "Karlstad", "Katrineholm", "Kiruna", "Köping", "Kramfors", "Kristianstad", "Kristinehamn", "Landskrona", "Lidingö", "Lidköping", "Lindesberg", "Linköping", "Ljungby", "Ludvika", "Luleå", "Lund", "Lycksele", "Malmö", "Mariestad", "Mjölby", "Mölndal", "Mora", "Motala", "Nacka", "Nässjö", "Norrköping", "Norrtälje", "Nyköping", "Nynäshamn", "Örebro", "Örnsköldsvik", "Oskarshamn", "Östersund", "Piteå", "Rabbalshede", "Ronneby", "Säffle", "Sandviken", "Sävsjö", "Sigtuna", "Skara", "Skellefteå", "Skövde", "Söderhamn", "Södertälje", "Sollentuna", "Solna", "Sölvesborg", "Stockholm", "Strängnäs", "Sundbyberg", "Sundsvall", "Täby", "Tierp", "Tranås", "Trelleborg", "Trollhättan", "Uddevalla", "Ulricehamn", "Umeå", "Upplands Väsby", "Uppsala", "Vänersborg", "Varberg", "Värnamo", "Västerås", "Västervik", "Växjö", "Vetlanda", "Vimmerby", "Visby", "Ystad"]
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
[
{
"format": "##{x}{x}{x}{x}{x}{x}",
"format": "#{x}{x}{x}{x}{x}{x}",
"x": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"],
"weight": 2
},
+1 -3
View File
@@ -1,4 +1,3 @@
[
{
"format": "{base} {suffix}",
"base": [
@@ -9,6 +8,5 @@
},
["Aktiebolaget Vega", "Bröderna Lund", "Ekonomihuset", "Hantverkargruppen", "Lindberg", "Mälardalens Bygg", "Nordström", "Skandinaviska Handelshuset", "Sundbergs Verkstad", "Svensson & Co", "Vasa Logistik"]
],
"suffix": ["AB", "AB", "AB", "AB", "HB", "KB"]
"suffix": [{ "format": "AB", "weight": 4 }, "HB", "KB"]
}
]
+6 -8
View File
@@ -1,15 +1,13 @@
[
{
"format": "{year}-{month}-{day}",
"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"],
"year": [
{ "format": "#1970" },
{ "format": "#1980" },
{ "format": "#1990", "weight": 2 },
{ "format": "2#0#00", "weight": 3 },
{ "format": "2#0#10", "weight": 3 },
{ "format": "2#020", "weight": 2 }
"197{digits(1)}",
"198{digits(1)}",
{ "format": "199{digits(1)}", "weight": 2 },
{ "format": "200{digits(1)}", "weight": 3 },
{ "format": "201{digits(1)}", "weight": 3 },
{ "format": "202{digits(1)}", "weight": 2 }
]
}
]
+4 -7
View File
@@ -1,4 +1,3 @@
[
{
"format": "{local}@{domain}",
"local": [
@@ -7,23 +6,21 @@
"weight": 3,
"first": ["agnes", "alice", "anders", "anna", "anton", "asa", "axel", "bjorn", "david", "ebba", "elin", "elsa", "emil", "emma", "erik", "ester", "frida", "fredrik", "gustav", "hampus", "hanna", "henrik", "hugo", "ida", "ingrid", "isak", "johan", "jonas", "julia", "karin", "kjell", "klara", "lars", "lena", "linus", "magnus", "maja", "mats", "mattias", "mikael", "moa", "nils", "olle", "oskar", "par", "patrik", "per", "pontus", "rasmus", "samuel", "sara", "sofia", "soren", "stina", "sven", "tobias", "tuva", "viktor", "wilma", "ylva"],
"last": ["ahlberg", "andersson", "berg", "berglund", "bergstrom", "blom", "dahlberg", "eklund", "ekstrom", "engstrom", "eriksson", "falk", "forsberg", "fredriksson", "gustafsson", "hellstrom", "holm", "holmberg", "jakobsson", "johansson", "jonsson", "karlsson", "larsson", "lind", "lindberg", "lindgren", "lindqvist", "lundberg", "lundgren", "lundqvist", "magnusson", "nilsson", "norberg", "nystrom", "olsson", "persson", "petersson", "samuelsson", "sandberg", "sjoberg", "sjogren", "strand", "strom", "sundberg", "svensson", "soderberg", "wallin", "aberg", "oberg"],
"sep": [".", ".", "_", "-", ""],
"n": ["", "", "", "1", "7", "42", "88", "99"]
"sep": [{ "format": ".", "weight": 2 }, "_", "-", ""],
"n": [{ "format": "", "weight": 3 }, "1", "7", "42", "88", "99"]
},
{
"format": "{adj}{noun}{n}",
"weight": 2,
"adj": ["blixt", "busig", "cool", "frisk", "galen", "glad", "kaxig", "kvick", "listig", "lugn", "mega", "modig", "mysig", "pigg", "rolig", "smart", "snabb", "stark", "super", "trygg", "turbo", "varm", "vild"],
"noun": ["abborren", "bamsen", "bjorn", "drake", "ekorre", "falken", "grodan", "hund", "igelkott", "kaninen", "katt", "korpen", "krabban", "musen", "nallen", "ninja", "orn", "panda", "pirat", "raven", "robot", "sillen", "sparven", "uggla", "varg", "vargen"],
"n": ["", "", "7", "13", "14", "42", "77", "99", "123", "420", "2024"]
"n": [{ "format": "", "weight": 2 }, "7", "13", "14", "42", "77", "99", "123", "420", "2024"]
},
{
"format": "{w}{n}",
"weight": 1,
"w": ["blip", "blupp", "boop", "busigekorre", "coolkatten", "doot", "fizz", "floof", "galnaalgen", "gladgrodan", "glorp", "kapow", "kazoo", "kvickharen", "listigaraven", "megaraven", "mysmoln", "nattugglan", "pew", "pigghund", "pixelfox", "plonk", "rolighund", "snabbkatten", "snork", "splat", "superhunden", "turbohund", "vildvargen", "vroom", "wahoo", "waooooh", "woot", "yeet", "zap", "zoom"],
"n": ["", "", "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
"n": [{ "format": "", "weight": 2 }, "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
}
],
"domain": ["example.com", "example.org", "example.net", "example.se", "example.nu", "example.io", "mail.example.se", "webmail.example.se", "inkorg.example.se", "post.example.se", "demo.example.se", "test.example.org", "dev.example.se"]
}
]
+7 -5
View File
@@ -2,9 +2,11 @@
{
"format": "{o}.{o}.{o}.{o}",
"weight": 8,
"o": [{ "format": "0", "weight": 3 }, { "format": "10", "weight": 4 }, { "format": "#100", "weight": 2 }]
},
{
"format": "{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}"
}
"o": [
{ "format": "{digits(1)}", "weight": 3 },
{ "format": "{int(10,99)}", "weight": 4 },
{ "format": "1{digits(2)}", "weight": 2 }
]
},
"{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}:{hex(4)}"
]
+2 -11
View File
@@ -1,4 +1,3 @@
[
{
"format": "{prefix}{femalefirst|malefirst} {last}",
"femalefirst": ["Agnes", "Alice", "Alicia", "Alma", "Amanda", "Anna", "Astrid", "Cornelia", "Ebba", "Elin", "Ella", "Ellen", "Elsa", "Emilia", "Emma", "Ester", "Eva", "Frida", "Hanna", "Hedda", "Ida", "Ingrid", "Isabelle", "Julia", "Karin", "Klara", "Kristina", "Lena", "Linnéa", "Lova", "Maja", "Maria", "Moa", "Molly", "Märta", "Nellie", "Nora", "Olivia", "Saga", "Sara", "Selma", "Signe", "Siri", "Sofia", "Stina", "Tilde", "Tuva", "Wilma", "Ylva", "Åsa"],
@@ -24,18 +23,10 @@
{
"format": "{first}{last}",
"weight": 0.133,
"first": ["Norr"],
"first": "Norr",
"last": ["berg", "blad", "crona", "dahl", "ed", "fors", "gren", "holm", "in", "kvist", "löf", "lund", "man", "mark", "qvist", "stedt", "sten", "strand", "ström", "vall"]
},
["Berg", "Blom", "Eismar", "Falk", "Holm", "Lind", "Norberg", "Strand", "Ström", "von Flemming", "Åberg", "Öberg"]
],
"prefix": [
"",
{
"format": "{string} ",
"string": ["dr", "prof"],
"weight": 0.05
"prefix": ["", { "format": "{string} ", "string": ["dr", "prof"], "weight": 0.05 }]
}
]
}
]
+6 -6
View File
@@ -3,15 +3,15 @@
"format": "{prefix}-{a} {b} {c}",
"weight": 10,
"prefix": ["070", "072", "073", "076", "079"],
"a": [{ "format": "000" }],
"b": [{ "format": "00" }],
"c": [{ "format": "00" }]
"a": "{digits(3)}",
"b": "{digits(2)}",
"c": "{digits(2)}"
},
{
"format": "{prefix}-{a} {b} {c}",
"prefix": ["08", "011", "013", "018", "019", "021", "023", "026", "031", "033", "035", "036", "040", "042", "044", "046", "054", "060", "063", "090"],
"a": [{ "format": "000" }],
"b": [{ "format": "00" }],
"c": [{ "format": "00" }]
"a": "{digits(3)}",
"b": "{digits(2)}",
"c": "{digits(2)}"
}
]
+6 -8
View File
@@ -1,13 +1,11 @@
[
{
"format": "{amt}{ore} kr",
"amt": [
{ "format": "1", "weight": 2 },
{ "format": "10", "weight": 4 },
{ "format": "100", "weight": 3 },
{ "format": "1 000", "weight": 1 },
{ "format": "10 000", "weight": 0.3 }
{ "format": "{int(1,9)}", "weight": 2 },
{ "format": "{int(10,99)}", "weight": 4 },
{ "format": "{int(100,999)}", "weight": 3 },
"{int(1,9)} {digits(3)}",
{ "format": "{int(10,99)} {digits(3)}", "weight": 0.3 }
],
"ore": ["", { "format": ",{c}", "c": [{ "format": "00" }], "weight": 0.4 }]
"ore": ["", { "format": ",{c}", "c": "{digits(2)}", "weight": 0.4 }]
}
]
+1 -1
View File
@@ -7,7 +7,7 @@
"noun": ["arkivet", "berget", "bäcken", "dalen", "eken", "fjället", "floden", "fyren", "fågeln", "gården", "glaciären", "hamnen", "holmen", "klippan", "kusten", "lampan", "lyktan", "motorn", "måsen", "ravinen", "räven", "seglaren", "signalen", "sjön", "skogen", "slätten", "stigen", "stjärnan", "stranden", "tornet", "trädgården", "vandraren", "vinden", "vågen", "ängen"],
"verb": ["anar", "bevakar", "bygger", "bär", "döljer", "famnar", "följer", "formar", "gömmer", "hälsar", "korsar", "leder", "lockar", "lyser", "minns", "möter", "når", "skyddar", "speglar", "söker", "tänder", "vaktar", "väcker", "värnar", "återvänder"],
"prep": ["bakom", "bland", "bortom", "framför", "genom", "intill", "kring", "längs", "mot", "nära", "ovanför", "runt", "under", "vid", "över"],
"end": [".", ".", ".", "!"]
"end": [{ "format": ".", "weight": 3 }, "!"]
},
{
"format": "{q} {adj} {noun} {verb} {prep} {noun}?",
+2 -5
View File
@@ -1,6 +1,5 @@
[
{
"format": "00{mmdd}-000{luhn()}",
"format": "{digits(2)}{mmdd}-{digits(3)}{luhn()}",
"mmdd": [
{
"format": "{m}{d}",
@@ -16,10 +15,8 @@
},
{
"format": "{m}{d}",
"weight": 1,
"m": ["02"],
"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"]
}
]
}
]
+13 -4
View File
@@ -1,8 +1,17 @@
[
{
"format": "{hour}:{minute}{sec}",
"hour": [{ "format": "#00", "weight": 4 }, { "format": "#10", "weight": 4 }, { "format": "2{t}", "t": ["0", "1", "2", "3"], "weight": 2 }],
"minute": [{ "format": "{t}0", "t": ["0", "1", "2", "3", "4", "5"] }],
"sec": ["", { "format": ":{s}", "s": [{ "format": "{t}0", "t": ["0", "1", "2", "3", "4", "5"] }], "weight": 0.4 }]
"hour": [
{ "format": "0{digits(1)}", "weight": 4 },
{ "format": "1{digits(1)}", "weight": 4 },
{ "format": "2{t}", "t": ["0", "1", "2", "3"], "weight": 2 }
],
"minute": { "format": "{t}{digits(1)}", "t": ["0", "1", "2", "3", "4", "5"] },
"sec": [
"",
{
"format": ":{s}",
"s": { "format": "{t}{digits(1)}", "t": ["0", "1", "2", "3", "4", "5"] },
"weight": 0.4
}
]
}
+11 -6
View File
@@ -1,12 +1,17 @@
[
{
"format": "https://{host}{path}",
"host": ["example.com", "www.example.com", "example.org", "example.se", "www.example.se", "blogg.example.net", "butik.example.com", "shop.example.se", "api.example.se", "dokument.example.org", "nyheter.example.net", "mail.example.se"],
"path": [
"",
"",
{ "format": "/{seg}", "seg": ["om", "om-oss", "produkter", "blogg", "kontakt", "hjalp", "artiklar", "logga-in", "nyheter", "start", "sok", "kundvagn", "kassa", "villkor", "integritet", "priser", "tjanster", "foretag", "karriar", "support", "konto", "installningar", "registrera"] },
{ "format": "/{seg}/{sub}", "weight": 0.5, "seg": ["blogg", "produkter", "hjalp", "dokument", "kategori"], "sub": ["oversikt", "kom-igang", "guide", "2024", "senaste", "arkiv", "detaljer"] }
]
{ "format": "", "weight": 2 },
{
"format": "/{seg}",
"seg": ["om", "om-oss", "produkter", "blogg", "kontakt", "hjalp", "artiklar", "logga-in", "nyheter", "start", "sok", "kundvagn", "kassa", "villkor", "integritet", "priser", "tjanster", "foretag", "karriar", "support", "konto", "installningar", "registrera"]
},
{
"format": "/{seg}/{sub}",
"weight": 0.5,
"seg": ["blogg", "produkter", "hjalp", "dokument", "kategori"],
"sub": ["oversikt", "kom-igang", "guide", "2024", "senaste", "arkiv", "detaljer"]
}
]
}
+5 -6
View File
@@ -4,21 +4,20 @@
"weight": 2,
"first": ["agnes", "alice", "anders", "anna", "asa", "axel", "bjorn", "ebba", "elin", "emil", "emma", "erik", "frida", "gustav", "hanna", "hugo", "ida", "johan", "jonas", "julia", "karin", "klara", "lars", "lena", "magnus", "maja", "moa", "nils", "oskar", "per", "sara", "sofia", "viktor", "ylva"],
"last": ["ahl", "alm", "berg", "bjork", "dahl", "ek", "falk", "gran", "hag", "hed", "holm", "lind", "lund", "mark", "nor", "ohman", "ros", "sand", "sjo", "skog", "sten", "strom", "vik"],
"sep": ["", "", ".", "_"],
"n": ["", "", "", "1", "7", "23", "99", "2024"]
"sep": [{ "format": "", "weight": 2 }, ".", "_"],
"n": [{ "format": "", "weight": 3 }, "1", "7", "23", "99", "2024"]
},
{
"format": "{adj}{sep}{noun}{n}",
"weight": 2,
"adj": ["blixt", "busig", "cool", "frisk", "galen", "glad", "kaxig", "kvick", "listig", "lugn", "mega", "modig", "mysig", "pigg", "rolig", "smart", "snabb", "stark", "super", "trygg", "turbo", "varm", "vild"],
"noun": ["abborre", "bamse", "bjorn", "drake", "ekorre", "falk", "groda", "hund", "igelkott", "kanin", "katt", "korp", "krabba", "mus", "nalle", "ninja", "orn", "panda", "pirat", "rav", "robot", "sill", "sparv", "uggla", "varg"],
"sep": ["", "", ".", "_"],
"n": ["", "", "7", "13", "42", "77", "99", "123", "420", "2024"]
"sep": [{ "format": "", "weight": 2 }, ".", "_"],
"n": [{ "format": "", "weight": 2 }, "7", "13", "42", "77", "99", "123", "420", "2024"]
},
{
"format": "{w}{n}",
"weight": 1,
"w": ["blip", "blupp", "boop", "busigekorre", "coolkatten", "fizz", "floof", "galnaalgen", "gladgrodan", "glorp", "kapow", "kazoo", "kvickharen", "listigaraven", "megaraven", "mysmoln", "nattugglan", "pigghund", "pixelfox", "plonk", "rolighund", "snabbkatten", "snork", "splat", "superhunden", "turbohund", "vildvargen", "vroom", "wahoo", "waooooh", "woot", "yeet", "zap", "zoom"],
"n": ["", "", "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
"n": [{ "format": "", "weight": 2 }, "7", "14", "42", "69", "99", "123", "420", "1337", "2000", "9000"]
}
]
+1 -3
View File
@@ -1,8 +1,6 @@
[
{
"format": "{pre}{n}.{n}.{n}{suffix}",
"n": [{ "format": "0", "weight": 3 }, { "format": "10" }],
"n": [{ "format": "{digits(1)}", "weight": 3 }, "{int(10,99)}"],
"pre": ["", { "format": "v", "weight": 0.4 }],
"suffix": ["", { "format": "-{tag}.{m}", "tag": ["alpha", "beta", "rc"], "m": ["1", "2", "3"], "weight": 0.3 }]
}
]
+45 -46
View File
@@ -13,15 +13,14 @@ import (
// --- format string edge cases ---
func TestEscapeEdgeCases(t *testing.T) {
func TestHashIsLiteral(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"format":""}`: "", // empty format
`{"format":"#"}`: "#", // trailing escape is a literal #
`{"format":"##"}`: "#", // escaped hash
`{"format":"#0#1#A#a"}`: "01Aa", // escaped class chars stay literal
`{"format":"#{x#}"}`: "{x}", // escaping braces disables tokens
`{"format":"x}y"}`: "x}y", // an unmatched } is literal (x, y aren't classes)
`{"format":"","x":"v"}`: "",
`{"format":"#","x":"v"}`: "#",
`{"format":"##","x":"v"}`: "##",
`{"format":"#0#1#A#a","x":"v"}`: "#0#1#A#a",
`{"format":"#{x}","x":"v"}`: "#v",
}
for tmpl, want := range cases {
if got := mustRender(t, f, tmpl); got != want {
@@ -33,7 +32,7 @@ func TestEscapeEdgeCases(t *testing.T) {
func TestMultibyteFormat(t *testing.T) {
// Scanning is rune-aware: multibyte literals coexist with class chars and
// tokens without corrupting indices.
got := mustRender(t, engine(2), `{"format":"Öster{x}-0å","x":["väg"]}`)
got := mustRender(t, engine(2), `{"format":"Öster{x}-{digits(1)}å","x":"väg"}`)
if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) {
t.Fatalf("multibyte format = %q", got)
}
@@ -43,7 +42,7 @@ func TestAlternationThreeWay(t *testing.T) {
f := engine(4)
seen := map[string]bool{}
for i := 0; i < 200; i++ {
seen[mustRender(t, f, `{"format":"{a|b|c}","a":["A"],"b":["B"],"c":["C"]}`)] = true
seen[mustRender(t, f, `{"format":"{a|b|c}","a":"A","b":"B","c":"C"}`)] = true
}
if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 {
t.Fatalf("3-way alternation produced %v, want A, B and C", seen)
@@ -70,11 +69,11 @@ func TestNewErrors(t *testing.T) {
want string
}{
"separator without repeat": {
map[string]string{"a": `{"format":"{x}","x":["1"],"separator":","}`},
map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`},
"has no effect without a repeat above 1",
},
"separator with an explicit repeat of 1": {
map[string]string{"a": `{"format":"{x}","x":["1"],"repeat":1,"separator":","}`},
map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`},
"has no effect without a repeat above 1",
},
"weight outside a choice": {
@@ -86,38 +85,38 @@ func TestNewErrors(t *testing.T) {
"weight only skews a choice's items",
},
"weight used as a field": {
map[string]string{"a": `{"format":"{name} {weight}kg","name":["Anvil"],"weight":["7"]}`},
map[string]string{"a": `{"format":"{name} {weight}kg","name":"Anvil","weight":["7"]}`},
"can never be a field",
},
"an option name used as a token": {
map[string]string{"a": `{"format":"{weight}"}`},
map[string]string{"a": `"{weight}"`},
`"weight" is an option and can never be a field`,
},
"category name with a dot": {
map[string]string{"a.b": `["1"]`},
map[string]string{"a.b": `"1"`},
`category "a.b" contains "."`,
},
"folder name with a dot": {
map[string]string{"a.b/cat": `["1"]`},
map[string]string{"a.b/cat": `"1"`},
`/a.b: folder "a.b" contains "."`,
},
// The token grammar reserves three more characters. A name carrying one
// still resolves by dot path, but no format can name it, so it is rejected
// where it is authored rather than at the token that cannot reach it.
"field name with a pipe": {
map[string]string{"a": `{"format":"{x}","x":["1"],"b|c":["2"]}`},
map[string]string{"a": `{"format":"{x}","x":"1","b|c":"2"}`},
`field "b|c" contains "|"`,
},
"field name with a paren": {
map[string]string{"a": `{"format":"{x}","x":["1"],"b(c":["2"]}`},
map[string]string{"a": `{"format":"{x}","x":"1","b(c":"2"}`},
`field "b(c" contains "("`,
},
"field name with a closing brace": {
map[string]string{"a": `{"format":"{x}","x":["1"],"b}c":["2"]}`},
map[string]string{"a": `{"format":"{x}","x":"1","b}c":"2"}`},
`field "b}c" contains "}"`,
},
"category name with a pipe": {
map[string]string{"a|b": `["1"]`},
map[string]string{"a|b": `"1"`},
`category "a|b" contains "|"`,
},
// An empty name is not a path segment, so List never offered it — while a
@@ -128,42 +127,42 @@ func TestNewErrors(t *testing.T) {
`field "" is empty`,
},
"folder name with a paren": {
map[string]string{"a(b/cat": `["1"]`},
map[string]string{"a(b/cat": `"1"`},
`folder "a(b" contains "("`,
},
// A repeated arm skews an alternation, which weight is the spelling for.
"repeated alternation arm": {
map[string]string{"a": `{"format":"{x|x}","x":["1"]}`},
map[string]string{"a": `{"format":"{x|x}","x":"1"}`},
`arm "x" is repeated`,
},
"repeated arm among others": {
map[string]string{"a": `{"format":"{x|y|x}","x":["1"],"y":["2"]}`},
map[string]string{"a": `{"format":"{x|y|x}","x":"1","y":"2"}`},
`arm "x" is repeated`,
},
"repeated path arm": {
map[string]string{"a": `{"format":"{p.v|p.v}","p":{"format":"{v}","v":["1"]}}`},
map[string]string{"a": `{"format":"{p.v|p.v}","p":{"format":"{v}","v":"1"}}`},
`arm "p.v" is repeated`,
},
// A reference arm is the only kind that reaches the repeat check by passing
// the per-arm checks rather than falling through them.
"repeated reference arm": {
map[string]string{"a": `["x"]`, "b": `{"format":"{..a|..a}"}`},
map[string]string{"a": `"x"`, "b": `"{..a|..a}"`},
`arm "..a" is repeated`,
},
// An arm that is broken on its own terms is reported as that, not as a
// repeat: the repeat is a consequence of the real mistake.
"repeated arm with no path": {
map[string]string{"a": `{"format":"{..|..}"}`},
map[string]string{"a": `"{..|..}"`},
"reference has no path",
},
// No field can be named "", so the token is told that rather than sent to
// name one — the fix "no field" points at is itself a load error.
"repeated empty arm": {
map[string]string{"a": `{"format":"{|}"}`},
map[string]string{"a": `"{|}"`},
"a name is never empty",
},
"bare empty token": {
map[string]string{"a": `{"format":"[{}]","x":["1"]}`},
map[string]string{"a": `{"format":"[{}]","x":"1"}`},
"a name is never empty",
},
}
@@ -183,16 +182,16 @@ func TestNewErrors(t *testing.T) {
path string
want string
}{
"option name as a field": {map[string]string{"a": `{"format":"{name} {Weight}kg","name":["Anvil"],"Weight":["7"]}`}, "a", "Anvil 7kg"},
"format spelling as a field": {map[string]string{"a": `{"format":"{Format}","Format":["PDF"]}`}, "a", "PDF"},
"hyphenated field": {map[string]string{"a": `{"format":"{x-y}","x-y":["1"]}`}, "a.x-y", "1"},
"category named Format": {map[string]string{"Format": `["1"]`}, "Format", "1"},
"folder named Repeat": {map[string]string{"Repeat/cat": `["1"]`}, "Repeat.cat", "1"},
"field with a closing paren": {map[string]string{"a": `{"format":"{b)c}","b)c":["2"]}`}, "a", "2"},
"repeat without a separator": {map[string]string{"a": `{"format":"{x}","repeat":3,"x":["1"]}`}, "a", "111"},
"option name as a field": {map[string]string{"a": `{"format":"{name} {Weight}kg","name":"Anvil","Weight":"7"}`}, "a", "Anvil 7kg"},
"format spelling as a field": {map[string]string{"a": `{"format":"{Format}","Format":"PDF"}`}, "a", "PDF"},
"hyphenated field": {map[string]string{"a": `{"format":"{x-y}","x-y":"1"}`}, "a.x-y", "1"},
"category named Format": {map[string]string{"Format": `"1"`}, "Format", "1"},
"folder named Repeat": {map[string]string{"Repeat/cat": `"1"`}, "Repeat.cat", "1"},
"field with a closing paren": {map[string]string{"a": `{"format":"{b)c}","b)c":"2"}`}, "a", "2"},
"repeat without a separator": {map[string]string{"a": `{"format":"{x}","repeat":3,"x":"1"}`}, "a", "111"},
// One name in two separate tokens is two independent draws, not a repeated
// arm; only a repeat within one alternation is rejected.
"one name in two tokens": {map[string]string{"a": `{"format":"{x}{x}","x":["1"]}`}, "a", "11"},
"one name in two tokens": {map[string]string{"a": `{"format":"{x}{x}","x":"1"}`}, "a", "11"},
}
for name, c := range accepted {
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files)))
@@ -213,7 +212,7 @@ func TestDeepDottedPath(t *testing.T) {
// on the path are single-variant, so it resolves deterministically.
f := engine(1)
f.categories = map[string]node{
"deep": compiled(t, `[{"format":"{a}","a":[{"format":"{b}","b":[{"format":"{c}","c":[{"format":"{d}","d":["leaf"]}]}]}]}]`),
"deep": compiled(t, `{"format":"{a}","a":{"format":"{b}","b":{"format":"{c}","c":{"format":"{d}","d":"leaf"}}}}`),
}
if got, err := f.Fake("deep.a.b.c.d"); err != nil || got != "leaf" {
t.Fatalf("Fake(deep.a.b.c.d) = %q, %v, want leaf", got, err)
@@ -224,11 +223,11 @@ func TestDeepDottedPath(t *testing.T) {
}
}
func TestDescendIntoLiteralErrors(t *testing.T) {
func TestDescendIntoStringErrors(t *testing.T) {
f := engine(1)
f.categories = map[string]node{"greeting": compiled(t, `["hej"]`)}
if _, err := f.Fake("greeting.extra"); err == nil {
t.Fatal("Fake(greeting.extra) = nil error, want descend-into-literal error")
f.categories = map[string]node{"greeting": compiled(t, `"hej"`)}
if _, err := f.Fake("greeting.extra"); err == nil || !strings.Contains(err.Error(), `no field "extra"`) {
t.Fatalf("Fake(greeting.extra) = %v, want a no-field error", err)
}
}
@@ -236,9 +235,9 @@ func TestDescendIntoLiteralErrors(t *testing.T) {
// one call and failing on the next: every variant must carry the rest of the path.
func TestPathThroughChoice(t *testing.T) {
dir := writeData(t, map[string]string{
"every": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`,
"notall": `[{"format":"{f}","f":["1"]},["plain"]]`,
"some": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"],"extra":["x"]}]`,
"every": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`,
"notall": `[{"format":"{f}","f":"1"},"plain"]`,
"some": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2","extra":"x"}]`,
})
f := newGenerator(t, dir, WithSeed(1))
for i := 0; i < 200; i++ {
@@ -270,7 +269,7 @@ func TestPathThroughChoice(t *testing.T) {
// wherever it appears.
func TestPathKeyIsUnambiguous(t *testing.T) {
dir := writeData(t, map[string]string{
"cat": `[{"format":"{a.b}","a.b":["1"]},{"format":"{a}","a":{"format":"{b}","b":["2"]}}]`,
"cat": `[{"format":"{a.b}","a.b":"1"},{"format":"{a}","a":{"format":"{b}","b":"2"}}]`,
})
_, err := New(WithoutShippedData(), WithDataPath(dir))
if err == nil || !strings.Contains(err.Error(), `field "a.b" contains "."`) {
@@ -278,7 +277,7 @@ func TestPathKeyIsUnambiguous(t *testing.T) {
}
// The same data without the dotted key is fine, and the path resolves.
f := newGenerator(t, writeData(t, map[string]string{
"cat": `{"format":"{a.b}","a":{"format":"{b}","b":["2"]}}`,
"cat": `{"format":"{a.b}","a":{"format":"{b}","b":"2"}}`,
}), WithSeed(1))
if !slices.Contains(f.List(), "cat.a.b") {
t.Error("List() omits cat.a.b, which the data carries")
@@ -303,7 +302,7 @@ func TestMissingFieldNamesItself(t *testing.T) {
func TestCategoryRootShapes(t *testing.T) {
dir := writeData(t, map[string]string{
"obj": `{"format":"00"}`, // object root
"obj": `"{digits(2)}"`, // object root
"lit": `"hello"`, // bare-string root
})
f := newGenerator(t, dir, WithSeed(1))
+4 -10
View File
@@ -109,10 +109,9 @@ func New(opts ...Option) (*Generator, error) {
}
// List returns the sorted dotted paths Fake can render: every category, the dotted
// fields within a template, and folder segments — descending transparently through
// single-variant choices the way a reference does. A choice consumes no segment, so
// a path continues through a multi-variant one only where every variant carries it,
// which is the rule Fake applies too: List is the set of paths Fake accepts.
// fields within a template, and folder segments. A choice consumes no segment, so a
// path continues through one only where every variant carries it, which is the
// rule Fake applies too: List is the set of paths Fake accepts.
func (f *Generator) List() []string {
var out []string
for _, name := range sortedNames(f.categories) {
@@ -148,22 +147,17 @@ func paths(n node) []string {
}
return out
case *choice:
if len(n.items) == 1 {
return paths(n.items[0])
}
out := []string{""}
for p := range n.shared {
out = append(out, p)
}
return out
case literal:
return []string{""}
}
return nil
}
// sharedPaths is the sub-paths every item carries — the only ones a path may step
// through a multi-variant choice to reach. It intersects, bailing as soon as the set
// through a choice to reach. It intersects, bailing as soon as the set
// is empty, which is immediate for a choice of plain strings.
func sharedPaths(items []node) map[string]bool {
shared := subPaths(items[0])
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Rewrite data files from the class-char format grammar to the literal-text one.
convert.py data/sv_SE/person.json ...
Old: 0 1 A a are character classes, # escapes. New: text is literal; a run of
0/A/a becomes {digits(n)}/{upper(n)}/{lower(n)}, 1 followed by n zeros becomes
{int(10^n, 10^(n+1)-1)}, a literal brace becomes {{ or }}.
"""
import re
import sys
RUN = {"0": "{{digits({})}}", "A": "{{upper({})}}", "a": "{{lower({})}}"}
def literal(c):
return {"{": "{{", "}": "}}"}.get(c, c)
def convert_format(f):
out, i, n = [], 0, len(f)
while i < n:
c = f[i]
if c == "#":
i += 1
if i < n:
out.append(literal(f[i]))
i += 1
else:
out.append("#")
elif c == "{":
j = f.find("}", i)
if j < 0:
out.append(f[i:])
break
out.append(f[i:j + 1])
i = j + 1
elif c in RUN:
k = 0
while i < n and f[i] == c:
k += 1
i += 1
out.append(RUN[c].format(k))
elif c == "1":
i += 1
k = 0
while i < n and f[i] == "0":
k += 1
i += 1
out.append("{{int({},{})}}".format(10 ** k, 10 ** (k + 1) - 1))
else:
out.append(literal(c))
i += 1
return "".join(out)
FORMAT_VALUE = re.compile(r'("format":\s*")((?:[^"\\]|\\.)*)(")')
def convert_text(text):
return FORMAT_VALUE.sub(lambda m: m.group(1) + convert_format(m.group(2)) + m.group(3), text)
if __name__ == "__main__":
for path in sys.argv[1:]:
with open(path) as fh:
before = fh.read()
after = convert_text(before)
if after != before:
with open(path, "w") as fh:
fh.write(after)
print("converted", path)
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Rewrite data files to the one spelling each shape has.
reshape.py data/sv_SE/person.json ...
A one-item choice becomes its item; an object holding only a format becomes that
string; weight 1 and repeat 1 are dropped; a repeated choice item becomes one
item with a weight.
"""
import json
import sys
OPTIONS = ("format", "weight", "repeat", "separator")
WIDTH = 110
def reshape(n):
if isinstance(n, list):
items = [reshape(x) for x in n]
if len(items) == 1:
return items[0]
out, at = [], {}
for x in items:
key = json.dumps(x, sort_keys=True)
if key in at:
i = at[key]
if isinstance(out[i], str):
out[i] = {"format": out[i], "weight": 2}
elif isinstance(out[i], dict):
out[i]["weight"] = out[i].get("weight", 1) + 1
else:
out.append(x)
continue
at[key] = len(out)
out.append(x)
return out
if isinstance(n, dict):
d = {k: (v if k in OPTIONS else reshape(v)) for k, v in n.items()}
if d.get("weight") == 1:
del d["weight"]
if d.get("repeat") == 1:
del d["repeat"]
if list(d) == ["format"]:
return d["format"]
return d
return n
def scalar(v):
return json.dumps(v, ensure_ascii=False)
def one_line(n):
if isinstance(n, dict):
return "{ " + ", ".join(scalar(k) + ": " + one_line(v) for k, v in n.items()) + " }"
if isinstance(n, list):
return "[" + ", ".join(one_line(x) for x in n) + "]"
return scalar(n)
def dump(n, indent=0):
pad = " " * indent
if isinstance(n, dict):
line = one_line(n)
if len(pad) + len(line) <= WIDTH and not any(isinstance(v, dict) for v in n.values()):
return line
body = ",\n".join(pad + " " + scalar(k) + ": " + dump(v, indent + 1) for k, v in n.items())
return "{\n" + body + "\n" + pad + "}"
if isinstance(n, list):
if all(isinstance(x, str) for x in n):
return one_line(n)
line = one_line(n)
if len(pad) + len(line) <= WIDTH:
return line
body = ",\n".join(pad + " " + dump(x, indent + 1) for x in n)
return "[\n" + body + "\n" + pad + "]"
return scalar(n)
if __name__ == "__main__":
for path in sys.argv[1:]:
with open(path) as fh:
before = json.load(fh)
after = dump(reshape(before)) + "\n"
with open(path, "w") as fh:
fh.write(after)
print("reshaped", path)
+16 -16
View File
@@ -13,13 +13,13 @@ import (
// folder segments — descending transparently through single-variant choices.
func TestList(t *testing.T) {
dir := writeData(t, map[string]string{
"person": `{"format":"{first} {last}","first":["A"],"last":["B"]}`,
"person": `{"format":"{first} {last}","first":"A","last":"B"}`,
"word": `["x", "y"]`,
"geo/city": `["Z"]`,
"geo/city": `"Z"`,
// A bound {..path} reference is a render edge, not an addressable field.
"greeting": `{"format":"hej {..person.first} and {own}","own":["x"]}`,
"greeting": `{"format":"hej {..person.first} and {own}","own":"x"}`,
// Only the fields every variant carries are addressable, so "extra" is not.
"coin": `[{"format":"{code}","code":["A"],"name":["Aa"]},{"format":"{code}","code":["B"],"name":["Bb"],"extra":["x"]}]`,
"coin": `[{"format":"{code}","code":"A","name":"Aa"},{"format":"{code}","code":"B","name":"Bb","extra":"x"}]`,
})
got := newGenerator(t, dir, WithSeed(1)).List()
want := []string{"coin", "coin.code", "coin.name", "geo.city", "greeting", "greeting.own", "person", "person.first", "person.last", "word"}
@@ -66,8 +66,8 @@ func TestNewEmptyDirErrors(t *testing.T) {
// namespace, so data/<loc>/person.json is reachable as "<loc>.person".
func TestFoldersBecomeDotPaths(t *testing.T) {
dir := writeData(t, map[string]string{
"sv_SE/greeting": `["hej"]`,
"en_US/greeting": `["hi"]`,
"sv_SE/greeting": `"hej"`,
"en_US/greeting": `"hi"`,
})
f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "sv_SE.greeting"); got != "hej" {
@@ -82,7 +82,7 @@ func TestFoldersBecomeDotPaths(t *testing.T) {
// a/b/c, then deep JSON fields inside the file at the end of that path.
func TestNestedFoldersAndJSON(t *testing.T) {
dir := writeData(t, map[string]string{
"a/b/c/thing": `{"format":"{x}","x":{"format":"{y}","y":{"format":"{z}","z":["leaf"]}}}`,
"a/b/c/thing": `{"format":"{x}","x":{"format":"{y}","y":{"format":"{z}","z":"leaf"}}}`,
})
f := newGenerator(t, dir, WithSeed(1))
// Folders a.b.c, file thing, then JSON fields x.y.z — one continuous path.
@@ -96,7 +96,7 @@ func TestNestedFoldersAndJSON(t *testing.T) {
}
func TestRenderingAFolderErrors(t *testing.T) {
dir := writeData(t, map[string]string{"sv_SE/greeting": `["hej"]`})
dir := writeData(t, map[string]string{"sv_SE/greeting": `"hej"`})
f := newGenerator(t, dir, WithSeed(1))
if _, err := f.Fake("sv_SE"); err == nil {
t.Fatal("Fake(folder) = nil error, want a not-a-value error")
@@ -106,8 +106,8 @@ func TestRenderingAFolderErrors(t *testing.T) {
// TestMultiPathLastWins loads two dirs; on a name clash the later dir wins, and
// non-clashing entries from both are reachable (data combines).
func TestMultiPathLastWins(t *testing.T) {
a := writeData(t, map[string]string{"greeting": `["from-a"]`, "only-a": `["a"]`})
b := writeData(t, map[string]string{"greeting": `["from-b"]`, "only-b": `["b"]`})
a := writeData(t, map[string]string{"greeting": `"from-a"`, "only-a": `"a"`})
b := writeData(t, map[string]string{"greeting": `"from-b"`, "only-b": `"b"`})
f := newGeneratorN(t, []string{a, b}, WithSeed(1))
if got := fake(t, f, "greeting"); got != "from-b" {
t.Fatalf("greeting = %q, want from-b (last loaded wins)", got)
@@ -124,8 +124,8 @@ func TestMultiPathLastWins(t *testing.T) {
// children rather than replacing wholesale: each dir adds a file to sv_SE, and
// a per-file clash inside still resolves last-wins.
func TestMultiPathMergesFolders(t *testing.T) {
a := writeData(t, map[string]string{"sv_SE/person": `["from-a"]`, "sv_SE/shared": `["a"]`})
b := writeData(t, map[string]string{"sv_SE/company": `["from-b"]`, "sv_SE/shared": `["b"]`})
a := writeData(t, map[string]string{"sv_SE/person": `"from-a"`, "sv_SE/shared": `"a"`})
b := writeData(t, map[string]string{"sv_SE/company": `"from-b"`, "sv_SE/shared": `"b"`})
f := newGeneratorN(t, []string{a, b}, WithSeed(1))
if got := fake(t, f, "sv_SE.person"); got != "from-a" {
t.Fatalf("sv_SE.person = %q, want from-a (folder merged, not replaced)", got)
@@ -163,10 +163,10 @@ func TestListedPathsAllRender(t *testing.T) {
// carrying no JSON never contributed a namespace, so neither may fail the load.
func TestHiddenEntriesAreSkipped(t *testing.T) {
dir := writeData(t, map[string]string{
"cat": `["V"]`,
".git/HEAD": `["ignored"]`,
".hidden": `["ignored"]`,
"empty.folder/doc": `["ignored"]`,
"cat": `"V"`,
".git/HEAD": `"ignored"`,
".hidden": `"ignored"`,
"empty.folder/doc": `"ignored"`,
})
if err := os.Rename(filepath.Join(dir, "empty.folder", "doc.json"), filepath.Join(dir, "empty.folder", "doc.txt")); err != nil {
t.Fatal(err)
+84 -27
View File
@@ -1,22 +1,18 @@
package fejkdata
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
)
// node is a compiled template element: literal, choice, or template. Compiling
// JSON into these once (see compile) means rendering never re-inspects the raw
// JSON or re-sums weights.
// node is a compiled template element: a choice or a template. Compiling JSON
// into these once (see compile) means rendering never re-inspects the raw JSON or
// re-sums weights.
type node interface{ isNode() }
// literal is emitted verbatim, never formatted.
type literal string
func (literal) isNode() {}
// group is a namespace of named children, built from a directory of JSON files
// and subdirectories. It has no value of its own: descend into a named child by
// dot path; rendering one is an error (see Fake).
@@ -36,9 +32,10 @@ type choice struct {
func (*choice) isNode() {}
// template renders a format string, substituting {tokens} from fields. repeat
// (default 1) renders that format that many times and joins the results with
// separator (default ""), each render an independent pick.
// template renders a format string, substituting {tokens} from fields. A bare
// JSON string is a template with no fields. repeat (default 1) renders that format
// that many times and joins the results with separator (default ""), each render
// an independent pick.
type template struct {
format string
fields map[string]node
@@ -46,6 +43,9 @@ type template struct {
separator string
ops []op // format compiled once (see compileOps); what expand walks
grow int // minimum output size, to size the render buffer
fixed bool // no op varies, so every render is lit
lit string // the whole output when fixed
refs map[string]string // each {..path} the format reads -> the head it is bound under
// bound maps each field the format addresses by dotted path to one path token
// reading it, which is the half of an overlap the fences name. nil when the
// format takes no path.
@@ -72,7 +72,7 @@ func compile(v any) (node, error) {
func compileItem(v any) (node, error) {
switch v := v.(type) {
case string:
return literal(v), nil
return compileString(v)
case []any:
return compileChoice(v)
case map[string]any:
@@ -82,10 +82,39 @@ func compileItem(v any) (node, error) {
}
}
func compileString(s string) (node, error) {
if err := checkTokens(s, nil); err != nil {
return nil, err
}
t := &template{format: s, repeat: 1}
t.compileFormat()
return t, nil
}
// compileFormat compiles the format into ops once every field is in place.
func (t *template) compileFormat() {
t.ops, t.grow, t.bound, t.held = compileOps(t.format, t.refs)
t.fixed = true
for _, o := range t.ops {
if o.kind != 'l' {
t.fixed = false
}
}
if t.fixed && len(t.ops) == 1 {
t.lit = t.ops[0].lit
}
}
func compileChoice(items []any) (node, error) {
if len(items) == 0 {
return nil, fmt.Errorf("empty choice")
}
if len(items) == 1 {
return nil, fmt.Errorf("a one-item choice is its item; write the item")
}
if err := checkNoRepeatedItem(items); err != nil {
return nil, err
}
c := &choice{items: make([]node, len(items))}
cum := make([]float64, len(items))
var total float64
@@ -112,14 +141,37 @@ func compileChoice(items []any) (node, error) {
}
c.cum = cum
}
if len(c.items) > 1 {
// Safe to precompute: a choice's items come from one file, so no group can
// appear inside one, and neither mergeChildren nor linkRefs can reach in.
c.shared = sharedPaths(c.items)
}
return c, nil
}
// checkNoRepeatedItem rejects a choice that lists one item twice: a pick is even
// over the items, so a repeat is a second spelling of weight. The error names the
// spelling that does skew a pick.
func checkNoRepeatedItem(items []any) error {
seen := make(map[string]int, len(items))
for i, raw := range items {
key, isString := raw.(string)
if !isString {
b, err := json.Marshal(raw)
if err != nil {
return err
}
key = "\x00" + string(b)
}
if j, dup := seen[key]; dup {
if s, isString := raw.(string); isString {
return fmt.Errorf("choice item %q is repeated; skew the odds with a weight instead: { \"format\": %q, \"weight\": 2 }", s, s)
}
return fmt.Errorf("choice item %d repeats item %d; skew the odds with a weight on one of them instead", i, j)
}
seen[string(key)] = i
}
return nil
}
func compileTemplate(m map[string]any) (node, error) {
format, ok := m["format"].(string)
if !ok {
@@ -160,19 +212,22 @@ func compileTemplate(m map[string]any) (node, error) {
}
t.fields[k] = n
}
if _, weighted := m["weight"]; len(t.fields) == 0 && repeat == 1 && !weighted {
return nil, fmt.Errorf("an object holding only a format is a string; write %q", format)
}
if err := checkTokens(format, t.fields); err != nil {
return nil, err
}
t.ops, t.grow, t.bound, t.held = compileOps(format)
if err := checkNoOverlap(format, t.bound); err != nil {
t.compileFormat()
if err := checkNoOverlap(format, t.bound, nil); err != nil {
return nil, err
}
return t, nil
}
// checkPath reports whether a token's dotted tail can address a node whichever way
// the draw goes, by the reachability rule descend applies — a multi-variant choice
// must carry the whole remaining path in the set every variant shares — plus the
// the draw goes, by the reachability rule descend applies — a choice must carry
// the whole remaining path in the set every variant shares — plus the
// rules a held draw adds, which descend has no need of: a level a path reads may
// not carry a repeat, and each variant answers for that itself. So a path that
// validates here resolves on every render, and a typo is a New-time error.
@@ -191,7 +246,6 @@ func checkPath(n node, tail []string, level string) error {
}
return checkPath(child, tail[1:], level+"."+tail[0])
case *choice:
if len(n.items) > 1 {
if want := strings.Join(tail, "."); !n.shared[want] {
return unreachableInChoice(n, want)
}
@@ -203,17 +257,13 @@ func checkPath(n node, tail []string, level string) error {
}
}
return nil
}
return checkPath(n.items[0], tail, level)
case literal:
return fmt.Errorf("a plain string has no field %q", tail[0])
default:
return fmt.Errorf("cannot descend into %T at %q", n, tail[0])
}
}
// repeatOf reads a template's "repeat" (default 1): how many times its format
// is rendered and concatenated. A present one must be a positive integer.
// is rendered and concatenated. A present one must be an integer above 1.
func repeatOf(m map[string]any) (int, error) {
rv, ok := m["repeat"]
if !ok {
@@ -226,6 +276,9 @@ func repeatOf(m map[string]any) (int, error) {
if math.IsNaN(r) || math.IsInf(r, 0) || r < 1 || r != math.Trunc(r) {
return 0, fmt.Errorf("repeat must be a positive integer, got %v", rv)
}
if r == 1 {
return 0, fmt.Errorf("repeat 1 is the default, so it has no effect; drop it")
}
if r > maxLen { // cap so a fat-fingered repeat can't build a multi-GB string
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, maxLen)
}
@@ -250,14 +303,18 @@ func weightOf(raw any) (float64, error) {
if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) {
return 0, fmt.Errorf("weight must be finite and non-negative, got %v", w)
}
if w == 1 {
return 0, fmt.Errorf("weight 1 is the default, so it has no effect; drop it")
}
return w, nil
}
// reservedInName is what a category, folder or field name may not contain: a dot
// separates the segments of a path, '|' the arms of a token, '(' opens a function
// call and '}' ends the token. A name carrying one is reachable by no format, so it
// is rejected where it is authored rather than at the token that cannot reach it.
const reservedInName = ".|(}"
// call and braces delimit the token. A name carrying one is reachable by no format,
// so it is rejected where it is authored rather than at the token that cannot
// reach it.
const reservedInName = ".|({}"
// reservedList spells reservedInName for an error message, so the two cannot drift.
var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
+48
View File
@@ -0,0 +1,48 @@
package fejkdata
import (
"strings"
"testing"
)
func TestOneItemChoiceIsRejected(t *testing.T) {
for _, src := range []string{`["x"]`, `[{"format":"{d}","d":"x"}]`, `{"format":"{w}","w":["only"]}`} {
if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), "one-item choice") {
t.Errorf("compile(%s) = %v, want the one-item choice rejected", src, err)
}
}
}
func TestRepeatedChoiceItemIsRejected(t *testing.T) {
for src, want := range map[string]string{
`["a", "a", "b"]`: `{ "format": "a", "weight": 2 }`,
`[{"format":"{x}","x":"1"},{"format":"{x}","x":"1"}]`: "repeats item",
`{"format":"{w}","w":["", "", "x"]}`: `{ "format": "", "weight": 2 }`,
} {
if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("compile(%s) = %v, want an error naming %s", src, err, want)
}
}
if _, err := compile(parse(t, `[{"format":"a","weight":2}, "b"]`)); err != nil {
t.Errorf("compile(weighted a, b) = %v", err)
}
}
func TestInertObjectIsRejected(t *testing.T) {
for src, want := range map[string]string{
`{"format":"Malmö"}`: `write "Malmö"`,
`{"format":"{digits(3)}"}`: `write "{digits(3)}"`,
`[{"format":"a","weight":1},"b"]`: "weight 1",
`{"format":"{x}","x":"v","repeat":1}`: "repeat 1",
`{"format":"{x}","x":"v","separator":","}`: "separator",
} {
if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("compile(%s) = %v, want an error mentioning %s", src, err, want)
}
}
for _, ok := range []string{`[{"format":"a","weight":2},"b"]`, `{"format":"ab","repeat":2}`, `{"format":"{x}","x":"v"}`, `"{digits(3)}"`} {
if _, err := compile(parse(t, ok)); err != nil {
t.Errorf("compile(%s) = %v", ok, err)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package fejkdata
import (
"os"
"regexp"
"strings"
"testing"
)
var jsonBlock = regexp.MustCompile("(?s)```json\n(.*?)```")
func readme(t *testing.T) string {
t.Helper()
src, err := os.ReadFile("README.md")
if err != nil {
t.Fatal(err)
}
return string(src)
}
func TestReadmeExamplesLoadAndRender(t *testing.T) {
n := 0
for _, m := range jsonBlock.FindAllStringSubmatch(readme(t), -1) {
body := m[1]
if strings.Contains(body, "…") {
continue
}
f, err := New(WithDataPath(writeData(t, map[string]string{"example": body})), WithSeed(1))
if err != nil {
t.Errorf("README example does not load: %v\n%s", err, body)
continue
}
for i := 0; i < 20; i++ {
if _, err := f.Fake("example"); err != nil {
t.Errorf("README example does not render: %v\n%s", err, body)
break
}
}
n++
}
if n < 8 {
t.Fatalf("found only %d README examples", n)
}
}
func TestReadmeSQLExampleOutput(t *testing.T) {
src := readme(t)
src = src[strings.Index(src, "### Your own data"):]
block := jsonBlock.FindStringSubmatch(src)
want := regexp.MustCompile(`(?m)^# (INSERT .*)$`).FindStringSubmatch(src)
if block == nil || want == nil {
t.Fatal("README lost the SQL example or its printed output")
}
f, err := New(WithDataPath(writeData(t, map[string]string{"sql": block[1]})), WithSeed(1))
if err != nil {
t.Fatal(err)
}
if got := fake(t, f, "sql"); got != want[1] {
t.Errorf("README SQL example with seed 1 = %q, README prints %q", got, want[1])
}
}
+147 -80
View File
@@ -8,31 +8,47 @@ import (
// refPrefix marks a {..path} token: a reference to a node elsewhere in the data
// root rather than a sibling field. The path is resolved across every loaded
// directory (see linkRefs), and is stricter than the one Fake takes: a reference
// binds one node, so it cannot step through a multi-variant choice even where Fake
// and List can.
// directory (see linkRefs).
const refPrefix = ".."
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
// linkRefs resolves every {..path} reference in the assembled tree, binding the
// target node into the referring template's fields under the token's key so the
// ordinary resolver renders it like a sibling. It runs once, after all data is
// merged, so a reference sees the final (override-resolved) tree. A path that is
// unknown, names a folder, or steps through a multi-variant choice fails here,
// keeping a bad reference a New-time error, never a random render-time one.
// linkRefs resolves every {..path} reference in the assembled tree. The head of the
// path — up to the category it names — is bound into the referring template's
// fields, and the rest reads into it the way a sibling path does, so a reference
// is held like a sibling. It runs once, after all data is merged, so a reference
// sees the final (override-resolved) tree. A path that is unknown, names a folder,
// or reads a field not every variant carries fails here, keeping a bad reference a
// New-time error, never a random render-time one.
func linkRefs(root map[string]node) error {
return walkNodes(root, func(path string, n node) error {
t, ok := n.(*template)
if !ok {
return nil
}
for _, name := range refTokens(t.format) {
target, err := lookup(root, strings.Split(name[len(refPrefix):], "."))
names := refTokens(t.format)
if len(names) == 0 {
return nil
}
if t.fields == nil {
t.fields = map[string]node{}
}
t.refs = make(map[string]string, len(names))
for _, name := range names {
head, target, tail, err := resolveRef(root, strings.Split(name[len(refPrefix):], "."))
if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
}
t.fields[name] = target
key := refPrefix + strings.Join(head, ".")
if err := checkPath(target, tail, key); err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
}
t.fields[key] = target
t.refs[name] = key
}
t.compileFormat()
if err := checkNoOverlap(t.format, t.bound, t.refs); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return nil
})
@@ -41,7 +57,7 @@ func linkRefs(root map[string]node) error {
// checkBoundLevelsHeld rejects every route to a held name except the ones that read
// its draw. An expansion holds one draw of that name; anything else that renders it
// draws again, and the two disagree. checkNoOverlap settles the spellings within one
// format (a token, a calc operand); this settles the rest — a reference, whether it
// format (a token, an operand); this settles the rest — a reference, whether it
// sits in that format or in anything the format renders, however deep.
//
// It runs after checkNoCycles, whose guarantee is what lets the walk terminate.
@@ -55,33 +71,48 @@ func checkBoundLevelsHeld(root map[string]node) error {
for head := range t.held {
heads = append(heads, head)
}
sort.Strings(heads) // so which overlap is reported does not vary
// Operand heads first, then paths, each in name order: a level read both
// ways is reported by the operand's fence, and which overlap is reported
// does not vary.
sort.Slice(heads, func(i, j int) bool {
_, pi := t.bound[heads[i]]
_, pj := t.bound[heads[j]]
if pi != pj {
return !pi
}
return heads[i] < heads[j]
})
readers := boundReaders(t.format, t.bound, t.refs)
for _, head := range heads {
// What one draw answers for depends on how the draw is read. A path may
// read into anything the level contains; a calc renders its operand, so
// that draw fixes exactly the value the render produces.
// What one draw answers for depends on how the draw is read: a path pins
// the levels it passes through and the leaf it lands on, an operand
// exactly the value its render produces.
held := map[node]bool{}
reader, isPath := t.bound[head]
if isPath {
cover(t.fields[head], held)
for _, r := range readers {
if a := splitArm(r.name, t.refs); a.key == head {
coverPath(t.fields[head], a.tail, held)
}
}
} else {
operandDraw(t.fields[head], held)
}
if len(held) == 0 {
continue // an early out: a literal head holds nothing to reach
continue // an early out: a fixed head holds nothing to reach
}
// One seen set across the edges: a node that cannot reach the level
// cannot reach it by another route either, so it is walked once here.
seen := map[node]bool{}
for _, e := range renderEdges(t) {
if splitArm(e.label).key == head {
if splitArm(e.label, t.refs).key == head {
continue // a token or operand reading this draw, the routes allowed
}
if renders(e.to, held, seen) {
if isPath {
return fmt.Errorf("%s: %s renders %q, which {%s} reads a path into; name the fields you want instead", path, e.reached(), head, reader)
}
return fmt.Errorf("%s: %s renders %q, which a {calc()} also reads; reach it one way so it is drawn once", path, e.reached(), head)
return fmt.Errorf("%s: %s renders %q, which a {%s()} also reads; reach it one way so it is drawn once", path, e.reached(), head, operandReader(t, head))
}
}
}
@@ -89,32 +120,65 @@ func checkBoundLevelsHeld(root map[string]node) error {
})
}
// cover collects what one held draw of a level answers for: the level and
// everything contained in it, since a path may read any of it. Literals are left
// out — one fixed string cannot disagree with itself, and a literal is a value, so
// two that spell the same text are indistinguishable.
func cover(n node, into map[node]bool) {
if _, fixed := n.(literal); fixed {
// operandReader names the builtin whose operand holds head.
func operandReader(t *template, head string) string {
fn := ""
_ = eachToken(t.format, func(tok ftoken) error {
if tok.kind != 'b' || fn != "" {
return nil
}
if name, _, isFunc := funcCall(tok.body); isFunc {
for _, operand := range tokenOperands(tok.body) {
if splitArm(operand, t.refs).key == head {
fn = name
}
}
}
return nil
})
return fn
}
// coverPath collects what holding one path pins: every choice level the path
// passes through, whole, and the leaf it renders.
func coverPath(n node, tail []string, into map[node]bool) {
if _, isChoice := n.(*choice); isChoice || len(tail) == 0 {
cover(n, into, false)
return
}
into[n] = true
for _, c := range contained(n) {
cover(c.node, into)
t, ok := n.(*template)
if !ok {
return
}
if child, ok := t.fields[tail[0]]; ok {
coverPath(child, tail[1:], into)
}
}
// operandDraw collects what one held draw of a {calc()} operand answers for: the
// operand and what rendering it settles inside itself. A calc renders its operand
// cover collects a level and everything contained in it. A fixed string outside a
// choice is left out — it cannot disagree with itself — but inside one each
// variant carries its own, so there it counts.
func cover(n node, into map[node]bool, inChoice bool) {
if isFixed(n) && !inChoice {
return
}
into[n] = true
_, isChoice := n.(*choice)
for _, c := range contained(n) {
cover(c.node, into, inChoice || isChoice)
}
}
// operandDraw collects what one held draw of an operand answers for: the operand
// and what rendering it settles inside itself. The builtin renders its operand
// whole, so that draw fixes every value the render produced, and a second route to
// any of them disagrees with it.
//
// The walk stops at a {..path} edge, which is where the operand's own value ends
// and a shared source begins: two names referencing one category are two draws, the
// same rule {word} {word} follows. cover stops there too, by way of named, so both
// halves of the fence end at the same boundary. A literal is left out for the
// reason cover leaves one out.
// same rule {word} {word} follows.
func operandDraw(n node, into map[node]bool) {
if _, fixed := n.(literal); fixed {
if isFixed(n) {
return
}
if into[n] {
@@ -129,6 +193,12 @@ func operandDraw(n node, into map[node]bool) {
}
}
// isFixed is a string that varies nothing: fixed text with no fields to read into.
func isFixed(n node) bool {
t, ok := n.(*template)
return ok && t.fixed && len(t.fields) == 0
}
// renders reports whether rendering n can reach anything in want, following the
// same edges expand does. seen keeps a node shared by several routes from being
// walked twice; checkNoCycles has already proved the graph is a DAG, so the walk
@@ -226,47 +296,35 @@ func sortedNames(m map[string]node) []string {
return names
}
// lookup finds the single node a reference path names, walking groups and
// template fields by segment and descending a single-variant choice as a
// transparent wrapper. A missing segment, a folder target, or a step through a
// multi-variant choice (which has no one value to bind) is an error.
func lookup(root map[string]node, segments []string) (node, error) {
// resolveRef walks a reference path through the folders to the category it names,
// returning that head, the node, and the tail left to read into it.
func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) {
var n node = &group{children: root}
for i := 0; i < len(segments); i++ {
switch c := n.(type) {
case *group:
child, ok := c.children[segments[i]]
i := 0
for ; i < len(segments); i++ {
g, ok := n.(*group)
if !ok {
return nil, fmt.Errorf("no entry %q", segments[i])
break
}
child, ok := g.children[segments[i]]
if !ok {
return nil, nil, nil, fmt.Errorf("no entry %q", segments[i])
}
n = child
case *template:
child, ok := c.fields[segments[i]]
if !ok {
return nil, fmt.Errorf("no field %q", segments[i])
}
n = child
case *choice:
if len(c.items) != 1 {
return nil, fmt.Errorf("%q steps through a %d-way choice", segments[i], len(c.items))
}
n, i = c.items[0], i-1 // a choice consumes no segment; reprocess it unwrapped
default:
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[i])
}
}
if _, ok := n.(*group); ok {
return nil, fmt.Errorf("names a folder, not a value")
return nil, nil, nil, fmt.Errorf("names a folder, not a value")
}
return n, nil
return segments[:i], n, segments[i:], nil
}
// refTokens returns just the {..path} reference names among a format's field
// tokens (linkRefs binds each into the template's fields).
// refTokens returns the {..path} names a format reads, as tokens or as operands.
func refTokens(format string) []string {
var refs []string
for _, name := range fieldTokens(format) {
if isRef(name) {
seen := map[string]bool{}
for _, name := range append(fieldTokens(format), operandTokens(format)...) {
if isRef(name) && !seen[name] {
seen[name] = true
refs = append(refs, name)
}
}
@@ -274,27 +332,27 @@ func refTokens(format string) []string {
}
// renderEdge is a child a node renders into, labelled by what reaches it (a field
// name, reference, or choice index) for a readable cycle report. operand marks a
// label that is a {calc()} operand name rather than a token, so an error can name
// it the way the author wrote it.
// name, reference, or choice index) for a readable cycle report. operand names
// the builtin when the label is its operand rather than a token, so an error can
// name it the way the author wrote it.
type renderEdge struct {
to node
label string
operand bool
operand string
}
// reached names an edge as the author spelled it, the vocabulary boundReaders uses
// for the sibling fence.
func (e renderEdge) reached() string {
if e.operand {
return fmt.Sprintf("calc operand %q", e.label)
if e.operand != "" {
return fmt.Sprintf("%s operand %q", e.operand, e.label)
}
return "{" + e.label + "}"
}
// renderEdges lists the children rendering n recurses into, mirroring expand: a
// choice's items, and a template's field/reference tokens plus its calc operands.
// A literal or group renders nothing, so it has no edges.
// choice's items, and a template's field/reference tokens plus its operands. A
// group renders nothing, so it has no edges.
func renderEdges(n node) []renderEdge {
switch n := n.(type) {
case *choice:
@@ -305,8 +363,8 @@ func renderEdges(n node) []renderEdge {
return es
case *template:
var es []renderEdge
add := func(name string, operand bool) {
a := splitArm(name)
add := func(name, operand string) {
a := splitArm(name, n.refs)
c, ok := n.fields[a.key]
if !ok {
return
@@ -315,12 +373,21 @@ func renderEdges(n node) []renderEdge {
es = append(es, renderEdge{leaf, name, operand})
}
}
for _, name := range fieldTokens(n.format) {
add(name, false)
_ = eachToken(n.format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
for _, name := range calcOperands(n.format) {
add(name, true)
if fn, _, isFunc := funcCall(t.body); isFunc {
for _, operand := range tokenOperands(t.body) {
add(operand, fn)
}
return nil
}
for _, name := range strings.Split(t.body, "|") {
add(name, "")
}
return nil
})
return es
default:
return nil
+98 -41
View File
@@ -1,13 +1,16 @@
package fejkdata
import "testing"
import (
"strings"
"testing"
)
// TestRootReferenceAcrossFolders is the headline case: a category in one folder
// pulls a value from another via a {..path} reference resolved from the data root.
func TestRootReferenceAcrossFolders(t *testing.T) {
dir := writeData(t, map[string]string{
"en_US/person": `["Pat Smith"]`,
"sv_SE/greeting": `{"format":"Hej, {..en_US.person}!"}`,
"en_US/person": `"Pat Smith"`,
"sv_SE/greeting": `"Hej, {..en_US.person}!"`,
})
f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" {
@@ -15,12 +18,11 @@ func TestRootReferenceAcrossFolders(t *testing.T) {
}
}
// TestReferenceIntoAField reaches a field inside a referenced category, crossing
// a single-variant choice and then a template field (..who.last).
// TestReferenceIntoAField reaches a field inside a referenced category.
func TestReferenceIntoAField(t *testing.T) {
dir := writeData(t, map[string]string{
"who": `[{"format":"{first} {last}","first":["Ada"],"last":["Byron"]}]`,
"card": `{"format":"signed {..who.last}"}`,
"who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`,
"card": `"signed {..who.last}"`,
})
f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "card"); got != "signed Byron" {
@@ -32,8 +34,8 @@ func TestReferenceIntoAField(t *testing.T) {
// alternation, so a field and a cross-file value share one slot.
func TestReferenceInAlternation(t *testing.T) {
dir := writeData(t, map[string]string{
"far": `["X"]`,
"near": `{"format":"{here|..far}","here":["H"]}`,
"far": `"X"`,
"near": `{"format":"{here|..far}","here":"H"}`,
})
f := newGenerator(t, dir, WithSeed(2))
seen := map[string]bool{}
@@ -48,8 +50,8 @@ func TestReferenceInAlternation(t *testing.T) {
// TestReferenceCombinesLoadedPaths is the point of references over the merge
// model: data layered from two dirs can point at each other through the root.
func TestReferenceCombinesLoadedPaths(t *testing.T) {
a := writeData(t, map[string]string{"en_US/word": `["river"]`})
b := writeData(t, map[string]string{"mine/slug": `{"format":"the-{..en_US.word}"}`})
a := writeData(t, map[string]string{"en_US/word": `"river"`})
b := writeData(t, map[string]string{"mine/slug": `"the-{..en_US.word}"`})
f := newGeneratorN(t, []string{a, b}, WithSeed(1))
if got := fake(t, f, "mine.slug"); got != "the-river" {
t.Fatalf("slug = %q, want the-river", got)
@@ -60,9 +62,9 @@ func TestReferenceCombinesLoadedPaths(t *testing.T) {
// linking order cannot matter.
func TestReferenceChain(t *testing.T) {
dir := writeData(t, map[string]string{
"a": `{"format":"{..b}"}`,
"b": `{"format":"{..c}"}`,
"c": `["deep"]`,
"a": `"{..b}"`,
"b": `"{..c}"`,
"c": `"deep"`,
})
f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "a"); got != "deep" {
@@ -74,37 +76,37 @@ func TestReferenceChain(t *testing.T) {
// fails at load, never at a random render.
func TestReferenceErrors(t *testing.T) {
cases := map[string]map[string]string{
"missing target": {"card": `{"format":"{..nope.gone}"}`},
"folder target": {"en_US/word": `["w"]`, "card": `{"format":"{..en_US}"}`},
"multi-variant on the path": {
"who": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`,
"card": `{"format":"{..who.f}"}`,
"missing target": {"card": `"{..nope.gone}"`},
"folder target": {"en_US/word": `"w"`, "card": `"{..en_US}"`},
"a variant on the path lacks the field": {
"who": `[{"format":"{f}","f":"1"},{"format":"{g}","g":"2"}]`,
"card": `"{..who.f}"`,
},
"empty reference path": {"card": `{"format":"{..}"}`},
"empty reference path": {"card": `"{..}"`},
// A reference that leads back to its own value never terminates at render,
// so New must reject the cycle up front (direct, mutual, or chained).
"direct cycle": {"a": `{"format":"x{..a}"}`},
"mutual cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..a}"}`},
"chain cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..c}"}`, "c": `{"format":"{..a}"}`},
"direct cycle": {"a": `"x{..a}"`},
"mutual cycle": {"a": `"{..b}"`, "b": `"{..a}"`},
"chain cycle": {"a": `"{..b}"`, "b": `"{..c}"`, "c": `"{..a}"`},
// calc renders its operands, so a cycle through one must be caught too.
"calc operand cycle": {"x": `{"format":"{calc(y)}","y":[{"format":"{..x}"}]}`},
"calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{..x}"}`},
// A field its parent's format never renders is still reachable by dot path,
// so a cycle hiding in one must fail at New rather than at render.
"cycle in an unrendered field": {"cat": `{"format":"hi","x":{"format":"{..cat.x}"}}`},
"cycle in an unrendered field": {"cat": `{"format":"hi","x":"{..cat.x}"}`},
"mutual cycle between unrendered fields": {
"cat": `{"format":"hi","x":{"format":"{..cat.y}"},"y":{"format":"{..cat.x}"}}`,
"cat": `{"format":"hi","x":"{..cat.y}","y":"{..cat.x}"}`,
},
"cycle in an unrendered field of a choice arm": {
"cat": `[{"format":"hi","x":{"format":"{..cat.x}"}}]`,
"cat": `{"format":"hi","x":"{..cat.x}"}`,
},
// The shipped layout puts categories in folders, so a cycle one level down
// is the common case, not an edge case.
"cycle in a subfolder": {"sv_SE/a": `{"format":"x{..sv_SE.a}"}`},
"mutual cycle within a subfolder": {"sv_SE/a": `{"format":"{..sv_SE.b}"}`, "sv_SE/b": `{"format":"{..sv_SE.a}"}`},
"mutual cycle across two folders": {"en_US/a": `{"format":"{..sv_SE.b}"}`, "sv_SE/b": `{"format":"{..en_US.a}"}`},
"cycle in a subfolder": {"sv_SE/a": `"x{..sv_SE.a}"`},
"mutual cycle within a subfolder": {"sv_SE/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..sv_SE.a}"`},
"mutual cycle across two folders": {"en_US/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..en_US.a}"`},
// ".." is reserved for bound references, so an authored key using it would
// name a node nothing can reach and nothing would validate.
"field key using the reference prefix": {"cat": `{"format":"hi","..x":{"format":"{..nope}"}}`},
"field key using the reference prefix": {"cat": `{"format":"hi","..x":"{..nope}"}`},
}
for name, files := range cases {
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil {
@@ -119,10 +121,10 @@ func TestReferenceErrors(t *testing.T) {
// starts with "." — it is skipped, not rejected.
func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) {
f := newGenerator(t, writeData(t, map[string]string{
"sv_SE/ok": `["fine"]`,
"sv_SE/..bad": `{"format":"{..nope}"}`,
"sv_SE/..y/ct": `{"format":"{..nope}"}`,
".git/config": `["not data"]`,
"sv_SE/ok": `"fine"`,
"sv_SE/..bad": `"{..nope}"`,
"sv_SE/..y/ct": `"{..nope}"`,
".git/config": `"not data"`,
}), WithSeed(1))
if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" {
t.Fatalf("List() = %v, want only sv_SE.ok", got)
@@ -133,7 +135,7 @@ func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) {
// over-rejecting: a field the format never renders may point back at its own
// category, which terminates, and stays renderable by path.
func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) {
dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":{"format":"see {..cat}"}}`})
dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {..cat}"}`})
f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "cat"); got != "hi" {
t.Fatalf("cat = %q, want hi", got)
@@ -149,9 +151,9 @@ func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) {
func TestNewErrorIsDeterministic(t *testing.T) {
cases := map[string]map[string]string{
"three bad references": {
"a": `{"format":"{..nope.one}"}`,
"b": `{"format":"{..nope.two}"}`,
"c": `{"format":"{..nope.three}"}`,
"a": `"{..nope.one}"`,
"b": `"{..nope.two}"`,
"c": `"{..nope.three}"`,
},
"two bad fields in one template": {
"cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`,
@@ -188,12 +190,12 @@ func TestNewErrorPathIsCanonical(t *testing.T) {
}{
{
"cycle inside a choice arm",
map[string]string{"cat": `[{"format":"hi","x":{"format":"{..cat.x}"}}]`},
map[string]string{"cat": `{"format":"hi","x":"{..cat.x}"}`},
"fejkdata: reference cycle: cat.x -> ..cat.x",
},
{
"bad reference reached through another reference",
map[string]string{"a": `{"format":"{..b}"}`, "b": `{"format":"{..nope}"}`},
map[string]string{"a": `"{..b}"`, "b": `"{..nope}"`},
`fejkdata: b: reference {..nope}: no entry "nope"`,
},
}
@@ -208,3 +210,58 @@ func TestNewErrorPathIsCanonical(t *testing.T) {
}
}
}
func TestReferencePathIsHeld(t *testing.T) {
dir := writeData(t, map[string]string{
"person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
"card": `"{..person.first} {..person.last}"`,
})
f := newGenerator(t, dir, WithSeed(3))
seen := map[string]bool{}
for i := 0; i < 50; i++ {
got := fake(t, f, "card")
if got != "Anna Andersson" && got != "Bo Berg" {
t.Fatalf("card = %q, want one person's first and last name", got)
}
seen[got] = true
}
if len(seen) != 2 {
t.Fatalf("card only ever rendered %v", seen)
}
}
func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"who": `[{"format":"{f}{h}","f":"1","h":"x"},{"format":"{g}{h}","g":"2","h":"y"}]`,
"card": `"{..who.f}"`,
})))
if err == nil || !strings.Contains(err.Error(), "not every variant") {
t.Fatalf("New = %v, want the missing variant named", err)
}
}
func TestBareReferenceDrawsEachTime(t *testing.T) {
dir := writeData(t, map[string]string{
"die": `["1","2","3","4","5","6"]`,
"roll": `"{..die} {..die}"`,
})
f := newGenerator(t, dir, WithSeed(1))
for i := 0; i < 50; i++ {
if got := fake(t, f, "roll"); got[0] != got[2] {
return
}
}
t.Fatal("two bare references always agreed; each should be its own draw")
}
func TestReferenceOverlapIsRejected(t *testing.T) {
for name, file := range map[string]string{
"head beside a path": `{"format":"{..cat.p} {..cat.p.first}","p":[{"format":"{first}","first":"A"},{"format":"{first}","first":"B"}]}`,
"sibling path beside a reference path": `{"format":"{p.first} {..cat.p.last}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`,
} {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
t.Errorf("%s: New = %v, want the overlap rejected", name, err)
}
}
}
+5 -25
View File
@@ -53,14 +53,10 @@ func descend(s *session, n node, segments []string) (node, error) {
}
return descend(s, child, segments[1:])
case *choice:
if len(n.items) > 1 {
if want := strings.Join(segments, "."); !n.shared[want] {
return nil, unreachableInChoice(n, want)
}
}
return descend(s, pick(s, n), segments)
case literal:
return nil, fmt.Errorf("a plain string has no field %q", segments[0])
default:
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0])
}
@@ -85,12 +81,13 @@ func unreachableInChoice(c *choice, want string) error {
// front, so rendering a compiled tree cannot fail.
func render(s *session, n node) string {
switch n := n.(type) {
case literal:
return string(n)
case *choice:
return render(s, pick(s, n))
case *template:
if n.repeat == 1 {
if n.fixed {
return n.lit
}
return expand(s, n)
}
var b strings.Builder
@@ -119,21 +116,6 @@ func pick(r rng, c *choice) node {
return c.items[i]
}
// classChar randomises one character-class rune: '0' digit 0-9, '1' digit 1-9,
// 'A' letter A-Z, 'a' letter a-z.
func classChar(s *session, c rune) byte {
switch c {
case '0':
return byte('0' + s.IntN(10))
case '1':
return byte('1' + s.IntN(9))
case 'A':
return byte('A' + s.IntN(26))
default: // 'a'
return byte('a' + s.IntN(26))
}
}
// expand renders a template's compiled ops. compile validated every token, so this
// cannot fail.
func expand(s *session, t *template) string {
@@ -153,8 +135,6 @@ func expand(s *session, t *template) string {
switch o.kind {
case 'l':
b.WriteString(o.lit)
case 'c':
b.WriteByte(classChar(s, o.r))
case 'f':
b.WriteString(readField(s, t, held, o.arms[s.IntN(len(o.arms))]))
case 'b':
@@ -163,8 +143,8 @@ func expand(s *session, t *template) string {
var operands []string
if len(o.operands) > 0 {
operands = make([]string, len(o.operands))
for j, name := range o.operands {
operands[j] = readField(s, t, held, arm{name: name, key: name})
for j, a := range o.operands {
operands[j] = readField(s, t, held, a)
}
}
b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far
+7 -7
View File
@@ -26,7 +26,7 @@ func TestNewDefaultsToShippedData(t *testing.T) {
}
func TestWithDataPathLayersOverShipped(t *testing.T) {
dir := writeData(t, map[string]string{"sv_SE/word": `["only-mine"]`, "greeting": `["hej"]`})
dir := writeData(t, map[string]string{"sv_SE/word": `"only-mine"`, "greeting": `"hej"`})
f, err := New(WithDataPath(dir), WithSeed(1))
if err != nil {
t.Fatalf("New = %v", err)
@@ -43,7 +43,7 @@ func TestWithDataPathLayersOverShipped(t *testing.T) {
}
func TestUserDataMayReferenceShipped(t *testing.T) {
dir := writeData(t, map[string]string{"greeting": `{"format":"Hej {..sv_SE.person}!"}`})
dir := writeData(t, map[string]string{"greeting": `"Hej {..sv_SE.person}!"`})
f, err := New(WithDataPath(dir), WithSeed(1))
if err != nil {
t.Fatalf("New = %v", err)
@@ -61,7 +61,7 @@ func TestWithoutShippedDataNeedsASource(t *testing.T) {
}
func TestWithoutShippedDataListsOnlyOwn(t *testing.T) {
dir := writeData(t, map[string]string{"greeting": `["hej"]`})
dir := writeData(t, map[string]string{"greeting": `"hej"`})
f := newGenerator(t, dir, WithSeed(1))
if got := f.List(); !reflect.DeepEqual(got, []string{"greeting"}) {
t.Errorf("List() = %v, want only the loaded dir", got)
@@ -70,10 +70,10 @@ func TestWithoutShippedDataListsOnlyOwn(t *testing.T) {
func TestWithDataFS(t *testing.T) {
fsys := fstest.MapFS{
"greeting.json": {Data: []byte(`["hej"]`)},
"nested/x.json": {Data: []byte(`{"format":"{y}","y":["z"]}`)},
".hidden.json": {Data: []byte(`["ignored"]`)},
"broken/no.json": {Data: []byte(`["x"]`)},
"greeting.json": {Data: []byte(`"hej"`)},
"nested/x.json": {Data: []byte(`{"format":"{y}","y":"z"}`)},
".hidden.json": {Data: []byte(`"ignored"`)},
"broken/no.json": {Data: []byte(`"x"`)},
}
f, err := New(WithoutShippedData(), WithDataFS(fsys), WithSeed(1))
if err != nil {
+146 -108
View File
@@ -6,41 +6,33 @@ import (
"strings"
)
// This file is the {token} grammar of a format string: how it is scanned
// (eachToken), how a function token is parsed (funcCall) and validated
// (checkFunc, checkTokens), and the builtin contract its functions implement.
// render.go evaluates these tokens; node.go compiles the surrounding JSON;
// reference.go binds {..path} tokens across the tree.
// ftoken is one unit of a scanned format string: a literal rune to emit, a class
// char to randomise ('0' '1' 'A' 'a'), or the body of a {…} token.
// ftoken is one unit of a scanned format string: a literal rune or the body of a
// {…} token.
type ftoken struct {
kind byte // 'l' literal rune, 'c' class char, 'b' brace body
r rune // for kinds 'l' and 'c'
kind byte // 'l' literal rune, 'b' brace body
r rune
body string
}
// eachToken scans a format string once and calls fn for each unit, the single
// source of truth for how '#' escapes and {…} braces are read — compileOps,
// checkTokens, fieldTokens and refTokens all drive off it so the grammar can't
// drift between the validator and the renderer. '#' escapes the next char to a
// literal ("#0" -> '0', "##" -> '#'); a '{' must reach a '}' (else an error); an
// unmatched '}' is an ordinary literal. The error stops the scan early.
// source of truth for how braces are read: "{{" and "}}" are literal braces, a "{"
// opens a token that must reach its "}", and a lone "}" is an error.
func eachToken(format string, fn func(ftoken) error) error {
rs := []rune(format)
for i := 0; i < len(rs); i++ {
var t ftoken
switch c := rs[i]; c {
case '#':
t.kind, t.r = 'l', '#'
if i++; i < len(rs) {
t.r = rs[i]
}
case '0', '1', 'A', 'a':
t.kind, t.r = 'c', c
case '{':
if i+1 < len(rs) && rs[i+1] == '{' {
t.kind, t.r = 'l', '{'
i++
break
}
end := i + 1
for end < len(rs) && rs[end] != '}' {
if rs[end] == '{' {
return fmt.Errorf("'{' inside a token in %q; a literal brace is written {{", format)
}
end++
}
if end >= len(rs) {
@@ -48,6 +40,13 @@ func eachToken(format string, fn func(ftoken) error) error {
}
t.kind, t.body = 'b', string(rs[i+1:end])
i = end
case '}':
if i+1 < len(rs) && rs[i+1] == '}' {
t.kind, t.r = 'l', '}'
i++
break
}
return fmt.Errorf("lone '}' in %q; a literal brace is written }}", format)
default:
t.kind, t.r = 'l', c
}
@@ -61,18 +60,18 @@ func eachToken(format string, fn func(ftoken) error) error {
// builtin is a format-string function invoked as {name(args)}. It receives the
// session (its rng, and the {seq()} counters), the output emitted so far in the
// current expansion (for derivations such as a checksum over preceding digits), and
// the values of the operands it named (only calc names any).
// Almost all are pure over (rng, emitted, args) — no wall-clock, no crypto/rand —
// so seeding stays reproducible; a time-based id derives its time from the rng.
// seq is the one exception: it advances per-session counter state, which is itself
// deterministic (1, 2, 3 …). arity is the exact arg count, or -1 for variadic
// (then check does all the validation). The optional check validates args at
// compile time (their values, beyond the count). The registry lives in builtins.go.
// the values of the operands it named (only calc names any). All must stay pure
// over (rng, emitted, args) so seeded output is reproducible; seq advances
// per-session counter state, which is itself deterministic. arity is the exact arg
// count, or -1 for variadic (then check does all the validation).
type builtin struct {
arity int
// prep parses validated args once, at compile time, into the closure expand calls.
prep func(args []string) callFn
check func(fields map[string]node, args []string) error
// operands names the fields the call reads, which expand renders for it; nil
// for a builtin that reads none.
operands func(args []string) []string
}
// funcCall splits a "{token}" body shaped name(args) into its parts; ok is false
@@ -141,23 +140,9 @@ func checkTokens(format string, fields map[string]node) error {
}
continue // a root reference; its target is checked at New (see linkRefs)
}
a := splitArm(name)
if err := checkSegments(a); err != nil {
if err := checkArm(name, fields); err != nil {
return fmt.Errorf("token {%s}: %w", t.body, err)
}
head, ok := fields[a.key]
if !ok {
if a.key == "" {
return fmt.Errorf("token {%s}: a name is never empty, so this token can name no field", t.body)
}
if isOption(a.key) {
return fmt.Errorf("token {%s}: %q is an option and can never be a field", t.body, a.key)
}
return fmt.Errorf("token {%s}: no field %q", t.body, a.key)
}
if err := checkPath(head, a.tail, a.key); err != nil {
return fmt.Errorf("token {%s}: field %q: %w", t.body, a.key, err)
}
}
// Last, so an arm broken on its own terms is reported as that: a repeat is
// the consequence of such a mistake, not the mistake itself.
@@ -165,11 +150,57 @@ func checkTokens(format string, fields map[string]node) error {
})
}
// checkNoRepeatedArm rejects {a|a|b}. An alternation picks its arms evenly, so a
// repeated one skews the odds — a third spelling of what weight is for, and one an
// author is far likelier to have typed by accident than meant. The error names the
// spelling that does skew a pick. It runs over the arms as written, so a repeated
// reference arm ({..a|..a}) is caught alongside a repeated sibling.
// checkArm validates one sibling name or path against a template's fields.
func checkArm(name string, fields map[string]node) error {
a := splitArm(name, nil)
if err := checkSegments(a); err != nil {
return err
}
head, ok := fields[a.key]
if !ok {
if a.key == "" {
return fmt.Errorf("a name is never empty, so this token can name no field")
}
if isOption(a.key) {
return fmt.Errorf("%q is an option and can never be a field", a.key)
}
return fmt.Errorf("no field %q", a.key)
}
if err := checkPath(head, a.tail, a.key); err != nil {
return fmt.Errorf("field %q: %w", a.key, err)
}
return nil
}
// tokenOperands lists the fields one {token} body reads as operands, empty for a
// field token or a builtin that reads none.
func tokenOperands(body string) []string {
name, args, ok := funcCall(body)
if !ok {
return nil
}
b, known := builtins[name]
if !known || b.operands == nil {
return nil
}
return b.operands(args)
}
// operandTokens lists every operand the builtins in a format read.
func operandTokens(format string) []string {
var names []string
_ = eachToken(format, func(t ftoken) error {
if t.kind == 'b' {
names = append(names, tokenOperands(t.body)...)
}
return nil
})
return names
}
// checkNoRepeatedArm rejects {a|a|b}: an alternation picks its arms evenly, so a
// repeated one is a second spelling of weight. The error names the spelling that
// does skew a pick.
func checkNoRepeatedArm(body string, names []string) error {
if len(names) < 2 {
return nil
@@ -198,10 +229,11 @@ func fieldTokens(format string) []string {
return names
}
// arm is one alternative of a {a|b} token, split into the key naming the node in
// a template's fields (a sibling field, or the whole "..path" string a reference is
// bound under) and the tail of a dotted path into it. A non-empty tail is what makes
// the arm a bound draw: its head is drawn once per expansion (see compileOps).
// arm is one alternative of a {a|b} token or one operand, split into the key
// naming the node in a template's fields (a sibling field, or the "..path" head a
// reference is bound under) and the tail of a dotted path into it. A non-empty
// tail is what makes the arm a bound draw: its head is drawn once per expansion
// (see compileOps).
type arm struct {
name string // as written, and the key a bound draw's value is held under
key string
@@ -209,32 +241,38 @@ type arm struct {
steps []string // key per level passed through; the head and leaf hold their own
}
// splitArm splits one token alternative into key and tail. A reference keeps its
// dots — linkRefs binds it whole — so only a sibling name reads as a path.
func splitArm(name string) arm {
// splitArm splits one name into key and tail. refs maps a reference to the head
// linkRefs bound it under; before linking, a reference is whole.
func splitArm(name string, refs map[string]string) arm {
if isRef(name) {
key, bound := refs[name]
if !bound || key == name {
return arm{name: name, key: name}
}
return pathArm(name, key, strings.Split(name[len(key)+1:], "."))
}
head, tail, dotted := strings.Cut(name, ".")
if !dotted {
return arm{name: name, key: name}
}
segs := strings.Split(tail, ".")
return pathArm(name, head, strings.Split(tail, "."))
}
func pathArm(name, key string, segs []string) arm {
var steps []string
for i := 0; i < len(segs)-1; i++ { // every level except the leaf's own
steps = append(steps, head+"."+strings.Join(segs[:i+1], "."))
steps = append(steps, key+"."+strings.Join(segs[:i+1], "."))
}
return arm{name: name, key: head, tail: segs, steps: steps}
return arm{name: name, key: key, tail: segs, steps: steps}
}
// checkNoOverlap rejects a format that both renders a level and reads a path into
// it — {p} beside {p.first}, or {p.addr} beside {p.addr.city}. The two spell one
// draw two ways: the path reads the level's held draw, while rendering the level
// expands it afresh, so their values disagree. One spelling, so there is nothing
// to get wrong. Names are compared in sorted order, so which pair is reported
// it — {p} beside {p.first}, or {p.addr} beside {p.addr.city}. The path reads the
// level's held draw while rendering the level expands it afresh, so their values
// would disagree. Names are compared in sorted order, so which pair is reported
// does not depend on where the tokens sit.
func checkNoOverlap(format string, bound map[string]string) error {
names := boundReaders(format, bound)
func checkNoOverlap(format string, bound map[string]string, refs map[string]string) error {
names := boundReaders(format, bound, refs)
// Stable over one format-order scan, so two readers of one name (a token and a
// calc operand both naming "p") are reported as the format writes them.
sort.SliceStable(names, func(i, j int) bool { return names[i].name < names[j].name })
@@ -252,23 +290,24 @@ func checkNoOverlap(format string, bound map[string]string) error {
type reader struct{ name, label string }
// boundReaders lists every way a format reaches a bound field, in the order the
// format writes them. A calc operand renders its field, so it names a level exactly
// format writes them. An operand renders its field, so it names a level exactly
// as a token does; one scan finds both, which is what puts them in one order.
func boundReaders(format string, bound map[string]string) []reader {
func boundReaders(format string, bound map[string]string, refs map[string]string) []reader {
var names []reader
_ = eachToken(format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
if _, _, isFunc := funcCall(t.body); isFunc {
for _, operand := range calcTokenOperands(t.body) {
if _, isBound := bound[operand]; isBound {
names = append(names, reader{operand, fmt.Sprintf("calc operand %q", operand)})
if fn, _, isFunc := funcCall(t.body); isFunc {
for _, operand := range tokenOperands(t.body) {
a := splitArm(operand, refs)
if _, isBound := bound[a.key]; isBound {
names = append(names, reader{a.name, fmt.Sprintf("%s operand %q", fn, operand)})
}
}
return nil
}
for _, a := range splitArms(t.body) {
for _, a := range splitArms(t.body, refs) {
if _, isBound := bound[a.key]; isBound {
names = append(names, reader{a.name, "token {" + a.name + "}"})
}
@@ -297,11 +336,11 @@ func checkSegments(a arm) error {
}
// splitArms splits a token body's '|' alternatives.
func splitArms(body string) []arm {
func splitArms(body string, refs map[string]string) []arm {
parts := strings.Split(body, "|")
arms := make([]arm, len(parts))
for i, p := range parts {
arms[i] = splitArm(p)
arms[i] = splitArm(p, refs)
}
return arms
}
@@ -311,39 +350,46 @@ func splitArms(body string) []arm {
// values of the operands it named, which expand read for it.
type callFn func(s *session, emitted string, operands []string) string
// op is one compiled unit of a format string: a literal run, a class char, a field
// alternation, or a builtin already bound to its args. compile builds these so
// render never re-scans the format.
// op is one compiled unit of a format string: a literal run, a field alternation,
// or a builtin already bound to its args. compile builds these so render never
// re-scans the format.
type op struct {
kind byte // 'l' literal run, 'c' class char, 'f' field alternation, 'b' builtin
kind byte // 'l' literal run, 'f' field alternation, 'b' builtin
lit string // kind 'l'
r rune // kind 'c'
arms []arm // kind 'f': the '|' alternatives, split into key and path once
call callFn
// operands names the sibling fields a {calc()} reads, in the order calcVars
// fixed; expand reads them before the call. nil for every other builtin.
operands []string
// operands are the fields the builtin reads, in the order its operands func
// fixed; expand reads them before the call. nil for a builtin that reads none.
operands []arm
}
// compileOps turns a format string into ops, and returns the smallest output it can
// produce (literals plus one byte per class char) to size the render buffer, plus
// two sets. bound is the levels the format addresses by dotted path, each mapped to
// the first path reading it, which is what the overlap fences name. held is every
// name drawn once per expansion — those levels, plus the siblings a {calc()} reads,
// so an operand shown is the operand computed. Both are nil when the format needs
// neither, so data that uses neither carries no render-time cost. Call checkTokens
// first: it is what proves the scan and every token are valid.
func compileOps(format string) ([]op, int, map[string]string, map[string]bool) {
// compileOps turns a format string into ops, and returns the size of its literal
// text to size the render buffer, plus two sets. bound is the levels the format
// addresses by dotted path, each mapped to the first path reading it, which is what
// the overlap fences name. held is every name drawn once per expansion — those
// levels, plus the fields an operand reads, so an operand shown is the operand
// computed. Both are nil when the format needs neither, so data that uses neither
// carries no render-time cost. Call checkTokens first: it is what proves the scan
// and every token are valid.
func compileOps(format string, refs map[string]string) ([]op, int, map[string]string, map[string]bool) {
var ops []op
var lit strings.Builder
var bound map[string]string
var held map[string]bool
grow := 0
hold := func(name string) {
hold := func(a arm) {
if held == nil {
held = map[string]bool{}
}
held[name] = true
held[a.key] = true
if len(a.tail) > 0 {
if bound == nil {
bound = map[string]string{}
}
if _, named := bound[a.key]; !named {
bound[a.key] = a.name // the first path reading it, for error messages
}
}
}
flush := func() {
if lit.Len() > 0 {
@@ -356,29 +402,21 @@ func compileOps(format string) ([]op, int, map[string]string, map[string]bool) {
switch t.kind {
case 'l':
lit.WriteRune(t.r)
case 'c':
flush()
grow++
ops = append(ops, op{kind: 'c', r: t.r})
case 'b':
flush()
if name, args, ok := funcCall(t.body); ok {
operands := calcTokenOperands(t.body)
for _, operand := range operands {
hold(operand) // a calc renders its operand, so the expansion holds that draw
var operands []arm
for _, operand := range tokenOperands(t.body) {
a := splitArm(operand, refs)
hold(a) // the builtin renders its operand, so the expansion holds that draw
operands = append(operands, a)
}
ops = append(ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands})
} else {
arms := splitArms(t.body)
arms := splitArms(t.body, refs)
for _, a := range arms {
if len(a.tail) > 0 {
if bound == nil {
bound = map[string]string{}
}
if _, named := bound[a.key]; !named {
bound[a.key] = a.name // the first path reading it, for error messages
}
hold(a.key)
hold(a)
}
}
ops = append(ops, op{kind: 'f', arms: arms})
+11 -11
View File
@@ -7,25 +7,25 @@ import "testing"
// cannot quietly shift the rng stream that reproducibility depends on.
func TestSeededOutputIsStable(t *testing.T) {
dir := writeData(t, map[string]string{
"alt": `{"format":"{a|b}","a":["A"],"b":["B"]}`,
"alt": `{"format":"{a|b}","a":"A","b":"B"}`,
"calc": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99","5.00","100.00"],"qty":["2","3","7"]}`,
"classes": `{"format":"00-11-AA-aa"}`,
"escapes": `{"format":"#0#1#A#a##{x}","x":["!"]}`,
"funcs": `{"format":"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"}`,
"nested": `{"format":"{outer}","outer":[{"format":"{inner}-00","inner":["i"]}]}`,
"ref": `{"format":"see {..alt}"}`,
"classes": `"{digits(2)}-{int(1,9)}{int(1,9)}-{upper(2)}-{lower(2)}"`,
"escapes": `{"format":"01Aa#{x}","x":"!"}`,
"funcs": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`,
"nested": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`,
"ref": `"see {..alt}"`,
"repeat": `{"format":"{w}","repeat":4,"separator":",","w":["x","y","z"]}`,
"sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":["012345678901234"],"e":["123456789012"],"m":["12345678"]}`,
"weights": `[{"format":"big","weight":9},{"format":"tiny","weight":1}]`,
"sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":"012345678901234","e":"123456789012","m":"12345678"}`,
"weights": `[{"format":"big","weight":9},"tiny"]`,
})
want := map[string][]string{
"alt": {"A", "B", "A", "A"},
"alt": {"A", "A", "B", "A"},
"calc": {"100.00 x 3 = 300.00", "100.00 x 3 = 300.00", "100.00 x 7 = 700.00", "5.00 x 3 = 15.00"},
"classes": {"49-74-VY-gj", "38-68-RP-xo", "47-68-IB-hs", "91-89-LP-gk"},
"escapes": {"01Aa#!", "01Aa#!", "01Aa#!", "01Aa#!"},
"funcs": {"0016a2 33 0.649 k4Kwj 1", "a00c07 84 0.175 6UjGv 2", "f70eb8 12 0.390 p5p6g 3", "9048a3 90 0.531 I3Aqv 4"},
"nested": {"i-89", "i-67", "i-47", "i-27"},
"ref": {"see A", "see A", "see B", "see A"},
"nested": {"i-73", "i-23", "i-67", "i-85"},
"ref": {"see A", "see A", "see A", "see B"},
"repeat": {"z,y,z,y", "z,z,y,y", "z,z,x,z", "x,z,y,y"},
"sums": {"90123456789012348 1234567890124 123456780", "90123456789012348 1234567890124 123456780", "90123456789012348 1234567890124 123456780", "90123456789012348 1234567890124 123456780"},
"weights": {"big", "big", "big", "big"},
+104 -54
View File
@@ -35,19 +35,33 @@ func mustRender(t *testing.T, f *Generator, s string) string {
return render(f.rand, compiled(t, s))
}
func TestLiteralStringIsVerbatim(t *testing.T) {
// A bare string is a literal, never formatted: 'a'/'A' must survive.
if got := mustRender(t, engine(1), `"Malmö"`); got != "Malmö" {
t.Fatalf("literal = %q, want Malmö", got)
func TestStringIsAFormat(t *testing.T) {
f := engine(1)
for src, want := range map[string]string{
`"Malmö"`: "Malmö",
`"100 Main St, Apt 1A #0"`: "100 Main St, Apt 1A #0",
`"{{x}}"`: "{x}",
} {
if got := mustRender(t, f, src); got != want {
t.Errorf("render(%s) = %q, want %q", src, got, want)
}
}
if got := mustRender(t, f, `"{digits(3)}"`); !regexp.MustCompile(`^[0-9]{3}$`).MatchString(got) {
t.Errorf(`render("{digits(3)}") = %q, want three digits`, got)
}
if _, err := compile(parse(t, `"{x}"`)); err == nil || !strings.Contains(err.Error(), `no field "x"`) {
t.Errorf(`compile("{x}") = %v, want a no-field error`, err)
}
}
func TestCharacterClasses(t *testing.T) {
func TestClassBuiltins(t *testing.T) {
cases := map[string]*regexp.Regexp{
`{"format":"0"}`: regexp.MustCompile(`^[0-9]$`),
`{"format":"1"}`: regexp.MustCompile(`^[1-9]$`),
`{"format":"A"}`: regexp.MustCompile(`^[A-Z]$`),
`{"format":"a"}`: regexp.MustCompile(`^[a-z]$`),
`"{digits(1)}"`: regexp.MustCompile(`^[0-9]$`),
`"{digits(3)}"`: regexp.MustCompile(`^[0-9]{3}$`),
`"{int(1,9)}"`: regexp.MustCompile(`^[1-9]$`),
`"{upper(1)}"`: regexp.MustCompile(`^[A-Z]$`),
`"{lower(1)}"`: regexp.MustCompile(`^[a-z]$`),
`"{upper(2)}{lower(2)}"`: regexp.MustCompile(`^[A-Z]{2}[a-z]{2}$`),
}
f := engine(7)
for tmpl, re := range cases {
@@ -59,15 +73,39 @@ func TestCharacterClasses(t *testing.T) {
}
}
func TestEscapeAndLiteralChars(t *testing.T) {
// '#' escapes the next char; non-class chars (7, x, -) are literal.
if got := mustRender(t, engine(1), `{"format":"#0#1#A#a## x7-z"}`); got != "01Aa# x7-z" {
t.Fatalf("escape = %q, want \"01Aa# x7-z\"", got)
func TestTextIsLiteral(t *testing.T) {
if got := mustRender(t, engine(1), `{"format":"100 Main St #1 {x}","x":"A"}`); got != "100 Main St #1 A" {
t.Fatalf("text = %q, want it verbatim", got)
}
}
func TestBraceEscapes(t *testing.T) {
f := engine(1)
for src, want := range map[string]string{
`{"format":"{{","x":"v"}`: "{",
`{"format":"}}","x":"v"}`: "}",
`{"format":"{{x}}","x":"v"}`: "{x}",
`{"format":"{{{x}}}","x":"v"}`: "{v}",
`{"format":"a{{{{b}}}}","x":"v"}`: "a{{b}}",
} {
if got := mustRender(t, f, src); got != want {
t.Errorf("render(%s) = %q, want %q", src, got, want)
}
}
for src, want := range map[string]string{
`"x}y"`: "}}",
`"}"`: "}}",
`{"format":"{a{b}","a":"Q"}`: "'{'",
`"{x"`: "unterminated",
} {
if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("compile(%s) = %v, want an error mentioning %s", src, err, want)
}
}
}
func TestTokenSubstitution(t *testing.T) {
if got := mustRender(t, engine(1), `{"format":"{x}sson","x":["Erik"]}`); got != "Eriksson" {
if got := mustRender(t, engine(1), `{"format":"{x}sson","x":"Erik"}`); got != "Eriksson" {
t.Fatalf("token = %q, want Eriksson", got)
}
}
@@ -76,7 +114,7 @@ func TestAlternationPicksOneField(t *testing.T) {
f := engine(3)
seen := map[string]bool{}
for i := 0; i < 100; i++ {
seen[mustRender(t, f, `{"format":"{a|b}","a":["A"],"b":["B"]}`)] = true
seen[mustRender(t, f, `{"format":"{a|b}","a":"A","b":"B"}`)] = true
}
if !seen["A"] || !seen["B"] || len(seen) != 2 {
t.Fatalf("alternation produced %v, want both A and B", seen)
@@ -96,7 +134,7 @@ func TestWeightSkewsDistribution(t *testing.T) {
f := engine(9)
heavy := 0
for i := 0; i < 1000; i++ {
if mustRender(t, f, `[{"format":"H","weight":10},{"format":"L"}]`) == "H" {
if mustRender(t, f, `[{"format":"H","weight":10},"L"]`) == "H" {
heavy++
}
}
@@ -118,7 +156,7 @@ func TestRepeatRendersFormatNTimes(t *testing.T) {
f, re := engine(7), regexp.MustCompile(`^[0-9]{4}$`)
seen := map[string]bool{}
for i := 0; i < 50; i++ {
got := mustRender(t, f, `{"format":"0","repeat":4}`)
got := mustRender(t, f, `{"format":"{digits(1)}","repeat":4}`)
if !re.MatchString(got) {
t.Fatalf("repeat-4 = %q, want 4 digits", got)
}
@@ -135,10 +173,10 @@ func TestFunctionTokenLuhn(t *testing.T) {
// 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
`"811218987{luhn()}"`: "8112189876", // personnummer body
`"7992739871{luhn()}"`: "79927398713", // classic Luhn vector
`"811218-987{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 {
@@ -151,7 +189,7 @@ func TestRecursionHasNoDepthLimit(t *testing.T) {
// Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep.
tmpl := `"deep"`
for i := 0; i < 50; i++ {
tmpl = `{"format":"{a}","a":[` + tmpl + `]}`
tmpl = `{"format":"{a}","a":` + tmpl + `}`
}
if got := mustRender(t, engine(1), tmpl); got != "deep" {
t.Fatalf("deep recursion = %q, want deep", got)
@@ -162,45 +200,56 @@ func TestCompileErrors(t *testing.T) {
// Every structural problem is caught up front, at compile/New time, never
// deferred to a random render that happens to hit the bad branch.
for _, bad := range []string{
`{"x":["Q"]}`, // object without "format"
`{"x":"Q"}`, // object without "format"
`{"format":"{y}","x":1}`, // a field is a bare number
`[1, 2]`, // a choice of numbers
`5`, // unsupported node type
`[]`, // empty choice
`{"format":"{x"}`, // unterminated brace
`{"format":"{y}","x":["Q"]}`, // token names a missing field
`{"format":"{}"}`, // empty token name
`{"format":"{a|}","a":["Q"]}`, // empty alternation segment
`[{"format":"A","weight":-1},{"format":"B"}]`, // negative weight
`"{x"`, // unterminated brace
`"x}y"`, // a lone } must be written }}
`["x"]`, // a one-item choice is its item
`["a","a"]`, // a repeated item is a weight
`{"format":"x"}`, // an object holding only a format is a string
`[{"format":"a","weight":1},"b"]`, // weight 1 is the default
`{"format":"{x}","x":"v","repeat":1}`, // repeat 1 is the default
`{"format":"{a{b}","a":"Q"}`, // a brace inside a token
`"{x}"`, // a bare string has no fields to name
`"{digits(0)}"`, // count must be positive
`"{upper(x)}"`, // count must be an integer
`"{lower()}"`, // wrong arity
`{"format":"{y}","x":"Q"}`, // token names a missing field
`"{}"`, // empty token name
`{"format":"{a|}","a":"Q"}`, // empty alternation segment
`[{"format":"A","weight":-1},"B"]`, // negative weight
`[{"format":"A","weight":0},{"format":"B","weight":0}]`, // weights sum to zero
`[{"format":"A","weight":1e308},{"format":"B","weight":1e308}]`, // weights overflow to +Inf
`[{"format":"A","weight":"heavy"}]`, // non-numeric weight
`{"format":"A","weight":"heavy"}`, // non-numeric weight
`{"format":"x","repeat":0}`, // repeat below 1
`{"format":"x","repeat":-2}`, // negative repeat
`{"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
`{"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
`{"format":"{seq(a,b)}"}`, // seq takes at most one name
`{"format":"{calc()}"}`, // calc needs an expression
`{"format":"{calc(1 +)}"}`, // dangling operator
`{"format":"{calc((1 + 2)}"}`, // unbalanced parenthesis
`{"format":"{calc(1 2)}"}`, // two operands, no operator
`{"format":"{calc(price)}"}`, // operand names no field
`{"format":"{calc(1, 2, 3)}"}`, // too many args
`{"format":"{calc(1, x)}"}`, // decimals arg not an integer
`{"format":"{calc(1, -1)}"}`, // decimals negative
`"{nope()}"`, // unknown function
`"{luhn(x)}"`, // function given args it takes none of
`"{luhn(}"`, // malformed function token
`"{int(1)}"`, // wrong arity
`"{int(a,b)}"`, // non-integer args
`"{int(5,1)}"`, // min > max
`"{hex(0)}"`, // count must be positive
`"{nanoid(-1)}"`, // negative count
`"{base64(0)}"`, // count must be positive
`"{float(1,2)}"`, // wrong arity
`"{float(1,2,-1)}"`, // negative decimals
`"{iban(US)}"`, // unsupported country
`"{seq(a,b)}"`, // seq takes at most one name
`"{calc()}"`, // calc needs an expression
`"{calc(1 +)}"`, // dangling operator
`"{calc((1 + 2)}"`, // unbalanced parenthesis
`"{calc(1 2)}"`, // two operands, no operator
`"{calc(price)}"`, // operand names no field
`"{calc(1, 2, 3)}"`, // too many args
`"{calc(1, x)}"`, // decimals arg not an integer
`"{calc(1, -1)}"`, // decimals negative
} {
if _, err := compile(parse(t, bad)); err == nil {
t.Errorf("compile(%s) = nil error, want error", bad)
@@ -211,7 +260,7 @@ func TestCompileErrors(t *testing.T) {
func TestFakePathNavigation(t *testing.T) {
f := engine(1)
f.categories = map[string]node{
"addr": compiled(t, `[{"format":"{street}","street":["Main"]}]`),
"addr": compiled(t, `{"format":"{street}","street":"Main"}`),
}
if got, err := f.Fake("addr"); err != nil || !strings.Contains(got, "Main") {
t.Fatalf("Fake(addr) = %q, %v", got, err)
@@ -235,15 +284,16 @@ func TestGrowIsALowerBound(t *testing.T) {
for _, format := range []string{
"",
"plain literal",
"00-11-AA-aa",
"#0#1#A#a## literal",
"{digits(2)}-{int(1,9)}{int(1,9)}-{upper(2)}-{lower(2)}",
"01Aa# literal",
"{{}} {{{x}}}",
"Ö dag åäö 日本語",
"{x}{x}{x}",
"{hex(8)}-{int(10,99)}-{nanoid(5)}",
"9{d}{luhn()}",
"{a|b} and {a|b}",
} {
src := `{"format":` + quote(format) + `,"x":["1"],"a":["A"],"b":["B"],"d":["012345678901234"]}`
src := `{"format":` + quote(format) + `,"x":"1","a":"A","b":"B","d":"012345678901234"}`
tmpl, ok := compiled(t, src).(*template)
if !ok {
t.Fatalf("format %q did not compile to a template", format)
+53
View File
@@ -0,0 +1,53 @@
package fejkdata
import "testing"
func TestTransforms(t *testing.T) {
f := engine(1)
for src, want := range map[string]string{
`{"format":"{uppercase(x)}|{lowercase(x)}|{ascii(x)}","x":"Åsa Ödegård-Nuñez"}`: "ÅSA ÖDEGÅRD-NUÑEZ|åsa ödegård-nuñez|Asa Odegard-Nunez",
`{"format":"{lowercase(ascii(x))}","x":"Åsa"}`: "asa",
`{"format":"{ascii(x)}","x":"Ærø ß 日本"}`: "AEro ss ",
} {
if got := mustRender(t, f, src); got != want {
t.Errorf("render(%s) = %q, want %q", src, got, want)
}
}
}
func TestTransformReadsTheHeldDraw(t *testing.T) {
f := engine(2)
src := `{"format":"{p.first}={lowercase(p.first)}","p":[{"format":"{first}","first":"Anna"},{"format":"{first}","first":"Bo"}]}`
for i := 0; i < 50; i++ {
if got := mustRender(t, f, src); got != "Anna=anna" && got != "Bo=bo" {
t.Fatalf("render = %q, want the transform over the same draw", got)
}
}
}
func TestTransformOverAReference(t *testing.T) {
dir := writeData(t, map[string]string{
"person": `[{"format":"{first} {last}","first":"Åsa","last":"Öberg"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`,
"email": `"{..person.first} {..person.last} <{lowercase(ascii(..person.first))}.{lowercase(ascii(..person.last))}@example.com>"`,
})
f := newGenerator(t, dir, WithSeed(5))
for i := 0; i < 50; i++ {
if got := fake(t, f, "email"); got != "Åsa Öberg <asa.oberg@example.com>" && got != "Bo Ek <bo.ek@example.com>" {
t.Fatalf("email = %q, want a record from one draw", got)
}
}
}
func TestTransformArgs(t *testing.T) {
for _, bad := range []string{
`{"format":"{lowercase()}","x":"v"}`,
`{"format":"{lowercase(nope)}","x":"v"}`,
`{"format":"{ascii(x,y)}","x":"v","y":"w"}`,
`{"format":"{lowercase(hex(2))}","x":"v"}`,
`{"format":"{lowercase(x)} {x.a}","x":{"format":"{a}","a":"1"}}`,
} {
if _, err := compile(parse(t, bad)); err == nil {
t.Errorf("compile(%s) = nil error, want it rejected", bad)
}
}
}