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 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. 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 ## CLI
```sh ```sh
@@ -98,8 +108,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}") // an inline template v, err = f.FakeTemplate("name: {/sv_SE.person.last}") // compile + render in one call
v, err = f.FakeTemplate(`{"format":"name: {x}","x":["bosse","lina"]}`) t, err := f.NewTemplate(`{"format":"name: {x}","x":["bosse","lina"]}`) // compile once
v = t.Fake() // render many times, no re-parse
``` ```
| Option | | | 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 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 `/`; dot-prefixed
are skipped, so a data directory can also be a checkout. 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`,
@@ -369,8 +380,9 @@ tokens add cost in proportion to the output.
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 or - **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 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 a path. A name may not contain a brace or a bracket, so a path can never collide
never collide, and no `--template` flag is needed to disambiguate them. 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 - **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.
+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, // failure comes before anything is written; a write failure surfaces from Flush,
// bufio keeping the first one. // bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, w io.Writer) error { 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) out := bufio.NewWriter(w)
for i := 0; i < in.repeat; i++ { for i := 0; i < in.repeat; i++ {
arg := in.paths[0] v, err := draw()
v, err := renderArg(f, arg)
if err != nil { if err != nil {
return err 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 // 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), // path: a format string carrying a { token, or a JSON object or array. A name may
// or a JSON object or array. A [ can begin a real category name, so a [ // not contain a brace or bracket, so both spellings collide with no path; the [
// counts as a template only when the whole argument is valid JSON. // gate is valid-JSON so a [ alone never swallows an argument that merely began
// with a copied bracket.
func isTemplate(arg string) bool { func isTemplate(arg string) bool {
if strings.ContainsRune(arg, '{') { if strings.ContainsRune(arg, '{') {
return true return true
@@ -254,14 +265,6 @@ func isTemplate(arg string) bool {
return strings.HasPrefix(arg, "[") && json.Valid([]byte(arg)) 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)) } 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.
+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 // 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 a bracket would be
// reachable by no format, so it is rejected where it is authored rather than at // misread by the command line as a JSON array. A name carrying one is rejected
// the token that cannot reach it. // where it is authored rather 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 CLI 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 CLI grammars reserve",
name, name[i:i+1], reservedList) name, name[i:i+1], reservedList)
} }
return nil return nil
+35 -13
View File
@@ -30,30 +30,52 @@ func (f *Generator) Fake(path string) (string, error) {
return render(f.rand, n), nil return render(f.rand, n), nil
} }
// FakeTemplate renders an inline template — a format string or a JSON value — the // Template is an inline template compiled, referenced and validated against a
// way a category's data is compiled and rendered, references reaching the loaded // generator's loaded data once, ready to render many times with [Template.Fake].
// tree with {/path}. It shares [Fake]'s lock: an inline node is compiled and type Template struct {
// referenced per draw, so a seeded sequence is reproducible when drawn from one g *Generator
// goroutine. n node
func (f *Generator) FakeTemplate(input string) (string, error) { }
f.mu.Lock()
defer f.mu.Unlock() // 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) n, err := compileInput(input)
if err != nil { if err != nil {
return "", fmt.Errorf("fejkdata: %w", err) return nil, fmt.Errorf("fejkdata: %w", err)
} }
if err := linkNodeRefs(n, f.categories); err != nil { 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 cycle is possible: a reference binds only into the loaded tree, which has
// no path into this node, so rendering it cannot reach itself. // no path into this node, so rendering it cannot reach itself.
if err := checkNodeRepeatReach(n); err != nil { 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 { 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 // descend walks named fields to the node a path names. It is the one render-side