Reserve brackets in names, add compile-once NewTemplate, let --repeat reuse it, record scope artifacts
Tests / vet + fmt + tests (pull_request) Failing after 16s

This commit is contained in:
2026-09-03 17:48:20 +02:00
parent 47ec883bb0
commit d8ece597c8
4 changed files with 77 additions and 40 deletions
+18 -6
View File
@@ -22,6 +22,16 @@ Forked from [github.com/Timewave-AB/fakes](https://github.com/Timewave-AB/fakes)
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.
## Audience, scale and horizon
fejkdata is for developers generating test and fixture data, from Go or the CLI —
in-memory and single-process, with no persistence or networking, so it reads no
configuration beyond what the caller passes. A single value is bounded at 1 048 576
renders (`MaxRepeat`); how large each render is stays what the data asked for. The
data format becomes the frozen public API at the first tagged release, which is the
promotion trigger for a compatibility review; until then there is no compatibility
promise.
## CLI
```sh
@@ -98,8 +108,9 @@ if err != nil {
}
v, err := f.Fake("sv_SE.address") // "Kungsvägen 68\n379 17 Stockholm"
paths := f.List() // every path Fake accepts, sorted
v, err = f.FakeTemplate("name: {/sv_SE.person.last}") // an inline template
v, err = f.FakeTemplate(`{"format":"name: {x}","x":["bosse","lina"]}`)
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 | |
@@ -121,8 +132,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
`mydata/sv_SE/person.json` is `sv_SE.person` and replaces the shipped one.
Sources merge in order; matching folders combine, any other clash is won by the
last loaded. Names may not use `.`, `|`, `(`, `{`, `}` or `/`; dot-prefixed entries
are skipped, so a data directory can also be a checkout.
last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]` or `/`; dot-prefixed
entries are skipped, so a data directory can also be a checkout.
Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`,
`person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`,
@@ -369,8 +380,9 @@ tokens add cost in proportion to the output.
make `-d=./x` a directory named `=./x`.
- **An argument is a template by its shape, not by a flag.** A JSON object or
array, or a string carrying a `{` token, is an inline template; anything else is
a path. A path can never contain a brace a name may not use one — so the two
never collide, and no `--template` flag is needed to disambiguate them.
a path. A name may not contain a brace or a bracket, so a path can never collide
with either spelling, and the `[` of a JSON array is gated on valid JSON so a
stray copied bracket never swallows an argument. No `--template` flag is needed.
- **The shipped data is embedded, not discovered.** A directory a machine happens
to have would make `--seed 42` machine-dependent. Data still lives in `data/`
as JSON; `--data-path` layers over it.
+16 -13
View File
@@ -227,10 +227,20 @@ func (in invocation) options() []fejkdata.Option {
// failure comes before anything is written; a write failure surfaces from Flush,
// bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, w io.Writer) error {
arg := in.paths[0]
var draw func() (string, error)
if isTemplate(arg) {
t, err := f.NewTemplate(arg)
if err != nil {
return err
}
draw = func() (string, error) { return t.Fake(), nil }
} else {
draw = func() (string, error) { return f.Fake(arg) }
}
out := bufio.NewWriter(w)
for i := 0; i < in.repeat; i++ {
arg := in.paths[0]
v, err := renderArg(f, arg)
v, err := draw()
if err != nil {
return err
}
@@ -244,9 +254,10 @@ func (in invocation) write(f *fejkdata.Generator, w io.Writer) error {
}
// isTemplate reports whether an argument is an inline template rather than a
// path: a format string carrying a { token (a path can never contain a brace),
// or a JSON object or array. A [ can begin a real category name, so a [
// counts as a template only when the whole argument is valid JSON.
// path: a format string carrying a { token, or a JSON object or array. A name may
// not contain a brace or bracket, so both spellings collide with no path; the [
// gate is valid-JSON so a [ alone never swallows an argument that merely began
// with a copied bracket.
func isTemplate(arg string) bool {
if strings.ContainsRune(arg, '{') {
return true
@@ -254,14 +265,6 @@ func isTemplate(arg string) bool {
return strings.HasPrefix(arg, "[") && json.Valid([]byte(arg))
}
// renderArg renders one positional argument: an inline template, or a path.
func renderArg(f *fejkdata.Generator, arg string) (string, error) {
if isTemplate(arg) {
return f.FakeTemplate(arg)
}
return f.Fake(arg)
}
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
// run returns the exit code: 0 ok, 1 runtime error, 2 misuse.
+8 -8
View File
@@ -335,23 +335,23 @@ func weightOf(raw any) (float64, error) {
// reservedInName is what a category, folder or field name may not contain: a dot
// separates the segments of a path, '|' the arms of a token, '(' opens a function
// call, braces delimit the token and '/' starts a reference. A name carrying one is
// reachable by no format, so it is rejected where it is authored rather than at
// the token that cannot reach it.
const reservedInName = ".|({}/"
// call, braces delimit the token, '/' starts a reference, and a bracket would be
// misread by the command line as a JSON array. A name carrying one is rejected
// where it is authored rather than where it would be unreachable.
const reservedInName = ".|({}/[]"
// reservedList spells reservedInName for an error message, so the two cannot drift.
var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
// checkName rejects a name the dot path and {token} grammars cannot spell. Both a
// category or folder and a field go through it, so there is one answer to what a
// name may contain.
// checkName rejects a name the dot path, {token} and CLI grammars cannot spell.
// Both a category or folder and a field go through it, so there is one answer to
// what a name may contain.
func checkName(name string) error {
if 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 {
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 CLI grammars reserve",
name, name[i:i+1], reservedList)
}
return nil
+35 -13
View File
@@ -30,30 +30,52 @@ func (f *Generator) Fake(path string) (string, error) {
return render(f.rand, n), nil
}
// FakeTemplate renders an inline template — a format string or a JSON value — the
// way a category's data is compiled and rendered, references reaching the loaded
// tree with {/path}. It shares [Fake]'s lock: an inline node is compiled and
// referenced per draw, so a seeded sequence is reproducible when drawn from one
// goroutine.
func (f *Generator) FakeTemplate(input string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
// Template is an inline template compiled, referenced and validated against a
// generator's loaded data once, ready to render many times with [Template.Fake].
type Template struct {
g *Generator
n node
}
// Fake renders the template with one draws.
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 "", fmt.Errorf("fejkdata: %w", err)
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := linkNodeRefs(n, f.categories); err != nil {
return "", fmt.Errorf("fejkdata: %w", err)
return nil, fmt.Errorf("fejkdata: %w", err)
}
// No cycle is possible: a reference binds only into the loaded tree, which has
// no path into this node, so rendering it cannot reach itself.
if err := checkNodeRepeatReach(n); err != nil {
return "", fmt.Errorf("fejkdata: %w", err)
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkNodeBoundLevelsHeld(n); err != nil {
return "", fmt.Errorf("fejkdata: %w", err)
return nil, fmt.Errorf("fejkdata: %w", err)
}
return render(f.rand, n), nil
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
}
// descend walks named fields to the node a path names. It is the one render-side