From d8ece597c86114b2699b5357322c4d2e5fc4d7be Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 3 Sep 2026 17:48:20 +0200 Subject: [PATCH] Reserve brackets in names, add compile-once NewTemplate, let --repeat reuse it, record scope artifacts --- README.md | 24 ++++++++++++++++------ cmd/fejkdata/main.go | 29 ++++++++++++++------------ node.go | 16 +++++++-------- render.go | 48 ++++++++++++++++++++++++++++++++------------ 4 files changed, 77 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index b1b5537..e579f32 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index db1d277..c2806ad 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -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. diff --git a/node.go b/node.go index d228839..2052deb 100644 --- a/node.go +++ b/node.go @@ -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 diff --git a/render.go b/render.go index 4d35b8c..2a0ea0d 100644 --- a/render.go +++ b/render.go @@ -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