Compare commits
5 Commits
addba9abf7
...
e08435afa7
| Author | SHA1 | Date | |
|---|---|---|---|
| e08435afa7 | |||
| 3a9f3a4758 | |||
| 4399b8b89f | |||
| 3bc3e35045 | |||
| 5ba38a8281 |
@@ -34,17 +34,25 @@ fejkdata -n 3 --separator ', ' sv_SE.word # nät, barn, sol
|
||||
fejkdata --list # every path the data offers
|
||||
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 'name: {/sv_SE.person.last}' # name: Eriksson (an inline template)
|
||||
fejkdata '{"format":"name: {x}","x":["bosse","lina"]}' # name: bosse
|
||||
```
|
||||
|
||||
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 or array, 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 — `{/sv_SE.person.last}` from the root, `{.name}`
|
||||
and `{..name}` relative to it — so `--data-path` categories are available too. A
|
||||
path never contains a brace, so the two cannot collide (see
|
||||
[Decisions](#decisions)).
|
||||
|
||||
| Flag | |
|
||||
|------|--|
|
||||
| `-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 |
|
||||
| `-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) |
|
||||
| `--list` | print every path, then exit |
|
||||
| `--version`, `-h`, `--help` | print, then exit |
|
||||
@@ -89,6 +97,8 @@ 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"]}`)
|
||||
```
|
||||
|
||||
| Option | |
|
||||
@@ -356,6 +366,10 @@ tokens add cost in proportion to the output.
|
||||
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
|
||||
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.
|
||||
- **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.
|
||||
|
||||
+31
-5
@@ -20,21 +20,28 @@ import (
|
||||
"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)
|
||||
<template> a format string or JSON value to render inline, e.g.
|
||||
'name: {/sv_SE.person.last}' or '{"format":"{x}","x":[1,2]}'
|
||||
|
||||
An argument containing a { token, or a JSON object or array, is a template; any
|
||||
other argument is a path (a path never contains a brace). Templates reach the data
|
||||
with a reference: {/sv_SE.person.first} from the root, whether the data is shipped
|
||||
or layered with --data-path.
|
||||
|
||||
-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
|
||||
--list list the paths the data offers, then exit
|
||||
--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
|
||||
--separator S string between repeated values (default newline)
|
||||
--version print the version, then exit
|
||||
|
||||
Flags may come before or after <path>; -- ends the flags. A short flag's value
|
||||
attaches or follows (-n3, -n 3); short flags bundle (-hn 3).
|
||||
Flags may come before or after <path|template>; -- ends the flags. A short flag's
|
||||
value attaches or follows (-n3, -n 3); short flags bundle (-hn 3).
|
||||
`
|
||||
|
||||
type invocation struct {
|
||||
@@ -221,7 +228,8 @@ func (in invocation) options() []fejkdata.Option {
|
||||
func (in invocation) write(f *fejkdata.Generator, w io.Writer) error {
|
||||
out := bufio.NewWriter(w)
|
||||
for i := 0; i < in.repeat; i++ {
|
||||
v, err := f.Fake(in.paths[0])
|
||||
arg := in.paths[0]
|
||||
v, err := renderArg(f, arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,6 +242,24 @@ func (in invocation) write(f *fejkdata.Generator, w io.Writer) error {
|
||||
return out.Flush()
|
||||
}
|
||||
|
||||
// isTemplate reports whether an argument is an inline template rather than a
|
||||
// path: a JSON object or array, or a format string carrying a { token. A path can
|
||||
// never contain a brace, so the two never collide.
|
||||
func isTemplate(arg string) bool {
|
||||
if strings.ContainsRune(arg, '{') {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(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.
|
||||
|
||||
@@ -301,6 +301,21 @@ func TestRunShippedDataByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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", "-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 TestRunNoShippedData(t *testing.T) {
|
||||
code, list, errb := runOut("--no-shipped-data", "-d", svSE, "--list")
|
||||
if code != 0 {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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 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}`, "no folder above"},
|
||||
{`{"format":"x"}`, "is a string"},
|
||||
{`{/misc.country} {/misc.country.alpha2}`, "renders a level"},
|
||||
} {
|
||||
_, 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 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)
|
||||
}
|
||||
}
|
||||
@@ -10,29 +10,36 @@ import (
|
||||
// visiting keys in sorted order so which of several broken nodes gets reported does
|
||||
// not depend on map iteration.
|
||||
func walkNodes(root map[string]node, fn func(path string, n node) error) error {
|
||||
seen := map[node]bool{}
|
||||
var visit func(string, node) error
|
||||
visit = func(path string, n node) error {
|
||||
if n == nil || seen[n] {
|
||||
return nil
|
||||
}
|
||||
seen[n] = true
|
||||
if err := fn(path, n); err != nil {
|
||||
for _, name := range sortedNames(root) {
|
||||
if err := eachNode(root[name], name, fn); err != nil {
|
||||
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, as in walkNodes — 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, name := range sortedNames(root) {
|
||||
if err := visit(name, root[name]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return visit(path, n)
|
||||
}
|
||||
|
||||
// namedNode is a contained child and the segment reaching it; a choice's items carry
|
||||
@@ -162,30 +169,46 @@ func pathLeaves(n node, tail []string) []node {
|
||||
// path, so nested repeats cannot build what one repeat may not. It runs after
|
||||
// checkNoCycles, whose guarantee is what lets the walk terminate.
|
||||
func checkRepeatReach(root map[string]node) error {
|
||||
reach := map[node]int{}
|
||||
var of func(n node) int
|
||||
of = func(n node) int {
|
||||
if r, done := reach[n]; done {
|
||||
mem := reachMemo{}
|
||||
return walkNodes(root, func(path string, n node) error {
|
||||
return repeatCheck(path, n, mem)
|
||||
})
|
||||
}
|
||||
|
||||
// checkNodeRepeatReach is checkRepeatReach for one inline node: each template in
|
||||
// it is bounded, following reference edges into the already-validated tree the same
|
||||
// way a repeat through a reference multiplies along a path.
|
||||
func checkNodeRepeatReach(n node) error {
|
||||
mem := reachMemo{}
|
||||
return eachNode(n, "template", func(path string, m node) error {
|
||||
return repeatCheck(path, m, mem)
|
||||
})
|
||||
}
|
||||
|
||||
type reachMemo map[node]int
|
||||
|
||||
func (m reachMemo) of(n node) int {
|
||||
if r, done := m[n]; done {
|
||||
return r
|
||||
}
|
||||
r := 1
|
||||
for _, e := range renderEdges(n) {
|
||||
if c := of(e.to); c > r {
|
||||
if c := m.of(e.to); c > r {
|
||||
r = c
|
||||
}
|
||||
}
|
||||
if t, ok := n.(*template); ok {
|
||||
r *= t.repeat
|
||||
}
|
||||
reach[n] = r
|
||||
m[n] = r
|
||||
return r
|
||||
}
|
||||
return walkNodes(root, func(path string, n node) error {
|
||||
if t, ok := n.(*template); ok && t.repeat > 1 && of(n) > MaxRepeat {
|
||||
return fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, of(n), MaxRepeat)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// checkNoCycles rejects a reference cycle: a node whose rendering can reach itself
|
||||
|
||||
@@ -15,6 +15,19 @@ import (
|
||||
// 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 {
|
||||
return heldCheck(path, n)
|
||||
})
|
||||
}
|
||||
|
||||
// checkNodeBoundLevelsHeld is checkBoundLevelsHeld for one inline node: the same
|
||||
// held fence, over its own templates and the shared tree they reference.
|
||||
func checkNodeBoundLevelsHeld(n node) error {
|
||||
return eachNode(n, "template", func(path string, m node) error {
|
||||
return heldCheck(path, m)
|
||||
})
|
||||
}
|
||||
|
||||
func heldCheck(path string, n node) error {
|
||||
t, ok := n.(*template)
|
||||
if !ok || len(t.held) == 0 {
|
||||
return nil
|
||||
@@ -26,7 +39,6 @@ func checkBoundLevelsHeld(root map[string]node) error {
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// heldHeads lists a template's held names, operand heads first, then paths, each
|
||||
|
||||
@@ -122,6 +122,18 @@ func (t *template) compileFormat() error {
|
||||
return checkNoRepeatedRead(t.format, c, t.refs)
|
||||
}
|
||||
|
||||
// compileInput compiles an inline template: a JSON value, or a bare format string
|
||||
// when the input is not JSON. A format string is a template with no fields, so a
|
||||
// string that is itself valid JSON (a JSON string literal) and a bare string
|
||||
// compile alike.
|
||||
func compileInput(input string) (node, error) {
|
||||
var raw any
|
||||
if err := json.Unmarshal([]byte(input), &raw); err != nil {
|
||||
return compile(input)
|
||||
}
|
||||
return compile(raw)
|
||||
}
|
||||
|
||||
func compileChoice(items []any) (node, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("empty choice")
|
||||
|
||||
@@ -72,6 +72,14 @@ func refSegments(name string, folder []string) ([]string, error) {
|
||||
// error, never a random render-time one.
|
||||
func linkRefs(root map[string]node) 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)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
@@ -100,6 +108,18 @@ func linkRefs(root map[string]node) error {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkNodeRefs binds the references in an inline node's templates against the
|
||||
// loaded tree. The node has no folder of its own, so every sigil is root-relative:
|
||||
// / and . mean the root, .. has no folder above it.
|
||||
func linkNodeRefs(n node, root map[string]node) error {
|
||||
return eachNode(n, "template", func(path string, m node) error {
|
||||
t, ok := m.(*template)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return linkTemplateRefs(nil, path, t, root)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,32 @@ 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()
|
||||
n, err := compileInput(input)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fejkdata: %w", err)
|
||||
}
|
||||
if err := linkNodeRefs(n, f.categories); err != nil {
|
||||
return "", 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)
|
||||
}
|
||||
if err := checkNodeBoundLevelsHeld(n); err != nil {
|
||||
return "", fmt.Errorf("fejkdata: %w", err)
|
||||
}
|
||||
return render(f.rand, n), nil
|
||||
}
|
||||
|
||||
// descend walks named fields to the node a path names. It is the one render-side
|
||||
// step that can fail, because the path comes from the caller and may name a field
|
||||
// that does not exist. A choice consumes no segment, so the rest of the path must
|
||||
|
||||
Reference in New Issue
Block a user