Compare commits

...

4 Commits

13 changed files with 278 additions and 195 deletions
+34 -18
View File
@@ -25,12 +25,13 @@ fejkdata '{"format":"name: {x}","x":["bosse","lina"]}' # name: bosse or name: l
A path names a category, or a field inside one: each dot segment descends one 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. An argument that is 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 a JSON object, array or string, or that carries a `{` token, is instead an
template**: a format string or a JSON value compiled and rendered on the spot. Its **inline template**: a format string or a JSON value compiled and rendered on the
tokens reach the data by reference from the root — `{/sv_SE.person.last}` (or spot. Its tokens reach the data by reference from the root —
`{.name}`, which means the same here), so shipped and `--data-path` categories are `{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike
available — and `{..name}` is rejected, an inline template having no folder to step available. An inline template sits in no folder, so the folder-relative `{.name}`
up from. A path never contains a brace, so the two cannot collide (see and `{..name}` are rejected naming the root spelling. A path never contains a
brace, a bracket or a quote, so the two cannot collide (see
[Decisions](#decisions)). [Decisions](#decisions)).
| Flag | | | Flag | |
@@ -46,7 +47,9 @@ up from. A path never contains a brace, so the two cannot collide (see
`--name value` and `--name=value` both work, a short flag's value attaches or `--name value` and `--name=value` both work, a short flag's value attaches or
follows (`-n3`, `-n 3`) and short flags bundle (`-hn 3`) — see follows (`-n3`, `-n 3`) and short flags bundle (`-hn 3`) — see
[Decisions](#decisions); flags go anywhere, `--` ends them. Exit codes: `0` success, `1` runtime error (missing [Decisions](#decisions); flags go anywhere, `--` ends them. Exit codes: `0` success, `1` runtime error (missing
dir, unknown path), `2` misuse. From a checkout: `go run ./cmd/fejkdata …`. dir, unknown path), `2` misuse — a bad flag, an argument that names neither a
template nor a path, or an inline template that does not compile. From a checkout:
`go run ./cmd/fejkdata …`.
### Your own data ### Your own data
@@ -107,8 +110,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 last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`;
entries are skipped, so a data directory can also be a checkout. dot-prefixed 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`,
@@ -346,8 +349,9 @@ tokens add cost in proportion to the output.
2. **Text means what it says** — a format renders as written; only `{…}` varies, 2. **Text means what it says** — a format renders as written; only `{…}` varies,
random characters included (`{digits(3)}`). One spelling per result; the wrong random characters included (`{digits(3)}`). One spelling per result; the wrong
one is a load error naming the right one. one is a load error naming the right one.
3. **Every mistake is a load error** — `New` rejects; `Fake` on a loaded generator 3. **Every mistake is a load error** — `New` rejects the data and `NewTemplate`
fails only for an unknown path. the inline template; on a loaded generator `Fake` fails only for an unknown
path, and `Template.Fake` cannot fail at all.
4. **Zero to a value in one command** — `go install`, then `fejkdata sv_SE.person`: 4. **Zero to a value in one command** — `go install`, then `fejkdata sv_SE.person`:
no checkout, no flag. Flags are GNU-form (`--seed 42`, `-n 3`) in any position; no checkout, no flag. Flags are GNU-form (`--seed 42`, `-n 3`) in any position;
the first custom template needs no escape and no option. the first custom template needs no escape and no option.
@@ -372,13 +376,24 @@ tokens add cost in proportion to the output.
naming the double-dash spelling, and `-s=42` is rejected naming both short 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 spellings: `=` belongs to the long form, and reading `=42` as the value would
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, array
array, or a string carrying a `{` token, is an inline template; anything else is or string, or a string carrying a `{` token, is an inline template; anything else
a path. A name may not contain a brace or a bracket, so a path can never collide is a path. A name may not contain a brace, a bracket or a quote, so a path can
with either spelling, and the `[` of a JSON array is gated on valid JSON so a never collide with any of those spellings, and the leading `[` or `"` is gated on
stray copied bracket never swallows an argument. No `--template` flag is needed. valid JSON so a stray copied bracket never swallows an argument — it names
Reserving both brackets — though only a leading `[` could collide — keeps one nothing, and says so. No `--template` flag is needed. Reserving the characters
simple name rule instead of a leading-position special case. whole — though only a leading one could collide — keeps one simple name rule
instead of a leading-position special case. The JSON string is what makes the
library's own advice reachable: the error for an object holding only a format
names `"…"`, and that spelling has to work where it is printed.
- **A padded JSON argument is rejected, not trimmed.** Padding is the one place the
two readings disagree — a format string renders it, JSON drops it — so the
spelling that renders is named rather than silently chosen.
- **`FakeTemplate` and `NewTemplate` both stay.** They reach the same value but not
at the same cost: `NewTemplate` pays the compile and validation once and renders
many times, `FakeTemplate` is the one-shot call, and `--repeat` is exactly the
case that needs the first. The pair is `regexp.MustCompile` and `regexp.Match`,
not two spellings of one result.
- **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.
@@ -467,6 +482,7 @@ fejkdata.go Generator, New, options, the embedded data set, List
node.go the node model and JSON -> node compilation node.go the node model and JSON -> node compilation
path.go the dotted-path walk, and proving a path resolves path.go the dotted-path walk, and proving a path resolves
render.go Fake and the recursive renderer (choices, format strings, expansions) render.go Fake and the recursive renderer (choices, format strings, expansions)
inline.go inline templates: Template, NewTemplate, FakeTemplate, and their compile and link
template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format
hold.go the hold: one draw per expansion for paths and operands, and its fences hold.go the hold: one draw per expansion for paths and operands, and its fences
reference.go reference sigils, and binding references across the tree reference.go reference sigils, and binding references across the tree
+51 -36
View File
@@ -27,11 +27,11 @@ const usage = `Usage: fejkdata [flags] <path|template>
<template> a format string or JSON value to render inline, e.g. <template> a format string or JSON value to render inline, e.g.
'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}' 'name: {/sv_SE.person.last}' or '{"format":"{x}","x":["bosse","lina"]}'
An argument containing a { token, or a JSON object or array, is a template; any An argument containing a { token, or a JSON object, array or string, is a
other argument is a path (a path never contains a brace). Templates reach the data template; any other argument is a path (a path never contains a brace, a bracket
with a reference: {/sv_SE.person.first} from the root, whether the data is shipped or a quote). Templates reach the data by reference from the root —
or layered with --data-path. A [ that is not valid JSON names no template and no {/sv_SE.person.first} — whether the data is shipped or layered with --data-path. A
path. bracket that is not valid JSON names no template and no path.
-d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash) -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 -h, --help print this help, then exit
@@ -195,18 +195,22 @@ func parseArgs(argv []string) (invocation, error) {
return in, nil return in, nil
} }
// check rejects a flag combination that cannot run. // check rejects a flag combination or an argument that cannot run, and reports
func (in invocation) check() error { // what the argument names, so its shape is settled before any data is read.
func (in invocation) check() (argKind, error) {
if in.list && len(in.paths) > 0 { if in.list && len(in.paths) > 0 {
return errors.New("--list takes no path") return argPath, errors.New("--list takes no path")
} }
if in.list && (in.repeatSet || in.separatorSet) { if in.list && (in.repeatSet || in.separatorSet) {
return errors.New("--list takes no --repeat or --separator") return argPath, errors.New("--list takes no --repeat or --separator")
} }
if !in.list && len(in.paths) != 1 { if in.list {
return fmt.Errorf("expected one path, got %d", len(in.paths)) return argPath, nil
} }
return nil if len(in.paths) != 1 {
return argPath, fmt.Errorf("expected one path, got %d", len(in.paths))
}
return classify(in.paths[0])
} }
func (in invocation) options() []fejkdata.Option { func (in invocation) options() []fejkdata.Option {
@@ -223,24 +227,19 @@ func (in invocation) options() []fejkdata.Option {
return opts return opts
} }
// write streams the path's renders to w, repeat of them joined by the separator // write streams the argument's renders to w, repeat of them joined by the
// and ended by a newline. A path that renders once renders every time, so a Fake // separator and ended by a newline. A value that renders once renders every time,
// failure comes before anything is written; a write failure surfaces from Flush, // so a render failure comes before anything is written; a write failure surfaces
// bufio keeping the first one. // from Flush, bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, w io.Writer) error { func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error {
arg := in.paths[0] arg := in.paths[0]
var draw func() (string, error) draw := func() (string, error) { return f.Fake(arg) }
switch { if kind == argTemplate {
case isTemplate(arg):
t, err := f.NewTemplate(arg) t, err := f.NewTemplate(arg)
if err != nil { if err != nil {
return templateError{err} return templateError{err}
} }
draw = func() (string, error) { return t.Fake(), nil } draw = func() (string, error) { return t.Fake(), nil }
case strings.HasPrefix(arg, "["):
return templateError{fmt.Errorf("%q starts with [ but is not valid JSON, so it names no template and no path", arg)}
default:
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++ {
@@ -264,16 +263,30 @@ type templateError struct{ error }
func (e templateError) Unwrap() error { return e.error } func (e templateError) Unwrap() error { return e.error }
// isTemplate reports whether an argument is an inline template rather than a type argKind int
// 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 [ const (
// gate is valid-JSON so a [ alone never swallows an argument that merely began argPath argKind = iota
// with a copied bracket. argTemplate
func isTemplate(arg string) bool { )
if strings.ContainsRune(arg, '{') {
return true // classify reads what a positional argument names by its shape: a format string
// carrying a { token, or a JSON object, array or string, is an inline template;
// anything else is a path. A name may hold neither a brace nor a bracket nor a
// quote, so no path collides with any of those spellings, and the JSON gate is
// valid-JSON so a copied bracket names nothing rather than swallowing an argument.
func classify(arg string) (argKind, error) {
if strings.ContainsRune(arg, '{') || (isJSONStart(arg) && json.Valid([]byte(arg))) {
return argTemplate, nil
} }
return strings.HasPrefix(arg, "[") && json.Valid([]byte(arg)) if i := strings.IndexAny(arg, "[]"); i >= 0 {
return argPath, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1])
}
return argPath, nil
}
func isJSONStart(arg string) bool {
return strings.HasPrefix(arg, "[") || strings.HasPrefix(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)) }
@@ -292,7 +305,8 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stdout, "fejkdata "+buildVersion()) fmt.Fprintln(stdout, "fejkdata "+buildVersion())
return 0 return 0
} }
if err := in.check(); err != nil { kind, err := in.check()
if err != nil {
return misuse(stderr, err) return misuse(stderr, err)
} }
f, err := fejkdata.New(in.options()...) f, err := fejkdata.New(in.options()...)
@@ -309,7 +323,7 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
return 0 return 0
} }
if err := in.write(f, stdout); err != nil { if err := in.write(f, kind, stdout); err != nil {
var te templateError var te templateError
if errors.As(err, &te) { if errors.As(err, &te) {
return misuse(stderr, te.error) return misuse(stderr, te.error)
@@ -321,7 +335,8 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
func misuse(stderr io.Writer, err error) int { func misuse(stderr io.Writer, err error) int {
fmt.Fprintf(stderr, "fejkdata: %v\ntry 'fejkdata --help'\n", err) // A library error already names the program, so the prefix is not doubled.
fmt.Fprintf(stderr, "fejkdata: %s\ntry 'fejkdata --help'\n", strings.TrimPrefix(err.Error(), "fejkdata: "))
return 2 return 2
} }
+33 -12
View File
@@ -301,21 +301,34 @@ func TestRunShippedDataByDefault(t *testing.T) {
} }
} }
func TestIsTemplate(t *testing.T) { func TestClassify(t *testing.T) {
for arg, want := range map[string]bool{ for arg, want := range map[string]argKind{
"sv_SE.person": false, "sv_SE.person": argPath,
"person.last": false, "person.last": argPath,
"[abc]": false, // a [ that is not valid JSON is not a template `"abc`: argPath, // a quote opening no JSON string is part of a name
"[abc].field": false, "name: {x}": argTemplate, // a { token: a path can never carry a brace
"name: {x}": true, // a { token: a path can never carry a brace `{"format":"x"}`: argTemplate,
`{"format":"x"}`: true, `["a","b"]`: argTemplate, // a JSON array carries no brace
`["a","b"]`: true, // a JSON array carries no brace `[1, 2]`: argTemplate,
`[1, 2]`: true, `"hello"`: argTemplate, // a JSON string, the spelling a format-only object names
} { } {
if got := isTemplate(arg); got != want { got, err := classify(arg)
t.Errorf("isTemplate(%q) = %v, want %v", arg, got, want) if err != nil || got != want {
t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want)
} }
} }
for _, arg := range []string{"[abc]", "[abc].field", "x[1]", "a]b"} {
if _, err := classify(arg); err == nil {
t.Errorf("classify(%q) = no error; want the bracket rejected", arg)
}
}
}
func TestRunShapeMisuseBeforeLoad(t *testing.T) {
code, _, errb := runOut("--no-shipped-data", "[abc]")
if code != 2 || !strings.Contains(errb, "[abc]") || strings.Contains(errb, "--data-path") {
t.Fatalf("shape misuse with no data = %d, %q; want the shape error before any load", code, errb)
}
} }
func TestRunInlineTemplate(t *testing.T) { func TestRunInlineTemplate(t *testing.T) {
@@ -327,6 +340,10 @@ func TestRunInlineTemplate(t *testing.T) {
if code != 0 || (out != "name: bosse\n" && out != "name: lina\n") { 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) t.Fatalf("inline JSON template = %d, %q, want one name, stderr %q", code, out, errb)
} }
code, out, errb = runOut("--seed", "1", `"name: {/sv_SE.person.last}"`)
if code != 0 || !strings.HasPrefix(out, "name: ") || strings.Contains(out, "{") {
t.Fatalf("inline JSON string = %d, %q, stderr %q", code, out, errb)
}
code, out, errb = runOut("--seed", "1", "-n", "2", `{digits(1)}`) code, out, errb = runOut("--seed", "1", "-n", "2", `{digits(1)}`)
if code != 0 || len(strings.Split(strings.TrimRight(out, "\n"), "\n")) != 2 { 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) t.Fatalf("inline template with --repeat = %d, %q, stderr %q", code, out, errb)
@@ -339,11 +356,15 @@ func TestRunTemplateMisuse(t *testing.T) {
"[red,green]", // a near-miss JSON array (unquoted strings) "[red,green]", // a near-miss JSON array (unquoted strings)
`{"format":"x"}`, // an object holding only a format `{"format":"x"}`, // an object holding only a format
"{/no.such.path}", // a reference into nothing "{/no.such.path}", // a reference into nothing
"x[1]", // a bracket no path may hold
} { } {
code, out, errb := runOut("--seed", "1", arg) code, out, errb := runOut("--seed", "1", arg)
if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") { if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") {
t.Errorf("run(%q) = %d, %q, %q; want misuse naming --help", arg, code, out, errb) t.Errorf("run(%q) = %d, %q, %q; want misuse naming --help", arg, code, out, errb)
} }
if strings.Contains(errb, "fejkdata: fejkdata:") {
t.Errorf("run(%q) doubled the program prefix: %q", arg, errb)
}
} }
} }
+1 -7
View File
@@ -62,19 +62,13 @@ func loadData(sources []dataSource) (map[string]node, error) {
if len(root) == 0 { if len(root) == 0 {
return nil, fmt.Errorf("no .json data found") return nil, fmt.Errorf("no .json data found")
} }
// The fences here and the scoped ones [Generator.NewTemplate] runs are one
// sequence split across two entry points; a new fence lands a twin below and
// in NewTemplate (see its comment for the one omission, checkNoCycles).
if err := linkRefs(root); err != nil { if err := linkRefs(root); err != nil {
return nil, err return nil, err
} }
if err := checkNoCycles(root); err != nil { if err := checkNoCycles(root); err != nil {
return nil, err return nil, err
} }
if err := checkRepeatReach(root); err != nil { if err := checkScope(treeScope(root)); err != nil {
return nil, err
}
if err := checkBoundLevelsHeld(root); err != nil {
return nil, err return nil, err
} }
return root, nil return root, nil
+21 -15
View File
@@ -165,24 +165,28 @@ func pathLeaves(n node, tail []string) []node {
return out return out
} }
// checkRepeatReach bounds the renders a repeat multiplies to along any root-to-leaf // nodeScope is the set of nodes one validation pass covers: a whole loaded tree,
// path, so nested repeats cannot build what one repeat may not. It runs after // or a single inline node.
// checkNoCycles, whose guarantee is what lets the walk terminate. type nodeScope func(fn func(path string, n node) error) error
func checkRepeatReach(root map[string]node) error {
mem := reachMemo{} func treeScope(root map[string]node) nodeScope {
return walkNodes(root, func(path string, n node) error { return func(fn func(path string, n node) error) error { return walkNodes(root, fn) }
return repeatCheck(path, n, mem)
})
} }
// checkNodeRepeatReach is checkRepeatReach for one inline node: each template in func inlineScope(n node) nodeScope {
// it is bounded, following reference edges into the already-validated tree the same return func(fn func(path string, m node) error) error { return eachNode(n, "template", fn) }
// way a repeat through a reference multiplies along a path. }
func checkNodeRepeatReach(n node) error {
// checkScope runs the per-node fences over a scope, each over the whole scope
// before the next, so which of several broken nodes is reported does not depend on
// the walk. It runs after checkNoCycles, whose guarantee is what lets the walks
// terminate.
func checkScope(s nodeScope) error {
mem := reachMemo{} mem := reachMemo{}
return eachNode(n, "template", func(path string, m node) error { if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil {
return repeatCheck(path, m, mem) return err
}) }
return s(heldCheck)
} }
type reachMemo map[node]int type reachMemo map[node]int
@@ -204,6 +208,8 @@ func (m reachMemo) of(n node) int {
return r return r
} }
// repeatCheck bounds the renders a repeat multiplies to along any root-to-leaf
// path, so nested repeats cannot build what one repeat may not.
func repeatCheck(path string, n node, mem reachMemo) error { func repeatCheck(path string, n node, mem reachMemo) error {
if t, ok := n.(*template); ok && t.repeat > 1 && mem.of(n) > MaxRepeat { 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 fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, mem.of(n), MaxRepeat)
+3 -19
View File
@@ -6,27 +6,11 @@ import (
"strings" "strings"
) )
// checkBoundLevelsHeld rejects every route to a held name except the ones that read // heldCheck rejects every route to a held name except the ones that read its draw.
// its draw. An expansion holds one draw of that name; anything else that renders it // An expansion holds one draw of that name; anything else that renders it draws
// draws again, and the two disagree. checkNoOverlap settles the spellings within one // again, and the two disagree. checkNoOverlap settles the spellings within one
// format (a token, an operand); this settles the rest — a reference, whether it // format (a token, an operand); this settles the rest — a reference, whether it
// sits in that format or in anything the format renders, however deep. // sits in that format or in anything the format renders, however deep.
//
// 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 { func heldCheck(path string, n node) error {
t, ok := n.(*template) t, ok := n.(*template)
if !ok || len(t.held) == 0 { if !ok || len(t.held) == 0 {
+97
View File
@@ -0,0 +1,97 @@
package fejkdata
import (
"encoding/json"
"fmt"
"strings"
)
// Template is an inline template compiled, referenced and validated against a
// generator's loaded data once, ready to render many times with [Template.Fake].
// It is safe for concurrent use: Fake serializes on its generator's lock, so a
// seeded sequence is reproducible only when a generator — and its templates — are
// drawn from one goroutine.
type Template struct {
g *Generator
n node
}
// Fake renders the template with one draw.
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.
//
// checkNoCycles is the one fence loadData runs that an inline node does not need:
// the loaded tree is proven acyclic at [New], the node is a finite tree, and no
// tree node can reference it, so nothing it renders can reach itself.
func (f *Generator) NewTemplate(input string) (*Template, error) {
n, err := compileInput(input)
if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := linkNodeRefs(n, f.categories); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkScope(inlineScope(n)); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
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
}
// compileInput compiles an inline template: a JSON value, or a bare format string
// when the input is not JSON. A JSON string literal and a bare string compile
// alike (both are a template with no fields); the other JSON scalars — a number,
// bool or null — are no template, so they are rejected here, as a data file that
// was one would be at load. Padding is where the two readings would disagree — a
// format string renders it, JSON drops it — so a padded JSON value is rejected
// naming the one that renders.
func compileInput(input string) (node, error) {
var raw any
if err := json.Unmarshal([]byte(input), &raw); err != nil {
return compile(input)
}
if trimmed := strings.TrimSpace(input); trimmed != input {
return nil, fmt.Errorf("a JSON template may not be padded with spaces, which a format string would render; write %s", trimmed)
}
return compile(raw)
}
// linkNodeRefs binds the references in an inline node's templates against the
// loaded tree. An inline template sits in no folder, so . and .. name nothing and
// are rejected for the root spelling they would otherwise silently mean.
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
}
for _, name := range refTokens(t.format) {
sigil, rest, err := refShape(name)
if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
}
if sigil != "/" {
return fmt.Errorf("%s: reference {%s}: an inline template has no folder; write {/%s}", path, name, rest)
}
}
return linkTemplateRefs(nil, path, t, root)
})
}
+18 -1
View File
@@ -94,7 +94,7 @@ func TestFakeTemplateErrors(t *testing.T) {
{`"{x}"`, `no field "x"`}, {`"{x}"`, `no field "x"`},
{`"{digits(0)}"`, "must be positive"}, {`"{digits(0)}"`, "must be positive"},
{`name: {/no.such.path}`, "no entry"}, {`name: {/no.such.path}`, "no entry"},
{`name: {..nope}`, "no folder above"}, {`name: {..nope}`, "write {/nope}"},
{`{"format":"x"}`, "is a string"}, {`{"format":"x"}`, "is a string"},
{`{/misc.country} {/misc.country.alpha2}`, "renders a level"}, {`{/misc.country} {/misc.country.alpha2}`, "renders a level"},
} { } {
@@ -105,6 +105,23 @@ func TestFakeTemplateErrors(t *testing.T) {
} }
} }
func TestFakeTemplateJSONString(t *testing.T) {
f := shipped(t)
got := tmpl(t, f, `"name: {/sv_SE.person.last}"`)
if !strings.HasPrefix(got, "name: ") || strings.Contains(got, "{") {
t.Fatalf("FakeTemplate(JSON string) = %q, want a rendered last name after the prefix", got)
}
}
func TestPaddedJSONIsRejected(t *testing.T) {
f := shipped(t)
in := `{"format":"{x}","x":["a","b"]}`
_, err := f.NewTemplate(" " + in + " ")
if err == nil || !strings.Contains(err.Error(), "write "+in) {
t.Fatalf("NewTemplate(padded JSON) = %v, want an error naming the unpadded spelling", err)
}
}
func TestNewTemplateReusable(t *testing.T) { func TestNewTemplateReusable(t *testing.T) {
f := shipped(t) f := shipped(t)
tmpl, err := f.NewTemplate(`{digits(2)}`) tmpl, err := f.NewTemplate(`{digits(2)}`)
+4 -2
View File
@@ -275,8 +275,6 @@ func TestNewErrors(t *testing.T) {
map[string]string{"a|b": `"1"`}, map[string]string{"a|b": `"1"`},
`category "a|b" contains "|"`, `category "a|b" contains "|"`,
}, },
// A bracket is not a token-grammar character, but the CLI reads an argument
// starting with [ as a JSON array, so a name carrying one would be misread.
"field name with a bracket": { "field name with a bracket": {
map[string]string{"a": `{"format":"{x}","x":"1","b[c":"2"}`}, map[string]string{"a": `{"format":"{x}","x":"1","b[c":"2"}`},
`field "b[c" contains "["`, `field "b[c" contains "["`,
@@ -285,6 +283,10 @@ func TestNewErrors(t *testing.T) {
map[string]string{"[abc]": `"1"`}, map[string]string{"[abc]": `"1"`},
`category "[abc]" contains "["`, `category "[abc]" contains "["`,
}, },
"field name with a quote": {
map[string]string{"a": `{"format":"{x}","x":"1","b\"c":"2"}`},
`field "b\"c" contains "\""`,
},
// An empty name is not a path segment, so List never offered it — while a // An empty name is not a path segment, so List never offered it — while a
// bare {}, a trailing dot in Fake("a.") and a {/a.} reference all reached // bare {}, a trailing dot in Fake("a.") and a {/a.} reference all reached
// it. The engine accepted spellings it would never advertise. // it. The engine accepted spellings it would never advertise.
+6 -19
View File
@@ -136,19 +136,6 @@ func (t *template) compileFormat() error {
return checkNoRepeatedRead(t.format, c, t.refs) 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 JSON string literal and a bare string compile
// alike (both are a template with no fields); the other JSON scalars — a number,
// bool or null — are no template, so they are rejected here, as a data file that
// was one would be at load.
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) { func compileChoice(items []any) (node, error) {
if len(items) == 0 { if len(items) == 0 {
return nil, fmt.Errorf("empty choice") return nil, fmt.Errorf("empty choice")
@@ -350,15 +337,15 @@ 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, '/' starts a reference, and a bracket would be // call, braces delimit the token, '/' starts a reference, and brackets and a quote
// misread by the command line as a JSON array. A name carrying one is rejected // open a JSON value. A name carrying one is rejected where it is authored rather
// where it is authored rather than where it would be unreachable. // 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, {token} and CLI grammars cannot spell. // checkName rejects a name the dot path, {token} and JSON grammars cannot spell.
// Both a category or folder and a field go through it, so there is one answer to // Both a category or folder and a field go through it, so there is one answer to
// what a name may contain. // what a name may contain.
func checkName(name string) error { func checkName(name string) error {
@@ -366,7 +353,7 @@ func checkName(name string) error {
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, {token} and CLI grammars reserve", return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve",
name, name[i:i+1], reservedList) name, name[i:i+1], reservedList)
} }
return nil return nil
+10
View File
@@ -47,3 +47,13 @@ func TestInertObjectIsRejected(t *testing.T) {
} }
} }
} }
func TestInlineFolderSigilsAreRejected(t *testing.T) {
f := shipped(t)
for _, input := range []string{"{.sv_SE.person.last}", "{..sv_SE.person.last}"} {
_, err := f.NewTemplate(input)
if err == nil || !strings.Contains(err.Error(), "write {/sv_SE.person.last}") {
t.Errorf("NewTemplate(%q) = %v, want an error naming the root spelling", input, err)
}
}
}
-13
View File
@@ -110,19 +110,6 @@ func linkTemplateRefs(folder []string, path string, t *template, root map[string
return nil 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)
})
}
// eachTemplate calls fn once per template, with the folder its category sits in // eachTemplate calls fn once per template, with the folder its category sits in
// and the dot path reaching it, folders and names in sorted order. // and the dot path reaching it, folders and names in sorted order.
func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error { func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error {
-53
View File
@@ -30,59 +30,6 @@ func (f *Generator) Fake(path string) (string, error) {
return render(f.rand, n), nil return render(f.rand, n), nil
} }
// Template is an inline template compiled, referenced and validated against a
// generator's loaded data once, ready to render many times with [Template.Fake].
// It is safe for concurrent use: Fake serializes on its generator's lock, so a
// seeded sequence is reproducible only when a generator — and its templates — are
// drawn from one goroutine.
type Template struct {
g *Generator
n node
}
// Fake renders the template with one draw.
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.
// NewTemplate runs the same fences loadData does for a category, scoped to one
// inline node with everything but checkNoCycles: a reference binds only into the
// loaded tree, which has no path into this node, so rendering it cannot reach
// itself. A new fence belongs in both places (loadData and here).
func (f *Generator) NewTemplate(input string) (*Template, error) {
n, err := compileInput(input)
if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := linkNodeRefs(n, f.categories); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkNodeRepeatReach(n); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkNodeBoundLevelsHeld(n); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
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
// step that can fail, because the path comes from the caller and may name a field // 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 // that does not exist. A choice consumes no segment, so the rest of the path must