Add {..path} references: render a node from elsewhere in the merged data root

This commit is contained in:
lilleman
2026-06-09 00:11:17 +02:00
parent 8db65ac598
commit 18de1b7004
4 changed files with 246 additions and 4 deletions
+17 -1
View File
@@ -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`):
+5 -1
View File
@@ -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
}
+90
View File
@@ -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
View File
@@ -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) {