Compare commits
2 Commits
fcc58a74ab
...
1bf5470c6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bf5470c6f | |||
| 5d1e567b8d |
@@ -110,7 +110,7 @@ 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 entries
|
last loaded. Names may not use `.`, `|`, `(`, `{`, `}` or `/`; dot-prefixed entries
|
||||||
are skipped, so a data directory can also be a checkout.
|
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`,
|
||||||
@@ -141,7 +141,7 @@ Every character is literal except a `{…}` token:
|
|||||||
| `{name.field}` | `field` of one draw of `name` ([Correlated fields](#correlated-fields)) |
|
| `{name.field}` | `field` of one draw of `name` ([Correlated fields](#correlated-fields)) |
|
||||||
| `{a\|b}` | one of the named fields, even odds |
|
| `{a\|b}` | one of the named fields, even odds |
|
||||||
| `{fn(args)}` | a builtin ([Functions](#functions)) |
|
| `{fn(args)}` | a builtin ([Functions](#functions)) |
|
||||||
| `{..path}` | a node reached from the data root ([References](#references)) |
|
| `{/path}`, `{.path}`, `{..path}` | a node reached from the data root, this file's folder, or the folder above ([References](#references)) |
|
||||||
| `{{`, `}}` | a literal `{` or `}` |
|
| `{{`, `}}` | a literal `{` or `}` |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -267,21 +267,25 @@ a mix.
|
|||||||
|
|
||||||
### References
|
### References
|
||||||
|
|
||||||
`{..path}` renders a node from the **data root** — the path `Fake` takes, across
|
A reference renders a node from elsewhere in the data — the path `Fake` takes,
|
||||||
every loaded source — so one category borrows another, even across folders or
|
across every loaded source — so one category borrows another. The sigil says
|
||||||
layered directories:
|
where the path starts, as in a filesystem: `{/en_US.person}` from the data root,
|
||||||
|
`{.username}` from the folder this file sits in, `{..username}` from the folder
|
||||||
|
above. `data/sv_SE/email.json` can therefore read its own locale's `username`
|
||||||
|
without naming `sv_SE`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"Hej, {..en_US.person}!"
|
"Hej, {/en_US.person}!"
|
||||||
```
|
```
|
||||||
|
|
||||||
Renders e.g. `Hej, Pat Smith!`. A reference into a category is held like a
|
Renders e.g. `Hej, Pat Smith!`. A reference into a category is held like a
|
||||||
[correlated](#correlated-fields) path — `{..sv_SE.person.first} {..sv_SE.person.last}`
|
[correlated](#correlated-fields) path — `{.person.first} {.person.last}` name one
|
||||||
name one person, `{lowercase(..sv_SE.person.first)}` reads that same draw — while
|
person, `{lowercase(.person.first)}` reads that same draw, and `{.person.first}`
|
||||||
a bare `{..misc.uuid} {..misc.uuid}` is two draws. Rejected at `New`: a path that
|
beside `{/sv_SE.person.last}` in `sv_SE` is one person too — while a bare
|
||||||
is unknown, names a folder, or reads a field not every variant of a choice
|
`{/misc.uuid} {/misc.uuid}` is two draws. Rejected at `New`: a path that is
|
||||||
carries, and a reference that leads back to its own value, directly, mutually or
|
unknown, names a folder, has no folder above, or reads a field not every variant
|
||||||
through a chain.
|
of a choice carries, and a reference that leads back to its own value, directly,
|
||||||
|
mutually or through a chain.
|
||||||
|
|
||||||
### Correlated fields
|
### Correlated fields
|
||||||
|
|
||||||
@@ -315,8 +319,8 @@ The sub-fields stay addressable — `Fake("address.place.locality")` renders, an
|
|||||||
|
|
||||||
A name any token reads as a path (`{p.first}`) or as an operand (`{calc(net * 2)}`,
|
A name any token reads as a path (`{p.first}`) or as an operand (`{calc(net * 2)}`,
|
||||||
`{uppercase(w)}`) is drawn **once per expansion**, and every other route to it —
|
`{uppercase(w)}`) is drawn **once per expansion**, and every other route to it —
|
||||||
a bare `{p}`, a second bare `{w}`, `{..cat.net}`, a nested template rendering
|
a bare `{p}`, a second bare `{w}`, `{/cat.net}`, a nested template rendering
|
||||||
`{..cat.p.last}`, at any depth — is a load error naming the spelling to use. A
|
`{/cat.p.last}`, at any depth — is a load error naming the spelling to use. A
|
||||||
name nothing reads that way is drawn each time: `{word} {word}` differs. An
|
name nothing reads that way is drawn each time: `{word} {word}` differs. An
|
||||||
expansion is one render of one format, so each `repeat` iteration and each nested
|
expansion is one render of one format, so each `repeat` iteration and each nested
|
||||||
template draws again.
|
template draws again.
|
||||||
@@ -351,11 +355,15 @@ tokens add cost in proportion to the output.
|
|||||||
- **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.
|
||||||
- **A bare reference draws each time; a reference path is held.** `{..p} {..p}`
|
- **A bare reference draws each time; a reference path is held.** `{/p} {/p}`
|
||||||
is two draws, as `{word} {word}` is, while `{..p.first}` beside a nested template
|
is two draws, as `{word} {word}` is, while `{/p.first}` beside a nested template
|
||||||
rendering `{..p.first}` is a load error: a bare token is by contract an
|
rendering `{/p.first}` is a load error: a bare token is by contract an
|
||||||
independent draw, a path pins its level, and any route into a pinned level from
|
independent draw, a path pins its level, and any route into a pinned level from
|
||||||
another expansion could show another row.
|
another expansion could show another row.
|
||||||
|
- **Reference sigils follow the filesystem.** `/` is the root, `.` this file's
|
||||||
|
folder, `..` the folder above — what those spellings already mean to anyone who
|
||||||
|
has typed a path. A locale's files reach each other without naming the locale,
|
||||||
|
so a folder renames and copies without editing its references.
|
||||||
- **Samples say what they emit, transforms what they do.** `{upper(2)}` is two
|
- **Samples say what they emit, transforms what they do.** `{upper(2)}` is two
|
||||||
letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on
|
letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on
|
||||||
whether the argument looks like a number.
|
whether the argument looks like a number.
|
||||||
@@ -398,7 +406,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
|
||||||
render.go Fake and the recursive renderer (choices, format strings, paths, held draws)
|
render.go Fake and the recursive renderer (choices, format strings, paths, held draws)
|
||||||
template.go the {token} grammar: scanning, arms, operands, validation
|
template.go the {token} grammar: scanning, arms, operands, validation
|
||||||
reference.go {..path} binding across the tree, the render graph, and the walks over it
|
reference.go {/path} binding across the tree, the render graph, and the walks over it
|
||||||
builtins.go the {name()} function registry and its implementations
|
builtins.go the {name()} function registry and its implementations
|
||||||
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
|
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
|
||||||
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
|
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
|
||||||
|
|||||||
+33
-34
@@ -92,13 +92,13 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
|
|||||||
// afresh beside the path that reads its held draw — the same overlap by
|
// afresh beside the path that reads its held draw — the same overlap by
|
||||||
// another spelling.
|
// another spelling.
|
||||||
rejected := map[string]string{
|
rejected := map[string]string{
|
||||||
"reference names the head": `{"format":"{p.first}|{..cat.p}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
"reference names the head": `{"format":"{p.first}|{/cat.p}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
||||||
"reference names the leaf": `{"format":"{p.addr}|{..cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`,
|
"reference names the leaf": `{"format":"{p.addr}|{/cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`,
|
||||||
// The reference need not sit in the format that binds: any field it renders
|
// The reference need not sit in the format that binds: any field it renders
|
||||||
// reaches the level just the same, however deep.
|
// reaches the level just the same, however deep.
|
||||||
"reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` +
|
"reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` +
|
||||||
`{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` +
|
`{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` +
|
||||||
`"inner":"{..cat.p}"}`,
|
`"inner":"{/cat.p}"}`,
|
||||||
}
|
}
|
||||||
for name, file := range rejected {
|
for name, file := range rejected {
|
||||||
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
|
||||||
@@ -108,7 +108,7 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// A reference to anything this format does not bind is untouched.
|
// A reference to anything this format does not bind is untouched.
|
||||||
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"cat": `{"format":"{p.first} {..surname}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
"cat": `{"format":"{p.first} {/surname}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
||||||
"surname": `["Eriksson","Lindqvist"]`,
|
"surname": `["Eriksson","Lindqvist"]`,
|
||||||
}))); err != nil {
|
}))); err != nil {
|
||||||
t.Errorf("New = %v, want a reference outside the bound level accepted", err)
|
t.Errorf("New = %v, want a reference outside the bound level accepted", err)
|
||||||
@@ -120,7 +120,7 @@ func TestCycleReachedOnlyByAPathTokenIsRejected(t *testing.T) {
|
|||||||
// cycle walk has to follow it there. A head whose own format names nothing
|
// cycle walk has to follow it there. A head whose own format names nothing
|
||||||
// would otherwise hide the cycle until render, where it is fatal.
|
// would otherwise hide the cycle until render, where it is fatal.
|
||||||
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"a": `{"format":"{p.x}","p":{"format":"static","x":"{..a}"}}`,
|
"a": `{"format":"{p.x}","p":{"format":"static","x":"{/a}"}}`,
|
||||||
})))
|
})))
|
||||||
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
|
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
|
||||||
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
|
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
|
||||||
@@ -131,7 +131,7 @@ func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) {
|
|||||||
// {p.a} renders q, so it is a route to the level {q.x} holds — even though p's
|
// {p.a} renders q, so it is a route to the level {q.x} holds — even though p's
|
||||||
// own format names nothing.
|
// own format names nothing.
|
||||||
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{..thing.q}"},` +
|
"thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{/thing.q}"},` +
|
||||||
`"q":{"format":"{x}","x":["1","2"]}}`,
|
`"q":{"format":"{x}","x":["1","2"]}}`,
|
||||||
})))
|
})))
|
||||||
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
||||||
@@ -151,14 +151,14 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
"a reference beside the operand": {
|
"a reference beside the operand": {
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"cat": `{"format":"{..cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
|
"cat": `{"format":"{/cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
|
||||||
},
|
},
|
||||||
`{..cat.net} renders "net"`,
|
`{/cat.net} renders "net"`,
|
||||||
},
|
},
|
||||||
"a reference one level down": {
|
"a reference one level down": {
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
|
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
|
||||||
`"q":"{..cat.net}"}`,
|
`"q":"{/cat.net}"}`,
|
||||||
},
|
},
|
||||||
`{q} renders "net"`,
|
`{q} renders "net"`,
|
||||||
},
|
},
|
||||||
@@ -167,7 +167,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
|||||||
"a reference to an operand wrapped in a choice": {
|
"a reference to an operand wrapped in a choice": {
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` +
|
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` +
|
||||||
`"q":"{..cat.n}"}`,
|
`"q":"{/cat.n}"}`,
|
||||||
},
|
},
|
||||||
`{q} renders "n"`,
|
`{q} renders "n"`,
|
||||||
},
|
},
|
||||||
@@ -176,7 +176,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
|||||||
"an operand reaching another operand": {
|
"an operand reaching another operand": {
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` +
|
"cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` +
|
||||||
`"b":"{..cat.a}"}`,
|
`"b":"{/cat.a}"}`,
|
||||||
},
|
},
|
||||||
`calc operand "b" renders "a"`,
|
`calc operand "b" renders "a"`,
|
||||||
},
|
},
|
||||||
@@ -185,15 +185,15 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
|||||||
// rejected, so the reference spelling has to be.
|
// rejected, so the reference spelling has to be.
|
||||||
"a reference into the operand": {
|
"a reference into the operand": {
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"cat": `{"format":"{calc(net * 2, 2)}|{..cat.net.v}",` +
|
"cat": `{"format":"{calc(net * 2, 2)}|{/cat.net.v}",` +
|
||||||
`"net":{"format":"{v}","v":["10.00","20.00"]}}`,
|
`"net":{"format":"{v}","v":["10.00","20.00"]}}`,
|
||||||
},
|
},
|
||||||
`{..cat.net.v} renders "net"`,
|
`{/cat.net.v} renders "net"`,
|
||||||
},
|
},
|
||||||
"a reference into the operand one level down": {
|
"a reference into the operand one level down": {
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` +
|
"cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` +
|
||||||
`"q":"{..cat.net.v}"}`,
|
`"q":"{/cat.net.v}"}`,
|
||||||
},
|
},
|
||||||
`{q} renders "net"`,
|
`{q} renders "net"`,
|
||||||
},
|
},
|
||||||
@@ -203,7 +203,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
|||||||
"a violation on the second of two held heads": {
|
"a violation on the second of two held heads": {
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` +
|
"cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` +
|
||||||
`"b":{"format":"{y}","y":["3","4"]},"w":"{..cat.b}"}`,
|
`"b":{"format":"{y}","y":["3","4"]},"w":"{/cat.b}"}`,
|
||||||
},
|
},
|
||||||
`{w} renders "b"`,
|
`{w} renders "b"`,
|
||||||
},
|
},
|
||||||
@@ -230,24 +230,24 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
|||||||
"a reference to a sibling the operand never renders": {
|
"a reference to a sibling the operand never renders": {
|
||||||
"cat": `{"format":"{calc(net * 2, 2)} {unit}",` +
|
"cat": `{"format":"{calc(net * 2, 2)} {unit}",` +
|
||||||
`"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` +
|
`"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` +
|
||||||
`"unit":"{..cat.net.spare}"}`,
|
`"unit":"{/cat.net.spare}"}`,
|
||||||
},
|
},
|
||||||
// Two operands drawing from one source are two names, so two draws: each is
|
// Two operands drawing from one source are two names, so two draws: each is
|
||||||
// held under its own name and shown once, and neither can disagree.
|
// held under its own name and shown once, and neither can disagree.
|
||||||
"two operands sharing one source": {
|
"two operands sharing one source": {
|
||||||
"die": `["1","2","3","4","5","6"]`,
|
"die": `["1","2","3","4","5","6"]`,
|
||||||
"cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{..die}",` +
|
"cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{/die}",` +
|
||||||
`"d2":"{..die}"}`,
|
`"d2":"{/die}"}`,
|
||||||
},
|
},
|
||||||
// Likewise a reference drawing from what the operand draws from: {..common}
|
// Likewise a reference drawing from what the operand draws from: {/common}
|
||||||
// and net are two names, not two spellings of one field.
|
// and net are two names, not two spellings of one field.
|
||||||
"a reference to what an operand renders through": {
|
"a reference to what an operand renders through": {
|
||||||
"common": `["1","2"]`,
|
"common": `["1","2"]`,
|
||||||
"cat": `{"format":"{calc(net * 2, 2)} {..common}","net":"{..common}"}`,
|
"cat": `{"format":"{calc(net * 2, 2)} {/common}","net":"{/common}"}`,
|
||||||
},
|
},
|
||||||
// A fixed string cannot disagree with itself, so it needs no fence.
|
// A fixed string cannot disagree with itself, so it needs no fence.
|
||||||
"a literal operand named twice": {
|
"a literal operand named twice": {
|
||||||
"cat": `{"format":"{calc(n * 2, 0)} {..cat.n}","n":"5"}`,
|
"cat": `{"format":"{calc(n * 2, 0)} {/cat.n}","n":"5"}`,
|
||||||
},
|
},
|
||||||
// One node reached twice while walking the operand: the walk must not
|
// One node reached twice while walking the operand: the walk must not
|
||||||
// revisit it, and the repeat is not a second route to anything.
|
// revisit it, and the repeat is not a second route to anything.
|
||||||
@@ -269,11 +269,11 @@ func TestAPathReachesEveryVariantItMightDraw(t *testing.T) {
|
|||||||
// to a held level disagrees silently.
|
// to a held level disagrees silently.
|
||||||
rejected := map[string]struct{ file, want string }{
|
rejected := map[string]struct{ file, want string }{
|
||||||
"a cycle in a later variant": {
|
"a cycle in a later variant": {
|
||||||
`{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat}"}]}`,
|
`{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat}"}]}`,
|
||||||
"reference cycle",
|
"reference cycle",
|
||||||
},
|
},
|
||||||
"a second route in a later variant": {
|
"a second route in a later variant": {
|
||||||
`{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat.q}"}],` +
|
`{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat.q}"}],` +
|
||||||
`"q":{"format":"{y}","y":["1","2"]}}`,
|
`"q":{"format":"{y}","y":["1","2"]}}`,
|
||||||
"reads a path into",
|
"reads a path into",
|
||||||
},
|
},
|
||||||
@@ -292,9 +292,9 @@ func TestALevelAPathNeverRendersIsAccepted(t *testing.T) {
|
|||||||
// path at all. Both orders must load.
|
// path at all. Both orders must load.
|
||||||
accepted := map[string]string{
|
accepted := map[string]string{
|
||||||
"reference in the head's own format": `{"format":"{p.first} {q.a}",` +
|
"reference in the head's own format": `{"format":"{p.first} {q.a}",` +
|
||||||
`"p":{"format":"{first} {..thing.q}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}`,
|
`"p":{"format":"{first} {/thing.q}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}`,
|
||||||
"the mirror shape": `{"format":"{p.first} {q.a}",` +
|
"the mirror shape": `{"format":"{p.first} {q.a}",` +
|
||||||
`"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {..thing.p}","a":["1","2"]}}`,
|
`"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {/thing.p}","a":["1","2"]}}`,
|
||||||
}
|
}
|
||||||
for name, file := range accepted {
|
for name, file := range accepted {
|
||||||
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"thing": file}))); err != nil {
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"thing": file}))); err != nil {
|
||||||
@@ -309,9 +309,9 @@ func TestADeepDiamondChainLoads(t *testing.T) {
|
|||||||
files := map[string]string{"l0": `"x"`}
|
files := map[string]string{"l0": `"x"`}
|
||||||
for i := 1; i <= 30; i++ {
|
for i := 1; i <= 30; i++ {
|
||||||
files[fmt.Sprintf("l%d", i)] = fmt.Sprintf(
|
files[fmt.Sprintf("l%d", i)] = fmt.Sprintf(
|
||||||
`{"format":"{a}{b}","a":"{..l%d}","b":"{..l%d}"}`, i-1, i-1)
|
`{"format":"{a}{b}","a":"{/l%d}","b":"{/l%d}"}`, i-1, i-1)
|
||||||
}
|
}
|
||||||
files["thing"] = `{"format":"{p.first} {..l30}","p":{"format":"x","first":["A","B"]}}`
|
files["thing"] = `{"format":"{p.first} {/l30}","p":{"format":"x","first":["A","B"]}}`
|
||||||
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil {
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil {
|
||||||
t.Fatalf("New = %v, want a deep diamond chain to load", err)
|
t.Fatalf("New = %v, want a deep diamond chain to load", err)
|
||||||
}
|
}
|
||||||
@@ -323,7 +323,7 @@ func TestASharedNodeIsWalkedOnce(t *testing.T) {
|
|||||||
// the shared node once per route.
|
// the shared node once per route.
|
||||||
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"cat": `{"format":"{p.first}|{q}","p":{"format":"{first}","first":["Anna","Bo"]},` +
|
"cat": `{"format":"{p.first}|{q}","p":{"format":"{first}","first":["Anna","Bo"]},` +
|
||||||
`"q":{"format":"{a}{b}","a":"{..shared}","b":"{..shared}"}}`,
|
`"q":{"format":"{a}{b}","a":"{/shared}","b":"{/shared}"}}`,
|
||||||
"shared": `"x"`,
|
"shared": `"x"`,
|
||||||
})), WithSeed(1))
|
})), WithSeed(1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -351,7 +351,7 @@ func TestReferenceToAMatchingStringIsAccepted(t *testing.T) {
|
|||||||
// held one. Two unrelated literals that merely spell the same text must not
|
// held one. Two unrelated literals that merely spell the same text must not
|
||||||
// read as the same node — the trap when comparing a value type.
|
// read as the same node — the trap when comparing a value type.
|
||||||
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"cat": `{"format":"{p.city} {..other.tag}","p":{"format":"{city}","city":"Stockholm"}}`,
|
"cat": `{"format":"{p.city} {/other.tag}","p":{"format":"{city}","city":"Stockholm"}}`,
|
||||||
"other": `{"format":"x","tag":"Stockholm"}`,
|
"other": `{"format":"x","tag":"Stockholm"}`,
|
||||||
}))); err != nil {
|
}))); err != nil {
|
||||||
t.Fatalf("New = %v, want a reference to a matching string accepted", err)
|
t.Fatalf("New = %v, want a reference to a matching string accepted", err)
|
||||||
@@ -642,12 +642,11 @@ func TestDeepPathsUnderOneHeadStayIndependentWhereTheyDiverge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEmptyPathSegmentIsRejected(t *testing.T) {
|
func TestEmptyPathSegmentIsRejected(t *testing.T) {
|
||||||
// "{a.}", "{.b}" and "{a..b}" are unfinished paths, and the segment naming
|
// "{a.}" and "{a..b}" are unfinished paths, and the segment naming nothing is
|
||||||
// nothing is reported as that rather than as a missing field. An empty name is
|
// reported as that rather than as a missing field. An empty name is rejected
|
||||||
// rejected where it is authored, so no data can make these resolve.
|
// where it is authored, so no data can make these resolve.
|
||||||
rejected := map[string]string{
|
rejected := map[string]string{
|
||||||
"trailing dot": `{"format":"[{a.}]","a":"x"}`,
|
"trailing dot": `{"format":"[{a.}]","a":"x"}`,
|
||||||
"leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":"V"}}`,
|
|
||||||
"double dot": `{"format":"[{a..b}]","a":"x"}`,
|
"double dot": `{"format":"[{a..b}]","a":"x"}`,
|
||||||
}
|
}
|
||||||
for name, file := range rejected {
|
for name, file := range rejected {
|
||||||
@@ -667,7 +666,7 @@ func TestCycleThroughAPathTokenIsRejected(t *testing.T) {
|
|||||||
// must be caught at New. Reaching render would be fatal: the recursion never
|
// must be caught at New. Reaching render would be fatal: the recursion never
|
||||||
// terminates, and a stack overflow cannot be recovered.
|
// terminates, and a stack overflow cannot be recovered.
|
||||||
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"a": `{"format":"{p.x}","p":{"format":"{x}","x":"{..a}"}}`,
|
"a": `{"format":"{p.x}","p":{"format":"{x}","x":"{/a}"}}`,
|
||||||
})))
|
})))
|
||||||
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
|
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
|
||||||
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
|
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
|
||||||
|
|||||||
+2
-4
@@ -129,10 +129,8 @@ func transformArg(fields map[string]node, a []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isRef(leaf) {
|
if isRef(leaf) {
|
||||||
if leaf == refPrefix {
|
_, _, err := refShape(leaf)
|
||||||
return fmt.Errorf("reference has no path")
|
return err
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return checkArm(leaf, fields)
|
return checkArm(leaf, fields)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (s dataSource) name(p string) string {
|
|||||||
// keyed by its base name (address.json -> "address"); each subdirectory becomes a
|
// keyed by its base name (address.json -> "address"); each subdirectory becomes a
|
||||||
// nested group, so folders turn into dot-path segments. Sources merge left to right:
|
// nested group, so folders turn into dot-path segments. Sources merge left to right:
|
||||||
// matching groups merge by their children, and any other clash is won by the last
|
// matching groups merge by their children, and any other clash is won by the last
|
||||||
// source loaded. Once merged, linkRefs binds every {..path} reference against the
|
// source loaded. Once merged, linkRefs binds every reference against the
|
||||||
// final tree.
|
// final tree.
|
||||||
func loadData(sources []dataSource) (map[string]node, error) {
|
func loadData(sources []dataSource) (map[string]node, error) {
|
||||||
root := map[string]node{}
|
root := map[string]node{}
|
||||||
|
|||||||
+4
-4
@@ -120,7 +120,7 @@ func TestNewErrors(t *testing.T) {
|
|||||||
`category "a|b" contains "|"`,
|
`category "a|b" 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.
|
||||||
"empty field name": {
|
"empty field name": {
|
||||||
map[string]string{"a": `{"format":"[{}]","":"VALUE"}`},
|
map[string]string{"a": `{"format":"[{}]","":"VALUE"}`},
|
||||||
@@ -146,13 +146,13 @@ func TestNewErrors(t *testing.T) {
|
|||||||
// A reference arm is the only kind that reaches the repeat check by passing
|
// A reference arm is the only kind that reaches the repeat check by passing
|
||||||
// the per-arm checks rather than falling through them.
|
// the per-arm checks rather than falling through them.
|
||||||
"repeated reference arm": {
|
"repeated reference arm": {
|
||||||
map[string]string{"a": `"x"`, "b": `"{..a|..a}"`},
|
map[string]string{"a": `"x"`, "b": `"{/a|/a}"`},
|
||||||
`arm "..a" is repeated`,
|
`arm "/a" is repeated`,
|
||||||
},
|
},
|
||||||
// An arm that is broken on its own terms is reported as that, not as a
|
// An arm that is broken on its own terms is reported as that, not as a
|
||||||
// repeat: the repeat is a consequence of the real mistake.
|
// repeat: the repeat is a consequence of the real mistake.
|
||||||
"repeated arm with no path": {
|
"repeated arm with no path": {
|
||||||
map[string]string{"a": `"{..|..}"`},
|
map[string]string{"a": `"{/|/}"`},
|
||||||
"reference has no path",
|
"reference has no path",
|
||||||
},
|
},
|
||||||
// No field can be named "", so the token is told that rather than sent to
|
// No field can be named "", so the token is told that rather than sent to
|
||||||
|
|||||||
+3
-3
@@ -16,8 +16,8 @@ func TestList(t *testing.T) {
|
|||||||
"person": `{"format":"{first} {last}","first":"A","last":"B"}`,
|
"person": `{"format":"{first} {last}","first":"A","last":"B"}`,
|
||||||
"word": `["x", "y"]`,
|
"word": `["x", "y"]`,
|
||||||
"geo/city": `"Z"`,
|
"geo/city": `"Z"`,
|
||||||
// A bound {..path} reference is a render edge, not an addressable field.
|
// A bound {/path} reference is a render edge, not an addressable field.
|
||||||
"greeting": `{"format":"hej {..person.first} and {own}","own":"x"}`,
|
"greeting": `{"format":"hej {/person.first} and {own}","own":"x"}`,
|
||||||
// Only the fields every variant carries are addressable, so "extra" is not.
|
// Only the fields every variant carries are addressable, so "extra" is not.
|
||||||
"coin": `[{"format":"{code}","code":"A","name":"Aa"},{"format":"{code}","code":"B","name":"Bb","extra":"x"}]`,
|
"coin": `[{"format":"{code}","code":"A","name":"Aa"},{"format":"{code}","code":"B","name":"Bb","extra":"x"}]`,
|
||||||
})
|
})
|
||||||
@@ -186,7 +186,7 @@ func TestRepeatProductAlongAPathIsCapped(t *testing.T) {
|
|||||||
for name, files := range map[string]map[string]string{
|
for name, files := range map[string]map[string]string{
|
||||||
"nested": {"cat": `{"format":"{a}","repeat":2048,"a":{"format":"{b}","repeat":2048,"b":"x"}}`},
|
"nested": {"cat": `{"format":"{a}","repeat":2048,"a":{"format":"{b}","repeat":2048,"b":"x"}}`},
|
||||||
"through a reference": {
|
"through a reference": {
|
||||||
"a": `{"format":"{..b}","repeat":2048}`,
|
"a": `{"format":"{/b}","repeat":2048}`,
|
||||||
"b": `{"format":"x","repeat":2048}`,
|
"b": `{"format":"x","repeat":2048}`,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type template struct {
|
|||||||
grow int // minimum output size, to size the render buffer
|
grow int // minimum output size, to size the render buffer
|
||||||
fixed bool // no op varies, so every render is lit
|
fixed bool // no op varies, so every render is lit
|
||||||
lit string // the whole output when fixed
|
lit string // the whole output when fixed
|
||||||
refs map[string]string // each {..path} the format reads -> the head it is bound under
|
refs map[string]refBinding // each reference the format reads -> what it is bound to
|
||||||
// bound maps each field the format addresses by dotted path to one path token
|
// bound maps each field the format addresses by dotted path to one path token
|
||||||
// reading it, which is the half of an overlap the fences name. nil when the
|
// reading it, which is the half of an overlap the fences name. nil when the
|
||||||
// format takes no path.
|
// format takes no path.
|
||||||
@@ -208,9 +208,6 @@ func compileTemplate(m map[string]any) (node, error) {
|
|||||||
if isOption(k) {
|
if isOption(k) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isRef(k) {
|
|
||||||
return nil, fmt.Errorf("field %q starts with %q, which is reserved for {..path} bindings", k, refPrefix)
|
|
||||||
}
|
|
||||||
if err := checkName(k); err != nil {
|
if err := checkName(k); err != nil {
|
||||||
return nil, fmt.Errorf("field %w", err)
|
return nil, fmt.Errorf("field %w", err)
|
||||||
}
|
}
|
||||||
@@ -321,10 +318,10 @@ 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 and braces delimit the token. A name carrying one is reachable by no format,
|
// call, braces delimit the token and '/' starts a reference. A name carrying one is
|
||||||
// so it is rejected where it is authored rather than at the token that cannot
|
// reachable by no format, so it is rejected where it is authored rather than at
|
||||||
// reach it.
|
// the token that cannot reach it.
|
||||||
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, ""), " ")
|
||||||
|
|||||||
+108
-24
@@ -6,26 +6,70 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// refPrefix marks a {..path} token: a reference to a node elsewhere in the data
|
// A reference names a node by path rather than as a sibling field: {/a.b} from the
|
||||||
// root rather than a sibling field. The path is resolved across every loaded
|
// data root, {.a} from the folder this file sits in, {..a} from the folder above.
|
||||||
// directory (see linkRefs).
|
func isRef(name string) bool { return strings.HasPrefix(name, ".") || strings.HasPrefix(name, "/") }
|
||||||
const refPrefix = ".."
|
|
||||||
|
|
||||||
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
|
// refBinding is what a reference was bound to: the head key its category is held
|
||||||
|
// under, and the tail read into it.
|
||||||
|
type refBinding struct {
|
||||||
|
key string
|
||||||
|
tail []string
|
||||||
|
}
|
||||||
|
|
||||||
// linkRefs resolves every {..path} reference in the assembled tree. The head of the
|
// refShape splits a reference into its sigil and the dotted path after it.
|
||||||
// path — up to the category it names — is bound into the referring template's
|
func refShape(name string) (sigil, rest string, err error) {
|
||||||
// fields, and the rest reads into it the way a sibling path does, so a reference
|
switch {
|
||||||
// is held like a sibling. It runs once, after all data is merged, so a reference
|
case strings.HasPrefix(name, "/"):
|
||||||
// sees the final (override-resolved) tree. A path that is unknown, names a folder,
|
sigil, rest = "/", name[1:]
|
||||||
// or reads a field not every variant carries fails here, keeping a bad reference a
|
case strings.HasPrefix(name, ".."):
|
||||||
// New-time error, never a random render-time one.
|
sigil, rest = "..", name[2:]
|
||||||
func linkRefs(root map[string]node) error {
|
default:
|
||||||
return walkNodes(root, func(path string, n node) error {
|
sigil, rest = ".", name[1:]
|
||||||
t, ok := n.(*template)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
if rest == "" {
|
||||||
|
return "", "", fmt.Errorf("reference has no path")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rest, ".") {
|
||||||
|
return "", "", fmt.Errorf("a reference starts with / (the root), . (this folder) or .. (the folder above)")
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(rest, ".") {
|
||||||
|
if seg == "" {
|
||||||
|
return "", "", fmt.Errorf("path has an empty segment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sigil, rest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refSegments resolves a reference written in folder to a path from the root.
|
||||||
|
func refSegments(name string, folder []string) ([]string, error) {
|
||||||
|
sigil, rest, err := refShape(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var base []string
|
||||||
|
switch sigil {
|
||||||
|
case ".":
|
||||||
|
base = folder
|
||||||
|
case "..":
|
||||||
|
if len(folder) == 0 {
|
||||||
|
return nil, fmt.Errorf("no folder above the root")
|
||||||
|
}
|
||||||
|
base = folder[:len(folder)-1]
|
||||||
|
}
|
||||||
|
return append(append([]string{}, base...), strings.Split(rest, ".")...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkRefs resolves every reference in the assembled tree. The head of the path —
|
||||||
|
// up to the category it names — is bound into the referring template's fields
|
||||||
|
// under its root path, and the rest reads into it the way a sibling path does, so
|
||||||
|
// a reference is held like a sibling and two spellings of one target are one
|
||||||
|
// draw. 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 reads a
|
||||||
|
// field not every variant carries fails here, keeping a bad reference a New-time
|
||||||
|
// 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 {
|
||||||
names := refTokens(t.format)
|
names := refTokens(t.format)
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -33,18 +77,22 @@ func linkRefs(root map[string]node) error {
|
|||||||
if t.fields == nil {
|
if t.fields == nil {
|
||||||
t.fields = map[string]node{}
|
t.fields = map[string]node{}
|
||||||
}
|
}
|
||||||
t.refs = make(map[string]string, len(names))
|
t.refs = make(map[string]refBinding, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
head, target, tail, err := resolveRef(root, strings.Split(name[len(refPrefix):], "."))
|
segments, err := refSegments(name, folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
||||||
}
|
}
|
||||||
key := refPrefix + strings.Join(head, ".")
|
head, target, tail, err := resolveRef(root, segments)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
||||||
|
}
|
||||||
|
key := "/" + strings.Join(head, ".")
|
||||||
if err := checkPath(target, tail, key); err != nil {
|
if err := checkPath(target, tail, key); err != nil {
|
||||||
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
|
||||||
}
|
}
|
||||||
t.fields[key] = target
|
t.fields[key] = target
|
||||||
t.refs[name] = key
|
t.refs[name] = refBinding{key, tail}
|
||||||
}
|
}
|
||||||
if err := t.compileFormat(); err != nil {
|
if err := t.compileFormat(); err != nil {
|
||||||
return fmt.Errorf("%s: %w", path, err)
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
@@ -53,6 +101,42 @@ func linkRefs(root map[string]node) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error {
|
||||||
|
var inCategory func(folder []string, path string, n node) error
|
||||||
|
inCategory = func(folder []string, path string, n node) error {
|
||||||
|
if t, ok := n.(*template); ok {
|
||||||
|
if err := fn(folder, path, t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range contained(n) {
|
||||||
|
if err := inCategory(folder, join(path, c.name), c.node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var inFolder func(folder []string, children map[string]node) error
|
||||||
|
inFolder = func(folder []string, children map[string]node) error {
|
||||||
|
for _, name := range sortedNames(children) {
|
||||||
|
path := join(strings.Join(folder, "."), name)
|
||||||
|
if g, ok := children[name].(*group); ok {
|
||||||
|
if err := inFolder(append(folder[:len(folder):len(folder)], name), g.children); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := inCategory(folder, path, children[name]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return inFolder(nil, root)
|
||||||
|
}
|
||||||
|
|
||||||
// checkBoundLevelsHeld rejects every route to a held name except the ones that read
|
// checkBoundLevelsHeld rejects every route to a held name except the ones that read
|
||||||
// its draw. An expansion holds one draw of that name; anything else that renders it
|
// its draw. An expansion holds one draw of that name; anything else that renders it
|
||||||
// draws again, and the two disagree. checkNoOverlap settles the spellings within one
|
// draws again, and the two disagree. checkNoOverlap settles the spellings within one
|
||||||
@@ -173,7 +257,7 @@ func cover(n node, into map[node]bool, inChoice bool) {
|
|||||||
// whole, so that draw fixes every value the render produced, and a second route to
|
// whole, so that draw fixes every value the render produced, and a second route to
|
||||||
// any of them disagrees with it.
|
// any of them disagrees with it.
|
||||||
//
|
//
|
||||||
// The walk stops at a {..path} edge, which is where the operand's own value ends
|
// The walk stops at a reference edge, which is where the operand's own value ends
|
||||||
// and a shared source begins: two names referencing one category are two draws, the
|
// and a shared source begins: two names referencing one category are two draws, the
|
||||||
// same rule {word} {word} follows.
|
// same rule {word} {word} follows.
|
||||||
func operandDraw(n node, into map[node]bool) {
|
func operandDraw(n node, into map[node]bool) {
|
||||||
@@ -271,7 +355,7 @@ func contained(n node) []namedNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// named skips a bound {..path} key: it is a render edge, not containment, so using
|
// named skips a bound {/path} key: it is a render edge, not containment, so using
|
||||||
// it as a path segment would report a node under a path that does not reach it. Only
|
// it as a path segment would report a node under a path that does not reach it. Only
|
||||||
// a template's fields hold bindings — loadDir skips a dot-prefixed entry, so a
|
// a template's fields hold bindings — loadDir skips a dot-prefixed entry, so a
|
||||||
// group's children never carry the prefix — so this one skip serves both.
|
// group's children never carry the prefix — so this one skip serves both.
|
||||||
@@ -317,7 +401,7 @@ func resolveRef(root map[string]node, segments []string) (head []string, target
|
|||||||
return segments[:i], n, segments[i:], nil
|
return segments[:i], n, segments[i:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// refTokens returns the {..path} names a format reads, as tokens or as operands.
|
// refTokens returns the reference names a format reads, as tokens or as operands.
|
||||||
func refTokens(format string) []string {
|
func refTokens(format string) []string {
|
||||||
var refs []string
|
var refs []string
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
+86
-39
@@ -6,11 +6,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestRootReferenceAcrossFolders is the headline case: a category in one folder
|
// TestRootReferenceAcrossFolders is the headline case: a category in one folder
|
||||||
// pulls a value from another via a {..path} reference resolved from the data root.
|
// pulls a value from another via a {/path} reference resolved from the data root.
|
||||||
func TestRootReferenceAcrossFolders(t *testing.T) {
|
func TestRootReferenceAcrossFolders(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"en_US/person": `"Pat Smith"`,
|
"en_US/person": `"Pat Smith"`,
|
||||||
"sv_SE/greeting": `"Hej, {..en_US.person}!"`,
|
"sv_SE/greeting": `"Hej, {/en_US.person}!"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" {
|
if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" {
|
||||||
@@ -22,7 +22,7 @@ func TestRootReferenceAcrossFolders(t *testing.T) {
|
|||||||
func TestReferenceIntoAField(t *testing.T) {
|
func TestReferenceIntoAField(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`,
|
"who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`,
|
||||||
"card": `"signed {..who.last}"`,
|
"card": `"signed {/who.last}"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
if got := fake(t, f, "card"); got != "signed Byron" {
|
if got := fake(t, f, "card"); got != "signed Byron" {
|
||||||
@@ -30,12 +30,12 @@ func TestReferenceIntoAField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReferenceInAlternation lets a reference stand as one arm of a {a|..b}
|
// TestReferenceInAlternation lets a reference stand as one arm of a {a|/b}
|
||||||
// alternation, so a field and a cross-file value share one slot.
|
// alternation, so a field and a cross-file value share one slot.
|
||||||
func TestReferenceInAlternation(t *testing.T) {
|
func TestReferenceInAlternation(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"far": `"X"`,
|
"far": `"X"`,
|
||||||
"near": `{"format":"{here|..far}","here":"H"}`,
|
"near": `{"format":"{here|/far}","here":"H"}`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(2))
|
f := newGenerator(t, dir, WithSeed(2))
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -51,7 +51,7 @@ func TestReferenceInAlternation(t *testing.T) {
|
|||||||
// model: data layered from two dirs can point at each other through the root.
|
// model: data layered from two dirs can point at each other through the root.
|
||||||
func TestReferenceCombinesLoadedPaths(t *testing.T) {
|
func TestReferenceCombinesLoadedPaths(t *testing.T) {
|
||||||
a := writeData(t, map[string]string{"en_US/word": `"river"`})
|
a := writeData(t, map[string]string{"en_US/word": `"river"`})
|
||||||
b := writeData(t, map[string]string{"mine/slug": `"the-{..en_US.word}"`})
|
b := writeData(t, map[string]string{"mine/slug": `"the-{/en_US.word}"`})
|
||||||
f := newGeneratorN(t, []string{a, b}, WithSeed(1))
|
f := newGeneratorN(t, []string{a, b}, WithSeed(1))
|
||||||
if got := fake(t, f, "mine.slug"); got != "the-river" {
|
if got := fake(t, f, "mine.slug"); got != "the-river" {
|
||||||
t.Fatalf("slug = %q, want the-river", got)
|
t.Fatalf("slug = %q, want the-river", got)
|
||||||
@@ -62,8 +62,8 @@ func TestReferenceCombinesLoadedPaths(t *testing.T) {
|
|||||||
// linking order cannot matter.
|
// linking order cannot matter.
|
||||||
func TestReferenceChain(t *testing.T) {
|
func TestReferenceChain(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"a": `"{..b}"`,
|
"a": `"{/b}"`,
|
||||||
"b": `"{..c}"`,
|
"b": `"{/c}"`,
|
||||||
"c": `"deep"`,
|
"c": `"deep"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
@@ -76,37 +76,37 @@ func TestReferenceChain(t *testing.T) {
|
|||||||
// fails at load, never at a random render.
|
// fails at load, never at a random render.
|
||||||
func TestReferenceErrors(t *testing.T) {
|
func TestReferenceErrors(t *testing.T) {
|
||||||
cases := map[string]map[string]string{
|
cases := map[string]map[string]string{
|
||||||
"missing target": {"card": `"{..nope.gone}"`},
|
"missing target": {"card": `"{/nope.gone}"`},
|
||||||
"folder target": {"en_US/word": `"w"`, "card": `"{..en_US}"`},
|
"folder target": {"en_US/word": `"w"`, "card": `"{/en_US}"`},
|
||||||
"a variant on the path lacks the field": {
|
"a variant on the path lacks the field": {
|
||||||
"who": `[{"format":"{f}","f":"1"},{"format":"{g}","g":"2"}]`,
|
"who": `[{"format":"{f}","f":"1"},{"format":"{g}","g":"2"}]`,
|
||||||
"card": `"{..who.f}"`,
|
"card": `"{/who.f}"`,
|
||||||
},
|
},
|
||||||
"empty reference path": {"card": `"{..}"`},
|
"empty reference path": {"card": `"{/}"`},
|
||||||
// A reference that leads back to its own value never terminates at render,
|
// A reference that leads back to its own value never terminates at render,
|
||||||
// so New must reject the cycle up front (direct, mutual, or chained).
|
// so New must reject the cycle up front (direct, mutual, or chained).
|
||||||
"direct cycle": {"a": `"x{..a}"`},
|
"direct cycle": {"a": `"x{/a}"`},
|
||||||
"mutual cycle": {"a": `"{..b}"`, "b": `"{..a}"`},
|
"mutual cycle": {"a": `"{/b}"`, "b": `"{/a}"`},
|
||||||
"chain cycle": {"a": `"{..b}"`, "b": `"{..c}"`, "c": `"{..a}"`},
|
"chain cycle": {"a": `"{/b}"`, "b": `"{/c}"`, "c": `"{/a}"`},
|
||||||
// calc renders its operands, so a cycle through one must be caught too.
|
// calc renders its operands, so a cycle through one must be caught too.
|
||||||
"calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{..x}"}`},
|
"calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{/x}"}`},
|
||||||
// A field its parent's format never renders is still reachable by dot path,
|
// A field its parent's format never renders is still reachable by dot path,
|
||||||
// so a cycle hiding in one must fail at New rather than at render.
|
// so a cycle hiding in one must fail at New rather than at render.
|
||||||
"cycle in an unrendered field": {"cat": `{"format":"hi","x":"{..cat.x}"}`},
|
"cycle in an unrendered field": {"cat": `{"format":"hi","x":"{/cat.x}"}`},
|
||||||
"mutual cycle between unrendered fields": {
|
"mutual cycle between unrendered fields": {
|
||||||
"cat": `{"format":"hi","x":"{..cat.y}","y":"{..cat.x}"}`,
|
"cat": `{"format":"hi","x":"{/cat.y}","y":"{/cat.x}"}`,
|
||||||
},
|
},
|
||||||
"cycle in an unrendered field of a choice arm": {
|
"cycle in an unrendered field of a choice arm": {
|
||||||
"cat": `{"format":"hi","x":"{..cat.x}"}`,
|
"cat": `{"format":"hi","x":"{/cat.x}"}`,
|
||||||
},
|
},
|
||||||
// The shipped layout puts categories in folders, so a cycle one level down
|
// The shipped layout puts categories in folders, so a cycle one level down
|
||||||
// is the common case, not an edge case.
|
// is the common case, not an edge case.
|
||||||
"cycle in a subfolder": {"sv_SE/a": `"x{..sv_SE.a}"`},
|
"cycle in a subfolder": {"sv_SE/a": `"x{/sv_SE.a}"`},
|
||||||
"mutual cycle within a subfolder": {"sv_SE/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..sv_SE.a}"`},
|
"mutual cycle within a subfolder": {"sv_SE/a": `"{/sv_SE.b}"`, "sv_SE/b": `"{/sv_SE.a}"`},
|
||||||
"mutual cycle across two folders": {"en_US/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..en_US.a}"`},
|
"mutual cycle across two folders": {"en_US/a": `"{/sv_SE.b}"`, "sv_SE/b": `"{/en_US.a}"`},
|
||||||
// ".." is reserved for bound references, so an authored key using it would
|
// ".." is reserved for bound references, so an authored key using it would
|
||||||
// name a node nothing can reach and nothing would validate.
|
// name a node nothing can reach and nothing would validate.
|
||||||
"field key using the reference prefix": {"cat": `{"format":"hi","..x":"{..nope}"}`},
|
"field key using the reference prefix": {"cat": `{"format":"hi","..x":"{/nope}"}`},
|
||||||
}
|
}
|
||||||
for name, files := range cases {
|
for name, files := range cases {
|
||||||
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil {
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil {
|
||||||
@@ -122,8 +122,8 @@ func TestReferenceErrors(t *testing.T) {
|
|||||||
func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) {
|
func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) {
|
||||||
f := newGenerator(t, writeData(t, map[string]string{
|
f := newGenerator(t, writeData(t, map[string]string{
|
||||||
"sv_SE/ok": `"fine"`,
|
"sv_SE/ok": `"fine"`,
|
||||||
"sv_SE/..bad": `"{..nope}"`,
|
"sv_SE/..bad": `"{/nope}"`,
|
||||||
"sv_SE/..y/ct": `"{..nope}"`,
|
"sv_SE/..y/ct": `"{/nope}"`,
|
||||||
".git/config": `"not data"`,
|
".git/config": `"not data"`,
|
||||||
}), WithSeed(1))
|
}), WithSeed(1))
|
||||||
if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" {
|
if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" {
|
||||||
@@ -135,7 +135,7 @@ func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) {
|
|||||||
// over-rejecting: a field the format never renders may point back at its own
|
// over-rejecting: a field the format never renders may point back at its own
|
||||||
// category, which terminates, and stays renderable by path.
|
// category, which terminates, and stays renderable by path.
|
||||||
func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) {
|
func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {..cat}"}`})
|
dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {/cat}"}`})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
if got := fake(t, f, "cat"); got != "hi" {
|
if got := fake(t, f, "cat"); got != "hi" {
|
||||||
t.Fatalf("cat = %q, want hi", got)
|
t.Fatalf("cat = %q, want hi", got)
|
||||||
@@ -151,9 +151,9 @@ func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) {
|
|||||||
func TestNewErrorIsDeterministic(t *testing.T) {
|
func TestNewErrorIsDeterministic(t *testing.T) {
|
||||||
cases := map[string]map[string]string{
|
cases := map[string]map[string]string{
|
||||||
"three bad references": {
|
"three bad references": {
|
||||||
"a": `"{..nope.one}"`,
|
"a": `"{/nope.one}"`,
|
||||||
"b": `"{..nope.two}"`,
|
"b": `"{/nope.two}"`,
|
||||||
"c": `"{..nope.three}"`,
|
"c": `"{/nope.three}"`,
|
||||||
},
|
},
|
||||||
"two bad fields in one template": {
|
"two bad fields in one template": {
|
||||||
"cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`,
|
"cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`,
|
||||||
@@ -180,7 +180,7 @@ func TestNewErrorIsDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestNewErrorPathIsCanonical pins the node path a load error names: a choice arm
|
// TestNewErrorPathIsCanonical pins the node path a load error names: a choice arm
|
||||||
// adds no segment, and a bound {..path} reference is not a containment segment at
|
// adds no segment, and a bound {/path} reference is not a containment segment at
|
||||||
// all, so a bad reference is reported against the node that holds it.
|
// all, so a bad reference is reported against the node that holds it.
|
||||||
func TestNewErrorPathIsCanonical(t *testing.T) {
|
func TestNewErrorPathIsCanonical(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -190,13 +190,13 @@ func TestNewErrorPathIsCanonical(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"cycle inside a choice arm",
|
"cycle inside a choice arm",
|
||||||
map[string]string{"cat": `{"format":"hi","x":"{..cat.x}"}`},
|
map[string]string{"cat": `{"format":"hi","x":"{/cat.x}"}`},
|
||||||
"fejkdata: reference cycle: cat.x -> ..cat.x",
|
"fejkdata: reference cycle: cat.x -> /cat.x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bad reference reached through another reference",
|
"bad reference reached through another reference",
|
||||||
map[string]string{"a": `"{..b}"`, "b": `"{..nope}"`},
|
map[string]string{"a": `"{/b}"`, "b": `"{/nope}"`},
|
||||||
`fejkdata: b: reference {..nope}: no entry "nope"`,
|
`fejkdata: b: reference {/nope}: no entry "nope"`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -214,7 +214,7 @@ func TestNewErrorPathIsCanonical(t *testing.T) {
|
|||||||
func TestReferencePathIsHeld(t *testing.T) {
|
func TestReferencePathIsHeld(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
|
"person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
|
||||||
"card": `"{..person.first} {..person.last}"`,
|
"card": `"{/person.first} {/person.last}"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(3))
|
f := newGenerator(t, dir, WithSeed(3))
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -233,7 +233,7 @@ func TestReferencePathIsHeld(t *testing.T) {
|
|||||||
func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) {
|
func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) {
|
||||||
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
||||||
"who": `[{"format":"{f}{h}","f":"1","h":"x"},{"format":"{g}{h}","g":"2","h":"y"}]`,
|
"who": `[{"format":"{f}{h}","f":"1","h":"x"},{"format":"{g}{h}","g":"2","h":"y"}]`,
|
||||||
"card": `"{..who.f}"`,
|
"card": `"{/who.f}"`,
|
||||||
})))
|
})))
|
||||||
if err == nil || !strings.Contains(err.Error(), "not every variant") {
|
if err == nil || !strings.Contains(err.Error(), "not every variant") {
|
||||||
t.Fatalf("New = %v, want the missing variant named", err)
|
t.Fatalf("New = %v, want the missing variant named", err)
|
||||||
@@ -243,7 +243,7 @@ func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) {
|
|||||||
func TestBareReferenceDrawsEachTime(t *testing.T) {
|
func TestBareReferenceDrawsEachTime(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"die": `["1","2","3","4","5","6"]`,
|
"die": `["1","2","3","4","5","6"]`,
|
||||||
"roll": `"{..die} {..die}"`,
|
"roll": `"{/die} {/die}"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(1))
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
@@ -256,8 +256,8 @@ func TestBareReferenceDrawsEachTime(t *testing.T) {
|
|||||||
|
|
||||||
func TestReferenceOverlapIsRejected(t *testing.T) {
|
func TestReferenceOverlapIsRejected(t *testing.T) {
|
||||||
for name, file := range map[string]string{
|
for name, file := range map[string]string{
|
||||||
"head beside a path": `{"format":"{..cat.p} {..cat.p.first}","p":[{"format":"{first}","first":"A"},{"format":"{first}","first":"B"}]}`,
|
"head beside a path": `{"format":"{/cat.p} {/cat.p.first}","p":[{"format":"{first}","first":"A"},{"format":"{first}","first":"B"}]}`,
|
||||||
"sibling path beside a reference path": `{"format":"{p.first} {..cat.p.last}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`,
|
"sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`,
|
||||||
} {
|
} {
|
||||||
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
|
||||||
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
||||||
@@ -265,3 +265,50 @@ func TestReferenceOverlapIsRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRelativeReferences(t *testing.T) {
|
||||||
|
dir := writeData(t, map[string]string{
|
||||||
|
"sv_SE/username": `"bob"`,
|
||||||
|
"sv_SE/email": `"{.username}@example.com"`,
|
||||||
|
"sv_SE/deep/card": `"{..username} via {/sv_SE.username}"`,
|
||||||
|
"top": `"{.sv_SE.username}"`,
|
||||||
|
})
|
||||||
|
f := newGenerator(t, dir, WithSeed(1))
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"sv_SE.email": "bob@example.com",
|
||||||
|
"sv_SE.deep.card": "bob via bob",
|
||||||
|
"top": "bob",
|
||||||
|
} {
|
||||||
|
if got := fake(t, f, path); got != want {
|
||||||
|
t.Errorf("%s = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeAndRootSpellingsBindOneDraw(t *testing.T) {
|
||||||
|
dir := writeData(t, map[string]string{
|
||||||
|
"sv_SE/person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`,
|
||||||
|
"sv_SE/card": `"{.person.first} {/sv_SE.person.last}"`,
|
||||||
|
})
|
||||||
|
f := newGenerator(t, dir, WithSeed(3))
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if got := fake(t, f, "sv_SE.card"); got != "Anna Andersson" && got != "Bo Berg" {
|
||||||
|
t.Fatalf("card = %q, want both spellings to read one person", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferenceSigilErrors(t *testing.T) {
|
||||||
|
for name, files := range map[string]map[string]string{
|
||||||
|
"no folder above the root": {"a": `"{..b}"`, "b": `"x"`},
|
||||||
|
"three dots": {"a": `"{...b}"`, "b": `"x"`},
|
||||||
|
"root sigil alone": {"a": `"{/}"`},
|
||||||
|
"dot alone": {"a": `"{.}"`},
|
||||||
|
"missing sibling": {"sv_SE/a": `"{.nope}"`},
|
||||||
|
"slash in a field name": {"a": `{"format":"{x}","x":"1","a/b":"2"}`},
|
||||||
|
} {
|
||||||
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil {
|
||||||
|
t.Errorf("%s: New = nil error, want a reference error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ type draws struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readField renders one arm of a token. An arm's key is a sibling field or a
|
// readField renders one arm of a token. An arm's key is a sibling field or a
|
||||||
// {..path} reference, which linkRefs bound into fields too. A name the expansion
|
// {/path} reference, which linkRefs bound into fields too. A name the expansion
|
||||||
// holds — a level some token addresses by dotted path, or a sibling a {calc()}
|
// holds — a level some token addresses by dotted path, or a sibling a {calc()}
|
||||||
// reads — is drawn once and kept, so {place.postal-code} and {place.locality} read
|
// reads — is drawn once and kept, so {place.postal-code} and {place.locality} read
|
||||||
// one row, either read twice gives one value, and a shown operand is the operand
|
// one row, either read twice gives one value, and a shown operand is the operand
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestWithDataPathLayersOverShipped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUserDataMayReferenceShipped(t *testing.T) {
|
func TestUserDataMayReferenceShipped(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{"greeting": `"Hej {..sv_SE.person}!"`})
|
dir := writeData(t, map[string]string{"greeting": `"Hej {/sv_SE.person}!"`})
|
||||||
f, err := New(WithDataPath(dir), WithSeed(1))
|
f, err := New(WithDataPath(dir), WithSeed(1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New = %v", err)
|
t.Fatalf("New = %v", err)
|
||||||
|
|||||||
+22
-18
@@ -135,10 +135,10 @@ func checkTokens(format string, fields map[string]node) error {
|
|||||||
names := strings.Split(t.body, "|")
|
names := strings.Split(t.body, "|")
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if isRef(name) {
|
if isRef(name) {
|
||||||
if name == refPrefix {
|
if _, _, err := refShape(name); err != nil {
|
||||||
return fmt.Errorf("token {%s}: reference has no path", t.body)
|
return fmt.Errorf("token {%s}: %w", t.body, err)
|
||||||
}
|
}
|
||||||
continue // a root reference; its target is checked at New (see linkRefs)
|
continue // its target is checked at New (see linkRefs)
|
||||||
}
|
}
|
||||||
if err := checkArm(name, fields); err != nil {
|
if err := checkArm(name, fields); err != nil {
|
||||||
return fmt.Errorf("token {%s}: %w", t.body, err)
|
return fmt.Errorf("token {%s}: %w", t.body, err)
|
||||||
@@ -230,7 +230,7 @@ func fieldTokens(format string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// arm is one alternative of a {a|b} token or one operand, split into the key
|
// arm is one alternative of a {a|b} token or one operand, split into the key
|
||||||
// naming the node in a template's fields (a sibling field, or the "..path" head a
|
// naming the node in a template's fields (a sibling field, or the head a
|
||||||
// reference is bound under) and the tail of a dotted path into it. A non-empty
|
// reference is bound under) and the tail of a dotted path into it. A non-empty
|
||||||
// tail is what makes the arm a bound draw: its head is drawn once per expansion
|
// tail is what makes the arm a bound draw: its head is drawn once per expansion
|
||||||
// (see compileOps).
|
// (see compileOps).
|
||||||
@@ -241,15 +241,19 @@ type arm struct {
|
|||||||
steps []string // key per level passed through; the head and leaf hold their own
|
steps []string // key per level passed through; the head and leaf hold their own
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitArm splits one name into key and tail. refs maps a reference to the head
|
// splitArm splits one name into key and tail. refs maps a reference to what
|
||||||
// linkRefs bound it under; before linking, a reference is whole.
|
// linkRefs bound it to; before linking, a reference is whole.
|
||||||
func splitArm(name string, refs map[string]string) arm {
|
func splitArm(name string, refs map[string]refBinding) arm {
|
||||||
if isRef(name) {
|
if isRef(name) {
|
||||||
key, bound := refs[name]
|
b, bound := refs[name]
|
||||||
if !bound || key == name {
|
if !bound || len(b.tail) == 0 {
|
||||||
return arm{name: name, key: name}
|
key := name
|
||||||
|
if bound {
|
||||||
|
key = b.key
|
||||||
}
|
}
|
||||||
return pathArm(name, key, strings.Split(name[len(key)+1:], "."))
|
return arm{name: name, key: key}
|
||||||
|
}
|
||||||
|
return pathArm(name, b.key, b.tail)
|
||||||
}
|
}
|
||||||
head, tail, dotted := strings.Cut(name, ".")
|
head, tail, dotted := strings.Cut(name, ".")
|
||||||
if !dotted {
|
if !dotted {
|
||||||
@@ -271,7 +275,7 @@ func pathArm(name, key string, segs []string) arm {
|
|||||||
// level's held draw while rendering the level expands it afresh, so their values
|
// level's held draw while rendering the level expands it afresh, so their values
|
||||||
// would disagree. Names are compared in sorted order, so which pair is reported
|
// would disagree. Names are compared in sorted order, so which pair is reported
|
||||||
// does not depend on where the tokens sit.
|
// does not depend on where the tokens sit.
|
||||||
func checkNoOverlap(format string, bound map[string]string, refs map[string]string) error {
|
func checkNoOverlap(format string, bound map[string]string, refs map[string]refBinding) error {
|
||||||
names := boundReaders(format, bound, refs)
|
names := boundReaders(format, bound, refs)
|
||||||
// Stable over one format-order scan, so two readers of one name (a token and a
|
// Stable over one format-order scan, so two readers of one name (a token and a
|
||||||
// calc operand both naming "p") are reported as the format writes them.
|
// calc operand both naming "p") are reported as the format writes them.
|
||||||
@@ -292,7 +296,7 @@ type reader struct{ name, label string }
|
|||||||
// boundReaders lists every way a format reaches a bound field, in the order the
|
// boundReaders lists every way a format reaches a bound field, in the order the
|
||||||
// format writes them. An operand renders its field, so it names a level exactly
|
// format writes them. An operand renders its field, so it names a level exactly
|
||||||
// as a token does; one scan finds both, which is what puts them in one order.
|
// as a token does; one scan finds both, which is what puts them in one order.
|
||||||
func boundReaders(format string, bound map[string]string, refs map[string]string) []reader {
|
func boundReaders(format string, bound map[string]string, refs map[string]refBinding) []reader {
|
||||||
var names []reader
|
var names []reader
|
||||||
_ = eachToken(format, func(t ftoken) error {
|
_ = eachToken(format, func(t ftoken) error {
|
||||||
if t.kind != 'b' {
|
if t.kind != 'b' {
|
||||||
@@ -336,7 +340,7 @@ func checkSegments(a arm) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// splitArms splits a token body's '|' alternatives.
|
// splitArms splits a token body's '|' alternatives.
|
||||||
func splitArms(body string, refs map[string]string) []arm {
|
func splitArms(body string, refs map[string]refBinding) []arm {
|
||||||
parts := strings.Split(body, "|")
|
parts := strings.Split(body, "|")
|
||||||
arms := make([]arm, len(parts))
|
arms := make([]arm, len(parts))
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
@@ -396,7 +400,7 @@ func (c *formatOps) hold(a arm, label string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *formatOps) function(body string, refs map[string]string) {
|
func (c *formatOps) function(body string, refs map[string]refBinding) {
|
||||||
name, args, _ := funcCall(body)
|
name, args, _ := funcCall(body)
|
||||||
var operands []arm
|
var operands []arm
|
||||||
for _, operand := range tokenOperands(body) {
|
for _, operand := range tokenOperands(body) {
|
||||||
@@ -407,7 +411,7 @@ func (c *formatOps) function(body string, refs map[string]string) {
|
|||||||
c.ops = append(c.ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands})
|
c.ops = append(c.ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *formatOps) field(body string, refs map[string]string) {
|
func (c *formatOps) field(body string, refs map[string]refBinding) {
|
||||||
arms := splitArms(body, refs)
|
arms := splitArms(body, refs)
|
||||||
for _, a := range arms {
|
for _, a := range arms {
|
||||||
if len(a.tail) > 0 {
|
if len(a.tail) > 0 {
|
||||||
@@ -419,7 +423,7 @@ func (c *formatOps) field(body string, refs map[string]string) {
|
|||||||
|
|
||||||
// compileOps compiles a format string. Call checkTokens first: it is what proves
|
// compileOps compiles a format string. Call checkTokens first: it is what proves
|
||||||
// the scan and every token are valid.
|
// the scan and every token are valid.
|
||||||
func compileOps(format string, refs map[string]string) formatOps {
|
func compileOps(format string, refs map[string]refBinding) formatOps {
|
||||||
var c formatOps
|
var c formatOps
|
||||||
var lit strings.Builder
|
var lit strings.Builder
|
||||||
flush := func() {
|
flush := func() {
|
||||||
@@ -450,7 +454,7 @@ func compileOps(format string, refs map[string]string) formatOps {
|
|||||||
// checkNoRepeatedRead rejects a bare token repeated on a held name: {w} {w} beside
|
// checkNoRepeatedRead rejects a bare token repeated on a held name: {w} {w} beside
|
||||||
// {uppercase(w)} would read one draw twice, where {w} {w} alone draws twice. The
|
// {uppercase(w)} would read one draw twice, where {w} {w} alone draws twice. The
|
||||||
// error names the single-token spelling.
|
// error names the single-token spelling.
|
||||||
func checkNoRepeatedRead(format string, c formatOps, refs map[string]string) error {
|
func checkNoRepeatedRead(format string, c formatOps, refs map[string]refBinding) error {
|
||||||
count := map[string]int{}
|
count := map[string]int{}
|
||||||
return eachToken(format, func(t ftoken) error {
|
return eachToken(format, func(t ftoken) error {
|
||||||
if t.kind != 'b' {
|
if t.kind != 'b' {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestSeededOutputIsStable(t *testing.T) {
|
|||||||
"escapes": `{"format":"01Aa#{x}","x":"!"}`,
|
"escapes": `{"format":"01Aa#{x}","x":"!"}`,
|
||||||
"funcs": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`,
|
"funcs": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`,
|
||||||
"nested": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`,
|
"nested": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`,
|
||||||
"ref": `"see {..alt}"`,
|
"ref": `"see {/alt}"`,
|
||||||
"repeat": `{"format":"{w}","repeat":4,"separator":",","w":["x","y","z"]}`,
|
"repeat": `{"format":"{w}","repeat":4,"separator":",","w":["x","y","z"]}`,
|
||||||
"sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":"012345678901234","e":"123456789012","m":"12345678"}`,
|
"sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":"012345678901234","e":"123456789012","m":"12345678"}`,
|
||||||
"weights": `[{"format":"big","weight":9},"tiny"]`,
|
"weights": `[{"format":"big","weight":9},"tiny"]`,
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ func TestTransformReadsTheHeldDraw(t *testing.T) {
|
|||||||
func TestTransformOverAReference(t *testing.T) {
|
func TestTransformOverAReference(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"person": `[{"format":"{first} {last}","first":"Åsa","last":"Öberg"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`,
|
"person": `[{"format":"{first} {last}","first":"Åsa","last":"Öberg"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`,
|
||||||
"email": `"{..person.first} {..person.last} <{lowercase(ascii(..person.first))}.{lowercase(ascii(..person.last))}@example.com>"`,
|
"email": `"{/person.first} {/person.last} <{lowercase(ascii(/person.first))}.{lowercase(ascii(/person.last))}@example.com>"`,
|
||||||
})
|
})
|
||||||
f := newGenerator(t, dir, WithSeed(5))
|
f := newGenerator(t, dir, WithSeed(5))
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
|
|||||||
Reference in New Issue
Block a user