Add inline templates to the CLI and library #4
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user