Add inline templates to the CLI and library #4
@@ -1,32 +1,17 @@
|
|||||||
# fejkdata
|
# fejkdata
|
||||||
|
|
||||||
A Go library and CLI for generating locale-aware fake data from JSON templates.
|
Locale-aware fake data for tests and fixtures, generated from JSON templates. Use
|
||||||
Forked from [github.com/Timewave-AB/fakes](https://github.com/Timewave-AB/fakes).
|
it from Go or the CLI — no data on disk, no dependencies, and a seed makes output
|
||||||
|
reproducible.
|
||||||
|
|
||||||
## Goals
|
```sh
|
||||||
|
go install gitea.larvit.se/larvit/fejkdata/cmd/fejkdata@latest
|
||||||
1. **Valid by construction** — every value passes the check its real consumer
|
fejkdata sv_SE.person # Sara Eriksson
|
||||||
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
|
||||||
@@ -34,17 +19,27 @@ 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.
|
level — folders, then the category (a JSON file), then fields. An argument that is
|
||||||
|
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 path N times (up to 1048576), each an independent draw, streamed |
|
| `-n`, `--repeat N` | render the value N times (up to 1048576), each an independent draw, streamed |
|
||||||
| `--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 |
|
||||||
@@ -52,7 +47,9 @@ level — folders, then the category (a JSON file), then fields.
|
|||||||
`--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. From a checkout: `go run ./cmd/fejkdata …`.
|
dir, unknown path), `2` misuse — a bad flag, an argument that names neither a
|
||||||
|
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
|
||||||
|
|
||||||
@@ -89,6 +86,9 @@ 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 `/`; dot-prefixed entries
|
last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`;
|
||||||
are skipped, so a data directory can also be a checkout.
|
dot-prefixed entries 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`,
|
||||||
@@ -341,6 +341,26 @@ 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.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
- **Options and fields share one namespace.** `format`, `weight`, `repeat` and
|
- **Options and fields share one namespace.** `format`, `weight`, `repeat` and
|
||||||
@@ -356,6 +376,33 @@ 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.
|
||||||
@@ -444,6 +491,7 @@ 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
|
path.go the dotted-path walk, and proving a path resolves
|
||||||
render.go Fake and the recursive renderer (choices, format strings, expansions)
|
render.go Fake and the recursive renderer (choices, format strings, expansions)
|
||||||
|
inline.go inline templates: Template, NewTemplate, FakeTemplate, and their compile and link
|
||||||
template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format
|
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
|
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
|
reference.go reference sigils, and binding references across the tree
|
||||||
@@ -457,4 +505,4 @@ data/ shipped data (JSON), embedded at build: locale folders + a misc
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE). Forked from [github.com/Timewave-AB/fakes](https://github.com/Timewave-AB/fakes).
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ func transformArg(fields map[string]node, a []string) error {
|
|||||||
_, _, err := refShape(leaf)
|
_, _, err := refShape(leaf)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return checkArm(leaf, fields)
|
return checkArm(leaf, fields, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func transformOperand(a []string) []string {
|
func transformOperand(a []string) []string {
|
||||||
|
|||||||
+79
-20
@@ -9,6 +9,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -20,21 +21,30 @@ import (
|
|||||||
"gitea.larvit.se/larvit/fejkdata"
|
"gitea.larvit.se/larvit/fejkdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const usage = `Usage: fejkdata [flags] <path>
|
const usage = `Usage: fejkdata [flags] <path|template>
|
||||||
|
|
||||||
<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 path N times, 1..1048576 (default 1)
|
-n, --repeat N render the value N times, 1..1048576 (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>; -- ends the flags. A short flag's value
|
Flags may come before or after <path|template>; -- ends the flags. A short flag's
|
||||||
attaches or follows (-n3, -n 3); short flags bundle (-hn 3).
|
value attaches or follows (-n3, -n 3); short flags bundle (-hn 3).
|
||||||
`
|
`
|
||||||
|
|
||||||
type invocation struct {
|
type invocation struct {
|
||||||
@@ -186,18 +196,22 @@ func parseArgs(argv []string) (invocation, error) {
|
|||||||
return in, nil
|
return in, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// check rejects a flag combination that cannot run.
|
// check rejects a flag combination or an argument that cannot run, and reports
|
||||||
func (in invocation) check() error {
|
// what the argument names, so its shape is settled before any data is read.
|
||||||
|
func (in invocation) check() (argKind, error) {
|
||||||
if in.list && len(in.paths) > 0 {
|
if in.list && len(in.paths) > 0 {
|
||||||
return errors.New("--list takes no path")
|
return argPath, errors.New("--list takes no path")
|
||||||
}
|
}
|
||||||
if in.list && (in.repeatSet || in.separatorSet) {
|
if in.list && (in.repeatSet || in.separatorSet) {
|
||||||
return errors.New("--list takes no --repeat or --separator")
|
return argPath, errors.New("--list takes no --repeat or --separator")
|
||||||
}
|
}
|
||||||
if !in.list && len(in.paths) != 1 {
|
if in.list {
|
||||||
return fmt.Errorf("expected one path, got %d", len(in.paths))
|
return argPath, nil
|
||||||
}
|
}
|
||||||
return 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 {
|
||||||
@@ -214,14 +228,23 @@ func (in invocation) options() []fejkdata.Option {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// write streams the path's renders to w, repeat of them joined by the separator
|
// write streams the argument's renders to w, repeat of them joined by the
|
||||||
// and ended by a newline. A path that renders once renders every time, so a Fake
|
// separator and ended by a newline. A value that renders once renders every time,
|
||||||
// failure comes before anything is written; a write failure surfaces from Flush,
|
// so a render failure comes before anything is written; a write failure surfaces
|
||||||
// bufio keeping the first one.
|
// from Flush, bufio keeping the first one.
|
||||||
func (in invocation) write(f *fejkdata.Generator, w io.Writer) error {
|
func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error {
|
||||||
|
arg := in.paths[0]
|
||||||
|
draw := func() (string, error) { return f.Fake(arg) }
|
||||||
|
if kind == argTemplate {
|
||||||
|
t, err := f.NewTemplate(arg)
|
||||||
|
if err != nil {
|
||||||
|
return templateError{err}
|
||||||
|
}
|
||||||
|
draw = func() (string, error) { return t.Fake(), nil }
|
||||||
|
}
|
||||||
out := bufio.NewWriter(w)
|
out := bufio.NewWriter(w)
|
||||||
for i := 0; i < in.repeat; i++ {
|
for i := 0; i < in.repeat; i++ {
|
||||||
v, err := f.Fake(in.paths[0])
|
v, err := draw()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -234,6 +257,36 @@ func (in invocation) write(f *fejkdata.Generator, w io.Writer) error {
|
|||||||
return out.Flush()
|
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)) }
|
||||||
|
|
||||||
// run returns the exit code: 0 ok, 1 runtime error, 2 misuse.
|
// run returns the exit code: 0 ok, 1 runtime error, 2 misuse.
|
||||||
@@ -250,7 +303,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stdout, "fejkdata "+buildVersion())
|
fmt.Fprintln(stdout, "fejkdata "+buildVersion())
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if err := in.check(); err != nil {
|
kind, err := in.check()
|
||||||
|
if err != nil {
|
||||||
return misuse(stderr, err)
|
return misuse(stderr, err)
|
||||||
}
|
}
|
||||||
f, err := fejkdata.New(in.options()...)
|
f, err := fejkdata.New(in.options()...)
|
||||||
@@ -267,7 +321,11 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if err := in.write(f, stdout); err != nil {
|
if err := in.write(f, kind, stdout); err != nil {
|
||||||
|
var te templateError
|
||||||
|
if errors.As(err, &te) {
|
||||||
|
return misuse(stderr, te.error)
|
||||||
|
}
|
||||||
fmt.Fprintln(stderr, err)
|
fmt.Fprintln(stderr, err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -275,7 +333,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func misuse(stderr io.Writer, err error) int {
|
func misuse(stderr io.Writer, err error) int {
|
||||||
fmt.Fprintf(stderr, "fejkdata: %v\ntry 'fejkdata --help'\n", err)
|
// A library error already names the program, so the prefix is not doubled.
|
||||||
|
fmt.Fprintf(stderr, "fejkdata: %s\ntry 'fejkdata --help'\n", strings.TrimPrefix(err.Error(), "fejkdata: "))
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -301,6 +302,92 @@ 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 {
|
||||||
|
|||||||
@@ -68,10 +68,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 := checkRepeatReach(root); err != nil {
|
if err := checkScope(treeScope(root)); err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := checkBoundLevelsHeld(root); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return root, nil
|
return root, nil
|
||||||
|
|||||||
+2
-1
@@ -39,7 +39,8 @@ var ErrNoData = errors.New("no data: WithoutShippedData needs at least one WithD
|
|||||||
|
|
||||||
// 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.
|
// from one goroutine. The compiled tree is immutable after [New], and Fake,
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -10,29 +10,36 @@ import (
|
|||||||
// visiting keys in sorted order so which of several broken nodes gets reported does
|
// visiting keys in sorted order so which of several broken nodes gets reported does
|
||||||
// not depend on map iteration.
|
// not depend on map iteration.
|
||||||
func walkNodes(root map[string]node, fn func(path string, n node) error) error {
|
func walkNodes(root map[string]node, fn func(path string, n node) error) error {
|
||||||
seen := map[node]bool{}
|
for _, name := range sortedNames(root) {
|
||||||
var visit func(string, node) error
|
if err := eachNode(root[name], name, fn); err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
for _, c := range contained(n) {
|
}
|
||||||
|
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 {
|
if err := visit(join(path, c.name), c.node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, name := range sortedNames(root) {
|
return visit(path, n)
|
||||||
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
|
// namedNode is a contained child and the segment reaching it; a choice's items carry
|
||||||
@@ -158,34 +165,56 @@ func pathLeaves(n node, tail []string) []node {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkRepeatReach bounds the renders a repeat multiplies to along any root-to-leaf
|
// nodeScope is the set of nodes one validation pass covers: a whole loaded tree,
|
||||||
// path, so nested repeats cannot build what one repeat may not. It runs after
|
// or a single inline node.
|
||||||
// checkNoCycles, whose guarantee is what lets the walk terminate.
|
type nodeScope func(fn func(path string, n node) error) error
|
||||||
func checkRepeatReach(root map[string]node) error {
|
|
||||||
reach := map[node]int{}
|
func treeScope(root map[string]node) nodeScope {
|
||||||
var of func(n node) int
|
return func(fn func(path string, n node) error) error { return walkNodes(root, fn) }
|
||||||
of = func(n node) int {
|
}
|
||||||
if r, done := reach[n]; done {
|
|
||||||
return r
|
func inlineScope(n node) nodeScope {
|
||||||
}
|
return func(fn func(path string, m node) error) error { return eachNode(n, "template", fn) }
|
||||||
r := 1
|
}
|
||||||
for _, e := range renderEdges(n) {
|
|
||||||
if c := of(e.to); c > r {
|
// checkScope runs the per-node fences over a scope, each over the whole scope
|
||||||
r = c
|
// 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.
|
||||||
if t, ok := n.(*template); ok {
|
func checkScope(s nodeScope) error {
|
||||||
r *= t.repeat
|
mem := reachMemo{}
|
||||||
}
|
if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil {
|
||||||
reach[n] = r
|
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
|
return r
|
||||||
}
|
}
|
||||||
return walkNodes(root, func(path string, n node) error {
|
r := 1
|
||||||
if t, ok := n.(*template); ok && t.repeat > 1 && of(n) > MaxRepeat {
|
for _, e := range renderEdges(n) {
|
||||||
return fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, of(n), MaxRepeat)
|
if c := m.of(e.to); c > r {
|
||||||
|
r = c
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
})
|
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
|
// checkNoCycles rejects a reference cycle: a node whose rendering can reach itself
|
||||||
|
|||||||
@@ -6,27 +6,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkBoundLevelsHeld rejects every route to a held name except the ones that read
|
// heldCheck rejects every route to a held name except the ones that read its draw.
|
||||||
// its draw. An expansion holds one draw of that name; anything else that renders it
|
// An expansion holds one draw of that name; anything else that renders it draws
|
||||||
// draws again, and the two disagree. checkNoOverlap settles the spellings within one
|
// again, and the two disagree. checkNoOverlap settles the spellings within one
|
||||||
// format (a token, an operand); this settles the rest — a reference, whether it
|
// format (a token, an operand); this settles the rest — a reference, whether it
|
||||||
// sits in that format or in anything the format renders, however deep.
|
// sits in that format or in anything the format renders, however deep.
|
||||||
//
|
func heldCheck(path string, n node) error {
|
||||||
// It runs after checkNoCycles, whose guarantee is what lets the walk terminate.
|
t, ok := n.(*template)
|
||||||
func checkBoundLevelsHeld(root map[string]node) error {
|
if !ok || len(t.held) == 0 {
|
||||||
return walkNodes(root, func(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
|
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
|
// heldHeads lists a template's held names, operand heads first, then paths, each
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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
@@ -0,0 +1,159 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,6 +275,18 @@ func TestNewErrors(t *testing.T) {
|
|||||||
map[string]string{"a|b": `"1"`},
|
map[string]string{"a|b": `"1"`},
|
||||||
`category "a|b" contains "|"`,
|
`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
|
// 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
|
// bare {}, a trailing dot in Fake("a.") and a {/a.} reference all reached
|
||||||
// it. The engine accepted spellings it would never advertise.
|
// it. The engine accepted spellings it would never advertise.
|
||||||
|
|||||||
@@ -87,10 +87,24 @@ 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("unsupported node type %T", v)
|
return nil, fmt.Errorf("a template value must be a string, a list or an object, not %s", jsonKind(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
|
||||||
@@ -288,7 +302,7 @@ 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; checkRepeatReach bounds what nested ones multiply to
|
if r > MaxRepeat { // caps the renders one repeat asks for; repeatCheck bounds what nested ones multiply to
|
||||||
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, MaxRepeat)
|
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, MaxRepeat)
|
||||||
}
|
}
|
||||||
return int(r), nil
|
return int(r), nil
|
||||||
@@ -323,23 +337,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 and '/' starts a reference. A name carrying one is
|
// call, braces delimit the token, '/' starts a reference, and brackets and a quote
|
||||||
// reachable by no format, so it is rejected where it is authored rather than at
|
// open a JSON value. A name carrying one is rejected where it is authored rather
|
||||||
// the token that cannot reach it.
|
// than where it would be unreachable.
|
||||||
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 and {token} grammars cannot spell. Both a
|
// checkName rejects a name the dot path, {token} and JSON grammars cannot spell.
|
||||||
// category or folder and a field go through it, so there is one answer to what a
|
// Both a category or folder and a field go through it, so there is one answer to
|
||||||
// name may contain.
|
// what a 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 and {token} grammars reserve",
|
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve",
|
||||||
name, name[i:i+1], reservedList)
|
name, name[i:i+1], reservedList)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -47,3 +47,13 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+35
-28
@@ -72,37 +72,44 @@ func refSegments(name string, folder []string) ([]string, error) {
|
|||||||
// error, never a random render-time one.
|
// 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 eachTemplate(root, func(folder []string, path string, t *template) error {
|
||||||
names := refTokens(t.format)
|
return linkTemplateRefs(folder, path, t, root)
|
||||||
if len(names) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if t.fields == nil {
|
|
||||||
t.fields = map[string]node{}
|
|
||||||
}
|
|
||||||
t.refs = make(map[string]refBinding, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
segments, err := refSegments(name, folder)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
|
||||||
}
|
|
||||||
head, target, tail, err := resolveRef(root, segments)
|
|
||||||
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 {
|
|
||||||
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
|
||||||
}
|
|
||||||
t.fields[key] = target
|
|
||||||
t.refs[name] = refBinding{key, tail}
|
|
||||||
}
|
|
||||||
if err := t.compileFormat(); err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", path, err)
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if t.fields == nil {
|
||||||
|
t.fields = map[string]node{}
|
||||||
|
}
|
||||||
|
t.refs = make(map[string]refBinding, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
segments, err := refSegments(name, folder)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
||||||
|
}
|
||||||
|
head, target, tail, err := resolveRef(root, segments)
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
||||||
|
}
|
||||||
|
t.fields[key] = target
|
||||||
|
t.refs[name] = refBinding{key, tail}
|
||||||
|
}
|
||||||
|
if err := t.compileFormat(); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// eachTemplate calls fn once per template, with the folder its category sits in
|
// eachTemplate calls fn once per template, with the folder its category sits in
|
||||||
// and the dot path reaching it, folders and names in sorted order.
|
// and the dot path reaching it, folders and names in sorted order.
|
||||||
func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error {
|
func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error {
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ 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()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -140,7 +140,7 @@ func checkTokens(format string, fields map[string]node) error {
|
|||||||
}
|
}
|
||||||
continue // its target is checked at New (see linkRefs)
|
continue // its target is checked at New (see linkRefs)
|
||||||
}
|
}
|
||||||
if err := checkArm(name, fields); err != nil {
|
if err := checkArm(name, fields, len(names) == 1); err != nil {
|
||||||
return fmt.Errorf("token {%s}: %w", t.body, err)
|
return fmt.Errorf("token {%s}: %w", t.body, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,9 @@ 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.
|
||||||
func checkArm(name string, fields map[string]node) error {
|
// wholeToken says the name is the token's entire body, so {/name} would render
|
||||||
|
// 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
|
||||||
@@ -164,6 +166,13 @@ func checkArm(name string, fields map[string]node) 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 {
|
||||||
@@ -172,6 +181,17 @@ func checkArm(name string, fields map[string]node) 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user