diff --git a/README.md b/README.md index deafb52..dc3821d 100644 --- a/README.md +++ b/README.md @@ -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`): diff --git a/data.go b/data.go index be1bcaa..302c8af 100644 --- a/data.go +++ b/data.go @@ -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 } diff --git a/reference_test.go b/reference_test.go new file mode 100644 index 0000000..ddda49b --- /dev/null +++ b/reference_test.go @@ -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) + } + } +} diff --git a/template.go b/template.go index 034da1a..7491fbf 100644 --- a/template.go +++ b/template.go @@ -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) {