Add {..path} references: render a node from elsewhere in the merged data root
This commit is contained in:
@@ -221,6 +221,20 @@ form prefixes the century outside the checksummed core:
|
||||
A function must be deterministic in the seeded rng (no wall-clock), so a seeded
|
||||
faker stays reproducible.
|
||||
|
||||
**References.** A `{..path}` token renders a node from the **data root** instead
|
||||
of a sibling field — the dot path is the one `Fake` takes, resolved across every
|
||||
loaded directory. One category can borrow another, even across folders or layered
|
||||
data dirs:
|
||||
|
||||
```json
|
||||
{ "format": "Hej, {..en_US.person}!" }
|
||||
```
|
||||
|
||||
renders e.g. `Hej, Pat Smith!`. References are bound when you create the faker, so
|
||||
a path that is unknown, names a folder, or steps through a multi-variant choice
|
||||
fails at `New`. A reference must not lead back to its own value (directly or
|
||||
through a chain), or rendering won't terminate.
|
||||
|
||||
**Format string.** Every character is literal except:
|
||||
|
||||
| Token | Expands to |
|
||||
@@ -232,8 +246,10 @@ faker stays reproducible.
|
||||
| `#` | escape — the next char is literal (`#0` → `0`, `##` → `#`) |
|
||||
| `{name}` | render the sibling field `name` |
|
||||
| `{name()}` | call a built-in function (see **Functions**) |
|
||||
| `{..path}` | render the node at a dot path from the data root (see **References**) |
|
||||
|
||||
`{a|b}` renders one of the sibling fields `a` or `b`, chosen at random.
|
||||
`{a|b}` renders one of the sibling fields `a` or `b`, chosen at random; an arm
|
||||
may be a `{..path}` reference too (`{name|..en_US.person}`).
|
||||
|
||||
**Putting it together** (`person.json`):
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
// a node keyed by its base name (address.json -> "address"); each subdirectory
|
||||
// becomes a nested group, so folders turn into dot-path segments. Paths are
|
||||
// merged left to right: matching groups merge by their children, and any other
|
||||
// clash (a leaf, or a leaf-vs-group) is won by the last directory loaded.
|
||||
// clash (a leaf, or a leaf-vs-group) is won by the last directory loaded. Once
|
||||
// merged, linkRefs binds every {..path} reference against the final tree.
|
||||
func loadData(paths []string) (map[string]node, error) {
|
||||
root := map[string]node{}
|
||||
for _, p := range paths {
|
||||
@@ -26,6 +27,9 @@ func loadData(paths []string) (map[string]node, error) {
|
||||
if len(root) == 0 {
|
||||
return nil, fmt.Errorf("no .json data found in %v", paths)
|
||||
}
|
||||
if err := linkRefs(root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package fakes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRootReferenceAcrossFolders is the headline case: a category in one folder
|
||||
// pulls a value from another via a {..path} reference resolved from the data root.
|
||||
func TestRootReferenceAcrossFolders(t *testing.T) {
|
||||
dir := writeData(t, map[string]string{
|
||||
"en_US/person": `["Pat Smith"]`,
|
||||
"sv_SE/greeting": `{"format":"Hej, {..en_US.person}!"}`,
|
||||
})
|
||||
f := newFakes(t, dir, WithSeed(1))
|
||||
if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" {
|
||||
t.Fatalf("greeting = %q, want \"Hej, Pat Smith!\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferenceIntoAField reaches a field inside a referenced category, crossing
|
||||
// a single-variant choice and then a template field (..who.last).
|
||||
func TestReferenceIntoAField(t *testing.T) {
|
||||
dir := writeData(t, map[string]string{
|
||||
"who": `[{"format":"{first} {last}","first":["Ada"],"last":["Byron"]}]`,
|
||||
"card": `{"format":"signed {..who.last}"}`,
|
||||
})
|
||||
f := newFakes(t, dir, WithSeed(1))
|
||||
if got := fake(t, f, "card"); got != "signed Byron" {
|
||||
t.Fatalf("card = %q, want \"signed Byron\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferenceInAlternation lets a reference stand as one arm of a {a|..b}
|
||||
// alternation, so a field and a cross-file value share one slot.
|
||||
func TestReferenceInAlternation(t *testing.T) {
|
||||
dir := writeData(t, map[string]string{
|
||||
"far": `["X"]`,
|
||||
"near": `{"format":"{here|..far}","here":["H"]}`,
|
||||
})
|
||||
f := newFakes(t, dir, WithSeed(2))
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
seen[fake(t, f, "near")] = true
|
||||
}
|
||||
if !seen["H"] || !seen["X"] || len(seen) != 2 {
|
||||
t.Fatalf("alternation produced %v, want both H and X", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferenceCombinesLoadedPaths is the point of references over the merge
|
||||
// model: data layered from two dirs can point at each other through the root.
|
||||
func TestReferenceCombinesLoadedPaths(t *testing.T) {
|
||||
a := writeData(t, map[string]string{"en_US/word": `["river"]`})
|
||||
b := writeData(t, map[string]string{"mine/slug": `{"format":"the-{..en_US.word}"}`})
|
||||
f := newFakesN(t, []string{a, b}, WithSeed(1))
|
||||
if got := fake(t, f, "mine.slug"); got != "the-river" {
|
||||
t.Fatalf("slug = %q, want the-river", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferenceChain follows a reference to a node that is itself a reference, so
|
||||
// linking order cannot matter.
|
||||
func TestReferenceChain(t *testing.T) {
|
||||
dir := writeData(t, map[string]string{
|
||||
"a": `{"format":"{..b}"}`,
|
||||
"b": `{"format":"{..c}"}`,
|
||||
"c": `["deep"]`,
|
||||
})
|
||||
f := newFakes(t, dir, WithSeed(1))
|
||||
if got := fake(t, f, "a"); got != "deep" {
|
||||
t.Fatalf("a = %q, want deep", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferenceErrors lists the references New must reject up front, so a bad path
|
||||
// fails at load, never at a random render.
|
||||
func TestReferenceErrors(t *testing.T) {
|
||||
cases := map[string]map[string]string{
|
||||
"missing target": {"card": `{"format":"{..nope.gone}"}`},
|
||||
"folder target": {"en_US/word": `["w"]`, "card": `{"format":"{..en_US}"}`},
|
||||
"multi-variant on the path": {
|
||||
"who": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`,
|
||||
"card": `{"format":"{..who.f}"}`,
|
||||
},
|
||||
"empty reference path": {"card": `{"format":"{..}"}`},
|
||||
}
|
||||
for name, files := range cases {
|
||||
if _, err := New([]string{writeData(t, files)}); err == nil {
|
||||
t.Errorf("%s: New = nil error, want a reference error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
-2
@@ -175,8 +175,9 @@ func expand(r rng, format string, fields map[string]node) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resolve renders a "{token}" body: one or more field names separated by '|',
|
||||
// of which one is chosen at random. checkTokens guarantees every name exists.
|
||||
// resolve renders a "{token}" body: one or more names separated by '|', one
|
||||
// picked at random. A name is a sibling field or a {..path} reference, which
|
||||
// linkRefs bound into fields too; checkTokens and linkRefs guarantee both exist.
|
||||
func resolve(r rng, token string, fields map[string]node) string {
|
||||
names := strings.Split(token, "|")
|
||||
return render(r, fields[names[r.IntN(len(names))]])
|
||||
@@ -366,6 +367,12 @@ func checkTokens(format string, fields map[string]node) error {
|
||||
}
|
||||
} else {
|
||||
for _, name := range strings.Split(body, "|") {
|
||||
if isRef(name) {
|
||||
if name == refPrefix {
|
||||
return fmt.Errorf("token {%s}: reference has no path", body)
|
||||
}
|
||||
continue // a root reference; its target is checked at New (see linkRefs)
|
||||
}
|
||||
if _, ok := fields[name]; !ok {
|
||||
return fmt.Errorf("token {%s}: no field %q", body, name)
|
||||
}
|
||||
@@ -377,6 +384,131 @@ func checkTokens(format string, fields map[string]node) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// refPrefix marks a {..path} token: a reference to a node elsewhere in the data
|
||||
// root rather than a sibling field. The path after it is the same dot path Fake
|
||||
// takes, resolved across every loaded directory (see linkRefs).
|
||||
const refPrefix = ".."
|
||||
|
||||
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
|
||||
|
||||
// linkRefs resolves every {..path} reference in the assembled tree, binding the
|
||||
// target node into the referring template's fields under the token's key so the
|
||||
// ordinary resolver renders it like a sibling. It runs once, after all data is
|
||||
// merged, so a reference sees the final (override-resolved) tree. A path that is
|
||||
// unknown, names a folder, or steps through a multi-variant choice fails here,
|
||||
// keeping a bad reference a New-time error, never a random render-time one. The
|
||||
// seen set both skips shared nodes and stops a cyclic reference looping the walk.
|
||||
func linkRefs(root map[string]node) error {
|
||||
seen := map[node]bool{}
|
||||
var visit func(node) error
|
||||
visit = func(n node) error {
|
||||
if n == nil || seen[n] {
|
||||
return nil
|
||||
}
|
||||
seen[n] = true
|
||||
switch n := n.(type) {
|
||||
case *group:
|
||||
for _, c := range n.children {
|
||||
if err := visit(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *choice:
|
||||
for _, it := range n.items {
|
||||
if err := visit(it); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *template:
|
||||
for _, name := range refTokens(n.format) {
|
||||
target, err := lookup(root, strings.Split(name[len(refPrefix):], "."))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reference {%s}: %w", name, err)
|
||||
}
|
||||
n.fields[name] = target
|
||||
}
|
||||
for _, c := range n.fields {
|
||||
if err := visit(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, c := range root {
|
||||
if err := visit(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookup finds the single node a reference path names, walking groups and
|
||||
// template fields by segment and descending a single-variant choice as a
|
||||
// transparent wrapper. A missing segment, a folder target, or a step through a
|
||||
// multi-variant choice (which has no one value to bind) is an error.
|
||||
func lookup(root map[string]node, segments []string) (node, error) {
|
||||
var n node = &group{children: root}
|
||||
for i := 0; i < len(segments); i++ {
|
||||
switch c := n.(type) {
|
||||
case *group:
|
||||
child, ok := c.children[segments[i]]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no entry %q", segments[i])
|
||||
}
|
||||
n = child
|
||||
case *template:
|
||||
child, ok := c.fields[segments[i]]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no field %q", segments[i])
|
||||
}
|
||||
n = child
|
||||
case *choice:
|
||||
if len(c.items) != 1 {
|
||||
return nil, fmt.Errorf("%q steps through a %d-way choice", segments[i], len(c.items))
|
||||
}
|
||||
n, i = c.items[0], i-1 // a choice consumes no segment; reprocess it unwrapped
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[i])
|
||||
}
|
||||
}
|
||||
if _, ok := n.(*group); ok {
|
||||
return nil, fmt.Errorf("names a folder, not a value")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// refTokens returns the reference names ({..path}, including ones inside a
|
||||
// {a|..path} alternation) used in a format string, scanning braces the way
|
||||
// expand does. Function tokens carry no references.
|
||||
func refTokens(format string) []string {
|
||||
var refs []string
|
||||
rs := []rune(format)
|
||||
for i := 0; i < len(rs); i++ {
|
||||
switch rs[i] {
|
||||
case '#':
|
||||
i++
|
||||
case '{':
|
||||
end := i + 1
|
||||
for end < len(rs) && rs[end] != '}' {
|
||||
end++
|
||||
}
|
||||
if end >= len(rs) {
|
||||
return refs // checkTokens already rejected the unterminated brace
|
||||
}
|
||||
if body := string(rs[i+1 : end]); strings.IndexByte(body, '(') < 0 {
|
||||
for _, name := range strings.Split(body, "|") {
|
||||
if isRef(name) {
|
||||
refs = append(refs, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// repeatOf reads a template's "repeat" (default 1): how many times its format
|
||||
// is rendered and concatenated. A present one must be a positive integer.
|
||||
func repeatOf(m map[string]any) (int, error) {
|
||||
|
||||
Reference in New Issue
Block a user