Compare commits

...

23 Commits

Author SHA1 Message Date
lilleman 44601c90ab Offer the reference hint only where it would render the same value, and name the guard set in the help
Tests / vet + fmt + tests (pull_request) Successful in 57s
Tests / vet + fmt + tests (push) Successful in 7s
2026-09-03 21:12:04 +02:00
lilleman 8ed8d9bb51 Tests: no hint where a reference would mean something else, and every reference the help advertises resolves 2026-09-03 21:11:11 +02:00
lilleman 1cd422352b Cut the comments the README already records, and record the cycle-fence and exit-code rules there
Tests / vet + fmt + tests (pull_request) Successful in 58s
2026-09-03 21:00:42 +02:00
lilleman 0cdbdb7bf4 Fix the help example, read padding past the shape gate, catch a stray closing brace, and stop the fieldless hint naming a spelling that fails 2026-09-03 20:58:01 +02:00
lilleman d8048df169 Tests: padded and stray-brace arguments, the inline held fence, a template under concurrent use, and the fieldless-token hint 2026-09-03 20:57:25 +02:00
lilleman 71cccaf4c9 Reject a leading quote that opens no JSON, and give the inline walk one home
Tests / vet + fmt + tests (pull_request) Successful in 55s
2026-09-03 20:43:04 +02:00
lilleman 704d18d109 Tests: a leading quote that opens no JSON names neither a template nor a path 2026-09-03 20:42:34 +02:00
lilleman 44c91668f9 Give the inline pipeline its own file, and record the shape, padding and template-API decisions
Tests / vet + fmt + tests (pull_request) Successful in 55s
2026-09-03 20:37:44 +02:00
lilleman 6916894ab5 One CLI classifier settling the argument shape before load; JSON strings are templates and a quote is reserved in names 2026-09-03 20:34:42 +02:00
lilleman 80e8d72bb7 One fence sequence over a node scope, and inline templates reject the folder sigils 2026-09-03 20:34:42 +02:00
lilleman 7c7bee5cd9 Tests: one fence sequence, inline folder sigils rejected, JSON string arguments, quoted names, and shape misuse before load 2026-09-03 20:31:49 +02:00
lilleman 841513bb27 Lead the README with what it does and a quick example, move Goals beside Decisions
Tests / vet + fmt + tests (pull_request) Successful in 57s
2026-09-03 20:14:11 +02:00
lilleman 873b7678ba Address reviewer findings: reference hint, consumer-terms type errors, misuse exit code, immutability and parity docs
Tests / vet + fmt + tests (pull_request) Successful in 57s
2026-09-03 19:21:42 +02:00
lilleman 4d58c93757 Tests: template misuse exits 2, and a bare token hints its reference spelling 2026-09-03 19:21:42 +02:00
lilleman 305d8f8edd Address stability nits: doc grammar, scalar-JSON note, bracket-rule rationale
Tests / vet + fmt + tests (pull_request) Successful in 56s
2026-09-03 17:53:05 +02:00
lilleman d8ece597c8 Reserve brackets in names, add compile-once NewTemplate, let --repeat reuse it, record scope artifacts
Tests / vet + fmt + tests (pull_request) Failing after 16s
2026-09-03 17:48:20 +02:00
lilleman 47ec883bb0 Tests: reusable NewTemplate, and bracket names rejected at load 2026-09-03 17:48:20 +02:00
lilleman 031a3ec7d6 Resolve architect findings: gate [ on json.Valid, fix the root-relative README claim, test isTemplate
Tests / vet + fmt + tests (pull_request) Failing after 16s
2026-09-03 17:40:29 +02:00
lilleman e08435afa7 Document inline templates: goal 4, the CLI and library, the shape-based disambiguation
Tests / vet + fmt + tests (pull_request) Successful in 56s
2026-09-03 17:30:09 +02:00
lilleman 3a9f3a4758 Route inline templates to FakeTemplate at the CLI 2026-09-03 17:29:07 +02:00
lilleman 4399b8b89f Tests for inline templates at the CLI 2026-09-03 17:28:14 +02:00
lilleman 3bc3e35045 Add FakeTemplate: compile an inline template and render it against the loaded tree 2026-09-03 17:27:54 +02:00
lilleman 5ba38a8281 Tests for FakeTemplate: inline format strings, JSON templates, references, and their errors 2026-09-03 17:27:51 +02:00
16 changed files with 685 additions and 152 deletions
+76 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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
} }
+87
View File
@@ -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 {
+1 -4
View File
@@ -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
View File
@@ -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
+58 -29
View File
@@ -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 {
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 return r
} }
r := 1 r := 1
for _, e := range renderEdges(n) { for _, e := range renderEdges(n) {
if c := of(e.to); c > r { if c := m.of(e.to); c > r {
r = c r = c
} }
} }
if t, ok := n.(*template); ok { if t, ok := n.(*template); ok {
r *= t.repeat r *= t.repeat
} }
reach[n] = r m[n] = r
return r return r
} }
return walkNodes(root, func(path string, n node) error {
if t, ok := n.(*template); ok && t.repeat > 1 && of(n) > MaxRepeat { // repeatCheck bounds the renders a repeat multiplies to along any root-to-leaf
return fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, of(n), MaxRepeat) // 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 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
+4 -8
View File
@@ -6,15 +6,12 @@ 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.
func checkBoundLevelsHeld(root map[string]node) error {
return walkNodes(root, func(path string, n node) error {
t, ok := n.(*template) t, ok := n.(*template)
if !ok || len(t.held) == 0 { if !ok || len(t.held) == 0 {
return nil return nil
@@ -26,7 +23,6 @@ func checkBoundLevelsHeld(root map[string]node) error {
} }
} }
return nil 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
+88
View File
@@ -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
View File
@@ -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)
}
}
+12
View File
@@ -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.
+24 -10
View File
@@ -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
+10
View File
@@ -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)
}
}
}
+8 -1
View File
@@ -72,6 +72,14 @@ 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 {
return linkTemplateRefs(folder, path, t, root)
})
}
// 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
@@ -100,7 +108,6 @@ func linkRefs(root map[string]node) error {
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 // eachTemplate calls fn once per template, with the folder its category sits in
+6
View File
@@ -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
View File
@@ -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 {