Compare commits

..

1 Commits

Author SHA1 Message Date
lilleman d72d326de5 Tests for weight 0, a never-numeric calc operand, the repeat product along a path, and ErrNoData
Tests / vet + fmt + tests (pull_request) Failing after 15s
2026-09-02 18:22:23 +02:00
36 changed files with 1362 additions and 2740 deletions
+1 -17
View File
@@ -16,28 +16,12 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0
- name: Relevant changes
id: changes
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
git fetch --no-tags origin "$GITHUB_BASE_REF"
base="origin/$GITHUB_BASE_REF"
else
base='${{ github.event.before }}'
fi
if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data README.md; then
echo "code=false" >> "$GITHUB_OUTPUT"
else
echo "code=true" >> "$GITHUB_OUTPUT"
fi
# `docker build` streams the context to the daemon. A compose bind-mount of # `docker build` streams the context to the daemon. A compose bind-mount of
# `.` mounts an empty host dir instead, because the job is itself a container. # `.` mounts an empty host dir instead, because the job is itself a container.
- name: Latest supported Go - name: Latest supported Go
if: ${{ steps.changes.outputs.code == 'true' }}
run: docker build . run: docker build .
# Runs even when the step above failed, so a red build says whether the # Runs even when the step above failed, so a red build says whether the
# failure is version-specific. # failure is version-specific.
- name: Lowest supported Go - name: Lowest supported Go
if: ${{ !cancelled() && steps.changes.outputs.code == 'true' }} if: ${{ !cancelled() }}
run: docker build --build-arg GO_VERSION=1.22.12 . run: docker build --build-arg GO_VERSION=1.22.12 .
+1
View File
@@ -1,3 +1,4 @@
.claude .claude
*.out *.out
__pycache__/ __pycache__/
todo.md
-1
View File
@@ -7,4 +7,3 @@
- One spelling per result: reject the other at `New`, and let the error name the spelling to use. - 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 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. - A README example is a `json` block that loads and renders as a category; `readme_test.go` runs every one.
- Cyclomatic complexity is gated at 14: the table-shaped dispatches (`eachToken`, `calc.factor`, `walkPath`) sit at 1314 and stay whole; anything else that reaches 14 is decomposed.
-1
View File
@@ -13,7 +13,6 @@ RUN go mod download
COPY . . COPY . .
RUN go vet ./... && \ RUN go vet ./... && \
go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 14 -ignore _test . && \
{ unformatted="$(gofmt -l .)"; test -z "$unformatted" || \ { unformatted="$(gofmt -l .)"; test -z "$unformatted" || \
{ echo "unformatted files:"; echo "$unformatted"; exit 1; }; } && \ { echo "unformatted files:"; echo "$unformatted"; exit 1; }; } && \
go test -race ./... go test -race ./...
+57 -168
View File
@@ -1,17 +1,32 @@
# fejkdata # fejkdata
Locale-aware fake data for tests and fixtures, generated from JSON templates. Use A Go library and CLI for generating locale-aware fake data from JSON templates.
it as a Go library or the CLI — no data on disk, no dependencies, and a seed makes output Forked from [github.com/Timewave-AB/fakes](https://github.com/Timewave-AB/fakes).
reproducible.
```sh ## Goals
go install gitea.larvit.se/larvit/fejkdata/cmd/fejkdata@latest
fejkdata sv_SE.person # Sara Eriksson 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 ## CLI
```sh ```sh
go install gitea.larvit.se/larvit/fejkdata/cmd/fejkdata@latest
fejkdata sv_SE.person # Sara Eriksson fejkdata sv_SE.person # Sara Eriksson
fejkdata sv_SE.person.last # Eriksson fejkdata sv_SE.person.last # Eriksson
fejkdata --seed 42 sv_SE.address # the same address every run fejkdata --seed 42 sv_SE.address # the same address every run
@@ -19,27 +34,17 @@ fejkdata -n 3 --separator ', ' sv_SE.word # nät, barn, sol
fejkdata --list # every path the data offers fejkdata --list # every path the data offers
fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data
fejkdata --no-shipped-data -d ./mydata --list # only your data fejkdata --no-shipped-data -d ./mydata --list # only your data
fejkdata 'name: {/sv_SE.person.last}' # name: <a surname> — an inline template
fejkdata '{"format":"name: {x}","x":["bosse","lina"]}' # name: bosse or name: lina
``` ```
A path names a category, or a field inside one: each dot segment descends one 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. An argument that is level — folders, then the category (a JSON file), then fields.
a JSON object, array or string, or that carries a `{` token, is instead an
**inline template**: a format string or a JSON value compiled and rendered on the
spot. Its tokens reach the data by reference from the root —
`{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike
available. An inline template sits in no folder, so the folder-relative `{.name}`
and `{..name}` are rejected naming the root spelling. A path never contains a
brace, a bracket or a quote, so the two cannot collide (see
[Decisions](#decisions)).
| Flag | | | Flag | |
|------|--| |------|--|
| `-d`, `--data-path D` | a directory to layer over the shipped data; repeatable, the last wins a name clash | | `-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 | | `--no-shipped-data` | load only the `--data-path` directories |
| `-s`, `--seed N` | reproducible output | | `-s`, `--seed N` | reproducible output |
| `-n`, `--repeat N` | render the value N times (up to 1048576), each an independent draw, streamed | | `-n`, `--repeat N` | render the path N times, each an independent draw |
| `--separator S` | between repeated values (default a newline) | | `--separator S` | between repeated values (default a newline) |
| `--list` | print every path, then exit | | `--list` | print every path, then exit |
| `--version`, `-h`, `--help` | print, then exit | | `--version`, `-h`, `--help` | print, then exit |
@@ -47,9 +52,7 @@ brace, a bracket or a quote, so the two cannot collide (see
`--name value` and `--name=value` both work, a short flag's value attaches or `--name value` and `--name=value` both work, a short flag's value attaches or
follows (`-n3`, `-n 3`) and short flags bundle (`-hn 3`) — see follows (`-n3`, `-n 3`) and short flags bundle (`-hn 3`) — see
[Decisions](#decisions); flags go anywhere, `--` ends them. Exit codes: `0` success, `1` runtime error (missing [Decisions](#decisions); flags go anywhere, `--` ends them. Exit codes: `0` success, `1` runtime error (missing
dir, unknown path), `2` misuse — a bad flag, an argument that names neither a dir, unknown path), `2` misuse. From a checkout: `go run ./cmd/fejkdata …`.
template nor a path, or an inline template that does not compile. From a checkout:
`go run ./cmd/fejkdata …`.
### Your own data ### Your own data
@@ -86,9 +89,6 @@ if err != nil {
} }
v, err := f.Fake("sv_SE.address") // "Kungsvägen 68\n379 17 Stockholm" v, err := f.Fake("sv_SE.address") // "Kungsvägen 68\n379 17 Stockholm"
paths := f.List() // every path Fake accepts, sorted paths := f.List() // every path Fake accepts, sorted
v, err = f.FakeTemplate("name: {/sv_SE.person.last}") // compile + render in one call
t, err := f.NewTemplate(`{"format":"name: {x}","x":["bosse","lina"]}`) // compile once
v = t.Fake() // render many times, no re-parse
``` ```
| Option | | | Option | |
@@ -110,8 +110,8 @@ 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 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. `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 Sources merge in order; matching folders combine, any other clash is won by the
last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`; last loaded. Names may not use `.`, `|`, `(`, `{` or `}`; dot-prefixed entries
dot-prefixed entries are skipped, so a data directory can also be a checkout. are skipped, so a data directory can also be a checkout.
Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`, Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`,
`person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`, `person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`,
@@ -141,7 +141,7 @@ Every character is literal except a `{…}` token:
| `{name.field}` | `field` of one draw of `name` ([Correlated fields](#correlated-fields)) | | `{name.field}` | `field` of one draw of `name` ([Correlated fields](#correlated-fields)) |
| `{a\|b}` | one of the named fields, even odds | | `{a\|b}` | one of the named fields, even odds |
| `{fn(args)}` | a builtin ([Functions](#functions)) | | `{fn(args)}` | a builtin ([Functions](#functions)) |
| `{/path}`, `{.path}`, `{..path}` | a node reached from the data root, this file's folder, or the folder above ([References](#references)) | | `{..path}` | a node reached from the data root ([References](#references)) |
| `{{`, `}}` | a literal `{` or `}` | | `{{`, `}}` | a literal `{` or `}` |
```json ```json
@@ -167,8 +167,8 @@ An item of a choice may carry a `weight` (default `1`) to skew its odds:
Renders `070-412 38 91` ten times as often as `08-…`. A string item is weighted by 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 writing it as `{ "format": "AB", "weight": 3 }`. Rejected at load: a weight that
is negative, non-numeric, `0` (never drawn — remove the item), `1` (the default) is negative, non-numeric, `1` (the default) or outside a choice, a set summing to
or outside a choice, and a repeated item — `["a", "a", "b"]` is `[{ "format": "a", "weight": 2 }, "b"]`, zero, and a repeated item — `["a", "a", "b"]` is `[{ "format": "a", "weight": 2 }, "b"]`,
and the error says so. and the error says so.
### Repeat ### Repeat
@@ -180,9 +180,7 @@ many times — each an independent draw — joined by `separator` (default `""`)
{ "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] } { "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] }
``` ```
Renders e.g. `bar foo baz`. Rejected at load: a `separator` without a `repeat`, Renders e.g. `bar foo baz`. A `separator` without a `repeat` is rejected.
a `separator` of `""` (the default), and a `repeat` that multiplies to more than
1 048 576 renders along any path of nested repeats.
### Options and fields ### Options and fields
@@ -194,11 +192,9 @@ choice naming its item.
### Functions ### Functions
A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad
count, range, country or expression fails fast; an integer is written plain count, range, country or expression fails fast, and a length, count or decimal
(`5`, not `+5` or `05`); bounds are finite; a sample that could only ever emit one place beyond a sane maximum is rejected, so a fat-fingered `hex(2000000000)`
value (`int(5,5)`, `float(1,1,2)`) is rejected naming the text to write instead; never tries to allocate gigabytes. Every builtin draws only from the seed — a
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 time-based id takes its timestamp from the rng, not the clock — so seeded output
stays reproducible. stays reproducible.
@@ -244,11 +240,8 @@ hyphenated field can't be an operand.
{ "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } { "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] }
``` ```
Renders e.g. `19.99 x 3 = 59.97`. An operand that can never be a number (`"abc"`, Renders e.g. `19.99 x 3 = 59.97`. A non-numeric operand yields `NaN` and a
or a choice of such) is rejected at load, as is a division by a constant zero division by zero `Inf`; both print rather than fail.
(`1/0`, or a fixed `"0"` field); an operand that sometimes is not a number yields
`NaN`, and a division by one that is not constant `Inf` — both print rather than
fail.
### Transforms ### Transforms
@@ -271,25 +264,21 @@ a mix.
### References ### References
A reference renders a node from elsewhere in the data — the path `Fake` takes, `{..path}` renders a node from the **data root** — the path `Fake` takes, across
across every loaded source — so one category borrows another. The sigil says every loaded source — so one category borrows another, even across folders or
where the path starts, as in a filesystem: `{/en_US.person}` from the data root, layered directories:
`{.username}` from the folder this file sits in, `{..username}` from the folder
above. `data/sv_SE/email.json` can therefore read its own locale's `username`
without naming `sv_SE`:
```json ```json
"Hej, {/en_US.person}!" "Hej, {..en_US.person}!"
``` ```
Renders e.g. `Hej, Pat Smith!`. A reference into a category is held like a Renders e.g. `Hej, Pat Smith!`. A reference into a category is held like a
[correlated](#correlated-fields) path — `{.person.first} {.person.last}` name one [correlated](#correlated-fields) path — `{..sv_SE.person.first} {..sv_SE.person.last}`
person, `{lowercase(.person.first)}` reads that same draw, and `{.person.first}` name one person, `{lowercase(..sv_SE.person.first)}` reads that same draw — while
beside `{/sv_SE.person.last}` in `sv_SE` is one person too — while a bare a bare `{..misc.uuid} {..misc.uuid}` is two draws. Rejected at `New`: a path that
`{/misc.uuid} {/misc.uuid}` is two draws. Rejected at `New`: a path that is is unknown, names a folder, or reads a field not every variant of a choice
unknown, names a folder, has no folder above, or reads a field not every variant carries, and a reference that leads back to its own value, directly, mutually or
of a choice carries, and a reference that leads back to its own value, directly, through a chain.
mutually or through a chain.
### Correlated fields ### Correlated fields
@@ -323,8 +312,8 @@ The sub-fields stay addressable — `Fake("address.place.locality")` renders, an
A name any token reads as a path (`{p.first}`) or as an operand (`{calc(net * 2)}`, A name any token reads as a path (`{p.first}`) or as an operand (`{calc(net * 2)}`,
`{uppercase(w)}`) is drawn **once per expansion**, and every other route to it — `{uppercase(w)}`) is drawn **once per expansion**, and every other route to it —
a bare `{p}`, a second bare `{w}`, `{/cat.net}`, a nested template rendering a bare `{p}`, a second bare `{w}`, `{..cat.net}`, a nested template rendering
`{/cat.p.last}`, at any depth — is a load error naming the spelling to use. A `{..cat.p.last}`, at any depth — is a load error naming the spelling to use. A
name nothing reads that way is drawn each time: `{word} {word}` differs. An name nothing reads that way is drawn each time: `{word} {word}` differs. An
expansion is one render of one format, so each `repeat` iteration and each nested expansion is one render of one format, so each `repeat` iteration and each nested
template draws again. template draws again.
@@ -341,29 +330,6 @@ 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 list's length, a weighted one O(log n), and long formats, deep nesting and many
tokens add cost in proportion to the output. tokens add cost in proportion to the output.
## Goals
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 the data and `NewTemplate`
the inline template; on a loaded generator `Fake` fails only for an unknown
path, and `Template.Fake` cannot fail at all.
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.
9. **Fast enough to be free** — a value renders in about a microsecond and `New`
parses and validates the whole set once upfront, so generating fixtures stays
noise against a test's own runtime.
## Decisions ## Decisions
- **Options and fields share one namespace.** `format`, `weight`, `repeat` and - **Options and fields share one namespace.** `format`, `weight`, `repeat` and
@@ -379,87 +345,17 @@ tokens add cost in proportion to the output.
naming the double-dash spelling, and `-s=42` is rejected naming both short naming the double-dash spelling, and `-s=42` is rejected naming both short
spellings: `=` belongs to the long form, and reading `=42` as the value would spellings: `=` belongs to the long form, and reading `=42` as the value would
make `-d=./x` a directory named `=./x`. make `-d=./x` a directory named `=./x`.
- **An argument is a template by its shape, not by a flag.** A JSON object, array
or string, or a string carrying a `{` token, is an inline template; anything else
is a path. A name may not contain a brace, a bracket or a quote, so a path can
never collide with any of those spellings, and the leading `[` or `"` is gated on
valid JSON so a stray copied bracket never swallows an argument — it names
nothing, and says so. No `--template` flag is needed. Reserving the characters
whole — though only a leading one could collide — keeps one simple name rule
instead of a leading-position special case. The JSON string is what makes the
library's own advice reachable: the error for an object holding only a format
names `"…"`, and that spelling has to work where it is printed.
- **An inline template skips the cycle fence, and only that one.** `New` proves the
loaded tree acyclic, an inline node is a finite tree of its own, and nothing in
the tree can reference it, so no render of it reaches itself. Every other fence
runs over both, from one `checkScope`.
- **An inline template that does not compile is misuse (exit 2), including a
reference that resolves to nothing** — the whole argument is the spelling under
test, and `NewTemplate` compiles, links and validates as one step. An unknown
*path* stays a runtime error (exit 1): there the argument is well-formed and only
the data is absent.
- **A padded JSON argument is rejected, not trimmed.** Padding is the one place the
two readings disagree — a format string renders it, JSON drops it — so the
spelling that renders is named rather than silently chosen.
- **`FakeTemplate` and `NewTemplate` both stay.** They reach the same value but not
at the same cost: `NewTemplate` pays the compile and validation once and renders
many times, `FakeTemplate` is the one-shot call, and `--repeat` is exactly the
case that needs the first. The pair is `regexp.MustCompile` and `regexp.Match`,
not two spellings of one result.
- **The shipped data is embedded, not discovered.** A directory a machine happens - **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/` to have would make `--seed 42` machine-dependent. Data still lives in `data/`
as JSON; `--data-path` layers over it. as JSON; `--data-path` layers over it.
- **A bare reference draws each time; a reference path is held.** `{/p} {/p}` - **A bare reference draws each time; a reference path is held.** `{..p} {..p}`
is two draws, as `{word} {word}` is, while `{/p.first}` beside a nested template is two draws, as `{word} {word}` is, while `{..p.first}` beside a nested template
rendering `{/p.first}` is a load error: a bare token is by contract an rendering `{..p.first}` is a load error: a bare token is by contract an
independent draw, a path pins its level, and any route into a pinned level from independent draw, a path pins its level, and any route into a pinned level from
another expansion could show another row. another expansion could show another row.
- **Reference sigils follow the filesystem.** `/` is the root, `.` this file's
folder, `..` the folder above — what those spellings already mean to anyone who
has typed a path. A locale's files reach each other without naming the locale,
so a folder renames and copies without editing its references.
- **After the first tag, a new fence is a major version.** Data files are the
public API, and one spelling per result grows by tightening, so every fence
invalidates some file. Each such release names the rejected spelling and its
replacement in the changelog and in the load error, and that is the whole
migration: a fence rejects one spelling with one replacement, so the fix is
local to each site. A fence that would need a non-local rewrite ships a
converter with its release instead. Before the first tag there is no
compatibility promise.
- **A `--data-path` override rebinds every reference to the category it
replaces.** References bind against the merged tree, so once shipped data uses
`{.person}`, a consumer's `sv_SE/person.json` is what every shipped reference
into `person` reads, and `New` fails on shipped data the consumer never wrote
when that file lacks a field those references read. Accepted: overriding is the
point of layering, the error names the reference and the field, and the fix is
the consumer's file carrying the fields the shipped tree reads.
- **The repeat cap bounds renders, not bytes.** A repeat, alone or nested, may
ask for at most 1 048 576 renders; how large each render is stays what the data
asked for, so `{hex(1048576)}` repeated to the cap is a terabyte, loaded without
complaint. A byte estimate would need every builtin to declare a width to fence
a shape no data comes near, and the harm lands on the author who wrote it.
- **64-bit targets only.** The gate builds amd64, and the buffer sizing a render
pre-computes (renders × bytes) assumes a 64-bit int; on a 32-bit target it could
overflow and panic.
- **A constant zero divisor is a load error; a divisor that is not constant prints
`Inf`.** `1/0` and a fixed `"0"` field are decidable, so they join the
never-numeric operand as a load error; the fold stops where an operand varies,
so `a/(b*c)` with `b` fixed at `0` and `c` varying loads and prints `Inf` every
draw — catching it needs zero-absorbing algebra for a shape nobody writes.
- **In data, a default written out and a constant spelled as a sample are load
errors.** `weight: 1`, `repeat: 1`, `separator: ""`, `int(5,5)`, `float(1,1,2)`,
`+5` and `05` each spell what a shorter form already spells, so each is rejected
naming that form. The CLI's numbers follow the shell instead: `--seed 007` and
`--repeat +3` are 7 and 3, as every command line reads them.
- **Samples say what they emit, transforms what they do.** `{upper(2)}` is two - **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 letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on
whether the argument looks like a number. whether the argument looks like a number.
- **The performance gate asserts allocations, not wall-clock time.** `AllocsPerRun`
is deterministic across machines, so a ±10% ceiling does not flake under CI load,
while time varies with the machine and its neighbours. A rendering slowdown
almost always costs an allocation too (a lost pre-size, a per-item map, an extra
copy). The benchmark suite (see Development) reports time for a human, not as a
pass/fail gate.
## Development ## Development
@@ -472,7 +368,6 @@ docker compose run --rm cover # tests with coverage
docker compose run --rm bench # benchmarks docker compose run --rm bench # benchmarks
docker compose run --rm build # compile the library docker compose run --rm build # compile the library
docker compose run --rm vet # go vet docker compose run --rm vet # go vet
docker compose run --rm cyclo # cyclomatic complexity over 14 (test files excluded)
docker compose run --rm dev # interactive shell docker compose run --rm dev # interactive shell
``` ```
@@ -484,10 +379,8 @@ docker compose run --rm --user "$(id -u):$(id -g)" tidy # go mod tidy
``` ```
Every pull request runs `docker build .` against both the latest and the lowest Every pull request runs `docker build .` against both the latest and the lowest
supported Go, and must pass before it can be merged — unless it changes none of supported Go, and must pass before it can be merged. That build is the whole
the files the build and its tests read, nor the workflow itself, in which case gate — vet, format check and tests — so run it locally before pushing:
it's skipped (see [Decisions](#decisions)). That build is the whole gate — vet,
complexity, format check and tests — so run it locally before pushing:
```sh ```sh
docker build . # latest docker build . # latest
@@ -500,13 +393,9 @@ GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the im
``` ```
fejkdata.go Generator, New, options, the embedded data set, List fejkdata.go Generator, New, options, the embedded data set, List
node.go the node model and JSON -> node compilation node.go the node model and JSON -> node compilation
path.go the dotted-path walk, and proving a path resolves render.go Fake and the recursive renderer (choices, format strings, paths, held draws)
render.go Fake and the recursive renderer (choices, format strings, expansions) template.go the {token} grammar: scanning, arms, operands, validation
inline.go inline templates: Template, NewTemplate, FakeTemplate, and their compile and link reference.go {..path} binding across the tree, the render graph, and the walks over it
template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format
hold.go the hold: one draw per expansion for paths and operands, and its fences
reference.go reference sigils, and binding references across the tree
graph.go the render graph: edges, cycles, the repeat bound, tree walks
builtins.go the {name()} function registry and its implementations builtins.go the {name()} function registry and its implementations
calc.go the {calc()} arithmetic evaluator: parser, eval, validation calc.go the {calc()} arithmetic evaluator: parser, eval, validation
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
@@ -516,4 +405,4 @@ data/ shipped data (JSON), embedded at build: locale folders + a misc
## License ## License
MIT — see [LICENSE](LICENSE). Forked from [github.com/Timewave-AB/fakes](https://github.com/Timewave-AB/fakes). MIT — see [LICENSE](LICENSE).
+34 -43
View File
@@ -92,13 +92,13 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
// afresh beside the path that reads its held draw — the same overlap by // afresh beside the path that reads its held draw — the same overlap by
// another spelling. // another spelling.
rejected := map[string]string{ 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"]}}`, "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 // The reference need not sit in the format that binds: any field it renders
// reaches the level just the same, however deep. // reaches the level just the same, however deep.
"reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` + "reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` +
`{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` + `{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` +
`"inner":"{/cat.p}"}`, `"inner":"{..cat.p}"}`,
} }
for name, file := range rejected { for name, file := range rejected {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
@@ -108,7 +108,7 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
} }
// A reference to anything this format does not bind is untouched. // A reference to anything this format does not bind is untouched.
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ 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"]`, "surname": `["Eriksson","Lindqvist"]`,
}))); err != nil { }))); err != nil {
t.Errorf("New = %v, want a reference outside the bound level accepted", err) t.Errorf("New = %v, want a reference outside the bound level accepted", err)
@@ -120,7 +120,7 @@ func TestCycleReachedOnlyByAPathTokenIsRejected(t *testing.T) {
// cycle walk has to follow it there. A head whose own format names nothing // 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. // would otherwise hide the cycle until render, where it is fatal.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"a": `{"format":"{p.x}","p":{"format":"static","x":"{/a}"}}`, "a": `{"format":"{p.x}","p":{"format":"static","x":"{..a}"}}`,
}))) })))
if err == nil || !strings.Contains(err.Error(), "reference cycle") { if err == nil || !strings.Contains(err.Error(), "reference cycle") {
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
@@ -131,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 // {p.a} renders q, so it is a route to the level {q.x} holds — even though p's
// own format names nothing. // own format names nothing.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{/thing.q}"},` + "thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{..thing.q}"},` +
`"q":{"format":"{x}","x":["1","2"]}}`, `"q":{"format":"{x}","x":["1","2"]}}`,
}))) })))
if err == nil || !strings.Contains(err.Error(), "reads a path into") { if err == nil || !strings.Contains(err.Error(), "reads a path into") {
@@ -151,14 +151,14 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
}{ }{
"a reference beside the operand": { "a reference beside the operand": {
map[string]string{ map[string]string{
"cat": `{"format":"{/cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`, "cat": `{"format":"{..cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
}, },
`{/cat.net} renders "net"`, `{..cat.net} renders "net"`,
}, },
"a reference one level down": { "a reference one level down": {
map[string]string{ map[string]string{
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` + "cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
`"q":"{/cat.net}"}`, `"q":"{..cat.net}"}`,
}, },
`{q} renders "net"`, `{q} renders "net"`,
}, },
@@ -167,7 +167,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a reference to an operand wrapped in a choice": { "a reference to an operand wrapped in a choice": {
map[string]string{ map[string]string{
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` + "cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` +
`"q":"{/cat.n}"}`, `"q":"{..cat.n}"}`,
}, },
`{q} renders "n"`, `{q} renders "n"`,
}, },
@@ -176,7 +176,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"an operand reaching another operand": { "an operand reaching another operand": {
map[string]string{ map[string]string{
"cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` + "cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` +
`"b":"{/cat.a}"}`, `"b":"{..cat.a}"}`,
}, },
`calc operand "b" renders "a"`, `calc operand "b" renders "a"`,
}, },
@@ -185,15 +185,15 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
// rejected, so the reference spelling has to be. // rejected, so the reference spelling has to be.
"a reference into the operand": { "a reference into the operand": {
map[string]string{ map[string]string{
"cat": `{"format":"{calc(net * 2, 2)}|{/cat.net.v}",` + "cat": `{"format":"{calc(net * 2, 2)}|{..cat.net.v}",` +
`"net":{"format":"{v}","v":["10.00","20.00"]}}`, `"net":{"format":"{v}","v":["10.00","20.00"]}}`,
}, },
`{/cat.net.v} renders "net"`, `{..cat.net.v} renders "net"`,
}, },
"a reference into the operand one level down": { "a reference into the operand one level down": {
map[string]string{ map[string]string{
"cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` + "cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` +
`"q":"{/cat.net.v}"}`, `"q":"{..cat.net.v}"}`,
}, },
`{q} renders "net"`, `{q} renders "net"`,
}, },
@@ -203,7 +203,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a violation on the second of two held heads": { "a violation on the second of two held heads": {
map[string]string{ map[string]string{
"cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` + "cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` +
`"b":{"format":"{y}","y":["3","4"]},"w":"{/cat.b}"}`, `"b":{"format":"{y}","y":["3","4"]},"w":"{..cat.b}"}`,
}, },
`{w} renders "b"`, `{w} renders "b"`,
}, },
@@ -230,24 +230,24 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a reference to a sibling the operand never renders": { "a reference to a sibling the operand never renders": {
"cat": `{"format":"{calc(net * 2, 2)} {unit}",` + "cat": `{"format":"{calc(net * 2, 2)} {unit}",` +
`"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` + `"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` +
`"unit":"{/cat.net.spare}"}`, `"unit":"{..cat.net.spare}"}`,
}, },
// Two operands drawing from one source are two names, so two draws: each is // 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. // held under its own name and shown once, and neither can disagree.
"two operands sharing one source": { "two operands sharing one source": {
"die": `["1","2","3","4","5","6"]`, "die": `["1","2","3","4","5","6"]`,
"cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{/die}",` + "cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{..die}",` +
`"d2":"{/die}"}`, `"d2":"{..die}"}`,
}, },
// Likewise a reference drawing from what the operand draws from: {/common} // Likewise a reference drawing from what the operand draws from: {..common}
// and net are two names, not two spellings of one field. // and net are two names, not two spellings of one field.
"a reference to what an operand renders through": { "a reference to what an operand renders through": {
"common": `["1","2"]`, "common": `["1","2"]`,
"cat": `{"format":"{calc(net * 2, 2)} {/common}","net":"{/common}"}`, "cat": `{"format":"{calc(net * 2, 2)} {..common}","net":"{..common}"}`,
}, },
// A fixed string cannot disagree with itself, so it needs no fence. // A fixed string cannot disagree with itself, so it needs no fence.
"a literal operand named twice": { "a literal operand named twice": {
"cat": `{"format":"{calc(n * 2, 0)} {/cat.n}","n":"5"}`, "cat": `{"format":"{calc(n * 2, 0)} {..cat.n}","n":"5"}`,
}, },
// One node reached twice while walking the operand: the walk must not // One node reached twice while walking the operand: the walk must not
// revisit it, and the repeat is not a second route to anything. // revisit it, and the repeat is not a second route to anything.
@@ -269,11 +269,11 @@ func TestAPathReachesEveryVariantItMightDraw(t *testing.T) {
// to a held level disagrees silently. // to a held level disagrees silently.
rejected := map[string]struct{ file, want string }{ rejected := map[string]struct{ file, want string }{
"a cycle in a later variant": { "a cycle in a later variant": {
`{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat}"}]}`, `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat}"}]}`,
"reference cycle", "reference cycle",
}, },
"a second route in a later variant": { "a second route in a later variant": {
`{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat.q}"}],` + `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat.q}"}],` +
`"q":{"format":"{y}","y":["1","2"]}}`, `"q":{"format":"{y}","y":["1","2"]}}`,
"reads a path into", "reads a path into",
}, },
@@ -292,9 +292,9 @@ func TestALevelAPathNeverRendersIsAccepted(t *testing.T) {
// path at all. Both orders must load. // path at all. Both orders must load.
accepted := map[string]string{ accepted := map[string]string{
"reference in the head's own format": `{"format":"{p.first} {q.a}",` + "reference in the head's own format": `{"format":"{p.first} {q.a}",` +
`"p":{"format":"{first} {/thing.q}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}`, `"p":{"format":"{first} {..thing.q}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}`,
"the mirror shape": `{"format":"{p.first} {q.a}",` + "the mirror shape": `{"format":"{p.first} {q.a}",` +
`"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {/thing.p}","a":["1","2"]}}`, `"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {..thing.p}","a":["1","2"]}}`,
} }
for name, file := range accepted { for name, file := range accepted {
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"thing": file}))); err != nil { if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"thing": file}))); err != nil {
@@ -309,9 +309,9 @@ func TestADeepDiamondChainLoads(t *testing.T) {
files := map[string]string{"l0": `"x"`} files := map[string]string{"l0": `"x"`}
for i := 1; i <= 30; i++ { for i := 1; i <= 30; i++ {
files[fmt.Sprintf("l%d", i)] = fmt.Sprintf( files[fmt.Sprintf("l%d", i)] = fmt.Sprintf(
`{"format":"{a}{b}","a":"{/l%d}","b":"{/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"]}}` files["thing"] = `{"format":"{p.first} {..l30}","p":{"format":"x","first":["A","B"]}}`
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil { if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil {
t.Fatalf("New = %v, want a deep diamond chain to load", err) t.Fatalf("New = %v, want a deep diamond chain to load", err)
} }
@@ -323,7 +323,7 @@ func TestASharedNodeIsWalkedOnce(t *testing.T) {
// the shared node once per route. // the shared node once per route.
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"{p.first}|{q}","p":{"format":"{first}","first":["Anna","Bo"]},` + "cat": `{"format":"{p.first}|{q}","p":{"format":"{first}","first":["Anna","Bo"]},` +
`"q":{"format":"{a}{b}","a":"{/shared}","b":"{/shared}"}}`, `"q":{"format":"{a}{b}","a":"{..shared}","b":"{..shared}"}}`,
"shared": `"x"`, "shared": `"x"`,
})), WithSeed(1)) })), WithSeed(1))
if err != nil { if err != nil {
@@ -351,7 +351,7 @@ func TestReferenceToAMatchingStringIsAccepted(t *testing.T) {
// held one. Two unrelated literals that merely spell the same text must not // held one. Two unrelated literals that merely spell the same text must not
// read as the same node — the trap when comparing a value type. // read as the same node — the trap when comparing a value type.
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"cat": `{"format":"{p.city} {/other.tag}","p":{"format":"{city}","city":"Stockholm"}}`, "cat": `{"format":"{p.city} {..other.tag}","p":{"format":"{city}","city":"Stockholm"}}`,
"other": `{"format":"x","tag":"Stockholm"}`, "other": `{"format":"x","tag":"Stockholm"}`,
}))); err != nil { }))); err != nil {
t.Fatalf("New = %v, want a reference to a matching string accepted", err) t.Fatalf("New = %v, want a reference to a matching string accepted", err)
@@ -642,11 +642,12 @@ func TestDeepPathsUnderOneHeadStayIndependentWhereTheyDiverge(t *testing.T) {
} }
func TestEmptyPathSegmentIsRejected(t *testing.T) { func TestEmptyPathSegmentIsRejected(t *testing.T) {
// "{a.}" and "{a..b}" are unfinished paths, and the segment naming nothing is // "{a.}", "{.b}" and "{a..b}" are unfinished paths, and the segment naming
// reported as that rather than as a missing field. An empty name is rejected // nothing is reported as that rather than as a missing field. An empty name is
// where it is authored, so no data can make these resolve. // rejected where it is authored, so no data can make these resolve.
rejected := map[string]string{ rejected := map[string]string{
"trailing dot": `{"format":"[{a.}]","a":"x"}`, "trailing dot": `{"format":"[{a.}]","a":"x"}`,
"leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":"V"}}`,
"double dot": `{"format":"[{a..b}]","a":"x"}`, "double dot": `{"format":"[{a..b}]","a":"x"}`,
} }
for name, file := range rejected { for name, file := range rejected {
@@ -666,7 +667,7 @@ func TestCycleThroughAPathTokenIsRejected(t *testing.T) {
// must be caught at New. Reaching render would be fatal: the recursion never // must be caught at New. Reaching render would be fatal: the recursion never
// terminates, and a stack overflow cannot be recovered. // terminates, and a stack overflow cannot be recovered.
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"a": `{"format":"{p.x}","p":{"format":"{x}","x":"{/a}"}}`, "a": `{"format":"{p.x}","p":{"format":"{x}","x":"{..a}"}}`,
}))) })))
if err == nil || !strings.Contains(err.Error(), "reference cycle") { if err == nil || !strings.Contains(err.Error(), "reference cycle") {
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
@@ -711,13 +712,3 @@ func TestRepeatedBareTokenOfAHeldNameIsRejected(t *testing.T) {
} }
} }
} }
func TestReadFieldPanicsOnAnUnheldPath(t *testing.T) {
tm, ok := compiled(t, `{"format":"{w}","w":{"format":"{x}","x":"1"}}`).(*template)
if !ok {
t.Fatal("not a template")
}
mustPanic(t, "unheld arm with a path", func() {
readField(engine(1).rand, tm, nil, arm{name: "w.x", key: "w", tail: []string{"x"}, path: "w.x"})
})
}
+23 -59
View File
@@ -2,7 +2,6 @@ package fejkdata
import ( import (
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"math" "math"
"strconv" "strconv"
@@ -10,12 +9,11 @@ import (
"unicode" "unicode"
) )
// maxLen caps sample output lengths (hex, nanoid, base64, digits, upper, lower) // maxLen caps sample output lengths (hex, nanoid, base64) and maxDecimals caps
// and maxDecimals float/calc decimal places, so a fat-fingered or overflowing // float/calc decimal places, so a fat-fingered or overflowing argument fails at
// argument fails at New instead of trying to allocate gigabytes — or panicking — // New instead of trying to allocate gigabytes — or panicking — at render.
// at render.
const ( const (
maxLen = 1 << 20 maxLen = 1 << 20 // 1,048,576 chars/bytes
maxDecimals = 1024 maxDecimals = 1024
) )
@@ -130,10 +128,12 @@ func transformArg(fields map[string]node, a []string) error {
return err return err
} }
if isRef(leaf) { if isRef(leaf) {
_, _, err := refShape(leaf) if leaf == refPrefix {
return err return fmt.Errorf("reference has no path")
} }
return checkArm(leaf, fields, false) return nil
}
return checkArm(leaf, fields)
} }
func transformOperand(a []string) []string { func transformOperand(a []string) []string {
@@ -177,7 +177,7 @@ var asciiFolds = map[rune]string{
func asciiFold(s string) string { func asciiFold(s string) string {
var b strings.Builder var b strings.Builder
for _, r := range s { for _, r := range s {
if r <= unicode.MaxASCII { if r < unicode.MaxASCII {
b.WriteRune(r) b.WriteRune(r)
} else { } else {
b.WriteString(asciiFolds[r]) b.WriteString(asciiFolds[r])
@@ -222,31 +222,10 @@ func randChars(r rng, n int, alphabet string) string {
return string(b) return string(b)
} }
// plainInt parses an integer arg written the one way: no sign, no leading zero.
func plainInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if errors.Is(err, strconv.ErrRange) {
return 0, fmt.Errorf("%q is past the integer range: %w", s, err)
}
if err != nil {
return 0, fmt.Errorf("%q is not an integer", s)
}
if strconv.Itoa(n) != s {
return 0, fmt.Errorf("%q is not a plain integer; write %d", s, n)
}
return n, nil
}
func posIntArg(_ map[string]node, a []string) error { func posIntArg(_ map[string]node, a []string) error {
n, err := plainInt(a[0]) n, err := strconv.Atoi(a[0])
if errors.Is(err, strconv.ErrRange) { if err != nil || n < 1 {
return fmt.Errorf("count %q exceeds the maximum %d", a[0], maxLen) return fmt.Errorf("count %q must be a positive integer", a[0])
}
if err != nil {
return fmt.Errorf("count %w", err)
}
if n < 1 {
return fmt.Errorf("count %q must be positive", a[0])
} }
if n > maxLen { if n > maxLen {
return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen) return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen)
@@ -255,20 +234,14 @@ func posIntArg(_ map[string]node, a []string) error {
} }
func intRangeArgs(_ map[string]node, a []string) error { func intRangeArgs(_ map[string]node, a []string) error {
lo, err := plainInt(a[0]) lo, e1 := strconv.Atoi(a[0])
if err != nil { hi, e2 := strconv.Atoi(a[1])
return fmt.Errorf("int(min,max): min %w", err) if e1 != nil || e2 != nil {
} return fmt.Errorf("int(min,max) needs integer args, got %q,%q", a[0], a[1])
hi, err := plainInt(a[1])
if err != nil {
return fmt.Errorf("int(min,max): max %w", err)
} }
if lo > hi { if lo > hi {
return fmt.Errorf("int(min,max): min %d > max %d", lo, hi) return fmt.Errorf("int(min,max): min %d > max %d", lo, hi)
} }
if lo == hi {
return fmt.Errorf("int(%d,%d) is the constant %d; write it as text", lo, hi, lo)
}
if uint64(hi)-uint64(lo) >= uint64(math.MaxInt64) { // span hi-lo+1 would overflow int -> IntN panic if uint64(hi)-uint64(lo) >= uint64(math.MaxInt64) { // span hi-lo+1 would overflow int -> IntN panic
return fmt.Errorf("int(min,max): range %d..%d is too wide", lo, hi) return fmt.Errorf("int(min,max): range %d..%d is too wide", lo, hi)
} }
@@ -278,28 +251,19 @@ func intRangeArgs(_ map[string]node, a []string) error {
func floatArgs(_ map[string]node, a []string) error { func floatArgs(_ map[string]node, a []string) error {
lo, e1 := strconv.ParseFloat(a[0], 64) lo, e1 := strconv.ParseFloat(a[0], 64)
hi, e2 := strconv.ParseFloat(a[1], 64) hi, e2 := strconv.ParseFloat(a[1], 64)
if e1 != nil || e2 != nil { dp, e3 := strconv.Atoi(a[2])
return fmt.Errorf("float(min,max,dp) needs numeric bounds, got %q,%q", a[0], a[1]) if e1 != nil || e2 != nil || e3 != nil {
} return fmt.Errorf("float(min,max,dp) needs numeric args, got %q,%q,%q", a[0], a[1], a[2])
dp, err := plainInt(a[2])
if err != nil {
return fmt.Errorf("float(min,max,dp): decimals %w", err)
}
if math.IsNaN(lo) || math.IsNaN(hi) || math.IsInf(lo, 0) || math.IsInf(hi, 0) {
return fmt.Errorf("float(min,max,dp) needs finite bounds, got %q,%q", a[0], a[1])
} }
if lo > hi { if lo > hi {
return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi) return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi)
} }
if dp < 0 || dp > maxDecimals {
return fmt.Errorf("float(min,max,dp): decimals %d out of range 0..%d", dp, maxDecimals)
}
if lo == hi {
return fmt.Errorf("float(%s,%s,%d) is the constant %q; write it as text", a[0], a[1], dp, strconv.FormatFloat(lo, 'f', dp, 64))
}
if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf" if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf"
return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi) return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi)
} }
if dp < 0 || dp > maxDecimals {
return fmt.Errorf("float(min,max,dp): decimals %d out of range 0..%d", dp, maxDecimals)
}
return nil return nil
} }
+3 -100
View File
@@ -70,114 +70,18 @@ func checkCalc(fields map[string]node, args []string) error {
return fmt.Errorf("calc(%q): %w", args[0], err) return fmt.Errorf("calc(%q): %w", args[0], err)
} }
for _, name := range calcVars(expr) { for _, name := range calcVars(expr) {
operand, ok := fields[name] if _, ok := fields[name]; !ok {
if !ok {
return fmt.Errorf("calc(%q): no field %q", args[0], name) return fmt.Errorf("calc(%q): no field %q", args[0], name)
} }
if text, never := neverNumeric(operand); never {
return fmt.Errorf("calc(%q): operand %q is never a number: it renders %q", args[0], name, text)
}
}
if divisor, zero := constantZeroDivisor(expr, fields); zero {
return fmt.Errorf("calc(%q) divides by %s, which is always zero", args[0], divisor)
} }
if len(args) == 2 { if len(args) == 2 {
dp, err := plainInt(args[1]) if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 || dp > maxDecimals {
if err != nil { return fmt.Errorf("calc decimals %q must be an integer in 0..%d", args[1], maxDecimals)
return fmt.Errorf("calc decimals %w", err)
}
if dp < 0 || dp > maxDecimals {
return fmt.Errorf("calc decimals %d must be in 0..%d", dp, maxDecimals)
} }
} }
return nil return nil
} }
// constantZeroDivisor finds a division whose right side is a constant zero: number
// literals and fixed operands folded, anything that varies left unknown.
func constantZeroDivisor(n calcNode, fields map[string]node) (string, bool) {
switch n := n.(type) {
case calcNeg:
return constantZeroDivisor(n.x, fields)
case calcBin:
if n.op == '/' {
if v, known := constantValue(n.r, fields); known && v == 0 {
return calcText(n.r), true
}
}
if d, zero := constantZeroDivisor(n.l, fields); zero {
return d, true
}
return constantZeroDivisor(n.r, fields)
}
return "", false
}
// constantValue evaluates an expression whose every operand is fixed.
func constantValue(n calcNode, fields map[string]node) (float64, bool) {
switch n := n.(type) {
case calcNum:
return float64(n), true
case calcVar:
t, ok := fields[string(n)].(*template)
if !ok || !t.fixed || t.repeat > 1 {
return 0, false
}
v, err := strconv.ParseFloat(strings.TrimSpace(t.lit), 64)
return v, err == nil
case calcNeg:
v, ok := constantValue(n.x, fields)
return -v, ok
case calcBin:
l, lok := constantValue(n.l, fields)
r, rok := constantValue(n.r, fields)
if !lok || !rok {
return 0, false
}
return calcBin{n.op, calcNum(l), calcNum(r)}.eval(nil), true
}
return 0, false
}
// calcText spells an expression node the way an author would read it.
func calcText(n calcNode) string {
switch n := n.(type) {
case calcNum:
return strconv.FormatFloat(float64(n), 'f', -1, 64)
case calcVar:
return string(n)
case calcNeg:
return "-" + calcText(n.x)
case calcBin:
return "(" + calcText(n.l) + " " + string(n.op) + " " + calcText(n.r) + ")"
}
return "?"
}
// neverNumeric reports a node no render of which is a number: fixed text that does
// not parse, or a choice of only such items. text is one such render.
func neverNumeric(n node) (text string, never bool) {
switch n := n.(type) {
case *template:
if !n.fixed || n.repeat > 1 {
return "", false
}
if _, err := strconv.ParseFloat(strings.TrimSpace(n.lit), 64); err != nil {
return n.lit, true
}
case *choice:
for _, it := range n.items {
t, itemNever := neverNumeric(it)
if !itemNever {
return "", false
}
text = t
}
return text, true
}
return "", false
}
// calcPrep parses the expression and decimals once, at compile time, and places each // calcPrep parses the expression and decimals once, at compile time, and places each
// operand name at the position expand will read it into. checkCalc proved both args // operand name at the position expand will read it into. checkCalc proved both args
// valid, so no step here can fail; dp -1 prints the minimal form. // valid, so no step here can fail; dp -1 prints the minimal form.
@@ -310,7 +214,6 @@ func (p *calcParser) binary(next func() (calcNode, error), ops ...byte) (calcNod
} }
} }
// factor is a table-shaped scanner, one case per token kind, kept whole on purpose.
func (p *calcParser) factor() (calcNode, error) { func (p *calcParser) factor() (calcNode, error) {
p.space() p.space()
if p.pos >= len(p.rs) { if p.pos >= len(p.rs) {
+4 -33
View File
@@ -72,16 +72,12 @@ func TestCalcFields(t *testing.T) {
} }
} }
// TestCalcNonNumericIsNaN pins the never-fail rule: a field that sometimes does // TestCalcNonNumericIsNaN pins the never-fail rule: a field that doesn't render
// not render to a number becomes NaN then, which propagates and prints visibly. // to a number becomes NaN, which propagates and prints visibly.
func TestCalcNonNumericIsNaN(t *testing.T) { func TestCalcNonNumericIsNaN(t *testing.T) {
f := engine(1) if got := mustRender(t, engine(1), `{"format":"{calc(x * 2)}","x":"abc"}`); got != "NaN" {
for i := 0; i < 50; i++ { t.Fatalf("calc over non-numeric field = %q, want NaN", got)
if got := mustRender(t, f, `{"format":"{calc(x * 2)}","x":["abc","1"]}`); got == "NaN" {
return
} }
}
t.Fatal("calc over a sometimes non-numeric field never printed NaN in 50 draws")
} }
// TestCalcReproducible pins that a calc over a random operand stays seed-stable. // TestCalcReproducible pins that a calc over a random operand stays seed-stable.
@@ -245,28 +241,3 @@ func TestCalcOverANeverNumericOperandIsRejected(t *testing.T) {
} }
} }
} }
func TestCalcConstantZeroDivisorIsRejected(t *testing.T) {
for src, want := range map[string]string{
`"{calc(1/0)}"`: "divides by 0",
`"{calc(2/(1-1))}"`: "divides by (1 - 1)",
`{"format":"{calc(x/y)}","x":"1","y":"0"}`: "divides by y",
`{"format":"{calc(x/(y*2))}","x":"1","y":" 0 "}`: "divides by (y * 2)",
`{"format":"{calc((a/0)+b)}","a":"1","b":"2"}`: "divides by 0",
`{"format":"{calc(a/-0)}","a":"1"}`: "divides by -0",
} {
if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("compile(%s) = %v, want the constant zero divisor rejected naming %q", src, err, want)
}
}
if _, err := compile(parse(t, `{"format":"{calc(a/(b*c))}","a":"1","b":"0","c":["1","2"]}`)); err != nil {
t.Errorf("compile(a/(b*c)) = %v, want a divisor that varies accepted", err)
}
f := engine(1)
for i := 0; i < 50; i++ {
if got := mustRender(t, f, `{"format":"{calc(x/y)}","x":"1","y":["0","1"]}`); got == "+Inf" {
return
}
}
t.Fatal("a sometimes-zero divisor never printed +Inf in 50 draws")
}
+33 -103
View File
@@ -8,8 +8,6 @@
package main package main
import ( import (
"bufio"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -21,30 +19,21 @@ import (
"gitea.larvit.se/larvit/fejkdata" "gitea.larvit.se/larvit/fejkdata"
) )
const usage = `Usage: fejkdata [flags] <path|template> const usage = `Usage: fejkdata [flags] <path>
<path> a category, or a dotted path into one (person, person.last) <path> a category, or a dotted path into one (person, person.last)
<template> a format string or JSON value to render inline, e.g.
'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}'
An argument containing a { token, or a JSON object, array or string, is a
template; any other argument is a path (a path never contains a brace, a bracket
or a quote). Templates reach the data by reference from the root —
{/sv_SE.person.last} — whether the data is shipped or layered with --data-path. An
argument carrying a bracket, a closing brace or a quote but no valid JSON names
neither.
-d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash) -d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash)
-h, --help print this help, then exit -h, --help print this help, then exit
--list list the paths the data offers, then exit --list list the paths the data offers, then exit
--no-shipped-data load only the --data-path directories --no-shipped-data load only the --data-path directories
-n, --repeat N render the value N times, 1..1048576 (default 1) -n, --repeat N render the path N times (default 1)
-s, --seed N seed for reproducible output -s, --seed N seed for reproducible output
--separator S string between repeated values (default newline) --separator S string between repeated values (default newline)
--version print the version, then exit --version print the version, then exit
Flags may come before or after <path|template>; -- ends the flags. A short flag's Flags may come before or after <path>; -- ends the flags. A short flag's value
value attaches or follows (-n3, -n 3); short flags bundle (-hn 3). attaches or follows (-n3, -n 3); short flags bundle (-hn 3).
` `
type invocation struct { type invocation struct {
@@ -54,11 +43,9 @@ type invocation struct {
noShipped bool noShipped bool
paths []string paths []string
repeat int repeat int
repeatSet bool
seed uint64 seed uint64
seeded bool seeded bool
separator string separator string
separatorSet bool
version bool version bool
} }
@@ -76,10 +63,10 @@ var flagDefs = []flagDef{
{"no-shipped-data", "", false, func(in *invocation, _ string) error { in.noShipped = true; return nil }}, {"no-shipped-data", "", false, func(in *invocation, _ string) error { in.noShipped = true; return nil }},
{"repeat", "n", true, func(in *invocation, v string) error { {"repeat", "n", true, func(in *invocation, v string) error {
n, err := strconv.Atoi(v) n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > fejkdata.MaxRepeat { if err != nil || n < 1 {
return fmt.Errorf("--repeat needs an integer in 1..%d, got %q", fejkdata.MaxRepeat, v) return fmt.Errorf("--repeat needs a positive integer, got %q", v)
} }
in.repeat, in.repeatSet = n, true in.repeat = n
return nil return nil
}}, }},
{"seed", "s", true, func(in *invocation, v string) error { {"seed", "s", true, func(in *invocation, v string) error {
@@ -90,7 +77,7 @@ var flagDefs = []flagDef{
in.seed, in.seeded = n, true in.seed, in.seeded = n, true
return nil return nil
}}, }},
{"separator", "", true, func(in *invocation, v string) error { in.separator, in.separatorSet = v, true; return nil }}, {"separator", "", true, func(in *invocation, v string) error { in.separator = v; return nil }},
{"version", "", false, func(in *invocation, _ string) error { in.version = true; return nil }}, {"version", "", false, func(in *invocation, _ string) error { in.version = true; return nil }},
} }
@@ -137,8 +124,8 @@ func splitFlags(arg string) ([]flagArg, error) {
} }
var flags []flagArg var flags []flagArg
letters := arg[1:] letters := arg[1:]
for i, r := range letters { for i := 0; i < len(letters); i++ {
letter := string(r) letter := letters[i : i+1]
def := flagByShort(letter) def := flagByShort(letter)
if def == nil { if def == nil {
return nil, fmt.Errorf("unknown flag -%s", letter) return nil, fmt.Errorf("unknown flag -%s", letter)
@@ -147,7 +134,7 @@ func splitFlags(arg string) ([]flagArg, error) {
flags = append(flags, flagArg{def: def}) flags = append(flags, flagArg{def: def})
continue continue
} }
rest := letters[i+len(letter):] rest := letters[i+1:]
if strings.HasPrefix(rest, "=") { if strings.HasPrefix(rest, "=") {
return nil, fmt.Errorf("-%s takes its value attached (-%s%s) or next (-%s %s); = belongs to --%s=%s", return nil, fmt.Errorf("-%s takes its value attached (-%s%s) or next (-%s %s); = belongs to --%s=%s",
letter, letter, rest[1:], letter, rest[1:], def.long, rest[1:]) letter, letter, rest[1:], letter, rest[1:], def.long, rest[1:])
@@ -196,22 +183,18 @@ func parseArgs(argv []string) (invocation, error) {
return in, nil return in, nil
} }
// check rejects a flag combination or an argument that cannot run, and reports // check rejects a flag combination that cannot run.
// what the argument names, so its shape is settled before any data is read. func (in invocation) check() error {
func (in invocation) check() (argKind, error) { if in.noShipped && len(in.dirs) == 0 {
return errors.New("--no-shipped-data needs at least one --data-path")
}
if in.list && len(in.paths) > 0 { if in.list && len(in.paths) > 0 {
return argPath, errors.New("--list takes no path") return errors.New("--list takes no path")
} }
if in.list && (in.repeatSet || in.separatorSet) { if !in.list && len(in.paths) != 1 {
return argPath, errors.New("--list takes no --repeat or --separator") return fmt.Errorf("expected one path, got %d", len(in.paths))
} }
if in.list { return nil
return argPath, nil
}
if len(in.paths) != 1 {
return argPath, fmt.Errorf("expected one path or template, got %d", len(in.paths))
}
return classify(in.paths[0])
} }
func (in invocation) options() []fejkdata.Option { func (in invocation) options() []fejkdata.Option {
@@ -228,63 +211,17 @@ func (in invocation) options() []fejkdata.Option {
return opts return opts
} }
// write streams the argument's renders to w, repeat of them joined by the // values renders the path repeat times, joined by the separator.
// separator and ended by a newline. A value that renders once renders every time, func (in invocation) values(f *fejkdata.Generator) (string, error) {
// so a render failure comes before anything is written; a write failure surfaces vals := make([]string, in.repeat)
// from Flush, bufio keeping the first one. for i := range vals {
func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error { v, err := f.Fake(in.paths[0])
arg := in.paths[0]
draw := func() (string, error) { return f.Fake(arg) }
if kind == argTemplate {
t, err := f.NewTemplate(arg)
if err != nil { if err != nil {
return templateError{err} return "", err
} }
draw = func() (string, error) { return t.Fake(), nil } vals[i] = v
} }
out := bufio.NewWriter(w) return strings.Join(vals, in.separator), nil
for i := 0; i < in.repeat; i++ {
v, err := draw()
if err != nil {
return err
}
if i > 0 {
out.WriteString(in.separator)
}
out.WriteString(v)
}
out.WriteString("\n")
return out.Flush()
}
// templateError marks a render failure that is the argument's own fault — an
// inline template that does not compile. run reports it as misuse (exit 2, with a
// pointer to --help), unlike an unknown path, which is a runtime error (exit 1).
type templateError struct{ error }
func (e templateError) Unwrap() error { return e.error }
type argKind int
const (
argPath argKind = iota
argTemplate
)
// classify reads what a positional argument names by its shape: a { token, or a
// JSON object, array or string, is an inline template; anything else is a path.
func classify(arg string) (argKind, error) {
if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) {
return argTemplate, nil
}
if i := strings.IndexAny(arg, `[]}"`); i >= 0 {
return argPath, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1])
}
return argPath, nil
}
func isJSONStart(arg string) bool {
return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`)
} }
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
@@ -303,14 +240,10 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stdout, "fejkdata "+buildVersion()) fmt.Fprintln(stdout, "fejkdata "+buildVersion())
return 0 return 0
} }
kind, err := in.check() if err := in.check(); err != nil {
if err != nil {
return misuse(stderr, err) return misuse(stderr, err)
} }
f, err := fejkdata.New(in.options()...) f, err := fejkdata.New(in.options()...)
if errors.Is(err, fejkdata.ErrNoData) {
return misuse(stderr, errors.New("--no-shipped-data needs at least one --data-path"))
}
if err != nil { if err != nil {
fmt.Fprintln(stderr, err) fmt.Fprintln(stderr, err)
return 1 return 1
@@ -321,20 +254,17 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
return 0 return 0
} }
if err := in.write(f, kind, stdout); err != nil { out, err := in.values(f)
var te templateError if err != nil {
if errors.As(err, &te) {
return misuse(stderr, te.error)
}
fmt.Fprintln(stderr, err) fmt.Fprintln(stderr, err)
return 1 return 1
} }
fmt.Fprintln(stdout, out)
return 0 return 0
} }
func misuse(stderr io.Writer, err error) int { func misuse(stderr io.Writer, err error) int {
// A library error already names the program, so the prefix is not doubled. fmt.Fprintf(stderr, "fejkdata: %v\ntry 'fejkdata --help'\n", err)
fmt.Fprintf(stderr, "fejkdata: %s\ntry 'fejkdata --help'\n", strings.TrimPrefix(err.Error(), "fejkdata: "))
return 2 return 2
} }
+1 -142
View File
@@ -2,9 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"os"
"path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
) )
@@ -93,7 +90,7 @@ func TestRunShortFlagValues(t *testing.T) {
{[]string{"-s=42", "-d", svSE, "address"}, "-s42"}, {[]string{"-s=42", "-d", svSE, "address"}, "-s42"},
{[]string{"-s=42", "-d", svSE, "address"}, "--seed=42"}, {[]string{"-s=42", "-d", svSE, "address"}, "--seed=42"},
{[]string{"-d=" + svSE, "address"}, "--data-path="}, {[]string{"-d=" + svSE, "address"}, "--data-path="},
{[]string{"-nd", "3", "-d", svSE, "word"}, `--repeat needs an integer in 1..1048576, got "d"`}, {[]string{"-nd", "3", "-d", svSE, "word"}, `--repeat needs a positive integer, got "d"`},
{[]string{"--seed=", "-d", svSE, "word"}, `--seed needs an unsigned integer, got ""`}, {[]string{"--seed=", "-d", svSE, "word"}, `--seed needs an unsigned integer, got ""`},
} { } {
code, out, errb := runOut(c.args...) code, out, errb := runOut(c.args...)
@@ -302,92 +299,6 @@ func TestRunShippedDataByDefault(t *testing.T) {
} }
} }
func TestClassify(t *testing.T) {
for arg, want := range map[string]argKind{
"sv_SE.person": argPath,
"person.last": argPath,
"name: {x}": argTemplate, // a { token: a path can never carry a brace
`{"format":"x"}`: argTemplate,
`["a","b"]`: argTemplate, // a JSON array carries no brace
`[1, 2]`: argTemplate,
` ["a","b"]`: argTemplate, // padding is the template's own error, not a shape verdict
`"hello"`: argTemplate, // a JSON string, the spelling a format-only object names
} {
got, err := classify(arg)
if err != nil || got != want {
t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want)
}
}
for arg, want := range map[string]string{
"[abc]": `holds a "["`,
"[abc].field": `holds a "["`,
"x[1]": `holds a "["`,
"a]b": `holds a "]"`,
"a}b": `holds a "}"`,
`"abc`: `holds a "\""`,
`"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it
} {
_, err := classify(arg)
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("classify(%q) = %v; want it rejected naming %s", arg, err, want)
}
}
}
func TestUsageReferencesResolve(t *testing.T) {
for _, token := range regexp.MustCompile(`\{/[^}]+\}`).FindAllString(usage, -1) {
code, out, errb := runOut("--seed", "1", token)
if code != 0 || strings.TrimSpace(out) == "" {
t.Errorf("usage advertises %s: run = %d, %q, stderr %q", token, code, out, errb)
}
}
}
func TestRunShapeMisuseBeforeLoad(t *testing.T) {
code, _, errb := runOut("--no-shipped-data", "[abc]")
if code != 2 || !strings.Contains(errb, "[abc]") || strings.Contains(errb, "--data-path") {
t.Fatalf("shape misuse with no data = %d, %q; want the shape error before any load", code, errb)
}
}
func TestRunInlineTemplate(t *testing.T) {
code, out, errb := runOut("--seed", "1", "name: {/sv_SE.person.last}")
if code != 0 || !strings.HasPrefix(out, "name: ") || strings.Contains(out, "{") {
t.Fatalf("inline format string = %d, %q, stderr %q", code, out, errb)
}
code, out, errb = runOut("--seed", "1", `{"format":"name: {x}","x":["bosse","lina"]}`)
if code != 0 || (out != "name: bosse\n" && out != "name: lina\n") {
t.Fatalf("inline JSON template = %d, %q, want one name, stderr %q", code, out, errb)
}
code, out, errb = runOut("--seed", "1", `"name: {/sv_SE.person.last}"`)
if code != 0 || !strings.HasPrefix(out, "name: ") || strings.Contains(out, "{") {
t.Fatalf("inline JSON string = %d, %q, stderr %q", code, out, errb)
}
code, out, errb = runOut("--seed", "1", "-n", "2", `{digits(1)}`)
if code != 0 || len(strings.Split(strings.TrimRight(out, "\n"), "\n")) != 2 {
t.Fatalf("inline template with --repeat = %d, %q, stderr %q", code, out, errb)
}
}
func TestRunTemplateMisuse(t *testing.T) {
for arg, want := range map[string]string{
"{bad": "unterminated",
"[red,green]": "names no template either",
`{"format":"x"}`: "is a string",
"{/no.such.path}": "no entry",
"x[1]": "names no template either",
` ["a","b"] `: "may not be padded",
} {
code, out, errb := runOut("--seed", "1", arg)
if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") || !strings.Contains(errb, want) {
t.Errorf("run(%q) = %d, %q, %q; want misuse naming %q and --help", arg, code, out, errb, want)
}
if strings.Contains(errb, "fejkdata: fejkdata:") {
t.Errorf("run(%q) doubled the program prefix: %q", arg, errb)
}
}
}
func TestRunNoShippedData(t *testing.T) { func TestRunNoShippedData(t *testing.T) {
code, list, errb := runOut("--no-shipped-data", "-d", svSE, "--list") code, list, errb := runOut("--no-shipped-data", "-d", svSE, "--list")
if code != 0 { if code != 0 {
@@ -405,55 +316,3 @@ func TestRunNoShippedData(t *testing.T) {
t.Errorf("--no-shipped-data alone = %d, %q, want misuse naming --data-path", code, errb) t.Errorf("--no-shipped-data alone = %d, %q, want misuse naming --data-path", code, errb)
} }
} }
func TestRunRepeatIsBounded(t *testing.T) {
for _, args := range [][]string{
{"--repeat", "9223372036854775807", "sv_SE.word"},
{"--repeat", "1048577", "sv_SE.word"},
{"-n", "99999999999", "sv_SE.word"},
} {
code, out, errb := runOut(args...)
if code != 2 || out != "" || !strings.Contains(errb, "1..1048576") {
t.Errorf("run(%v) = %d, %q, %q, want misuse naming the range", args, code, out, errb)
}
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "x.json"), []byte(`"x"`), 0o644); err != nil {
t.Fatal(err)
}
code, out, errb := runOut("--no-shipped-data", "-d", dir, "--repeat", "1048576", "--separator", "", "x")
if code != 0 || len(out) != 1048576+1 {
t.Errorf("repeat at the cap = %d, %d bytes, stderr %q; want every render streamed", code, len(out), errb)
}
}
func TestRunListTakesNoRepeatOrSeparator(t *testing.T) {
for _, args := range [][]string{{"--list", "-n", "3"}, {"--list", "--separator", ","}} {
code, _, errb := runOut(args...)
if code != 2 || !strings.Contains(errb, "--list takes no") {
t.Errorf("run(%v) = %d, %q, want misuse", args, code, errb)
}
}
}
func TestRunUnknownFlagIsNamedByRune(t *testing.T) {
code, _, errb := runOut("-ä", "sv_SE.word")
if code != 2 || !strings.Contains(errb, "unknown flag -ä") {
t.Errorf("run(-ä) = %d, %q, want the flag named whole", code, errb)
}
}
func TestRunEmptyDataPathIsNamed(t *testing.T) {
code, _, errb := runOut("--data-path=", "sv_SE.word")
if code != 1 || !strings.Contains(errb, "empty") {
t.Errorf("run(--data-path=) = %d, %q, want the empty path named", code, errb)
}
}
func TestRunNumbersFollowTheShell(t *testing.T) {
_, want, _ := runOut("--seed", "7", "--repeat", "3", "sv_SE.word")
code, got, errb := runOut("--seed", "007", "--repeat", "+3", "sv_SE.word")
if code != 0 || got != want {
t.Errorf("run(--seed 007 --repeat +3) = %d, %q, stderr %q; want the same as --seed 7 --repeat 3 %q", code, got, errb, want)
}
}
-4
View File
@@ -42,10 +42,6 @@ services:
<<: *go <<: *go
command: go vet ./... command: go vet ./...
cyclo:
<<: *go
command: go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 14 -ignore _test .
build: build:
<<: *go <<: *go
command: go build ./... command: go build ./...
+24 -43
View File
@@ -10,12 +10,11 @@ import (
) )
// dataSource is one tree to load: an fs.FS and the directory in it to start from. // dataSource is one tree to load: an fs.FS and the directory in it to start from.
// label prefixes file names in errors; onDisk marks path as a directory that must // label prefixes file names in errors; path, when set, is a directory on disk that
// exist. // must exist.
type dataSource struct { type dataSource struct {
fsys fs.FS fsys fs.FS
label string label string
onDisk bool
path string path string
root string root string
} }
@@ -32,18 +31,15 @@ func (s dataSource) name(p string) string {
// keyed by its base name (address.json -> "address"); each subdirectory becomes a // keyed by its base name (address.json -> "address"); each subdirectory becomes a
// nested group, so folders turn into dot-path segments. Sources merge left to right: // nested group, so folders turn into dot-path segments. Sources merge left to right:
// matching groups merge by their children, and any other clash is won by the last // matching groups merge by their children, and any other clash is won by the last
// source loaded. Once merged, linkRefs binds every reference against the // source loaded. Once merged, linkRefs binds every {..path} reference against the
// final tree. // final tree.
func loadData(sources []dataSource) (map[string]node, error) { func loadData(sources []dataSource) (map[string]node, error) {
root := map[string]node{} root := map[string]node{}
for _, src := range sources { for _, src := range sources {
if src.onDisk { if src.path != "" {
if src.path == "" {
return nil, fmt.Errorf("a data path is empty")
}
info, err := os.Stat(src.path) info, err := os.Stat(src.path)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("%s: no such directory", src.path)
} }
if !info.IsDir() { if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", src.path) return nil, fmt.Errorf("%s is not a directory", src.path)
@@ -68,7 +64,7 @@ func loadData(sources []dataSource) (map[string]node, error) {
if err := checkNoCycles(root); err != nil { if err := checkNoCycles(root); err != nil {
return nil, err return nil, err
} }
if err := checkScope(treeScope(root)); err != nil { if err := checkBoundLevelsHeld(root); err != nil {
return nil, err return nil, err
} }
return root, nil return root, nil
@@ -88,57 +84,42 @@ func loadDir(src dataSource, dir string) (*group, error) {
continue continue
} }
full := path.Join(dir, e.Name()) full := path.Join(dir, e.Name())
load := loadFile
if e.IsDir() { if e.IsDir() {
load = loadFolder
}
if err := load(src, g, full, e.Name()); err != nil {
return nil, err
}
}
return g, nil
}
// loadFolder adds a subdirectory as a nested group, unless nothing under it is data.
func loadFolder(src dataSource, g *group, full, name string) error {
child, err := loadDir(src, full) child, err := loadDir(src, full)
if err != nil { if err != nil {
return err return nil, err
} }
if len(child.children) == 0 { if len(child.children) == 0 {
return nil continue
} }
if err := checkName(e.Name()); err != nil {
return nil, fmt.Errorf("%s: folder %w", src.name(full), err)
}
g.children[e.Name()] = child
continue
}
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
if err := checkName(name); err != nil { if err := checkName(name); err != nil {
return fmt.Errorf("%s: folder %w", src.name(full), err) return nil, fmt.Errorf("%s: category %w", src.name(full), err)
}
g.children[name] = child
return nil
}
// loadFile compiles a *.json file into a category named after it; any other file
// is skipped.
func loadFile(src dataSource, g *group, full, file string) error {
if !strings.HasSuffix(file, ".json") {
return nil
}
name := strings.TrimSuffix(file, ".json")
if err := checkName(name); err != nil {
return fmt.Errorf("%s: category %w", src.name(full), err)
} }
b, err := fs.ReadFile(src.fsys, full) b, err := fs.ReadFile(src.fsys, full)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", src.name(full), err) return nil, fmt.Errorf("%s: %w", src.name(full), err)
} }
var raw any var raw any
if err := json.Unmarshal(b, &raw); err != nil { if err := json.Unmarshal(b, &raw); err != nil {
return fmt.Errorf("%s: %w", src.name(full), err) return nil, fmt.Errorf("%s: %w", src.name(full), err)
} }
n, err := compile(raw) n, err := compile(raw)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", src.name(full), err) return nil, fmt.Errorf("%s: %w", src.name(full), err)
} }
g.children[name] = n g.children[name] = n
return nil }
return g, nil
} }
// mergeChildren overlays src onto dst. Two groups under the same key merge // mergeChildren overlays src onto dst. Two groups under the same key merge
-37
View File
@@ -264,40 +264,3 @@ func luhnValid(s string) bool {
} }
return sum%10 == 0 return sum%10 == 0
} }
// swedishName matches one or more letter-words, optionally space/hyphen joined
// ("Storgatan", "Norra Promenaden", "von Flemming"). Used by the composition
// tests so shipped name lists can grow without re-enumerating them here.
var swedishName = regexp.MustCompile(`^\p{L}+([ -]\p{L}+)*$`)
func TestShippedStreetComposition(t *testing.T) {
// street is a choice of composed {first}{last} templates and literal names.
f := newGenerator(t, "data/sv_SE", WithSeed(5))
for i := 0; i < 300; i++ {
if s := fake(t, f, "address.street"); !swedishName.MatchString(s) {
t.Fatalf("street %q is not a Swedish street name", s)
}
}
}
func TestShippedLastNameComposition(t *testing.T) {
// last is a choice of patronymic {first}sson templates, compound
// {first}{last} templates and literal surnames.
f := newGenerator(t, "data/sv_SE", WithSeed(6))
for i := 0; i < 300; i++ {
if s := fake(t, f, "person.last"); !swedishName.MatchString(s) {
t.Fatalf("last name %q is not a Swedish surname", s)
}
}
}
func TestShippedStreetNumberFormats(t *testing.T) {
// Reachable via a hyphenated path; covers all five weighted number variants.
f := newGenerator(t, "data/sv_SE", WithSeed(8))
re := regexp.MustCompile(`^[1-9]\d{0,2}[A-Z]?$`)
for i := 0; i < 300; i++ {
if n := fake(t, f, "address.street-number"); !re.MatchString(n) {
t.Fatalf("street-number %q does not match %s", n, re)
}
}
}
+385
View File
@@ -0,0 +1,385 @@
package fejkdata
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
)
// --- format string edge cases ---
func TestHashIsLiteral(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"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 {
t.Errorf("render(%s) = %q, want %q", tmpl, got, want)
}
}
}
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}-{digits(1)}å","x":"väg"}`)
if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) {
t.Fatalf("multibyte format = %q", got)
}
}
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
}
if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 {
t.Fatalf("3-way alternation produced %v, want A, B and C", seen)
}
}
func TestNewErrors(t *testing.T) {
// Pointing New at a file (not a directory) fails.
file := filepath.Join(t.TempDir(), "xx_XX")
if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New(WithoutShippedData(), WithDataPath(file)); err == nil {
t.Error("New(file) = nil error, want not-a-directory error")
}
// Invalid JSON in a category file fails.
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"broken": `{ not json`}))); err == nil {
t.Error("New(invalid JSON) = nil error")
}
// An option that cannot take effect, and a category or folder no dot path can
// reach, are mistakes New must name rather than accept and ignore.
rejected := map[string]struct {
files map[string]string
want string
}{
"separator without repeat": {
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","separator":","}`},
"has no effect without a repeat above 1",
},
"weight outside a choice": {
map[string]string{"a": `{"format":"x","weight":5}`},
"weight only skews a choice's items",
},
"non-numeric weight outside a choice": {
map[string]string{"a": `{"format":"x","weight":"bad"}`},
"weight only skews a choice's items",
},
"weight used as a field": {
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": `"{weight}"`},
`"weight" is an option and can never be a field`,
},
"category name with a dot": {
map[string]string{"a.b": `"1"`},
`category "a.b" contains "."`,
},
"folder name with a dot": {
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"}`},
`field "b|c" contains "|"`,
},
"field name with a paren": {
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"}`},
`field "b}c" contains "}"`,
},
"category name with a pipe": {
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
// bare {}, a trailing dot in Fake("a.") and a {..a.} reference all reached
// it. The engine accepted spellings it would never advertise.
"empty field name": {
map[string]string{"a": `{"format":"[{}]","":"VALUE"}`},
`field "" is empty`,
},
"folder name with a paren": {
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"}`},
`arm "x" is repeated`,
},
"repeated arm among others": {
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"}}`},
`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": `"{..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": `"{..|..}"`},
"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": `"{|}"`},
"a name is never empty",
},
"bare empty token": {
map[string]string{"a": `{"format":"[{}]","x":"1"}`},
"a name is never empty",
},
}
for name, c := range rejected {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files)))
if err == nil {
t.Errorf("%s: New = nil error, want it rejected at load", name)
continue
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want)
}
}
// Data that works today must keep working, and stay reachable.
accepted := map[string]struct {
files map[string]string
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"},
// 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"},
}
for name, c := range accepted {
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files)))
if err != nil {
t.Errorf("%s: New = %v, want it accepted", name, err)
continue
}
if got, err := f.Fake(c.path); err != nil || got != c.want {
t.Errorf("%s: Fake(%q) = %q, %v, want %q", name, c.path, got, err, c.want)
}
}
}
// --- deep path navigation ---
func TestDeepDottedPath(t *testing.T) {
// A 5-segment path descends through alternating object/array nodes; choices
// 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"}}}}`),
}
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)
}
// Rendering the whole tree resolves the same chain.
if got, err := f.Fake("deep"); err != nil || got != "leaf" {
t.Fatalf("Fake(deep) = %q, %v, want leaf", got, err)
}
}
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 || !strings.Contains(err.Error(), `no field "extra"`) {
t.Fatalf("Fake(greeting.extra) = %v, want a no-field error", err)
}
}
// TestPathThroughChoice pins the rule that keeps a dotted path from rendering on
// 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"}]`,
})
f := newGenerator(t, dir, WithSeed(1))
for i := 0; i < 200; i++ {
if got := fake(t, f, "every.f"); got != "1" && got != "2" {
t.Fatalf("every.f = %q, want 1 or 2", got)
}
}
// A path only some variants carry is reported against what all of them carry.
if _, err := f.Fake("some.extra"); err == nil || !strings.Contains(err.Error(), "all carry [f]") {
t.Errorf("Fake(some.extra) = %v, want it to name what every variant carries", err)
}
var first string
for i := 0; i < 200; i++ {
_, err := f.Fake("notall.f")
if err == nil {
t.Fatal("notall.f = nil error, want the same failure every call")
}
if i == 0 {
first = err.Error()
} else if err.Error() != first {
t.Fatalf("notall.f error varies between calls:\n %s\n %s", first, err.Error())
}
}
}
// TestPathKeyIsUnambiguous pins that the one shape which could collide cannot be
// written: a field literally named "a.b" and a field "a" holding "b" would both
// spell "a.b", so a dotted field name is rejected at New and a dot means a path
// 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"}}]`,
})
_, err := New(WithoutShippedData(), WithDataPath(dir))
if err == nil || !strings.Contains(err.Error(), `field "a.b" contains "."`) {
t.Fatalf("New = %v, want the dotted field name rejected", err)
}
// 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"}}`,
}), WithSeed(1))
if !slices.Contains(f.List(), "cat.a.b") {
t.Error("List() omits cat.a.b, which the data carries")
}
if got := fake(t, f, "cat"); got != "2" {
t.Fatalf("cat = %q, want 2", got)
}
}
// TestMissingFieldNamesItself keeps the precise diagnosis for the ordinary typo: a
// single-variant choice always picks the same item, so it needs no every-variant
// guard and the error can name the field that is missing.
func TestMissingFieldNamesItself(t *testing.T) {
f := newGenerator(t, "data/sv_SE", WithSeed(1))
_, err := f.Fake("person.typo")
if err == nil || !strings.Contains(err.Error(), `no field "typo"`) {
t.Errorf("Fake(person.typo) = %v, want it to name the missing field", err)
}
}
// --- category root shapes ---
func TestCategoryRootShapes(t *testing.T) {
dir := writeData(t, map[string]string{
"obj": `"{digits(2)}"`, // object root
"lit": `"hello"`, // bare-string root
})
f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "obj"); !regexp.MustCompile(`^\d\d$`).MatchString(got) {
t.Errorf("object-root category = %q, want two digits", got)
}
if got := fake(t, f, "lit"); got != "hello" {
t.Errorf("string-root category = %q, want hello", got)
}
}
// --- very long lists ---
func TestLongStringList(t *testing.T) {
const n = 2000
names := make([]string, n)
for i := range names {
names[i] = fmt.Sprintf("name-%04d", i)
}
list, err := json.Marshal(names)
if err != nil {
t.Fatal(err)
}
f := newGenerator(t, writeData(t, map[string]string{"name": string(list)}), WithSeed(1))
valid := map[string]bool{}
for _, v := range names {
valid[v] = true
}
seen := map[string]bool{}
for i := 0; i < 20000; i++ {
v := fake(t, f, "name")
if !valid[v] {
t.Fatalf("got %q, not in the list", v)
}
seen[v] = true
}
if len(seen) < n*8/10 {
t.Fatalf("only %d/%d distinct values seen; selection looks skewed", len(seen), n)
}
}
// --- composition against the shipped sv_SE data ---
// swedishName matches one or more letter-words, optionally space/hyphen joined
// ("Storgatan", "Norra Promenaden", "von Flemming"). Used by the composition
// tests so shipped name lists can grow without re-enumerating them here.
var swedishName = regexp.MustCompile(`^\p{L}+([ -]\p{L}+)*$`)
func TestShippedStreetComposition(t *testing.T) {
// street is a choice of composed {first}{last} templates and literal names.
f := newGenerator(t, "data/sv_SE", WithSeed(5))
for i := 0; i < 300; i++ {
if s := fake(t, f, "address.street"); !swedishName.MatchString(s) {
t.Fatalf("street %q is not a Swedish street name", s)
}
}
}
func TestShippedLastNameComposition(t *testing.T) {
// last is a choice of patronymic {first}sson templates, compound
// {first}{last} templates and literal surnames.
f := newGenerator(t, "data/sv_SE", WithSeed(6))
for i := 0; i < 300; i++ {
if s := fake(t, f, "person.last"); !swedishName.MatchString(s) {
t.Fatalf("last name %q is not a Swedish surname", s)
}
}
}
func TestShippedStreetNumberFormats(t *testing.T) {
// Reachable via a hyphenated path; covers all five weighted number variants.
f := newGenerator(t, "data/sv_SE", WithSeed(8))
re := regexp.MustCompile(`^[1-9]\d{0,2}[A-Z]?$`)
for i := 0; i < 300; i++ {
if n := fake(t, f, "address.street-number"); !re.MatchString(n) {
t.Fatalf("street-number %q does not match %s", n, re)
}
}
}
+9 -31
View File
@@ -16,7 +16,6 @@ import (
crand "crypto/rand" crand "crypto/rand"
"embed" "embed"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"math/rand/v2" "math/rand/v2"
@@ -28,19 +27,9 @@ import (
//go:embed data //go:embed data
var shippedFS embed.FS var shippedFS embed.FS
// MaxRepeat caps a repeat, and the renders nested repeats multiply to along any
// path; the CLI's --repeat shares it.
const MaxRepeat = 1 << 20
var _ [^uint(0)>>63 - 1]struct{} // 64-bit only, per the README's Decisions
// ErrNoData is returned by New when no source is loaded at all.
var ErrNoData = errors.New("no data: WithoutShippedData needs at least one WithDataPath or WithDataFS")
// Generator generates fake data from a loaded namespace tree. Create one with [New]. // Generator generates fake data from a loaded namespace tree. Create one with [New].
// It is safe for concurrent use; a seeded sequence is reproducible only when drawn // It is safe for concurrent use; a seeded sequence is reproducible only when drawn
// from one goroutine. The compiled tree is immutable after [New], and Fake, // from one goroutine.
// NewTemplate and List read it concurrently without a lock.
type Generator struct { type Generator struct {
mu sync.Mutex mu sync.Mutex
rand *session rand *session
@@ -79,7 +68,7 @@ func WithSeed(seed uint64) Option {
// layer several; the last wins a name clash. // layer several; the last wins a name clash.
func WithDataPath(dir string) Option { func WithDataPath(dir string) Option {
return func(c *config) { return func(c *config) {
c.sources = append(c.sources, dataSource{fsys: os.DirFS(dir), label: dir, onDisk: true, path: dir}) c.sources = append(c.sources, dataSource{fsys: os.DirFS(dir), label: dir, path: dir})
} }
} }
@@ -110,17 +99,13 @@ func New(opts ...Option) (*Generator, error) {
} }
sources = append(sources, c.sources...) sources = append(sources, c.sources...)
if len(sources) == 0 { if len(sources) == 0 {
return nil, fmt.Errorf("fejkdata: %w", ErrNoData) return nil, fmt.Errorf("fejkdata: no data: WithoutShippedData needs at least one WithDataPath or WithDataFS")
} }
cats, err := loadData(sources) cats, err := loadData(sources)
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err) return nil, fmt.Errorf("fejkdata: %w", err)
} }
rng, err := newRand(c.seed, c.seeded) return &Generator{rand: newRand(c.seed, c.seeded), categories: cats}, nil
if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
return &Generator{rand: rng, categories: cats}, nil
} }
// List returns the sorted dotted paths Fake can render: every category, the dotted // List returns the sorted dotted paths Fake can render: every category, the dotted
@@ -211,19 +196,12 @@ func join(prefix, name string) string {
return prefix + "." + name return prefix + "." + name
} }
// randomBytes seeds an unseeded generator. func newRand(seed uint64, seeded bool) *session {
var randomBytes = crand.Read r := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
if !seeded {
func newRand(seed uint64, seeded bool) (*session, error) {
var r *rand.Rand
if seeded {
r = rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
} else {
var b [16]byte var b [16]byte
if _, err := randomBytes(b[:]); err != nil { _, _ = crand.Read(b[:])
return nil, fmt.Errorf("seeding from crypto/rand: %w", err)
}
r = rand.New(rand.NewPCG(binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:]))) r = rand.New(rand.NewPCG(binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:])))
} }
return &session{Rand: r, counters: map[string]uint64{}}, nil return &session{Rand: r, counters: map[string]uint64{}}
} }
+2 -22
View File
@@ -1,11 +1,8 @@
package fejkdata package fejkdata
import ( import (
"errors"
"io/fs"
"strings" "strings"
"testing" "testing"
"testing/fstest"
) )
// newGenerator creates a generator over a single data directory, failing on // newGenerator creates a generator over a single data directory, failing on
@@ -41,25 +38,8 @@ func fake(t *testing.T, f *Generator, path string) string {
func TestNewMissingDirectory(t *testing.T) { func TestNewMissingDirectory(t *testing.T) {
_, err := New(WithoutShippedData(), WithDataPath("data/de_DE")) _, err := New(WithoutShippedData(), WithDataPath("data/de_DE"))
if err == nil || !strings.Contains(err.Error(), "de_DE") || !errors.Is(err, fs.ErrNotExist) { if err == nil || !strings.Contains(err.Error(), "de_DE") {
t.Fatalf("New(missing) error = %v, want the real error, naming the path", err) t.Fatalf("New(missing) error = %v, want it to name the path", err)
}
}
func TestNewRejectsAnEmptyDataPath(t *testing.T) {
_, err := New(WithoutShippedData(), WithDataPath(""))
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("New(WithDataPath(\"\")) = %v, want the empty path named", err)
}
}
func TestNewReportsAnEntropyFailure(t *testing.T) {
saved := randomBytes
randomBytes = func([]byte) (int, error) { return 0, errors.New("no entropy") }
defer func() { randomBytes = saved }()
_, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{"w.json": {Data: []byte(`"x"`)}}))
if err == nil || !strings.Contains(err.Error(), "no entropy") {
t.Fatalf("New() without entropy = %v, want the failure reported", err)
} }
} }
-251
View File
@@ -1,251 +0,0 @@
package fejkdata
import (
"fmt"
"sort"
"strings"
)
// walkNodes calls fn once per contained node, passing the dot path that reaches it,
// visiting keys in sorted order so which of several broken nodes gets reported does
// not depend on map iteration.
func walkNodes(root map[string]node, fn func(path string, n node) error) error {
for _, name := range sortedNames(root) {
if err := eachNode(root[name], name, fn); err != nil {
return err
}
}
return nil
}
// eachNode visits n and every node contained within it once, passing the dot path
// that reaches each. It never crosses a reference edge — a bound {/path} field is
// skipped — so a single inline node is walked on its own.
func eachNode(n node, path string, fn func(path string, n node) error) error {
seen := map[node]bool{}
var visit func(string, node) error
visit = func(path string, m node) error {
if m == nil || seen[m] {
return nil
}
seen[m] = true
if err := fn(path, m); err != nil {
return err
}
for _, c := range contained(m) {
if err := visit(join(path, c.name), c.node); err != nil {
return err
}
}
return nil
}
return visit(path, n)
}
// namedNode is a contained child and the segment reaching it; a choice's items carry
// no segment, matching how a dot path steps over a choice.
type namedNode struct {
name string
node node
}
func contained(n node) []namedNode {
switch n := n.(type) {
case *group:
return named(n.children)
case *choice:
out := make([]namedNode, len(n.items))
for i, it := range n.items {
out[i] = namedNode{node: it}
}
return out
case *template:
return named(n.fields)
default:
return nil
}
}
// named skips a bound {/path} key: it is a render edge, not containment, so using
// it as a path segment would report a node under a path that does not reach it. Only
// a template's fields hold bindings — loadDir skips a dot-prefixed entry, so a
// group's children never carry the prefix — so this one skip serves both.
func named(m map[string]node) []namedNode {
out := make([]namedNode, 0, len(m))
for _, name := range sortedNames(m) {
if isRef(name) {
continue
}
out = append(out, namedNode{name: name, node: m[name]})
}
return out
}
func sortedNames(m map[string]node) []string {
names := make([]string, 0, len(m))
for name := range m {
names = append(names, name)
}
sort.Strings(names)
return names
}
// 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 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 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("%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 operands. A
// group renders nothing, so it has no edges.
func renderEdges(n node) []renderEdge {
switch n := n.(type) {
case *choice:
es := make([]renderEdge, len(n.items))
for i, it := range n.items {
es[i] = renderEdge{to: it, label: fmt.Sprintf("[%d]", i)}
}
return es
case *template:
var es []renderEdge
add := func(name, operand string) {
a := splitArm(name, n.refs)
c, ok := n.fields[a.key]
if !ok {
return
}
for _, leaf := range pathLeaves(c, a.tail) {
es = append(es, renderEdge{leaf, name, operand})
}
}
_ = eachToken(n.format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
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
}
}
// pathLeaves lists what a token's dotted tail renders: a choice on the way
// contributes every variant, since any of them may be the one drawn. checkPath has
// already proved the tail resolves in every variant.
func pathLeaves(n node, tail []string) []node {
var out []node
_ = walkPath(n, tail, pathWalk{
choice: func(c *choice, _ []string) ([]node, error) { return c.items, nil },
leaf: func(n node) error { out = append(out, n); return nil },
})
return out
}
// nodeScope is the set of nodes one validation pass covers: a whole loaded tree,
// or a single inline node.
type nodeScope func(fn func(path string, n node) error) error
func treeScope(root map[string]node) nodeScope {
return func(fn func(path string, n node) error) error { return walkNodes(root, fn) }
}
func inlineScope(n node) nodeScope {
return func(fn func(path string, m node) error) error { return eachNode(n, "template", fn) }
}
// checkScope runs the per-node fences over a scope, each over the whole scope
// before the next, so which of several broken nodes is reported does not depend on
// the walk. It runs after checkNoCycles, whose guarantee is what lets the walks
// terminate.
func checkScope(s nodeScope) error {
mem := reachMemo{}
if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil {
return err
}
return s(heldCheck)
}
type reachMemo map[node]int
func (m reachMemo) of(n node) int {
if r, done := m[n]; done {
return r
}
r := 1
for _, e := range renderEdges(n) {
if c := m.of(e.to); c > r {
r = c
}
}
if t, ok := n.(*template); ok {
r *= t.repeat
}
m[n] = r
return r
}
// repeatCheck bounds the renders a repeat multiplies to along any root-to-leaf
// path, so nested repeats cannot build what one repeat may not.
func repeatCheck(path string, n node, mem reachMemo) error {
if t, ok := n.(*template); ok && t.repeat > 1 && mem.of(n) > MaxRepeat {
return fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, mem.of(n), MaxRepeat)
}
return nil
}
// checkNoCycles rejects a reference cycle: a node whose rendering can reach itself
// — directly, mutually, or through a chain — never terminates, so it must fail at
// New rather than stack-overflow at render. It is a depth-first walk of the render
// graph (renderEdges); grey marks nodes on the current path so a back-edge to one
// is the cycle, while black lets a shared node (a DAG, not a cycle) be skipped.
// Every node is a root: a field its parent's format never renders is still reachable
// by dot path, so a cycle in one would otherwise reach render and be fatal there.
func checkNoCycles(root map[string]node) error {
const (
grey = 1
black = 2
)
color := map[node]int{}
var visit func(n node, path string) error
visit = func(n node, path string) error {
switch color[n] {
case grey:
return fmt.Errorf("reference cycle: %s", path)
case black:
return nil
}
color[n] = grey
for _, e := range renderEdges(n) {
if err := visit(e.to, path+" -> "+e.label); err != nil {
return err
}
}
color[n] = black
return nil
}
return walkNodes(root, func(path string, n node) error { return visit(n, path) })
}
-310
View File
@@ -1,310 +0,0 @@
package fejkdata
import (
"fmt"
"sort"
"strings"
)
// heldCheck 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, an operand); this settles the rest — a reference, whether it
// sits in that format or in anything the format renders, however deep.
func heldCheck(path string, n node) error {
t, ok := n.(*template)
if !ok || len(t.held) == 0 {
return nil
}
readers := boundReaders(t.format, t.bound, t.refs)
for _, head := range heldHeads(t) {
if err := checkHeadHeld(t, head, readers); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
return nil
}
// heldHeads lists a template's held names, 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.
func heldHeads(t *template) []string {
heads := make([]string, 0, len(t.held))
for head := range t.held {
heads = append(heads, head)
}
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]
})
return heads
}
// pinned collects what the hold of head answers for: a path pins the levels it
// passes through and the leaf it lands on, an operand exactly the value its render
// produces.
func pinned(t *template, head string, readers []reader) map[node]bool {
held := map[node]bool{}
if _, isPath := t.bound[head]; !isPath {
operandDraw(t.fields[head], held)
return held
}
for _, r := range readers {
if a := splitArm(r.name, t.refs); a.key == head {
coverPath(t.fields[head], a.tail, held)
}
}
return held
}
// checkHeadHeld rejects every route to what head's hold pins except the readers
// holding it. 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.
func checkHeadHeld(t *template, head string, readers []reader) error {
held := pinned(t, head, readers)
if len(held) == 0 {
return nil // a fixed head holds nothing to reach
}
reader, isPath := t.bound[head]
seen := map[node]bool{}
for _, e := range renderEdges(t) {
if splitArm(e.label, t.refs).key == head || !renders(e.to, held, seen) {
continue
}
if isPath {
return fmt.Errorf("%s renders %q, which {%s} reads a path into; name the fields you want instead", e.reached(), head, reader)
}
return fmt.Errorf("%s renders %q, which a {%s()} also reads; reach it one way so it is drawn once", e.reached(), head, operandReader(t, head))
}
return nil
}
// 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) {
_ = walkPath(n, tail, pathWalk{
choice: func(c *choice, _ []string) ([]node, error) { cover(c, into, false); return nil, nil },
leaf: func(n node) error { cover(n, into, false); return nil },
})
}
// 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 reference 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.
func operandDraw(n node, into map[node]bool) {
if isFixed(n) {
return
}
if into[n] {
return
}
into[n] = true
for _, e := range renderEdges(n) {
if isRef(e.label) {
continue
}
operandDraw(e.to, into)
}
}
// 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
// ends.
func renders(n node, want, seen map[node]bool) bool {
if want[n] {
return true
}
if seen[n] {
return false
}
seen[n] = true
for _, e := range renderEdges(n) {
if renders(e.to, want, seen) {
return true
}
}
return false
}
// checkNoOverlap rejects a format that both renders a level and reads a path into
// it — {p} beside {p.first}, {p.addr} beside {p.addr.city}, {.p} beside
// {/sv_SE.p.first}. The path reads the level's held draw while rendering the level
// expands it afresh, so their values would disagree. Reads are compared by their
// one spelling, in sorted order, so which pair is reported depends neither on how
// a reference was written nor on where the tokens sit.
func checkNoOverlap(format string, bound map[string]string, refs map[string]refBinding) 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].path < names[j].path })
for i, level := range names {
for _, path := range names[i+1:] {
if strings.HasPrefix(path.path, level.path+".") {
return fmt.Errorf("%s renders a level that {%s} reads a path into; name the fields you want instead", level.label, path.name)
}
}
}
return nil
}
// reader is one way a format reaches a bound field: as written, by its one
// spelling, and how to name it.
type reader struct{ name, path, label string }
// boundReaders lists every way a format reaches a bound field, in the order the
// 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, refs map[string]refBinding) []reader {
var names []reader
_ = eachToken(format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
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, a.path, fmt.Sprintf("%s operand %q", fn, operand)})
}
}
return nil
}
for _, a := range splitArms(t.body, refs) {
if _, isBound := bound[a.key]; isBound {
names = append(names, reader{a.name, a.path, "token {" + a.name + "}"})
}
}
return nil
})
return names
}
// checkNoRepeatedRead rejects a bare token repeated on a held name: {w} {w} beside
// {uppercase(w)} would read one draw twice, where {w} {w} alone draws twice. The
// error names the single-token spelling.
func checkNoRepeatedRead(format string, c formatOps, refs map[string]refBinding) error {
count := map[string]int{}
return eachToken(format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
if _, _, isFunc := funcCall(t.body); isFunc {
return nil
}
for _, a := range splitArms(t.body, refs) {
if len(a.tail) > 0 || !c.held[a.key] {
continue
}
if count[a.key]++; count[a.key] > 1 {
return fmt.Errorf("token {%s} is repeated, and %s holds %q to one draw per expansion; write {%s} once", a.name, c.holder[a.key], a.key, a.name)
}
}
return nil
})
}
// draws is what an expansion has already drawn for its held names: the variant each
// was drawn as, so every path under it reads one row, and the value each read, by
// its one spelling, so the same read written twice reads one value.
type draws struct {
variant map[string]node
value map[string]string
}
// readField renders one arm of a token. An arm's key is a sibling field or a
// reference linkRefs bound into fields. A name the expansion holds — a level some
// token addresses by dotted path, or a field an operand reads — is drawn once and
// kept, so {place.postal-code} and {place.locality} read one row, either read twice
// gives one value, and a shown operand is the operand computed. Every other name is
// drawn afresh, so {word} {word} still draws twice. checkTokens, checkPath and
// linkRefs prove every step, so the walk cannot fail.
func readField(s *session, t *template, held *draws, a arm) string {
if !t.held[a.key] {
if len(a.tail) > 0 {
panic(fmt.Sprintf("fejkdata: %q reads a path into %q, which the expansion does not hold", a.name, a.key))
}
return render(s, t.fields[a.key])
}
if v, read := held.value[a.path]; read {
return v
}
var v string
_ = walkPath(t.fields[a.key], a.tail, pathWalk{
// Hold the draw at every level passed through, so two paths sharing a
// prefix share it.
choice: func(c *choice, rest []string) ([]node, error) {
key := a.key
if consumed := len(a.tail) - len(rest); consumed > 0 {
key = a.steps[consumed-1]
}
n, drew := held.variant[key]
if !drew {
n = drawn(s, c)
held.variant[key] = n
}
return []node{n}, nil
},
leaf: func(n node) error { v = render(s, n); return nil },
})
held.value[a.path] = v
return v
}
// drawn resolves a choice to one variant, so a bound head is a concrete node the
// rest of the expansion shares. Nested choices unwrap too: a draw is one value, not
// another set to pick from.
func drawn(s *session, n node) node {
for c, ok := n.(*choice); ok; c, ok = n.(*choice) {
n = pick(s, c)
}
return n
}
-88
View File
@@ -1,88 +0,0 @@
package fejkdata
import (
"encoding/json"
"fmt"
"strings"
)
// Template is an inline template compiled, referenced and validated against a
// generator's loaded data once, ready to render many times with [Template.Fake].
// It is safe for concurrent use: Fake serializes on its generator's lock, so a
// seeded sequence is reproducible only when a generator — and its templates — are
// drawn from one goroutine.
type Template struct {
g *Generator
n node
}
// Fake renders the template with one draw.
func (t *Template) Fake() string {
t.g.mu.Lock()
defer t.g.mu.Unlock()
return render(t.g.rand, t.n)
}
// NewTemplate compiles an inline template — a format string or a JSON value — and
// binds its references against the loaded tree, so repeated renders pay the
// compile and validation once. It shares [New]'s guarantees: a bad template errors
// here, and rendering cannot fail.
func (f *Generator) NewTemplate(input string) (*Template, error) {
n, err := compileInput(input)
if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
scope := inlineScope(n)
if err := linkNodeRefs(scope, f.categories); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkScope(scope); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
return &Template{g: f, n: n}, nil
}
// FakeTemplate compiles and renders an inline template in one call. It is
// [NewTemplate] then [Template.Fake]; to render the same template many times, hold
// the *Template and call its Fake.
func (f *Generator) FakeTemplate(input string) (string, error) {
t, err := f.NewTemplate(input)
if err != nil {
return "", err
}
return t.Fake(), nil
}
// compileInput compiles an inline template: a JSON value, or a bare format string
// when the input is not JSON.
func compileInput(input string) (node, error) {
var raw any
if err := json.Unmarshal([]byte(input), &raw); err != nil {
return compile(input)
}
if trimmed := strings.TrimSpace(input); trimmed != input {
return nil, fmt.Errorf("a JSON template may not be padded with spaces, which a format string would render; write %s", trimmed)
}
return compile(raw)
}
// linkNodeRefs binds the references in an inline node's templates against the
// loaded tree.
func linkNodeRefs(scope nodeScope, root map[string]node) error {
return scope(func(path string, m node) error {
t, ok := m.(*template)
if !ok {
return nil
}
for _, name := range refTokens(t.format) {
sigil, rest, err := refShape(name)
if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
}
if sigil != "/" {
return fmt.Errorf("%s: reference {%s}: an inline template has no folder; write {/%s}", path, name, rest)
}
}
return linkTemplateRefs(nil, path, t, root)
})
}
-159
View File
@@ -1,159 +0,0 @@
package fejkdata
import (
"strings"
"testing"
)
func tmpl(t *testing.T, f *Generator, input string) string {
t.Helper()
s, err := f.FakeTemplate(input)
if err != nil {
t.Fatalf("FakeTemplate(%q): %v", input, err)
}
return s
}
func shipped(t *testing.T, opts ...Option) *Generator {
t.Helper()
f, err := New(append([]Option{WithSeed(1)}, opts...)...)
if err != nil {
t.Fatal(err)
}
return f
}
func TestFakeTemplateFormatString(t *testing.T) {
f := shipped(t)
got := tmpl(t, f, "name: {/sv_SE.person.last}")
if !strings.HasPrefix(got, "name: ") || strings.HasSuffix(got, " ") || strings.Contains(got, "{") {
t.Fatalf("FakeTemplate = %q, want a rendered last name after the prefix", got)
}
}
func TestFakeTemplateJSONObject(t *testing.T) {
f := shipped(t)
seen := map[string]bool{}
for i := 0; i < 50; i++ {
seen[tmpl(t, f, `{"format":"name: {x}","x":["bosse","lina"]}`)] = true
}
if !seen["name: bosse"] || !seen["name: lina"] || len(seen) != 2 {
t.Fatalf("JSON template produced %v, want both names", seen)
}
}
func TestFakeTemplateJSONArray(t *testing.T) {
f := shipped(t)
seen := map[string]bool{}
for i := 0; i < 50; i++ {
seen[tmpl(t, f, `["foo","bar","baz"]`)] = true
}
if len(seen) != 3 {
t.Fatalf("JSON array choice produced %v, want three items", seen)
}
}
func TestFakeTemplateCorrelatedReferences(t *testing.T) {
dir := writeData(t, map[string]string{
"person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`,
})
f := newGenerator(t, dir, WithSeed(1))
for i := 0; i < 100; i++ {
got := tmpl(t, f, "{/person.first} {/person.last}")
if got != "Ada Lovelace" && got != "Bo Ek" {
t.Fatalf("correlated references = %q, want one person's first and last", got)
}
}
}
func TestFakeTemplateDeterministic(t *testing.T) {
a, b := shipped(t), shipped(t)
for i := 0; i < 20; i++ {
in := "row: {/misc.uuid} {digits(3)}"
if x, y := tmpl(t, a, in), tmpl(t, b, in); x != y {
t.Fatalf("same seed diverged: %q != %q", x, y)
}
}
}
func TestFieldlessTokenHint(t *testing.T) {
f := shipped(t)
_, err := f.FakeTemplate(`{sv_SE.person.last}`)
if err == nil || !strings.Contains(err.Error(), "write {/sv_SE.person.last}") {
t.Fatalf("FakeTemplate(bare token) = %v, want a hint naming {/sv_SE.person.last}", err)
}
_, err = f.FakeTemplate(`{"format":"{x}","repeat":2}`)
if err == nil || !strings.Contains(err.Error(), `this template has none — write {/x}`) {
t.Errorf("FakeTemplate(fieldless object) = %v, want the hint without calling it a bare string", err)
}
// A hint is only a drop-in where the name is the whole token: {/x} inside a
// transform or an alternation renders a different value, so none is offered.
for _, input := range []string{`{ /sv_SE.person.last }`, "{lowercase(x)}", "{x|y}"} {
_, err := f.FakeTemplate(input)
if err == nil || strings.Contains(err.Error(), "write {") {
t.Errorf("FakeTemplate(%q) = %v, want no hint naming a spelling that means something else", input, err)
}
}
}
func TestFakeTemplateErrors(t *testing.T) {
f := shipped(t)
for _, c := range []struct {
input string
want string
}{
{`{"x":"Q"}`, "missing string \"format\""},
{`"{x}"`, `no field "x"`},
{`"{digits(0)}"`, "must be positive"},
{`name: {/no.such.path}`, "no entry"},
{`name: {..nope}`, "write {/nope}"},
{`{"format":"x"}`, "is a string"},
{`{/misc.country} {/misc.country.alpha2}`, "renders a level"},
{`{"format":"{/misc.country.alpha2} {x}","x":"{/misc.country}"}`, "reads a path into"},
} {
_, err := f.FakeTemplate(c.input)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("FakeTemplate(%q) = %v, want an error containing %q", c.input, err, c.want)
}
}
}
func TestFakeTemplateJSONString(t *testing.T) {
f := shipped(t)
got := tmpl(t, f, `"name: {/sv_SE.person.last}"`)
if !strings.HasPrefix(got, "name: ") || strings.Contains(got, "{") {
t.Fatalf("FakeTemplate(JSON string) = %q, want a rendered last name after the prefix", got)
}
}
func TestPaddedJSONIsRejected(t *testing.T) {
f := shipped(t)
in := `{"format":"{x}","x":["a","b"]}`
_, err := f.NewTemplate(" " + in + " ")
if err == nil || !strings.Contains(err.Error(), "write "+in) {
t.Fatalf("NewTemplate(padded JSON) = %v, want an error naming the unpadded spelling", err)
}
}
func TestNewTemplateReusable(t *testing.T) {
f := shipped(t)
reusable, err := f.NewTemplate(`{digits(2)}`)
if err != nil {
t.Fatalf("NewTemplate: %v", err)
}
seen := map[string]bool{}
for i := 0; i < 50; i++ {
seen[reusable.Fake()] = true
}
if len(seen) < 2 {
t.Fatalf("Template.Fake() repeated %v, want varied draws from one compile", seen)
}
}
func TestFakeTemplateRepeatBound(t *testing.T) {
f := shipped(t)
_, err := f.FakeTemplate(`{"format":"{x}","repeat":200,"x":{"format":"{y}","repeat":200,"y":{"format":"z","repeat":200}}}`)
if err == nil || !strings.Contains(err.Error(), "maximum") {
t.Errorf("nested repeat over the cap = %v, want it rejected naming the maximum", err)
}
}
+3 -217
View File
@@ -1,12 +1,9 @@
package fejkdata package fejkdata
import ( import (
"encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -19,8 +16,8 @@ func TestList(t *testing.T) {
"person": `{"format":"{first} {last}","first":"A","last":"B"}`, "person": `{"format":"{first} {last}","first":"A","last":"B"}`,
"word": `["x", "y"]`, "word": `["x", "y"]`,
"geo/city": `"Z"`, "geo/city": `"Z"`,
// A bound {/path} reference is a render edge, not an addressable field. // 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. // 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"}]`,
}) })
@@ -189,7 +186,7 @@ func TestRepeatProductAlongAPathIsCapped(t *testing.T) {
for name, files := range map[string]map[string]string{ for name, files := range map[string]map[string]string{
"nested": {"cat": `{"format":"{a}","repeat":2048,"a":{"format":"{b}","repeat":2048,"b":"x"}}`}, "nested": {"cat": `{"format":"{a}","repeat":2048,"a":{"format":"{b}","repeat":2048,"b":"x"}}`},
"through a reference": { "through a reference": {
"a": `{"format":"{/b}","repeat":2048}`, "a": `{"format":"{..b}","repeat":2048}`,
"b": `{"format":"x","repeat":2048}`, "b": `{"format":"x","repeat":2048}`,
}, },
} { } {
@@ -204,214 +201,3 @@ func TestRepeatProductAlongAPathIsCapped(t *testing.T) {
t.Errorf("New = %v, want 1024 x 1024 along one path accepted", err) t.Errorf("New = %v, want 1024 x 1024 along one path accepted", err)
} }
} }
func TestNewErrors(t *testing.T) {
// Pointing New at a file (not a directory) fails.
file := filepath.Join(t.TempDir(), "xx_XX")
if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New(WithoutShippedData(), WithDataPath(file)); err == nil {
t.Error("New(file) = nil error, want not-a-directory error")
}
// Invalid JSON in a category file fails.
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"broken": `{ not json`}))); err == nil {
t.Error("New(invalid JSON) = nil error")
}
// An option that cannot take effect, and a category or folder no dot path can
// reach, are mistakes New must name rather than accept and ignore.
rejected := map[string]struct {
files map[string]string
want string
}{
"separator without repeat": {
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","separator":","}`},
"has no effect without a repeat above 1",
},
"weight outside a choice": {
map[string]string{"a": `{"format":"x","weight":5}`},
"weight only skews a choice's items",
},
"non-numeric weight outside a choice": {
map[string]string{"a": `{"format":"x","weight":"bad"}`},
"weight only skews a choice's items",
},
"weight used as a field": {
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": `"{weight}"`},
`"weight" is an option and can never be a field`,
},
"category name with a dot": {
map[string]string{"a.b": `"1"`},
`category "a.b" contains "."`,
},
"folder name with a dot": {
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"}`},
`field "b|c" contains "|"`,
},
"field name with a paren": {
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"}`},
`field "b}c" contains "}"`,
},
"category name with a pipe": {
map[string]string{"a|b": `"1"`},
`category "a|b" contains "|"`,
},
"field name with a bracket": {
map[string]string{"a": `{"format":"{x}","x":"1","b[c":"2"}`},
`field "b[c" contains "["`,
},
"category name with a bracket": {
map[string]string{"[abc]": `"1"`},
`category "[abc]" contains "["`,
},
"field name with a quote": {
map[string]string{"a": `{"format":"{x}","x":"1","b\"c":"2"}`},
`field "b\"c" contains "\""`,
},
// An empty name is not a path segment, so List never offered it — while a
// bare {}, a trailing dot in Fake("a.") and a {/a.} reference all reached
// it. The engine accepted spellings it would never advertise.
"empty field name": {
map[string]string{"a": `{"format":"[{}]","":"VALUE"}`},
`field "" is empty`,
},
"folder name with a paren": {
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"}`},
`arm "x" is repeated`,
},
"repeated arm among others": {
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"}}`},
`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": `"{/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": `"{/|/}"`},
"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": `"{|}"`},
"a name is never empty",
},
"bare empty token": {
map[string]string{"a": `{"format":"[{}]","x":"1"}`},
"a name is never empty",
},
}
for name, c := range rejected {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files)))
if err == nil {
t.Errorf("%s: New = nil error, want it rejected at load", name)
continue
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want)
}
}
// Data that works today must keep working, and stay reachable.
accepted := map[string]struct {
files map[string]string
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"},
// 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"},
}
for name, c := range accepted {
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files)))
if err != nil {
t.Errorf("%s: New = %v, want it accepted", name, err)
continue
}
if got, err := f.Fake(c.path); err != nil || got != c.want {
t.Errorf("%s: Fake(%q) = %q, %v, want %q", name, c.path, got, err, c.want)
}
}
}
func TestCategoryRootShapes(t *testing.T) {
dir := writeData(t, map[string]string{
"obj": `"{digits(2)}"`, // object root
"lit": `"hello"`, // bare-string root
})
f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "obj"); !regexp.MustCompile(`^\d\d$`).MatchString(got) {
t.Errorf("object-root category = %q, want two digits", got)
}
if got := fake(t, f, "lit"); got != "hello" {
t.Errorf("string-root category = %q, want hello", got)
}
}
func TestLongStringList(t *testing.T) {
const n = 2000
names := make([]string, n)
for i := range names {
names[i] = fmt.Sprintf("name-%04d", i)
}
list, err := json.Marshal(names)
if err != nil {
t.Fatal(err)
}
f := newGenerator(t, writeData(t, map[string]string{"name": string(list)}), WithSeed(1))
valid := map[string]bool{}
for _, v := range names {
valid[v] = true
}
seen := map[string]bool{}
for i := 0; i < 20000; i++ {
v := fake(t, f, "name")
if !valid[v] {
t.Fatalf("got %q, not in the list", v)
}
seen[v] = true
}
if len(seen) < n*8/10 {
t.Fatalf("only %d/%d distinct values seen; selection looks skewed", len(seen), n)
}
}
+77 -96
View File
@@ -45,7 +45,7 @@ type template struct {
grow int // minimum output size, to size the render buffer grow int // minimum output size, to size the render buffer
fixed bool // no op varies, so every render is lit fixed bool // no op varies, so every render is lit
lit string // the whole output when fixed lit string // the whole output when fixed
refs map[string]refBinding // each reference the format reads -> what it is bound to 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 // 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 // reading it, which is the half of an overlap the fences name. nil when the
// format takes no path. // format takes no path.
@@ -57,15 +57,6 @@ type template struct {
func (*template) isNode() {} func (*template) isNode() {}
// field is the node a path segment names; a binding is a render edge, not a field.
func (t *template) field(seg string) (node, bool) {
if isRef(seg) {
return nil, false
}
n, ok := t.fields[seg]
return n, ok
}
// compile converts parsed JSON into a node tree, validating structure up front. // compile converts parsed JSON into a node tree, validating structure up front.
// Only a choice's items carry a weight, so one here would be inert whatever its type. // Only a choice's items carry a weight, so one here would be inert whatever its type.
func compile(v any) (node, error) { func compile(v any) (node, error) {
@@ -87,24 +78,10 @@ func compileItem(v any) (node, error) {
case map[string]any: case map[string]any:
return compileTemplate(v) return compileTemplate(v)
default: default:
return nil, fmt.Errorf("a template value must be a string, a list or an object, not %s", jsonKind(v)) return nil, fmt.Errorf("unsupported node type %T", v)
} }
} }
// jsonKind names a JSON value a template cannot hold, in the data format's own
// terms rather than the decoding library's.
func jsonKind(v any) string {
switch v.(type) {
case float64:
return "a number"
case bool:
return "a boolean"
case nil:
return "null"
}
return fmt.Sprintf("%T", v)
}
func compileString(s string) (node, error) { func compileString(s string) (node, error) {
if err := checkTokens(s, nil); err != nil { if err := checkTokens(s, nil); err != nil {
return nil, err return nil, err
@@ -167,13 +144,13 @@ func compileChoice(items []any) (node, error) {
c.items[i] = n c.items[i] = n
} }
if weighted { // uniform choices skip the weight table and pick in O(1) if weighted { // uniform choices skip the weight table and pick in O(1)
if math.IsInf(total, 1) { if total <= 0 || math.IsInf(total, 1) {
return nil, fmt.Errorf("choice weights must sum to a finite number, got %v", total) return nil, fmt.Errorf("choice weights must sum to a finite positive number, got %v", total)
} }
c.cum = cum c.cum = cum
} }
// Computed before linkRefs binds references into the items: a binding is keyed // Safe to precompute: a choice's items come from one file, so no group can
// by a reference sigil, which paths skips, so the set is the same after. // appear inside one, and neither mergeChildren nor linkRefs can reach in.
c.shared = sharedPaths(c.items) c.shared = sharedPaths(c.items)
return c, nil return c, nil
} }
@@ -204,75 +181,36 @@ func checkNoRepeatedItem(items []any) error {
} }
func compileTemplate(m map[string]any) (node, error) { func compileTemplate(m map[string]any) (node, error) {
o, err := readOptions(m)
if err != nil {
return nil, err
}
fields, err := compileFields(m)
if err != nil {
return nil, err
}
if len(fields) == 0 && o.repeat == 1 && !o.weighted {
return nil, fmt.Errorf("an object holding only a format is a string; write %q", o.format)
}
if err := checkTokens(o.format, fields); err != nil {
return nil, err
}
t := &template{format: o.format, fields: fields, repeat: o.repeat, separator: o.separator}
if err := t.compileFormat(); err != nil {
return nil, err
}
return t, nil
}
// templateOptions is what a template object's option keys say.
type templateOptions struct {
format string
repeat int
separator string
weighted bool
}
func readOptions(m map[string]any) (templateOptions, error) {
var o templateOptions
format, ok := m["format"].(string) format, ok := m["format"].(string)
if !ok { if !ok {
return o, fmt.Errorf("template object missing string \"format\"") return nil, fmt.Errorf("template object missing string \"format\"")
} }
o.format = format
repeat, err := repeatOf(m) repeat, err := repeatOf(m)
if err != nil { if err != nil {
return o, err return nil, err
} }
o.repeat = repeat sep := ""
if sv, ok := m["separator"]; ok { if sv, ok := m["separator"]; ok {
if o.separator, ok = sv.(string); !ok { if sep, ok = sv.(string); !ok {
return o, fmt.Errorf("separator must be a string, got %T", sv) return nil, fmt.Errorf("separator must be a string, got %T", sv)
} }
if repeat == 1 { if repeat == 1 {
return o, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1") return nil, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1")
}
if o.separator == "" {
return o, fmt.Errorf("separator \"\" is the default, so it has no effect; drop it")
} }
} }
_, o.weighted = m["weight"] t := &template{format: format, fields: make(map[string]node, len(m)), repeat: repeat, separator: sep}
return o, nil
}
// compileFields compiles every non-option key of a template object, in name order
// so which of several bad fields is reported does not vary.
func compileFields(m map[string]any) (map[string]node, error) {
fields := make(map[string]node, len(m))
keys := make([]string, 0, len(m)) keys := make([]string, 0, len(m))
for k := range m { for k := range m {
keys = append(keys, k) keys = append(keys, k)
} }
sort.Strings(keys) sort.Strings(keys) // so which of several bad fields is reported does not vary
for _, k := range keys { for _, k := range keys {
if isOption(k) { if isOption(k) {
continue continue
} }
if isRef(k) {
return nil, fmt.Errorf("field %q starts with %q, which is reserved for {..path} bindings", k, refPrefix)
}
if err := checkName(k); err != nil { if err := checkName(k); err != nil {
return nil, fmt.Errorf("field %w", err) return nil, fmt.Errorf("field %w", err)
} }
@@ -280,9 +218,55 @@ func compileFields(m map[string]any) (map[string]node, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("field %q: %w", k, err) return nil, fmt.Errorf("field %q: %w", k, err)
} }
fields[k] = n 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
}
if err := t.compileFormat(); 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 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.
func checkPath(n node, tail []string, level string) error {
if len(tail) == 0 {
return nil
}
switch n := n.(type) {
case *template:
if n.repeat > 1 {
return fmt.Errorf("the level %q carries a repeat, which a path reading one draw of it cannot apply", level)
}
child, ok := n.fields[tail[0]]
if !ok {
return fmt.Errorf("no field %q", tail[0])
}
return checkPath(child, tail[1:], level+"."+tail[0])
case *choice:
if want := strings.Join(tail, "."); !n.shared[want] {
return unreachableInChoice(n, want)
}
// Reachability is settled; each variant still answers for itself, so a
// rule about the level (its repeat) holds behind a choice as in front.
for _, item := range n.items {
if err := checkPath(item, tail, level); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("cannot descend into %T at %q", n, tail[0])
} }
return fields, nil
} }
// repeatOf reads a template's "repeat" (default 1): how many times its format // repeatOf reads a template's "repeat" (default 1): how many times its format
@@ -302,14 +286,14 @@ func repeatOf(m map[string]any) (int, error) {
if r == 1 { if r == 1 {
return 0, fmt.Errorf("repeat 1 is the default, so it has no effect; drop it") return 0, fmt.Errorf("repeat 1 is the default, so it has no effect; drop it")
} }
if r > MaxRepeat { // caps the renders one repeat asks for; repeatCheck bounds what nested ones multiply to 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, MaxRepeat) return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, maxLen)
} }
return int(r), nil return int(r), nil
} }
// weightOf reads a node's "weight" (default 1) from its raw JSON form. Only // weightOf reads a node's "weight" (default 1) from its raw JSON form. Only
// template objects carry weight; a present one must be finite and positive. // template objects carry weight; a present one must be finite and non-negative.
func weightOf(raw any) (float64, error) { func weightOf(raw any) (float64, error) {
m, ok := raw.(map[string]any) m, ok := raw.(map[string]any)
if !ok { if !ok {
@@ -324,10 +308,7 @@ func weightOf(raw any) (float64, error) {
return 0, fmt.Errorf("weight must be a number, got %T", wv) return 0, fmt.Errorf("weight must be a number, got %T", wv)
} }
if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) { if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) {
return 0, fmt.Errorf("weight must be finite and positive, got %v", w) return 0, fmt.Errorf("weight must be finite and non-negative, got %v", w)
}
if w == 0 {
return 0, fmt.Errorf("weight 0 means the item is never drawn; remove the item instead")
} }
if w == 1 { if w == 1 {
return 0, fmt.Errorf("weight 1 is the default, so it has no effect; drop it") return 0, fmt.Errorf("weight 1 is the default, so it has no effect; drop it")
@@ -337,23 +318,23 @@ func weightOf(raw any) (float64, error) {
// reservedInName is what a category, folder or field name may not contain: a dot // 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 // separates the segments of a path, '|' the arms of a token, '(' opens a function
// call, braces delimit the token, '/' starts a reference, and brackets and a quote // call and braces delimit the token. A name carrying one is reachable by no format,
// open a JSON value. A name carrying one is rejected where it is authored rather // so it is rejected where it is authored rather than at the token that cannot
// than where it would be unreachable. // reach it.
const reservedInName = ".|({}/[]\"" const reservedInName = ".|({}"
// reservedList spells reservedInName for an error message, so the two cannot drift. // reservedList spells reservedInName for an error message, so the two cannot drift.
var reservedList = strings.Join(strings.Split(reservedInName, ""), " ") var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
// checkName rejects a name the dot path, {token} and JSON grammars cannot spell. // checkName rejects a name the dot path and {token} grammars cannot spell. Both a
// Both a category or folder and a field go through it, so there is one answer to // category or folder and a field go through it, so there is one answer to what a
// what a name may contain. // name may contain.
func checkName(name string) error { func checkName(name string) error {
if name == "" { if name == "" {
return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name) return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name)
} }
if i := strings.IndexAny(name, reservedInName); i >= 0 { if i := strings.IndexAny(name, reservedInName); i >= 0 {
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve", return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path and {token} grammars reserve",
name, name[i:i+1], reservedList) name, name[i:i+1], reservedList)
} }
return nil return nil
-11
View File
@@ -35,7 +35,6 @@ func TestInertObjectIsRejected(t *testing.T) {
`[{"format":"a","weight":1},"b"]`: "weight 1", `[{"format":"a","weight":1},"b"]`: "weight 1",
`{"format":"{x}","x":"v","repeat":1}`: "repeat 1", `{"format":"{x}","x":"v","repeat":1}`: "repeat 1",
`{"format":"{x}","x":"v","separator":","}`: "separator", `{"format":"{x}","x":"v","separator":","}`: "separator",
`{"format":"{x}","x":"v","repeat":2,"separator":""}`: "default",
} { } {
if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) { 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) t.Errorf("compile(%s) = %v, want an error mentioning %s", src, err, want)
@@ -47,13 +46,3 @@ func TestInertObjectIsRejected(t *testing.T) {
} }
} }
} }
func TestInlineFolderSigilsAreRejected(t *testing.T) {
f := shipped(t)
for _, input := range []string{"{.sv_SE.person.last}", "{..sv_SE.person.last}"} {
_, err := f.NewTemplate(input)
if err == nil || !strings.Contains(err.Error(), "write {/sv_SE.person.last}") {
t.Errorf("NewTemplate(%q) = %v, want an error naming the root spelling", input, err)
}
}
}
-110
View File
@@ -1,110 +0,0 @@
package fejkdata
import (
"fmt"
"sort"
"strings"
)
// pathWalk is what one walk of a dotted path does at each kind of level: choice
// returns the variants to continue into (none stops the walk); level runs at each
// template a segment descends into; leaf runs where the tail ends. A nil action
// is skipped.
type pathWalk struct {
choice func(c *choice, rest []string) ([]node, error)
level func(t *template, rest []string) error
leaf func(n node) error
}
// walkPath descends tail from n: a group or template by its next segment, a
// choice by w.choice, which consumes no segment. A missing segment is an error,
// so no walk reaches past what the data holds. A table-shaped dispatch, one case
// per node kind, kept whole on purpose.
func walkPath(n node, tail []string, w pathWalk) error {
if len(tail) == 0 {
if w.leaf != nil {
return w.leaf(n)
}
return nil
}
switch n := n.(type) {
case *group:
child, ok := n.children[tail[0]]
if !ok {
return fmt.Errorf("no entry %q", tail[0])
}
return walkPath(child, tail[1:], w)
case *template:
if w.level != nil {
if err := w.level(n, tail); err != nil {
return err
}
}
child, ok := n.field(tail[0])
if !ok {
return fmt.Errorf("no field %q", tail[0])
}
return walkPath(child, tail[1:], w)
case *choice:
if w.choice == nil {
return nil
}
next, err := w.choice(n, tail)
if err != nil {
return err
}
for _, item := range next {
if err := walkPath(item, tail, w); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("cannot descend into %T at %q", n, tail[0])
}
// carriedByAll is the choice rule a path that must resolve on every call obeys:
// the rest of the tail must be one every variant carries.
func carriedByAll(c *choice, rest []string) error {
if want := strings.Join(rest, "."); !c.shared[want] {
return unreachableInChoice(c, want)
}
return nil
}
// unreachableInChoice reports that a path cannot step through this choice, listing
// what every variant does carry. It reads the precomputed set, so a failing path
// costs no more than a rendering one.
func unreachableInChoice(c *choice, want string) error {
if len(c.shared) == 0 {
return fmt.Errorf("no variant of this %d-way choice carries %q", len(c.items), want)
}
offered := make([]string, 0, len(c.shared))
for p := range c.shared {
offered = append(offered, p)
}
sort.Strings(offered)
return fmt.Errorf("not every variant of this %d-way choice carries %q; all carry %v", len(c.items), want, offered)
}
// checkPath proves a dotted tail resolves whichever way the draws go — a choice
// must carry the rest of the path in the set every variant shares — and that no
// level a path reads carries a repeat, which one draw of it could not apply. So a
// path that validates here resolves on every render, and a typo is a New-time
// error.
func checkPath(n node, tail []string, level string) error {
return walkPath(n, tail, pathWalk{
choice: func(c *choice, rest []string) ([]node, error) {
if err := carriedByAll(c, rest); err != nil {
return nil, err
}
return c.items, nil
},
level: func(t *template, rest []string) error {
if t.repeat > 1 {
return fmt.Errorf("the level %q carries a repeat, which a path reading one draw of it cannot apply", join(level, strings.Join(tail[:len(tail)-len(rest)], ".")))
}
return nil
},
})
}
-152
View File
@@ -1,152 +0,0 @@
package fejkdata
import (
"slices"
"strings"
"testing"
)
func TestWalkPathStopsAtAMissingSegment(t *testing.T) {
n := compiled(t, `{"format":"{a}","a":{"format":"{b}","b":"leaf"}}`)
var seen []string
walk := pathWalk{
level: func(tm *template, rest []string) error { seen = append(seen, "level:"+rest[0]); return nil },
leaf: func(n node) error { seen = append(seen, "leaf"); return nil },
}
if err := walkPath(n, []string{"a", "b"}, walk); err != nil {
t.Fatalf("walkPath(a.b) = %v", err)
}
if want := []string{"level:a", "level:b", "leaf"}; !slices.Equal(seen, want) {
t.Errorf("walk visited %v, want %v", seen, want)
}
seen = nil
err := walkPath(n, []string{"a", "nope", "deeper"}, walk)
if err == nil || !strings.Contains(err.Error(), `no field "nope"`) {
t.Errorf("walkPath(a.nope.deeper) = %v, want the missing segment named", err)
}
if slices.Contains(seen, "leaf") {
t.Errorf("walk reached a leaf past a missing segment: %v", seen)
}
}
func TestWalkPathChoiceConsumesNoSegment(t *testing.T) {
n := compiled(t, `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`)
var leaves []node
err := walkPath(n, []string{"f"}, pathWalk{
choice: func(c *choice, rest []string) ([]node, error) {
if len(rest) != 1 || rest[0] != "f" {
t.Errorf("choice saw rest %v, want [f]", rest)
}
return c.items, nil
},
leaf: func(n node) error { leaves = append(leaves, n); return nil },
})
if err != nil || len(leaves) != 2 {
t.Fatalf("walkPath through a choice = %v, %d leaves, want both variants' f", err, len(leaves))
}
}
func TestDeepDottedPath(t *testing.T) {
// A 5-segment path descends through alternating object/array nodes; choices
// 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"}}}}`),
}
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)
}
// Rendering the whole tree resolves the same chain.
if got, err := f.Fake("deep"); err != nil || got != "leaf" {
t.Fatalf("Fake(deep) = %q, %v, want leaf", got, err)
}
}
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 || !strings.Contains(err.Error(), `no field "extra"`) {
t.Fatalf("Fake(greeting.extra) = %v, want a no-field error", err)
}
}
// TestPathThroughChoice pins the rule that keeps a dotted path from rendering on
// 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"}]`,
})
f := newGenerator(t, dir, WithSeed(1))
for i := 0; i < 200; i++ {
if got := fake(t, f, "every.f"); got != "1" && got != "2" {
t.Fatalf("every.f = %q, want 1 or 2", got)
}
}
// A path only some variants carry is reported against what all of them carry.
if _, err := f.Fake("some.extra"); err == nil || !strings.Contains(err.Error(), "all carry [f]") {
t.Errorf("Fake(some.extra) = %v, want it to name what every variant carries", err)
}
var first string
for i := 0; i < 200; i++ {
_, err := f.Fake("notall.f")
if err == nil {
t.Fatal("notall.f = nil error, want the same failure every call")
}
if i == 0 {
first = err.Error()
} else if err.Error() != first {
t.Fatalf("notall.f error varies between calls:\n %s\n %s", first, err.Error())
}
}
}
// TestPathKeyIsUnambiguous pins that the one shape which could collide cannot be
// written: a field literally named "a.b" and a field "a" holding "b" would both
// spell "a.b", so a dotted field name is rejected at New and a dot means a path
// 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"}}]`,
})
_, err := New(WithoutShippedData(), WithDataPath(dir))
if err == nil || !strings.Contains(err.Error(), `field "a.b" contains "."`) {
t.Fatalf("New = %v, want the dotted field name rejected", err)
}
// 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"}}`,
}), WithSeed(1))
if !slices.Contains(f.List(), "cat.a.b") {
t.Error("List() omits cat.a.b, which the data carries")
}
if got := fake(t, f, "cat"); got != "2" {
t.Fatalf("cat = %q, want 2", got)
}
}
// TestMissingFieldNamesItself keeps the precise diagnosis for the ordinary typo: a
// single-variant choice always picks the same item, so it needs no every-variant
// guard and the error can name the field that is missing.
func TestMissingFieldNamesItself(t *testing.T) {
f := newGenerator(t, "data/sv_SE", WithSeed(1))
_, err := f.Fake("person.typo")
if err == nil || !strings.Contains(err.Error(), `no field "typo"`) {
t.Errorf("Fake(person.typo) = %v, want it to name the missing field", err)
}
}
func TestBindingKeyIsNotAPathSegment(t *testing.T) {
dir := writeData(t, map[string]string{
"color": `["red","blue"]`,
"name": `{"format":"{/color} {w}","w":"x"}`,
})
f := newGenerator(t, dir, WithSeed(1))
if slices.Contains(f.List(), "name./color") {
t.Error("List() advertises a binding key")
}
if _, err := f.Fake("name./color"); err == nil || !strings.Contains(err.Error(), `no field "/color"`) {
t.Errorf("Fake(name./color) = %v, want a no-field error", err)
}
}
-61
View File
@@ -1,61 +0,0 @@
package fejkdata
import (
"fmt"
"strings"
"testing"
"testing/fstest"
)
// One Fake call descends depth levels: each level's "a" is the next template down.
func nestedJSON(depth int) string {
s := `"leaf"`
for i := 0; i < depth; i++ {
s = fmt.Sprintf(`{"format":"{a}","a":%s}`, s)
}
return s
}
// One Fake call expands n sibling tokens.
func wideTokenJSON(n int) string {
var toks, fields strings.Builder
for i := 0; i < n; i++ {
fmt.Fprintf(&toks, "{f%d}", i)
if i > 0 {
fields.WriteByte(',')
}
fmt.Fprintf(&fields, `"f%d":"x"`, i)
}
return fmt.Sprintf(`{"format":"%s",%s}`, toks.String(), fields.String())
}
func TestNoRenderAllocRegression(t *testing.T) {
shapes := []struct {
name string
json string
base float64
}{
{"nested depth 25", nestedJSON(25), 27},
{"nested depth 100", nestedJSON(100), 102},
{"wide 500 tokens", wideTokenJSON(500), 9},
}
for _, s := range shapes {
f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{"x.json": {Data: []byte(s.json)}}))
if err != nil {
t.Fatalf("New(%s): %v", s.name, err)
}
allocs := testing.AllocsPerRun(10000, func() { f.Fake("x") })
if allocs > s.base*1.10 {
t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); bump the baseline only as a deliberate change", s.name, allocs, s.base*1.10, s.base)
}
}
}
func BenchmarkNestedDepth25(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(25)), "deep") }
func BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") }
func BenchmarkWideTokens100(b *testing.B) {
benchPath(b, tmpData(b, "wide", wideTokenJSON(100)), "wide")
}
func BenchmarkWideTokens500(b *testing.B) {
benchPath(b, tmpData(b, "wide", wideTokenJSON(500)), "wide")
}
+366 -104
View File
@@ -2,84 +2,30 @@ package fejkdata
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
) )
// A reference names a node by path rather than as a sibling field: {/a.b} from the // refPrefix marks a {..path} token: a reference to a node elsewhere in the data
// data root, {.a} from the folder this file sits in, {..a} from the folder above. // root rather than a sibling field. The path is resolved across every loaded
func isRef(name string) bool { return strings.HasPrefix(name, ".") || strings.HasPrefix(name, "/") } // directory (see linkRefs).
const refPrefix = ".."
// refBinding is what a reference was bound to: the head key its category is held func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
// under, and the tail read into it.
type refBinding struct {
key string
tail []string
}
// refShape splits a reference into its sigil and the dotted path after it. // linkRefs resolves every {..path} reference in the assembled tree. The head of the
func refShape(name string) (sigil, rest string, err error) { // path — up to the category it names — is bound into the referring template's
switch { // fields, and the rest reads into it the way a sibling path does, so a reference
case strings.HasPrefix(name, "/"): // is held like a sibling. It runs once, after all data is merged, so a reference
sigil, rest = "/", name[1:] // sees the final (override-resolved) tree. A path that is unknown, names a folder,
case strings.HasPrefix(name, ".."): // or reads a field not every variant carries fails here, keeping a bad reference a
sigil, rest = "..", name[2:] // New-time error, never a random render-time one.
default:
sigil, rest = ".", name[1:]
}
if rest == "" {
return "", "", fmt.Errorf("reference has no path")
}
if strings.HasPrefix(rest, "/") {
return "", "", fmt.Errorf("the path after %s starts at a name, not a /; write {%s%s}", sigil, sigil, rest[1:])
}
if strings.HasPrefix(rest, ".") {
return "", "", fmt.Errorf("a reference starts with / (the root), . (this folder) or .. (the folder above)")
}
for _, seg := range strings.Split(rest, ".") {
if seg == "" {
return "", "", fmt.Errorf("path has an empty segment")
}
}
return sigil, rest, nil
}
// refSegments resolves a reference written in folder to a path from the root.
func refSegments(name string, folder []string) ([]string, error) {
sigil, rest, err := refShape(name)
if err != nil {
return nil, err
}
var base []string
switch sigil {
case ".":
base = folder
case "..":
if len(folder) == 0 {
return nil, fmt.Errorf("no folder above the root")
}
base = folder[:len(folder)-1]
}
return append(append([]string{}, base...), strings.Split(rest, ".")...), nil
}
// linkRefs resolves every reference in the assembled tree. The head of the path —
// up to the category it names — is bound into the referring template's fields
// under its root path, and the rest reads into it the way a sibling path does, so
// a reference is held like a sibling and two spellings of one target are one
// draw. 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 { func linkRefs(root map[string]node) error {
return eachTemplate(root, func(folder []string, path string, t *template) error { return walkNodes(root, func(path string, n node) error {
return linkTemplateRefs(folder, path, t, root) t, ok := n.(*template)
}) if !ok {
} return nil
}
// linkTemplateRefs binds one template's references against root. A template with
// none is left untouched, so an inline format that references nothing costs only
// the refTokens scan.
func linkTemplateRefs(folder []string, path string, t *template, root map[string]node) error {
names := refTokens(t.format) names := refTokens(t.format)
if len(names) == 0 { if len(names) == 0 {
return nil return nil
@@ -87,69 +33,270 @@ func linkTemplateRefs(folder []string, path string, t *template, root map[string
if t.fields == nil { if t.fields == nil {
t.fields = map[string]node{} t.fields = map[string]node{}
} }
t.refs = make(map[string]refBinding, len(names)) t.refs = make(map[string]string, len(names))
for _, name := range names { for _, name := range names {
segments, err := refSegments(name, folder) head, target, tail, err := resolveRef(root, strings.Split(name[len(refPrefix):], "."))
if err != nil { if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err) return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
} }
head, target, tail, err := resolveRef(root, segments) key := refPrefix + strings.Join(head, ".")
if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
}
key := "/" + strings.Join(head, ".")
if err := checkPath(target, tail, key); err != nil { if err := checkPath(target, tail, key); err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err) return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
} }
t.fields[key] = target t.fields[key] = target
t.refs[name] = refBinding{key, tail} t.refs[name] = key
} }
if err := t.compileFormat(); err != nil { if err := t.compileFormat(); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
return nil return nil
})
} }
// eachTemplate calls fn once per template, with the folder its category sits in // checkBoundLevelsHeld rejects every route to a held name except the ones that read
// and the dot path reaching it, folders and names in sorted order. // its draw. An expansion holds one draw of that name; anything else that renders it
func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error { // draws again, and the two disagree. checkNoOverlap settles the spellings within one
var inCategory func(folder []string, path string, n node) error // format (a token, an operand); this settles the rest — a reference, whether it
inCategory = func(folder []string, path string, n node) error { // sits in that format or in anything the format renders, however deep.
if t, ok := n.(*template); ok { //
if err := fn(folder, path, t); err != nil { // It runs after checkNoCycles, whose guarantee is what lets the walk terminate.
return err func checkBoundLevelsHeld(root map[string]node) error {
return walkNodes(root, func(path string, n node) error {
t, ok := n.(*template)
if !ok || len(t.held) == 0 {
return nil
}
heads := make([]string, 0, len(t.held))
for head := range t.held {
heads = append(heads, head)
}
// 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 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 {
for _, r := range readers {
if a := splitArm(r.name, t.refs); a.key == head {
coverPath(t.fields[head], a.tail, held)
} }
} }
for _, c := range contained(n) { } else {
if err := inCategory(folder, join(path, c.name), c.node); err != nil { operandDraw(t.fields[head], held)
return err }
if len(held) == 0 {
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, 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 {%s()} also reads; reach it one way so it is drawn once", path, e.reached(), head, operandReader(t, head))
}
} }
} }
return nil return nil
})
}
// 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
} }
var inFolder func(folder []string, children map[string]node) error if name, _, isFunc := funcCall(tok.body); isFunc {
inFolder = func(folder []string, children map[string]node) error { for _, operand := range tokenOperands(tok.body) {
for _, name := range sortedNames(children) { if splitArm(operand, t.refs).key == head {
path := join(strings.Join(folder, "."), name) fn = name
if g, ok := children[name].(*group); ok {
if err := inFolder(append(folder[:len(folder):len(folder)], name), g.children); err != nil {
return err
} }
}
}
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
}
t, ok := n.(*template)
if !ok {
return
}
if child, ok := t.fields[tail[0]]; ok {
coverPath(child, tail[1:], into)
}
}
// 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.
func operandDraw(n node, into map[node]bool) {
if isFixed(n) {
return
}
if into[n] {
return
}
into[n] = true
for _, e := range renderEdges(n) {
if isRef(e.label) {
continue continue
} }
if err := inCategory(folder, path, children[name]); err != nil { operandDraw(e.to, into)
}
}
// 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
// ends.
func renders(n node, want, seen map[node]bool) bool {
if want[n] {
return true
}
if seen[n] {
return false
}
seen[n] = true
for _, e := range renderEdges(n) {
if renders(e.to, want, seen) {
return true
}
}
return false
}
// walkNodes calls fn once per contained node, passing the dot path that reaches it,
// visiting keys in sorted order so which of several broken nodes gets reported does
// not depend on map iteration.
func walkNodes(root map[string]node, fn func(path string, n node) error) error {
seen := map[node]bool{}
var visit func(string, node) error
visit = func(path string, n node) error {
if n == nil || seen[n] {
return nil
}
seen[n] = true
if err := fn(path, n); err != nil {
return err
}
for _, c := range contained(n) {
if err := visit(join(path, c.name), c.node); err != nil {
return err return err
} }
} }
return nil return nil
} }
return inFolder(nil, root) for _, name := range sortedNames(root) {
if err := visit(name, root[name]); err != nil {
return err
}
}
return nil
}
// namedNode is a contained child and the segment reaching it; a choice's items carry
// no segment, matching how a dot path steps over a choice.
type namedNode struct {
name string
node node
}
func contained(n node) []namedNode {
switch n := n.(type) {
case *group:
return named(n.children)
case *choice:
out := make([]namedNode, len(n.items))
for i, it := range n.items {
out[i] = namedNode{node: it}
}
return out
case *template:
return named(n.fields)
default:
return nil
}
}
// named skips a bound {..path} key: it is a render edge, not containment, so using
// it as a path segment would report a node under a path that does not reach it. Only
// a template's fields hold bindings — loadDir skips a dot-prefixed entry, so a
// group's children never carry the prefix — so this one skip serves both.
func named(m map[string]node) []namedNode {
out := make([]namedNode, 0, len(m))
for _, name := range sortedNames(m) {
if isRef(name) {
continue
}
out = append(out, namedNode{name: name, node: m[name]})
}
return out
}
func sortedNames(m map[string]node) []string {
names := make([]string, 0, len(m))
for name := range m {
names = append(names, name)
}
sort.Strings(names)
return names
} }
// resolveRef walks a reference path through the folders to the category it names, // 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. A descent of // returning that head, the node, and the tail left to read into it.
// its own rather than a walkPath: it walks groups only and returns where they end,
// not a leaf.
func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) {
var n node = &group{children: root} var n node = &group{children: root}
i := 0 i := 0
@@ -170,7 +317,7 @@ func resolveRef(root map[string]node, segments []string) (head []string, target
return segments[:i], n, segments[i:], nil return segments[:i], n, segments[i:], nil
} }
// refTokens returns the reference names a format reads, as tokens or as operands. // refTokens returns the {..path} names a format reads, as tokens or as operands.
func refTokens(format string) []string { func refTokens(format string) []string {
var refs []string var refs []string
seen := map[string]bool{} seen := map[string]bool{}
@@ -182,3 +329,118 @@ func refTokens(format string) []string {
} }
return refs return refs
} }
// 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 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 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("%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 operands. A
// group renders nothing, so it has no edges.
func renderEdges(n node) []renderEdge {
switch n := n.(type) {
case *choice:
es := make([]renderEdge, len(n.items))
for i, it := range n.items {
es[i] = renderEdge{to: it, label: fmt.Sprintf("[%d]", i)}
}
return es
case *template:
var es []renderEdge
add := func(name, operand string) {
a := splitArm(name, n.refs)
c, ok := n.fields[a.key]
if !ok {
return
}
for _, leaf := range pathLeaves(c, a.tail) {
es = append(es, renderEdge{leaf, name, operand})
}
}
_ = eachToken(n.format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
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
}
}
// pathLeaves lists what a token's dotted tail renders. A path draws the levels it
// passes through but renders only what it lands on, so the leaf is the edge — a
// bare token, whose tail is empty, lands on the field itself. A choice on the way
// contributes every variant, since any of them may be the one drawn. checkPath has
// already proved the tail resolves in every variant, so the walk drops nothing.
func pathLeaves(n node, tail []string) []node {
if len(tail) == 0 {
return []node{n}
}
if c, ok := n.(*choice); ok {
var out []node
for _, it := range c.items {
out = append(out, pathLeaves(it, tail)...)
}
return out
}
return pathLeaves(child(n, tail[0]), tail[1:])
}
// checkNoCycles rejects a reference cycle: a node whose rendering can reach itself
// — directly, mutually, or through a chain — never terminates, so it must fail at
// New rather than stack-overflow at render. It is a depth-first walk of the render
// graph (renderEdges); grey marks nodes on the current path so a back-edge to one
// is the cycle, while black lets a shared node (a DAG, not a cycle) be skipped.
// Every node is a root: a field its parent's format never renders is still reachable
// by dot path, so a cycle in one would otherwise reach render and be fatal there.
func checkNoCycles(root map[string]node) error {
const (
grey = 1
black = 2
)
color := map[node]int{}
var visit func(n node, path string) error
visit = func(n node, path string) error {
switch color[n] {
case grey:
return fmt.Errorf("reference cycle: %s", path)
case black:
return nil
}
color[n] = grey
for _, e := range renderEdges(n) {
if err := visit(e.to, path+" -> "+e.label); err != nil {
return err
}
}
color[n] = black
return nil
}
return walkNodes(root, func(path string, n node) error { return visit(n, path) })
}
+39 -120
View File
@@ -6,11 +6,11 @@ import (
) )
// TestRootReferenceAcrossFolders is the headline case: a category in one folder // TestRootReferenceAcrossFolders is the headline case: a category in one folder
// pulls a value from another via a {/path} reference resolved from the data root. // pulls a value from another via a {..path} reference resolved from the data root.
func TestRootReferenceAcrossFolders(t *testing.T) { func TestRootReferenceAcrossFolders(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"en_US/person": `"Pat Smith"`, "en_US/person": `"Pat Smith"`,
"sv_SE/greeting": `"Hej, {/en_US.person}!"`, "sv_SE/greeting": `"Hej, {..en_US.person}!"`,
}) })
f := newGenerator(t, dir, WithSeed(1)) f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" { if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" {
@@ -22,7 +22,7 @@ func TestRootReferenceAcrossFolders(t *testing.T) {
func TestReferenceIntoAField(t *testing.T) { func TestReferenceIntoAField(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`, "who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`,
"card": `"signed {/who.last}"`, "card": `"signed {..who.last}"`,
}) })
f := newGenerator(t, dir, WithSeed(1)) f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "card"); got != "signed Byron" { if got := fake(t, f, "card"); got != "signed Byron" {
@@ -30,12 +30,12 @@ func TestReferenceIntoAField(t *testing.T) {
} }
} }
// TestReferenceInAlternation lets a reference stand as one arm of a {a|/b} // TestReferenceInAlternation lets a reference stand as one arm of a {a|..b}
// alternation, so a field and a cross-file value share one slot. // alternation, so a field and a cross-file value share one slot.
func TestReferenceInAlternation(t *testing.T) { func TestReferenceInAlternation(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"far": `"X"`, "far": `"X"`,
"near": `{"format":"{here|/far}","here":"H"}`, "near": `{"format":"{here|..far}","here":"H"}`,
}) })
f := newGenerator(t, dir, WithSeed(2)) f := newGenerator(t, dir, WithSeed(2))
seen := map[string]bool{} seen := map[string]bool{}
@@ -51,7 +51,7 @@ func TestReferenceInAlternation(t *testing.T) {
// model: data layered from two dirs can point at each other through the root. // model: data layered from two dirs can point at each other through the root.
func TestReferenceCombinesLoadedPaths(t *testing.T) { func TestReferenceCombinesLoadedPaths(t *testing.T) {
a := writeData(t, map[string]string{"en_US/word": `"river"`}) a := writeData(t, map[string]string{"en_US/word": `"river"`})
b := writeData(t, map[string]string{"mine/slug": `"the-{/en_US.word}"`}) b := writeData(t, map[string]string{"mine/slug": `"the-{..en_US.word}"`})
f := newGeneratorN(t, []string{a, b}, WithSeed(1)) f := newGeneratorN(t, []string{a, b}, WithSeed(1))
if got := fake(t, f, "mine.slug"); got != "the-river" { if got := fake(t, f, "mine.slug"); got != "the-river" {
t.Fatalf("slug = %q, want the-river", got) t.Fatalf("slug = %q, want the-river", got)
@@ -62,8 +62,8 @@ func TestReferenceCombinesLoadedPaths(t *testing.T) {
// linking order cannot matter. // linking order cannot matter.
func TestReferenceChain(t *testing.T) { func TestReferenceChain(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"a": `"{/b}"`, "a": `"{..b}"`,
"b": `"{/c}"`, "b": `"{..c}"`,
"c": `"deep"`, "c": `"deep"`,
}) })
f := newGenerator(t, dir, WithSeed(1)) f := newGenerator(t, dir, WithSeed(1))
@@ -76,37 +76,37 @@ func TestReferenceChain(t *testing.T) {
// fails at load, never at a random render. // fails at load, never at a random render.
func TestReferenceErrors(t *testing.T) { func TestReferenceErrors(t *testing.T) {
cases := map[string]map[string]string{ cases := map[string]map[string]string{
"missing target": {"card": `"{/nope.gone}"`}, "missing target": {"card": `"{..nope.gone}"`},
"folder target": {"en_US/word": `"w"`, "card": `"{/en_US}"`}, "folder target": {"en_US/word": `"w"`, "card": `"{..en_US}"`},
"a variant on the path lacks the field": { "a variant on the path lacks the field": {
"who": `[{"format":"{f}","f":"1"},{"format":"{g}","g":"2"}]`, "who": `[{"format":"{f}","f":"1"},{"format":"{g}","g":"2"}]`,
"card": `"{/who.f}"`, "card": `"{..who.f}"`,
}, },
"empty reference path": {"card": `"{/}"`}, "empty reference path": {"card": `"{..}"`},
// A reference that leads back to its own value never terminates at render, // 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). // so New must reject the cycle up front (direct, mutual, or chained).
"direct cycle": {"a": `"x{/a}"`}, "direct cycle": {"a": `"x{..a}"`},
"mutual cycle": {"a": `"{/b}"`, "b": `"{/a}"`}, "mutual cycle": {"a": `"{..b}"`, "b": `"{..a}"`},
"chain cycle": {"a": `"{/b}"`, "b": `"{/c}"`, "c": `"{/a}"`}, "chain cycle": {"a": `"{..b}"`, "b": `"{..c}"`, "c": `"{..a}"`},
// calc renders its operands, so a cycle through one must be caught too. // calc renders its operands, so a cycle through one must be caught too.
"calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{/x}"}`}, "calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{..x}"}`},
// A field its parent's format never renders is still reachable by dot path, // 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. // so a cycle hiding in one must fail at New rather than at render.
"cycle in an unrendered field": {"cat": `{"format":"hi","x":"{/cat.x}"}`}, "cycle in an unrendered field": {"cat": `{"format":"hi","x":"{..cat.x}"}`},
"mutual cycle between unrendered fields": { "mutual cycle between unrendered fields": {
"cat": `{"format":"hi","x":"{/cat.y}","y":"{/cat.x}"}`, "cat": `{"format":"hi","x":"{..cat.y}","y":"{..cat.x}"}`,
}, },
"cycle in an unrendered field of a choice arm": { "cycle in an unrendered field of a choice arm": {
"cat": `{"format":"hi","x":"{/cat.x}"}`, "cat": `{"format":"hi","x":"{..cat.x}"}`,
}, },
// The shipped layout puts categories in folders, so a cycle one level down // The shipped layout puts categories in folders, so a cycle one level down
// is the common case, not an edge case. // is the common case, not an edge case.
"cycle in a subfolder": {"sv_SE/a": `"x{/sv_SE.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 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}"`}, "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 // ".." is reserved for bound references, so an authored key using it would
// name a node nothing can reach and nothing would validate. // name a node nothing can reach and nothing would validate.
"field key using the reference prefix": {"cat": `{"format":"hi","..x":"{/nope}"}`}, "field key using the reference prefix": {"cat": `{"format":"hi","..x":"{..nope}"}`},
} }
for name, files := range cases { for name, files := range cases {
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil { if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil {
@@ -122,8 +122,8 @@ func TestReferenceErrors(t *testing.T) {
func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) { func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) {
f := newGenerator(t, writeData(t, map[string]string{ f := newGenerator(t, writeData(t, map[string]string{
"sv_SE/ok": `"fine"`, "sv_SE/ok": `"fine"`,
"sv_SE/..bad": `"{/nope}"`, "sv_SE/..bad": `"{..nope}"`,
"sv_SE/..y/ct": `"{/nope}"`, "sv_SE/..y/ct": `"{..nope}"`,
".git/config": `"not data"`, ".git/config": `"not data"`,
}), WithSeed(1)) }), WithSeed(1))
if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" { if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" {
@@ -135,7 +135,7 @@ func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) {
// over-rejecting: a field the format never renders may point back at its own // over-rejecting: a field the format never renders may point back at its own
// category, which terminates, and stays renderable by path. // category, which terminates, and stays renderable by path.
func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) { func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) {
dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {/cat}"}`}) dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {..cat}"}`})
f := newGenerator(t, dir, WithSeed(1)) f := newGenerator(t, dir, WithSeed(1))
if got := fake(t, f, "cat"); got != "hi" { if got := fake(t, f, "cat"); got != "hi" {
t.Fatalf("cat = %q, want hi", got) t.Fatalf("cat = %q, want hi", got)
@@ -151,9 +151,9 @@ func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) {
func TestNewErrorIsDeterministic(t *testing.T) { func TestNewErrorIsDeterministic(t *testing.T) {
cases := map[string]map[string]string{ cases := map[string]map[string]string{
"three bad references": { "three bad references": {
"a": `"{/nope.one}"`, "a": `"{..nope.one}"`,
"b": `"{/nope.two}"`, "b": `"{..nope.two}"`,
"c": `"{/nope.three}"`, "c": `"{..nope.three}"`,
}, },
"two bad fields in one template": { "two bad fields in one template": {
"cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`, "cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`,
@@ -180,7 +180,7 @@ func TestNewErrorIsDeterministic(t *testing.T) {
} }
// TestNewErrorPathIsCanonical pins the node path a load error names: a choice arm // TestNewErrorPathIsCanonical pins the node path a load error names: a choice arm
// adds no segment, and a bound {/path} reference is not a containment segment at // adds no segment, and a bound {..path} reference is not a containment segment at
// all, so a bad reference is reported against the node that holds it. // all, so a bad reference is reported against the node that holds it.
func TestNewErrorPathIsCanonical(t *testing.T) { func TestNewErrorPathIsCanonical(t *testing.T) {
cases := []struct { cases := []struct {
@@ -190,13 +190,13 @@ func TestNewErrorPathIsCanonical(t *testing.T) {
}{ }{
{ {
"cycle inside a choice arm", "cycle inside a choice arm",
map[string]string{"cat": `{"format":"hi","x":"{/cat.x}"}`}, map[string]string{"cat": `{"format":"hi","x":"{..cat.x}"}`},
"fejkdata: reference cycle: cat.x -> /cat.x", "fejkdata: reference cycle: cat.x -> ..cat.x",
}, },
{ {
"bad reference reached through another reference", "bad reference reached through another reference",
map[string]string{"a": `"{/b}"`, "b": `"{/nope}"`}, map[string]string{"a": `"{..b}"`, "b": `"{..nope}"`},
`fejkdata: b: reference {/nope}: no entry "nope"`, `fejkdata: b: reference {..nope}: no entry "nope"`,
}, },
} }
for _, c := range cases { for _, c := range cases {
@@ -214,7 +214,7 @@ func TestNewErrorPathIsCanonical(t *testing.T) {
func TestReferencePathIsHeld(t *testing.T) { func TestReferencePathIsHeld(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`, "person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
"card": `"{/person.first} {/person.last}"`, "card": `"{..person.first} {..person.last}"`,
}) })
f := newGenerator(t, dir, WithSeed(3)) f := newGenerator(t, dir, WithSeed(3))
seen := map[string]bool{} seen := map[string]bool{}
@@ -233,7 +233,7 @@ func TestReferencePathIsHeld(t *testing.T) {
func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) { func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"who": `[{"format":"{f}{h}","f":"1","h":"x"},{"format":"{g}{h}","g":"2","h":"y"}]`, "who": `[{"format":"{f}{h}","f":"1","h":"x"},{"format":"{g}{h}","g":"2","h":"y"}]`,
"card": `"{/who.f}"`, "card": `"{..who.f}"`,
}))) })))
if err == nil || !strings.Contains(err.Error(), "not every variant") { if err == nil || !strings.Contains(err.Error(), "not every variant") {
t.Fatalf("New = %v, want the missing variant named", err) t.Fatalf("New = %v, want the missing variant named", err)
@@ -243,7 +243,7 @@ func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) {
func TestBareReferenceDrawsEachTime(t *testing.T) { func TestBareReferenceDrawsEachTime(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"die": `["1","2","3","4","5","6"]`, "die": `["1","2","3","4","5","6"]`,
"roll": `"{/die} {/die}"`, "roll": `"{..die} {..die}"`,
}) })
f := newGenerator(t, dir, WithSeed(1)) f := newGenerator(t, dir, WithSeed(1))
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
@@ -256,8 +256,8 @@ func TestBareReferenceDrawsEachTime(t *testing.T) {
func TestReferenceOverlapIsRejected(t *testing.T) { func TestReferenceOverlapIsRejected(t *testing.T) {
for name, file := range map[string]string{ 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"}]}`, "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"}]}`, "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}))) _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
if err == nil || !strings.Contains(err.Error(), "reads a path into") { if err == nil || !strings.Contains(err.Error(), "reads a path into") {
@@ -265,84 +265,3 @@ func TestReferenceOverlapIsRejected(t *testing.T) {
} }
} }
} }
func TestRelativeReferences(t *testing.T) {
dir := writeData(t, map[string]string{
"sv_SE/username": `"bob"`,
"sv_SE/email": `"{.username}@example.com"`,
"sv_SE/deep/card": `"{..username} via {/sv_SE.username}"`,
"top": `"{.sv_SE.username}"`,
})
f := newGenerator(t, dir, WithSeed(1))
for path, want := range map[string]string{
"sv_SE.email": "bob@example.com",
"sv_SE.deep.card": "bob via bob",
"top": "bob",
} {
if got := fake(t, f, path); got != want {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
}
func TestRelativeAndRootSpellingsBindOneDraw(t *testing.T) {
dir := writeData(t, map[string]string{
"sv_SE/person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
"sv_SE/card": `"{.person.first} {/sv_SE.person.last}"`,
})
f := newGenerator(t, dir, WithSeed(3))
for i := 0; i < 50; i++ {
if got := fake(t, f, "sv_SE.card"); got != "Anna Andersson" && got != "Bo Berg" {
t.Fatalf("card = %q, want both spellings to read one person", got)
}
}
}
func TestReferenceSigilErrors(t *testing.T) {
for name, files := range map[string]map[string]string{
"no folder above the root": {"a": `"{..b}"`, "b": `"x"`},
"three dots": {"a": `"{...b}"`, "b": `"x"`},
"root sigil alone": {"a": `"{/}"`},
"dot alone": {"a": `"{.}"`},
"missing sibling": {"sv_SE/a": `"{.nope}"`},
"slash in a field name": {"a": `{"format":"{x}","x":"1","a/b":"2"}`},
} {
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil {
t.Errorf("%s: New = nil error, want a reference error", name)
}
}
}
func TestSpellingsOfOneReferenceAreOneLevel(t *testing.T) {
person := `[{"format":"{first} {last}","first":"Ada","last":"Byron"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
"sv_SE/person": person,
"sv_SE/mail": `"{.person} <{/sv_SE.person.first}>"`,
})))
if err == nil || !strings.Contains(err.Error(), "reads a path into") || !strings.Contains(err.Error(), "{.person}") {
t.Errorf("New = %v, want the bare spelling rejected beside the path spelling", err)
}
dir := writeData(t, map[string]string{
"sv_SE/word": `["alpha","beta","gamma"]`,
"sv_SE/loud": `"{/sv_SE.word} {uppercase(.word)}"`,
})
f := newGenerator(t, dir, WithSeed(2))
for i := 0; i < 50; i++ {
got := strings.Fields(fake(t, f, "sv_SE.loud"))
if len(got) != 2 || strings.ToUpper(got[0]) != got[1] {
t.Fatalf("loud = %q, want one draw under both spellings", got)
}
}
}
func TestSlashAfterAReferenceSigilIsRejected(t *testing.T) {
for name, files := range map[string]map[string]string{
"after ..": {"sv_SE/person": `"Ada"`, "sv_SE/deep/a": `"{../person}"`},
"after .": {"sv_SE/person": `"Ada"`, "sv_SE/a": `"{./person}"`},
} {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, files)))
if err == nil || !strings.Contains(err.Error(), "person}") || !strings.Contains(err.Error(), "write {") {
t.Errorf("%s: New = %v, want the slash rejected naming the spelling", name, err)
}
}
}
+114 -13
View File
@@ -33,20 +33,48 @@ func (f *Generator) Fake(path string) (string, error) {
// descend walks named fields to the node a path names. It is the one render-side // descend walks named fields to the node a path names. It is the one render-side
// step that can fail, because the path comes from the caller and may name a field // step that can fail, because the path comes from the caller and may name a field
// that does not exist. A choice consumes no segment, so the rest of the path must // that does not exist. A choice consumes no segment, so the rest of the path must
// be one every variant carries before a variant is picked — a path that resolves // be one every variant carries (the set compile stored) before a variant is picked
// at all resolves on every call. // — a path that resolves at all resolves on every call.
func descend(s *session, root node, segments []string) (node, error) { func descend(s *session, n node, segments []string) (node, error) {
var found node if len(segments) == 0 {
err := walkPath(root, segments, pathWalk{ return n, nil
choice: func(c *choice, rest []string) ([]node, error) {
if err := carriedByAll(c, rest); err != nil {
return nil, err
} }
return []node{pick(s, c)}, nil switch n := n.(type) {
}, case *group:
leaf: func(n node) error { found = n; return nil }, child, ok := n.children[segments[0]]
}) if !ok {
return found, err return nil, fmt.Errorf("no entry %q", segments[0])
}
return descend(s, child, segments[1:])
case *template:
child, ok := n.fields[segments[0]]
if !ok {
return nil, fmt.Errorf("no field %q", segments[0])
}
return descend(s, child, segments[1:])
case *choice:
if want := strings.Join(segments, "."); !n.shared[want] {
return nil, unreachableInChoice(n, want)
}
return descend(s, pick(s, n), segments)
default:
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0])
}
}
// unreachableInChoice reports that a path cannot step through this choice, listing
// what every variant does carry. It reads the precomputed set, so a failing path
// costs no more than a rendering one.
func unreachableInChoice(c *choice, want string) error {
if len(c.shared) == 0 {
return fmt.Errorf("no variant of this %d-way choice carries %q", len(c.items), want)
}
offered := make([]string, 0, len(c.shared))
for p := range c.shared {
offered = append(offered, p)
}
sort.Strings(offered)
return fmt.Errorf("not every variant of this %d-way choice carries %q; all carry %v", len(c.items), want, offered)
} }
// render evaluates a compiled node to a string. compile validates every node up // render evaluates a compiled node to a string. compile validates every node up
@@ -124,3 +152,76 @@ func expand(s *session, t *template) string {
} }
return b.String() return b.String()
} }
// draws is what an expansion has already drawn for its held names: the variant each
// was drawn as, so every path under it reads one row, and the value each read, so
// the same name read twice reads one value.
type draws struct {
variant map[string]node
value map[string]string
}
// readField renders one arm of a token. An arm's key is a sibling field or a
// {..path} reference, which linkRefs bound into fields too. A name the expansion
// holds — a level some token addresses by dotted path, or a sibling a {calc()}
// reads — is drawn once and kept, so {place.postal-code} and {place.locality} read
// one row, either read twice gives one value, and a shown operand is the operand
// computed. Every other name is drawn afresh, so {word} {word} still draws twice.
// checkTokens, checkPath and linkRefs prove every step, so this cannot fail.
func readField(s *session, t *template, held *draws, a arm) string {
if !t.held[a.key] {
return render(s, t.fields[a.key])
}
if v, read := held.value[a.name]; read {
return v
}
n, drew := held.variant[a.key]
if !drew {
n = drawn(s, t.fields[a.key])
held.variant[a.key] = n
}
// Hold the draw at every level passed through, so two paths sharing a prefix
// share it.
for i, seg := range a.tail {
if i < len(a.steps) {
step, drew := held.variant[a.steps[i]]
if !drew {
step = drawn(s, child(n, seg))
held.variant[a.steps[i]] = step
}
n = step
continue
}
n = child(n, seg)
}
v := render(s, n)
held.value[a.name] = v
return v
}
// child is the node one path segment names below an already-drawn node. It holds
// while checkPath and the set a choice shares (see sharedPaths) agree with this
// walk: both prove the segment exists and that drawn leaves a template here. Each
// way that can break panics naming the segment, so a slip in that agreement
// reports where it happened rather than surfacing a nil node a level later.
func child(n node, seg string) node {
t, ok := n.(*template)
if !ok {
panic(fmt.Sprintf("fejkdata: %q under %T, which carries no fields", seg, n))
}
c, ok := t.fields[seg]
if !ok {
panic(fmt.Sprintf("fejkdata: no field %q under a drawn level", seg))
}
return c
}
// drawn resolves a choice to one variant, so a bound head is a concrete node the
// rest of the expansion shares. Nested choices unwrap too: a draw is one value, not
// another set to pick from.
func drawn(s *session, n node) node {
for c, ok := n.(*choice); ok; c, ok = n.(*choice) {
n = pick(s, c)
}
return n
}
+1 -7
View File
@@ -44,7 +44,7 @@ func TestWithDataPathLayersOverShipped(t *testing.T) {
} }
func TestUserDataMayReferenceShipped(t *testing.T) { func TestUserDataMayReferenceShipped(t *testing.T) {
dir := writeData(t, map[string]string{"greeting": `"Hej {/sv_SE.person}!"`}) dir := writeData(t, map[string]string{"greeting": `"Hej {..sv_SE.person}!"`})
f, err := New(WithDataPath(dir), WithSeed(1)) f, err := New(WithDataPath(dir), WithSeed(1))
if err != nil { if err != nil {
t.Fatalf("New = %v", err) t.Fatalf("New = %v", err)
@@ -108,12 +108,6 @@ func TestFakeIsSafeForConcurrentUse(t *testing.T) {
return return
} }
f.List() f.List()
tmpl, err := f.NewTemplate("{/sv_SE.person.last}")
if err != nil {
t.Error(err)
return
}
tmpl.Fake()
} }
}() }()
} }
+105 -55
View File
@@ -2,6 +2,7 @@ package fejkdata
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
) )
@@ -15,8 +16,7 @@ type ftoken struct {
// eachToken scans a format string once and calls fn for each unit, the single // eachToken scans a format string once and calls fn for each unit, the single
// source of truth for how braces are read: "{{" and "}}" are literal braces, a "{" // 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. A table-shaped // opens a token that must reach its "}", and a lone "}" is an error.
// scanner, one case per rune kind, kept whole on purpose.
func eachToken(format string, fn func(ftoken) error) error { func eachToken(format string, fn func(ftoken) error) error {
rs := []rune(format) rs := []rune(format)
for i := 0; i < len(rs); i++ { for i := 0; i < len(rs); i++ {
@@ -135,12 +135,12 @@ func checkTokens(format string, fields map[string]node) error {
names := strings.Split(t.body, "|") names := strings.Split(t.body, "|")
for _, name := range names { for _, name := range names {
if isRef(name) { if isRef(name) {
if _, _, err := refShape(name); err != nil { if name == refPrefix {
return fmt.Errorf("token {%s}: %w", t.body, err) return fmt.Errorf("token {%s}: reference has no path", t.body)
} }
continue // its target is checked at New (see linkRefs) continue // a root reference; its target is checked at New (see linkRefs)
} }
if err := checkArm(name, fields, len(names) == 1); err != nil { if err := checkArm(name, fields); err != nil {
return fmt.Errorf("token {%s}: %w", t.body, err) return fmt.Errorf("token {%s}: %w", t.body, err)
} }
} }
@@ -151,9 +151,7 @@ func checkTokens(format string, fields map[string]node) error {
} }
// checkArm validates one sibling name or path against a template's fields. // checkArm validates one sibling name or path against a template's fields.
// wholeToken says the name is the token's entire body, so {/name} would render func checkArm(name string, fields map[string]node) error {
// the same value and can be offered as the reference spelling.
func checkArm(name string, fields map[string]node, wholeToken bool) error {
a := splitArm(name, nil) a := splitArm(name, nil)
if err := checkSegments(a); err != nil { if err := checkSegments(a); err != nil {
return err return err
@@ -166,13 +164,6 @@ func checkArm(name string, fields map[string]node, wholeToken bool) error {
if isOption(a.key) { if isOption(a.key) {
return fmt.Errorf("%q is an option and can never be a field", a.key) return fmt.Errorf("%q is an option and can never be a field", a.key)
} }
if len(fields) == 0 {
hint := ""
if wholeToken && hintableRef(name) {
hint = fmt.Sprintf(" — write {/%s} to reference the data", name)
}
return fmt.Errorf("no field %q; a token names a sibling field, and this template has none%s", a.key, hint)
}
return fmt.Errorf("no field %q", a.key) return fmt.Errorf("no field %q", a.key)
} }
if err := checkPath(head, a.tail, a.key); err != nil { if err := checkPath(head, a.tail, a.key); err != nil {
@@ -181,17 +172,6 @@ func checkArm(name string, fields map[string]node, wholeToken bool) error {
return nil return nil
} }
// hintableRef reports whether {/name} is a reference the grammar accepts, so the
// hint never names a spelling that fails too.
func hintableRef(name string) bool {
for _, seg := range strings.Split(name, ".") {
if checkName(seg) != nil {
return false
}
}
return true
}
// tokenOperands lists the fields one {token} body reads as operands, empty for a // tokenOperands lists the fields one {token} body reads as operands, empty for a
// field token or a builtin that reads none. // field token or a builtin that reads none.
func tokenOperands(body string) []string { func tokenOperands(body string) []string {
@@ -250,35 +230,30 @@ func fieldTokens(format string) []string {
} }
// arm is one alternative of a {a|b} token or one operand, split into the key // 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 head a // 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 // 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 // tail is what makes the arm a bound draw: its head is drawn once per expansion
// (see compileOps). // (see compileOps).
type arm struct { type arm struct {
name string // as written, for messages name string // as written, and the key a bound draw's value is held under
key string key string
tail []string tail []string
steps []string // key per level passed through; the head and leaf hold their own steps []string // key per level passed through; the head and leaf hold their own
path string // key and tail, the one spelling every way of writing this read shares
} }
// splitArm splits one name into key and tail. refs maps a reference to what // splitArm splits one name into key and tail. refs maps a reference to the head
// linkRefs bound it to; before linking, a reference is whole. // linkRefs bound it under; before linking, a reference is whole.
func splitArm(name string, refs map[string]refBinding) arm { func splitArm(name string, refs map[string]string) arm {
if isRef(name) { if isRef(name) {
b, bound := refs[name] key, bound := refs[name]
if !bound || len(b.tail) == 0 { if !bound || key == name {
key := name return arm{name: name, key: name}
if bound {
key = b.key
} }
return arm{name: name, key: key, path: key} return pathArm(name, key, strings.Split(name[len(key)+1:], "."))
}
return pathArm(name, b.key, b.tail)
} }
head, tail, dotted := strings.Cut(name, ".") head, tail, dotted := strings.Cut(name, ".")
if !dotted { if !dotted {
return arm{name: name, key: name, path: name} return arm{name: name, key: name}
} }
return pathArm(name, head, strings.Split(tail, ".")) return pathArm(name, head, strings.Split(tail, "."))
} }
@@ -288,21 +263,62 @@ func pathArm(name, key string, segs []string) arm {
for i := 0; i < len(segs)-1; i++ { // every level except the leaf's own for i := 0; i < len(segs)-1; i++ { // every level except the leaf's own
steps = append(steps, key+"."+strings.Join(segs[:i+1], ".")) steps = append(steps, key+"."+strings.Join(segs[:i+1], "."))
} }
return arm{name: name, key: key, tail: segs, steps: steps, path: key + "." + strings.Join(segs, ".")} return arm{name: name, key: key, tail: segs, steps: steps}
} }
// splitArms splits a token body's '|' alternatives. // checkNoOverlap rejects a format that both renders a level and reads a path into
func splitArms(body string, refs map[string]refBinding) []arm { // it — {p} beside {p.first}, or {p.addr} beside {p.addr.city}. The path reads the
parts := strings.Split(body, "|") // level's held draw while rendering the level expands it afresh, so their values
arms := make([]arm, len(parts)) // would disagree. Names are compared in sorted order, so which pair is reported
for i, p := range parts { // does not depend on where the tokens sit.
arms[i] = splitArm(p, refs) 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 })
for i, level := range names {
for _, path := range names[i+1:] {
if strings.HasPrefix(path.name, level.name+".") {
return fmt.Errorf("%s renders a level that {%s} reads a path into; name the fields you want instead", level.label, path.name)
} }
return arms }
}
return nil
} }
// checkSegments rejects an unfinished path: "{a.}" and "{a..b}" each have a // reader is one way a format reaches a bound field, and how to name that spelling.
// segment naming nothing. A field really named "" would otherwise make them type reader struct{ name, label string }
// boundReaders lists every way a format reaches a bound field, in the order the
// 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, refs map[string]string) []reader {
var names []reader
_ = eachToken(format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
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, refs) {
if _, isBound := bound[a.key]; isBound {
names = append(names, reader{a.name, "token {" + a.name + "}"})
}
}
return nil
})
return names
}
// checkSegments rejects an unfinished path: "{a.}", "{.b}" and "{a..b}" each have
// a segment naming nothing. A field really named "" would otherwise make them
// resolve, so a typo would read as a path that worked. // resolve, so a typo would read as a path that worked.
func checkSegments(a arm) error { func checkSegments(a arm) error {
if len(a.tail) == 0 { if len(a.tail) == 0 {
@@ -319,6 +335,16 @@ func checkSegments(a arm) error {
return nil return nil
} }
// splitArms splits a token body's '|' alternatives.
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, refs)
}
return arms
}
// callFn is a builtin bound to one call site: its args already parsed. It reads the // callFn is a builtin bound to one call site: its args already parsed. It reads the
// output emitted so far in the current expansion (a derivation's payload) and the // output emitted so far in the current expansion (a derivation's payload) and the
// values of the operands it named, which expand read for it. // values of the operands it named, which expand read for it.
@@ -370,7 +396,7 @@ func (c *formatOps) hold(a arm, label string) {
} }
} }
func (c *formatOps) function(body string, refs map[string]refBinding) { func (c *formatOps) function(body string, refs map[string]string) {
name, args, _ := funcCall(body) name, args, _ := funcCall(body)
var operands []arm var operands []arm
for _, operand := range tokenOperands(body) { for _, operand := range tokenOperands(body) {
@@ -381,7 +407,7 @@ func (c *formatOps) function(body string, refs map[string]refBinding) {
c.ops = append(c.ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands}) c.ops = append(c.ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands})
} }
func (c *formatOps) field(body string, refs map[string]refBinding) { func (c *formatOps) field(body string, refs map[string]string) {
arms := splitArms(body, refs) arms := splitArms(body, refs)
for _, a := range arms { for _, a := range arms {
if len(a.tail) > 0 { if len(a.tail) > 0 {
@@ -393,7 +419,7 @@ func (c *formatOps) field(body string, refs map[string]refBinding) {
// compileOps compiles a format string. Call checkTokens first: it is what proves // compileOps compiles a format string. Call checkTokens first: it is what proves
// the scan and every token are valid. // the scan and every token are valid.
func compileOps(format string, refs map[string]refBinding) formatOps { func compileOps(format string, refs map[string]string) formatOps {
var c formatOps var c formatOps
var lit strings.Builder var lit strings.Builder
flush := func() { flush := func() {
@@ -420,3 +446,27 @@ func compileOps(format string, refs map[string]refBinding) formatOps {
flush() flush()
return c return c
} }
// checkNoRepeatedRead rejects a bare token repeated on a held name: {w} {w} beside
// {uppercase(w)} would read one draw twice, where {w} {w} alone draws twice. The
// error names the single-token spelling.
func checkNoRepeatedRead(format string, c formatOps, refs map[string]string) error {
count := map[string]int{}
return eachToken(format, func(t ftoken) error {
if t.kind != 'b' {
return nil
}
if _, _, isFunc := funcCall(t.body); isFunc {
return nil
}
for _, a := range splitArms(t.body, refs) {
if len(a.tail) > 0 || !c.held[a.key] {
continue
}
if count[a.key]++; count[a.key] > 1 {
return fmt.Errorf("token {%s} is repeated, and %s holds %q to one draw per expansion; write {%s} once", a.name, c.holder[a.key], a.key, a.name)
}
}
return nil
})
}
+1 -1
View File
@@ -13,7 +13,7 @@ func TestSeededOutputIsStable(t *testing.T) {
"escapes": `{"format":"01Aa#{x}","x":"!"}`, "escapes": `{"format":"01Aa#{x}","x":"!"}`,
"funcs": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`, "funcs": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`,
"nested": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`, "nested": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`,
"ref": `"see {/alt}"`, "ref": `"see {..alt}"`,
"repeat": `{"format":"{w}","repeat":4,"separator":",","w":["x","y","z"]}`, "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"}`, "sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":"012345678901234","e":"123456789012","m":"12345678"}`,
"weights": `[{"format":"big","weight":9},"tiny"]`, "weights": `[{"format":"big","weight":9},"tiny"]`,
+1 -69
View File
@@ -8,13 +8,7 @@ import (
) )
// engine builds a seeded generator with no loaded categories, for rendering tests. // engine builds a seeded generator with no loaded categories, for rendering tests.
func engine(seed uint64) *Generator { func engine(seed uint64) *Generator { return &Generator{rand: newRand(seed, true)} }
s, err := newRand(seed, true)
if err != nil {
panic(err)
}
return &Generator{rand: s}
}
// parse unmarshals a JSON template fragment into its dynamic form. // parse unmarshals a JSON template fragment into its dynamic form.
func parse(t *testing.T, s string) any { func parse(t *testing.T, s string) any {
@@ -244,18 +238,6 @@ func TestCompileErrors(t *testing.T) {
`"{base64(0)}"`, // count must be positive `"{base64(0)}"`, // count must be positive
`"{float(1,2)}"`, // wrong arity `"{float(1,2)}"`, // wrong arity
`"{float(1,2,-1)}"`, // negative decimals `"{float(1,2,-1)}"`, // negative decimals
`"{float(NaN,NaN,2)}"`, // bounds must be finite
`"{float(Inf,Inf,2)}"`, // same-sign infinities
`"{float(1,NaN,2)}"`, // one NaN bound
`"{float(-Inf,1,2)}"`, // one infinite bound
`"{digits(+5)}"`, // a count is a plain integer
`"{digits(05)}"`, // no leading zero
`"{int(+1,5)}"`, // a bound is a plain integer
`"{int(5,5)}"`, // a constant is written as text
`"{float(1,1,2)}"`, // a constant is written as text
`{"format":"{x}","x":"v","repeat":2,"separator":""}`, // separator "" is the default
`"{calc(1/0)}"`, // a constant zero divisor
`{"format":"{calc(x/y)}","x":"1","y":"0"}`, // a fixed zero divisor
`"{iban(US)}"`, // unsupported country `"{iban(US)}"`, // unsupported country
`"{seq(a,b)}"`, // seq takes at most one name `"{seq(a,b)}"`, // seq takes at most one name
`"{calc()}"`, // calc needs an expression `"{calc()}"`, // calc needs an expression
@@ -329,53 +311,3 @@ func quote(s string) string {
} }
return string(b) return string(b)
} }
func TestHashIsLiteral(t *testing.T) {
f := engine(1)
cases := map[string]string{
`{"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 {
t.Errorf("render(%s) = %q, want %q", tmpl, got, want)
}
}
}
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}-{digits(1)}å","x":"väg"}`)
if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) {
t.Fatalf("multibyte format = %q", got)
}
}
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
}
if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 {
t.Fatalf("3-way alternation produced %v, want A, B and C", seen)
}
}
func TestArgErrorsNameTheSpelling(t *testing.T) {
for src, want := range map[string]string{
`"{float(1,2,02)}"`: "write 2",
`{"format":"{calc(a,02)}","a":"1"}`: "write 2",
`"{digits(+5)}"`: "write 5",
`"{hex(99999999999999999999)}"`: "exceeds the maximum",
`"{int(007,9)}"`: "write 7",
} {
if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("compile(%s) = %v, want an error saying %q", src, err, want)
}
}
}
-28
View File
@@ -1,28 +0,0 @@
# Release checklist
What to settle before the first tag, then the work that follows in a later,
data-heavy release.
## Before the first release — settle the record flag and API contract
- `--format` vocabulary — confirm `text`, `json`, `ndjson`, `csv`, `sql`; the
`json`-as-array vs `ndjson`-as-lines split; `--table` (the SQL INSERT target);
the `--separator` rejection on record formats; and the exit codes (misuse 2,
runtime 1).
- Library surface — confirm `Record`, `FakeRecord`, `NewRecordTemplate`,
`RecordTemplate`, `Column`/`Columns()`, and the `JSON()`, `CSVHeader()`,
`CSVLine()`, `SQLInsert()` serializers.
- Typed scalars — columns are strings today (`"42"`, quoted SQL). Confirm that
stays out of scope, or add a per-column `kind` before the tag.
- Struct-filling — `fake:"..."` tags (reflection over an arbitrary struct) stay
out of scope; `Columns()` hands the caller the values to map themselves.
Confirm.
- Independent reference draw — within one record every tailed reference to a
category is one draw, with no spelling for "these columns should disagree".
Confirm the per-record contract, or add the spelling.
## Later, in a data-heavy release
- Shipped-data de-duplication — `email.json`'s `local` is a drifted copy of
`username.json`; fold it in when the shipped set grows and we add lots more
data.
+1 -7
View File
@@ -28,7 +28,7 @@ func TestTransformReadsTheHeldDraw(t *testing.T) {
func TestTransformOverAReference(t *testing.T) { func TestTransformOverAReference(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"person": `[{"format":"{first} {last}","first":"Åsa","last":"Öberg"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`, "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>"`, "email": `"{..person.first} {..person.last} <{lowercase(ascii(..person.first))}.{lowercase(ascii(..person.last))}@example.com>"`,
}) })
f := newGenerator(t, dir, WithSeed(5)) f := newGenerator(t, dir, WithSeed(5))
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
@@ -51,9 +51,3 @@ func TestTransformArgs(t *testing.T) {
} }
} }
} }
func TestAsciiKeepsDEL(t *testing.T) {
if got := mustRender(t, engine(1), `{"format":"{ascii(x)}","x":"a\u007fb"}`); got != "a\u007fb" {
t.Errorf("ascii over DEL = %q, want it kept: DEL is ASCII", got)
}
}