Updates from reviewers
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Coverage output
|
.claude
|
||||||
*.out
|
*.out
|
||||||
todo.md
|
todo.md
|
||||||
@@ -38,14 +38,18 @@ fakes -data-path ./data/sv_SE -data-path ./mydata word # layer dirs; the last w
|
|||||||
fakes -seed 42 -data-path ./data/sv_SE address
|
fakes -seed 42 -data-path ./data/sv_SE address
|
||||||
fakes -repeat 3 -data-path ./data/sv_SE person # three values, one per line
|
fakes -repeat 3 -data-path ./data/sv_SE person # three values, one per line
|
||||||
fakes -repeat 3 -separator ', ' -data-path ./data/sv_SE word # nät, barn, sol
|
fakes -repeat 3 -separator ', ' -data-path ./data/sv_SE word # nät, barn, sol
|
||||||
|
fakes -data-path ./data/sv_SE -list # every path this data offers
|
||||||
```
|
```
|
||||||
|
|
||||||
`-data-path` is repeatable (last wins a name clash) and the path comes last, after
|
`-data-path` is repeatable (last wins a name clash) and the path comes last —
|
||||||
the flags. `-repeat N` renders the path N times — each an independent draw — joined
|
all flags must precede it. `-repeat N` renders the path N times — each an
|
||||||
by `-separator` (default a newline, so values land one per line).
|
independent draw — joined by `-separator` (default a newline, so values land one
|
||||||
|
per line). Not sure what a data set offers? `-list` prints every path you can ask
|
||||||
|
for; `-version` prints the build version.
|
||||||
|
|
||||||
Without installing, run it from a checkout with `go run ./cmd/fakes …`. Exit
|
Without installing, run it from a checkout with `go run ./cmd/fakes …`. Exit
|
||||||
codes: `0` success, `1` runtime error (missing dir, unknown path), `2` misuse.
|
codes: `0` success (including `-list`, `-version`, `-h`), `1` runtime error
|
||||||
|
(missing dir, unknown path), `2` misuse.
|
||||||
|
|
||||||
### Generating a file from a custom template
|
### Generating a file from a custom template
|
||||||
|
|
||||||
@@ -136,6 +140,9 @@ bv, _ := b.Fake("person")
|
|||||||
av == bv // true
|
av == bv // true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`f.List()` returns the sorted paths the loaded data offers — the categories, their
|
||||||
|
dotted fields and folder segments (what the CLI's `-list` prints).
|
||||||
|
|
||||||
A `*Fakes` is **not** safe for concurrent use — create one per goroutine.
|
A `*Fakes` is **not** safe for concurrent use — create one per goroutine.
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
@@ -248,7 +255,9 @@ There are four kinds. **Derivations** read the digits emitted so far, so put the
|
|||||||
after their payload; **generators** read only the rng, so they stand alone; one
|
after their payload; **generators** read only the rng, so they stand alone; one
|
||||||
**session counter** (`seq`) advances state held on the faker; and one
|
**session counter** (`seq`) advances state held on the faker; and one
|
||||||
**computation** (`calc`) evaluates arithmetic over sibling fields. Arguments are
|
**computation** (`calc`) evaluates arithmetic over sibling fields. Arguments are
|
||||||
validated at `New` (a bad count, range, country, or expression fails fast).
|
validated at `New` (a bad count, range, country, or expression fails fast); a
|
||||||
|
length, count or decimal place beyond a sane maximum is rejected there too, so a
|
||||||
|
fat-fingered `hex(2000000000)` can't try to allocate gigabytes at render.
|
||||||
|
|
||||||
| Function | Kind | Emits |
|
| Function | Kind | Emits |
|
||||||
|----------|------|-------|
|
|----------|------|-------|
|
||||||
@@ -292,7 +301,8 @@ hyphenated field name can't be an operand. The expression is checked at `New`
|
|||||||
renders `19.99 x 3 = 59.97`. Each name is rendered where it appears, so a field
|
renders `19.99 x 3 = 59.97`. Each name is rendered where it appears, so a field
|
||||||
shown *and* used in a `calc` is drawn twice — keep a shared operand in a
|
shown *and* used in a `calc` is drawn twice — keep a shared operand in a
|
||||||
single-value field if the two must agree. A field that doesn't render to a number
|
single-value field if the two must agree. A field that doesn't render to a number
|
||||||
yields `NaN`, which prints rather than failing the render.
|
yields `NaN`, and a division by zero yields `Inf`; both print rather than failing
|
||||||
|
the render.
|
||||||
|
|
||||||
**References.** A `{..path}` token renders a node from the **data root** instead
|
**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
|
of a sibling field — the dot path is the one `Fake` takes, resolved across every
|
||||||
@@ -305,8 +315,9 @@ data dirs:
|
|||||||
|
|
||||||
renders e.g. `Hej, Pat Smith!`. References are bound when you create the faker, so
|
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
|
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
|
fails at `New`. A reference that leads back to its own value (directly, mutually,
|
||||||
through a chain), or rendering won't terminate.
|
or through a chain) is a cycle that would never finish rendering, so it too is
|
||||||
|
rejected at `New`.
|
||||||
|
|
||||||
**Format string.** Every character is literal except:
|
**Format string.** Every character is literal except:
|
||||||
|
|
||||||
@@ -324,6 +335,11 @@ through a chain), or rendering won't terminate.
|
|||||||
`{a|b}` renders one of the sibling fields `a` or `b`, chosen at random; an arm
|
`{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}`).
|
may be a `{..path}` reference too (`{name|..en_US.person}`).
|
||||||
|
|
||||||
|
Inside a `format`, `0 1 A a` are **always** character classes — so a fixed digit
|
||||||
|
or letter must be escaped (`#1`, `#A`) or it becomes random. A format of
|
||||||
|
`100 Main St` renders e.g. `506 mdin street`, not `100 Main St`. For a value with
|
||||||
|
no tokens at all, use a bare string node (`"100 Main St"`), emitted verbatim.
|
||||||
|
|
||||||
**Putting it together** (`person.json`):
|
**Putting it together** (`person.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -396,12 +412,15 @@ docker compose run --rm test # latest
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
fakes.go Fakes, New, options, seeding
|
fakes.go Fakes, New, List, options, seeding
|
||||||
template.go Fake, the recursive renderer (choices, format strings, paths)
|
node.go the node model and JSON -> node compilation
|
||||||
|
render.go Fake and the recursive renderer (choices, format strings, paths)
|
||||||
|
template.go the {token} grammar: scanning, function tokens, validation
|
||||||
|
reference.go {..path} binding across the tree, and cycle detection
|
||||||
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: folders/files -> namespace tree, multi-path merge
|
data.go data loading: folders/files -> namespace tree, multi-path merge
|
||||||
cmd/fakes/ the `fakes` CLI (New + Fake over stdout)
|
cmd/fakes/ the `fakes` CLI (New + Fake/List over stdout)
|
||||||
data/ shipped data (JSON): locale folders + a misc folder
|
data/ shipped data (JSON): locale folders + a misc folder
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+45
-21
@@ -3,10 +3,19 @@ package fakes
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxLen caps generator output lengths (hex, nanoid, base64) and maxDecimals caps
|
||||||
|
// float/calc decimal places, so a fat-fingered or overflowing argument fails at
|
||||||
|
// New instead of trying to allocate gigabytes — or panicking — at render.
|
||||||
|
const (
|
||||||
|
maxLen = 1 << 20 // 1,048,576 chars/bytes
|
||||||
|
maxDecimals = 1024
|
||||||
|
)
|
||||||
|
|
||||||
// builtins is the registry of {name(args)} functions. Two kinds: derivations read
|
// builtins is the registry of {name(args)} functions. Two kinds: derivations read
|
||||||
// the digits emitted so far in the current expansion (luhn, mod11, ean — place
|
// the digits emitted so far in the current expansion (luhn, mod11, ean — place
|
||||||
// them after their payload); generators read only the rng (uuid, ulid, ...). All
|
// them after their payload); generators read only the rng (uuid, ulid, ...). All
|
||||||
@@ -15,26 +24,31 @@ import (
|
|||||||
// the wall clock. Add a builtin only for what data can't express: a random v4
|
// the wall clock. Add a builtin only for what data can't express: a random v4
|
||||||
// UUID already ships as data (data/misc/uuid.json), so the builtin is v7.
|
// UUID already ships as data (data/misc/uuid.json), so the builtin is v7.
|
||||||
var builtins = map[string]builtin{
|
var builtins = map[string]builtin{
|
||||||
"luhn": {arity: 0, call: func(_ *session, e string, _ []string) string { return string(rune('0' + luhnCheck(e))) }},
|
"luhn": {arity: 0, call: func(_ *session, e string, _ map[string]node, _ []string) string {
|
||||||
"mod11": {arity: 0, call: func(_ *session, e string, _ []string) string { return mod11Check(e) }},
|
return string(rune('0' + luhnCheck(e)))
|
||||||
"ean": {arity: 0, call: func(_ *session, e string, _ []string) string { return eanCheck(e) }},
|
}},
|
||||||
"uuid": {arity: 0, call: func(s *session, _ string, _ []string) string { return uuidV7(s) }},
|
"mod11": {arity: 0, call: func(_ *session, e string, _ map[string]node, _ []string) string { return mod11Check(e) }},
|
||||||
"ulid": {arity: 0, call: func(s *session, _ string, _ []string) string { return ulid(s) }},
|
"ean": {arity: 0, call: func(_ *session, e string, _ map[string]node, _ []string) string { return eanCheck(e) }},
|
||||||
"objectid": {arity: 0, call: func(s *session, _ string, _ []string) string { return randHex(s, 24) }},
|
"uuid": {arity: 0, call: func(s *session, _ string, _ map[string]node, _ []string) string { return uuidV7(s) }},
|
||||||
"nanoid": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { return nanoid(s, atoi(a[0])) }},
|
"ulid": {arity: 0, call: func(s *session, _ string, _ map[string]node, _ []string) string { return ulid(s) }},
|
||||||
"hex": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { return randHex(s, atoi(a[0])) }},
|
"objectid": {arity: 0, call: func(s *session, _ string, _ map[string]node, _ []string) string { return randHex(s, 24) }},
|
||||||
"base64": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string {
|
"nanoid": {arity: 1, check: posIntArg, call: func(s *session, _ string, _ map[string]node, a []string) string { return nanoid(s, atoi(a[0])) }},
|
||||||
|
"hex": {arity: 1, check: posIntArg, call: func(s *session, _ string, _ map[string]node, a []string) string { return randHex(s, atoi(a[0])) }},
|
||||||
|
"base64": {arity: 1, check: posIntArg, call: func(s *session, _ string, _ map[string]node, a []string) string {
|
||||||
return base64.StdEncoding.EncodeToString(randBytes(s, atoi(a[0])))
|
return base64.StdEncoding.EncodeToString(randBytes(s, atoi(a[0])))
|
||||||
}},
|
}},
|
||||||
"int": {arity: 2, check: intRangeArgs, call: func(s *session, _ string, a []string) string {
|
"int": {arity: 2, check: intRangeArgs, call: func(s *session, _ string, _ map[string]node, a []string) string {
|
||||||
return strconv.Itoa(atoi(a[0]) + s.IntN(atoi(a[1])-atoi(a[0])+1))
|
return strconv.Itoa(atoi(a[0]) + s.IntN(atoi(a[1])-atoi(a[0])+1))
|
||||||
}},
|
}},
|
||||||
"float": {arity: 3, check: floatArgs, call: floatCall},
|
"float": {arity: 3, check: floatArgs, call: floatCall},
|
||||||
"iban": {arity: 1, check: ibanArg, call: func(s *session, _ string, a []string) string { return iban(s, a[0]) }},
|
"iban": {arity: 1, check: ibanArg, call: func(s *session, _ string, _ map[string]node, a []string) string { return iban(s, a[0]) }},
|
||||||
|
// calc is the one builtin that reads the sibling fields (to render its operands);
|
||||||
|
// every other ignores them. 1 or 2 args: the expression and an optional decimals.
|
||||||
|
"calc": {arity: -1, check: checkCalc, call: calcCall},
|
||||||
// seq is the one stateful builtin: a per-session counter from 1, advancing on
|
// seq is the one stateful builtin: a per-session counter from 1, advancing on
|
||||||
// each call. An optional name selects an independent counter; no name uses the
|
// each call. An optional name selects an independent counter; no name uses the
|
||||||
// default one. Deterministic by construction, so a seeded faker stays stable.
|
// default one. Deterministic by construction, so a seeded faker stays stable.
|
||||||
"seq": {arity: -1, check: seqArg, call: func(s *session, _ string, a []string) string {
|
"seq": {arity: -1, check: seqArg, call: func(s *session, _ string, _ map[string]node, a []string) string {
|
||||||
key := ""
|
key := ""
|
||||||
if len(a) == 1 {
|
if len(a) == 1 {
|
||||||
key = a[0]
|
key = a[0]
|
||||||
@@ -64,14 +78,18 @@ func randHex(r rng, n int) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func posIntArg(a []string) error {
|
func posIntArg(_ map[string]node, a []string) error {
|
||||||
if n, err := strconv.Atoi(a[0]); err != nil || n < 1 {
|
n, err := strconv.Atoi(a[0])
|
||||||
|
if err != nil || n < 1 {
|
||||||
return fmt.Errorf("count %q must be a positive integer", a[0])
|
return fmt.Errorf("count %q must be a positive integer", a[0])
|
||||||
}
|
}
|
||||||
|
if n > maxLen {
|
||||||
|
return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func intRangeArgs(a []string) error {
|
func intRangeArgs(_ map[string]node, a []string) error {
|
||||||
lo, e1 := strconv.Atoi(a[0])
|
lo, e1 := strconv.Atoi(a[0])
|
||||||
hi, e2 := strconv.Atoi(a[1])
|
hi, e2 := strconv.Atoi(a[1])
|
||||||
if e1 != nil || e2 != nil {
|
if e1 != nil || e2 != nil {
|
||||||
@@ -80,10 +98,13 @@ func intRangeArgs(a []string) error {
|
|||||||
if lo > hi {
|
if lo > hi {
|
||||||
return fmt.Errorf("int(min,max): min %d > max %d", lo, hi)
|
return fmt.Errorf("int(min,max): min %d > max %d", lo, hi)
|
||||||
}
|
}
|
||||||
|
if uint64(hi)-uint64(lo) >= uint64(math.MaxInt64) { // span hi-lo+1 would overflow int -> IntN panic
|
||||||
|
return fmt.Errorf("int(min,max): range %d..%d is too wide", lo, hi)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func floatArgs(a []string) error {
|
func floatArgs(_ map[string]node, a []string) error {
|
||||||
lo, e1 := strconv.ParseFloat(a[0], 64)
|
lo, e1 := strconv.ParseFloat(a[0], 64)
|
||||||
hi, e2 := strconv.ParseFloat(a[1], 64)
|
hi, e2 := strconv.ParseFloat(a[1], 64)
|
||||||
dp, e3 := strconv.Atoi(a[2])
|
dp, e3 := strconv.Atoi(a[2])
|
||||||
@@ -93,19 +114,22 @@ func floatArgs(a []string) error {
|
|||||||
if lo > hi {
|
if lo > hi {
|
||||||
return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi)
|
return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi)
|
||||||
}
|
}
|
||||||
if dp < 0 {
|
if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf"
|
||||||
return fmt.Errorf("float(min,max,dp): decimals %d < 0", dp)
|
return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi)
|
||||||
|
}
|
||||||
|
if dp < 0 || dp > maxDecimals {
|
||||||
|
return fmt.Errorf("float(min,max,dp): decimals %d out of range 0..%d", dp, maxDecimals)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func floatCall(s *session, _ string, a []string) string {
|
func floatCall(s *session, _ string, _ map[string]node, a []string) string {
|
||||||
lo, _ := strconv.ParseFloat(a[0], 64)
|
lo, _ := strconv.ParseFloat(a[0], 64)
|
||||||
hi, _ := strconv.ParseFloat(a[1], 64)
|
hi, _ := strconv.ParseFloat(a[1], 64)
|
||||||
return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', atoi(a[2]), 64)
|
return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', atoi(a[2]), 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seqArg(a []string) error {
|
func seqArg(_ map[string]node, a []string) error {
|
||||||
if len(a) > 1 {
|
if len(a) > 1 {
|
||||||
return fmt.Errorf("seq takes at most one name, got %d args", len(a))
|
return fmt.Errorf("seq takes at most one name, got %d args", len(a))
|
||||||
}
|
}
|
||||||
@@ -231,7 +255,7 @@ func eanCheck(s string) string {
|
|||||||
// a left-to-right derivation; it generates the whole value instead.
|
// a left-to-right derivation; it generates the whole value instead.
|
||||||
var ibanLen = map[string]int{"BE": 16, "DE": 22, "DK": 18, "ES": 24, "FI": 18, "NO": 15, "SE": 24}
|
var ibanLen = map[string]int{"BE": 16, "DE": 22, "DK": 18, "ES": 24, "FI": 18, "NO": 15, "SE": 24}
|
||||||
|
|
||||||
func ibanArg(a []string) error {
|
func ibanArg(_ map[string]node, a []string) error {
|
||||||
if _, ok := ibanLen[a[0]]; !ok {
|
if _, ok := ibanLen[a[0]]; !ok {
|
||||||
return fmt.Errorf("iban(%q): unsupported country code", a[0])
|
return fmt.Errorf("iban(%q): unsupported country code", a[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,31 @@ func TestBuiltinSeqPerSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuiltinArgLimits pins the New-time guards that keep a fat-fingered or
|
||||||
|
// overflowing argument from panicking or OOM-ing at render. Each must be rejected
|
||||||
|
// at compile, not detonate when the template is drawn.
|
||||||
|
func TestBuiltinArgLimits(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
`{"format":"{int(-9223372036854775808,9223372036854775807)}"}`, // span overflows int -> IntN panic
|
||||||
|
`{"format":"{hex(2000000000)}"}`, // ~2 GB string
|
||||||
|
`{"format":"{nanoid(2000000000)}"}`,
|
||||||
|
`{"format":"{base64(2000000000)}"}`,
|
||||||
|
`{"format":"{float(-1e308,1e308,2)}"}`, // span is +Inf
|
||||||
|
`{"format":"{float(0,1,100000)}"}`, // decimals -> huge string
|
||||||
|
`{"format":"{calc(1+1,100000)}"}`, // calc decimals -> huge string
|
||||||
|
`{"format":"{x}","x":["1"],"repeat":2000000000}`, // repeat -> multi-GB join
|
||||||
|
}
|
||||||
|
for _, tmpl := range bad {
|
||||||
|
if _, err := compile(parse(t, tmpl)); err == nil {
|
||||||
|
t.Errorf("compile(%s) = nil error, want a limit rejection", tmpl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ordinary values still compile.
|
||||||
|
if _, err := compile(parse(t, `{"format":"{hex(16)} {int(-5,5)} {float(0,1,3)} {calc(1+1,2)}"}`)); err != nil {
|
||||||
|
t.Errorf("compile of normal args failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuiltinIBAN(t *testing.T) {
|
func TestBuiltinIBAN(t *testing.T) {
|
||||||
wantLen := map[string]int{"SE": 24, "DE": 22, "NO": 15}
|
wantLen := map[string]int{"SE": 24, "DE": 22, "NO": 15}
|
||||||
f := engine(1)
|
f := engine(1)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
|
|
||||||
// calc is the {calc(expr[, dp])} token: an arithmetic expression over number
|
// calc is the {calc(expr[, dp])} token: an arithmetic expression over number
|
||||||
// literals and sibling-field names (rendered, then parsed as numbers), with
|
// literals and sibling-field names (rendered, then parsed as numbers), with
|
||||||
// + - * /, unary minus and parentheses. Unlike a builtin it reads the field
|
// + - * /, unary minus and parentheses. It is a registry builtin like any other
|
||||||
// environment, so expand and checkTokens route it here rather than the builtins
|
// {name(args)} function — the one whose check and call read the sibling fields (to
|
||||||
// registry. The value prints in minimal decimal form, or rounded to dp decimals
|
// render its operands). The value prints in minimal decimal form, or rounded to dp
|
||||||
// when given. A field that doesn't render to a number becomes NaN, which
|
// when given. A field that doesn't render to a number becomes NaN, which
|
||||||
// propagates and prints as "NaN" — visible, never a render error. The expression
|
// propagates and prints as "NaN" — visible, never a render error. The expression
|
||||||
// is re-parsed each render, mirroring how expand re-scans the format; checkCalc
|
// is re-parsed each render, mirroring how expand re-scans the format; checkCalc
|
||||||
@@ -58,8 +58,10 @@ func (n calcBin) eval(s *session, fields map[string]node) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkCalc validates a calc token at compile time: a parseable expression whose
|
// checkCalc validates a calc token at compile time: a parseable expression whose
|
||||||
// operands all name existing fields, and an optional non-negative integer dp.
|
// operands all name existing fields, and an optional non-negative integer dp. It
|
||||||
func checkCalc(args []string, fields map[string]node) error {
|
// is a builtin check (fields first), so calc dispatches through the registry like
|
||||||
|
// every other {name(args)} function.
|
||||||
|
func checkCalc(fields map[string]node, args []string) error {
|
||||||
if len(args) < 1 || len(args) > 2 {
|
if len(args) < 1 || len(args) > 2 {
|
||||||
return fmt.Errorf("calc takes an expression and an optional decimals count, got %d args", len(args))
|
return fmt.Errorf("calc takes an expression and an optional decimals count, got %d args", len(args))
|
||||||
}
|
}
|
||||||
@@ -73,17 +75,18 @@ func checkCalc(args []string, fields map[string]node) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(args) == 2 {
|
if len(args) == 2 {
|
||||||
if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 {
|
if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 || dp > maxDecimals {
|
||||||
return fmt.Errorf("calc decimals %q must be a non-negative integer", args[1])
|
return fmt.Errorf("calc decimals %q must be an integer in 0..%d", args[1], maxDecimals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// calcEval renders a calc token. checkCalc proved the expression parses and the
|
// calcCall renders a calc token. checkCalc proved the expression parses and the
|
||||||
// decimals arg is valid, so neither step here can fail. dp -1 prints the minimal
|
// decimals arg is valid, so neither step here can fail. dp -1 prints the minimal
|
||||||
// form; a given dp rounds to that many places.
|
// form; a given dp rounds to that many places. The emitted-so-far arg is unused —
|
||||||
func calcEval(s *session, args []string, fields map[string]node) string {
|
// calc reads sibling fields, not the preceding output.
|
||||||
|
func calcCall(s *session, _ string, fields map[string]node, args []string) string {
|
||||||
expr, _ := parseCalc(args[0])
|
expr, _ := parseCalc(args[0])
|
||||||
dp := -1
|
dp := -1
|
||||||
if len(args) == 2 {
|
if len(args) == 2 {
|
||||||
@@ -92,6 +95,25 @@ func calcEval(s *session, args []string, fields map[string]node) string {
|
|||||||
return strconv.FormatFloat(expr.eval(s, fields), 'f', dp, 64)
|
return strconv.FormatFloat(expr.eval(s, fields), 'f', dp, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calcOperands lists the sibling-field names every {calc(...)} token in a format
|
||||||
|
// reads, so cycle detection sees the field edges calc renders through (a function
|
||||||
|
// token otherwise carries no field edge).
|
||||||
|
func calcOperands(format string) []string {
|
||||||
|
var names []string
|
||||||
|
_ = eachToken(format, func(t ftoken) error {
|
||||||
|
if t.kind != 'b' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if name, args, ok := funcCall(t.body); ok && name == "calc" && len(args) >= 1 {
|
||||||
|
if expr, err := parseCalc(args[0]); err == nil {
|
||||||
|
names = append(names, calcVars(expr)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
// calcVars lists the field names an expression references, for the existence
|
// calcVars lists the field names an expression references, for the existence
|
||||||
// check in checkCalc.
|
// check in checkCalc.
|
||||||
func calcVars(n calcNode) []string {
|
func calcVars(n calcNode) []string {
|
||||||
|
|||||||
+47
-6
@@ -10,10 +10,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Timewave-AB/fakes"
|
"github.com/Timewave-AB/fakes"
|
||||||
@@ -25,7 +27,11 @@ const usage = `Usage: fakes -data-path D [-data-path D]... [-seed N] [-repeat N]
|
|||||||
<path> a category, or a dotted path into one (person, person.last)
|
<path> a category, or a dotted path into one (person, person.last)
|
||||||
-seed N seed for reproducible output
|
-seed N seed for reproducible output
|
||||||
-repeat N render the path N times (default 1)
|
-repeat N render the path N times (default 1)
|
||||||
-separator S string between repeated values (default newline)`
|
-separator S string between repeated values (default newline)
|
||||||
|
-list list the paths the data offers, then exit
|
||||||
|
-version print the version, then exit
|
||||||
|
|
||||||
|
Flags must come before <path>.`
|
||||||
|
|
||||||
// stringList collects a repeatable string flag, preserving order.
|
// stringList collects a repeatable string flag, preserving order.
|
||||||
type stringList []string
|
type stringList []string
|
||||||
@@ -45,19 +51,24 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
seed := fs.Uint64("seed", 0, "seed for reproducible output")
|
seed := fs.Uint64("seed", 0, "seed for reproducible output")
|
||||||
repeat := fs.Int("repeat", 1, "render the path this many times")
|
repeat := fs.Int("repeat", 1, "render the path this many times")
|
||||||
sep := fs.String("separator", "\n", "string between repeated values")
|
sep := fs.String("separator", "\n", "string between repeated values")
|
||||||
|
list := fs.Bool("list", false, "list the paths the data offers, then exit")
|
||||||
|
showVersion := fs.Bool("version", false, "print the version, then exit")
|
||||||
var dirs stringList
|
var dirs stringList
|
||||||
fs.Var(&dirs, "data-path", "a data directory to load (repeatable)")
|
fs.Var(&dirs, "data-path", "a data directory to load (repeatable)")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) { // -h/-help already printed usage
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if len(dirs) == 0 || fs.NArg() != 1 {
|
if *showVersion {
|
||||||
|
fmt.Fprintln(stdout, "fakes "+buildVersion())
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(dirs) == 0 {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if *repeat < 1 {
|
|
||||||
fmt.Fprintln(stderr, "repeat must be a positive integer")
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
var opts []fakes.Option
|
var opts []fakes.Option
|
||||||
fs.Visit(func(fl *flag.Flag) {
|
fs.Visit(func(fl *flag.Flag) {
|
||||||
@@ -66,6 +77,26 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if *list {
|
||||||
|
f, err := fakes.New(dirs, opts...)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for _, p := range f.List() {
|
||||||
|
fmt.Fprintln(stdout, p)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if fs.NArg() != 1 {
|
||||||
|
fs.Usage()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *repeat < 1 {
|
||||||
|
fmt.Fprintln(stderr, "repeat must be a positive integer")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
path := fs.Arg(0)
|
path := fs.Arg(0)
|
||||||
f, err := fakes.New(dirs, opts...)
|
f, err := fakes.New(dirs, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,3 +113,13 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stdout, strings.Join(vals, *sep))
|
fmt.Fprintln(stdout, strings.Join(vals, *sep))
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildVersion reports the module version stamped into the binary by `go install`
|
||||||
|
// (or "devel" for a local build), read from the build info — no version constant
|
||||||
|
// to bump, no extra dependency.
|
||||||
|
func buildVersion() string {
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" {
|
||||||
|
return info.Main.Version
|
||||||
|
}
|
||||||
|
return "devel"
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,42 @@ func TestRunUnknownCategoryFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunList(t *testing.T) {
|
||||||
|
// -list prints the discoverable paths and exits 0, no positional path needed.
|
||||||
|
code, out, errb := runOut("-data-path", svSE, "-list")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("run = %d, stderr=%q", code, errb)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"person", "person.last", "address"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("list output missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelpExitsZero(t *testing.T) {
|
||||||
|
// -h/-help is success (0), not misuse (2), so scripts under `set -e` survive.
|
||||||
|
for _, h := range []string{"-h", "-help"} {
|
||||||
|
code, _, errb := runOut(h)
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("%s = %d, want 0", h, code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errb, "Usage") {
|
||||||
|
t.Errorf("%s: stderr = %q, want usage", h, errb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunVersion(t *testing.T) {
|
||||||
|
code, out, errb := runOut("-version")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("-version = %d, stderr=%q", code, errb)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
t.Error("-version: want a version on stdout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunMissingDirFails(t *testing.T) {
|
func TestRunMissingDirFails(t *testing.T) {
|
||||||
code, _, errb := runOut("-data-path", "../../data/nope", "person")
|
code, _, errb := runOut("-data-path", "../../data/nope", "person")
|
||||||
if code != 1 {
|
if code != 1 {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ func loadData(paths []string) (map[string]node, error) {
|
|||||||
if err := linkRefs(root); err != nil {
|
if err := linkRefs(root); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := checkNoCycles(root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return root, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fakes generates fake data from a loaded namespace tree. Create one with [New].
|
// Fakes generates fake data from a loaded namespace tree. Create one with [New].
|
||||||
@@ -75,6 +76,50 @@ func New(paths []string, opts ...Option) (*Fakes, error) {
|
|||||||
return &Fakes{rand: newRand(c.seed, c.seeded), categories: cats}, nil
|
return &Fakes{rand: newRand(c.seed, c.seeded), categories: cats}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List returns the sorted dotted paths Fake can render: every category, the
|
||||||
|
// dotted fields within a template, and folder segments — descending transparently
|
||||||
|
// through single-variant choices the way a reference does. A multi-variant choice
|
||||||
|
// is one path (its pick is random); its items are not separately addressable. It's
|
||||||
|
// the discoverable map of what a loaded data set offers, powering the CLI's -list.
|
||||||
|
func (f *Fakes) List() []string {
|
||||||
|
var out []string
|
||||||
|
var walk func(prefix string, n node)
|
||||||
|
walk = func(prefix string, n node) {
|
||||||
|
switch n := n.(type) {
|
||||||
|
case *group:
|
||||||
|
for name, c := range n.children {
|
||||||
|
walk(join(prefix, name), c)
|
||||||
|
}
|
||||||
|
case *choice:
|
||||||
|
if len(n.items) == 1 { // a single-variant choice is a transparent wrapper
|
||||||
|
walk(prefix, n.items[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, prefix)
|
||||||
|
case *template:
|
||||||
|
out = append(out, prefix)
|
||||||
|
for name, c := range n.fields {
|
||||||
|
if isRef(name) { // a bound {..path} reference, not an authored field
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
walk(join(prefix, name), c)
|
||||||
|
}
|
||||||
|
case literal:
|
||||||
|
out = append(out, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk("", &group{children: f.categories})
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(prefix, name string) string {
|
||||||
|
if prefix == "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return prefix + "." + name
|
||||||
|
}
|
||||||
|
|
||||||
func newRand(seed uint64, seeded bool) *session {
|
func newRand(seed uint64, seeded bool) *session {
|
||||||
r := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
r := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||||
if !seeded {
|
if !seeded {
|
||||||
|
|||||||
@@ -3,9 +3,25 @@ package fakes
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestList pins the discoverable paths: every category, its dotted fields, and
|
||||||
|
// folder segments — descending transparently through single-variant choices.
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
dir := writeData(t, map[string]string{
|
||||||
|
"person": `{"format":"{first} {last}","first":["A"],"last":["B"]}`,
|
||||||
|
"word": `["x", "y"]`,
|
||||||
|
"geo/city": `["Z"]`,
|
||||||
|
})
|
||||||
|
got := newFakes(t, dir, WithSeed(1)).List()
|
||||||
|
want := []string{"geo.city", "person", "person.first", "person.last", "word"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("List() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// writeData builds a temp data directory from a map of relative path (without
|
// writeData builds a temp data directory from a map of relative path (without
|
||||||
// ".json") -> file content, creating parent folders as needed. The directory's
|
// ".json") -> file content, creating parent folders as needed. The directory's
|
||||||
// name carries no meaning anymore, so callers pick any layout they like —
|
// name carries no meaning anymore, so callers pick any layout they like —
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package fakes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// node is a compiled template element: literal, choice, or template. Compiling
|
||||||
|
// JSON into these once (see compile) means rendering never re-inspects the raw
|
||||||
|
// JSON or re-sums weights.
|
||||||
|
type node interface{ isNode() }
|
||||||
|
|
||||||
|
// literal is emitted verbatim, never formatted.
|
||||||
|
type literal string
|
||||||
|
|
||||||
|
func (literal) isNode() {}
|
||||||
|
|
||||||
|
// group is a namespace of named children, built from a directory of JSON files
|
||||||
|
// and subdirectories. It has no value of its own: descend into a named child by
|
||||||
|
// dot path; rendering one is an error (see Fake).
|
||||||
|
type group struct{ children map[string]node }
|
||||||
|
|
||||||
|
func (*group) isNode() {}
|
||||||
|
|
||||||
|
// choice picks one of its items. cum holds cumulative weights for a weighted
|
||||||
|
// pick; when nil the choice is uniform and selection is O(1).
|
||||||
|
type choice struct {
|
||||||
|
items []node
|
||||||
|
cum []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*choice) isNode() {}
|
||||||
|
|
||||||
|
// template renders a format string, substituting {tokens} from fields. repeat
|
||||||
|
// (default 1) renders that format that many times and joins the results with
|
||||||
|
// separator (default ""), each render an independent pick.
|
||||||
|
type template struct {
|
||||||
|
format string
|
||||||
|
fields map[string]node
|
||||||
|
repeat int
|
||||||
|
separator string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*template) isNode() {}
|
||||||
|
|
||||||
|
// compile converts parsed JSON into a node tree, validating structure up front.
|
||||||
|
func compile(v any) (node, error) {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
return literal(v), nil
|
||||||
|
case []any:
|
||||||
|
return compileChoice(v)
|
||||||
|
case map[string]any:
|
||||||
|
return compileTemplate(v)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported node type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileChoice(items []any) (node, error) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty choice")
|
||||||
|
}
|
||||||
|
c := &choice{items: make([]node, len(items))}
|
||||||
|
cum := make([]float64, len(items))
|
||||||
|
var total float64
|
||||||
|
weighted := false
|
||||||
|
for i, raw := range items {
|
||||||
|
w, err := weightOf(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if w != 1 {
|
||||||
|
weighted = true
|
||||||
|
}
|
||||||
|
total += w
|
||||||
|
cum[i] = total
|
||||||
|
n, err := compile(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.items[i] = n
|
||||||
|
}
|
||||||
|
if weighted { // uniform choices skip the weight table and pick in O(1)
|
||||||
|
if total <= 0 || math.IsInf(total, 1) {
|
||||||
|
return nil, fmt.Errorf("choice weights must sum to a finite positive number, got %v", total)
|
||||||
|
}
|
||||||
|
c.cum = cum
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileTemplate(m map[string]any) (node, error) {
|
||||||
|
format, ok := m["format"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("template object missing string \"format\"")
|
||||||
|
}
|
||||||
|
repeat, err := repeatOf(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sep := ""
|
||||||
|
if sv, ok := m["separator"]; ok {
|
||||||
|
if sep, ok = sv.(string); !ok {
|
||||||
|
return nil, fmt.Errorf("separator must be a string, got %T", sv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := &template{format: format, fields: make(map[string]node, len(m)), repeat: repeat, separator: sep}
|
||||||
|
for k, v := range m {
|
||||||
|
if k == "format" || k == "weight" || k == "repeat" || k == "separator" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := compile(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("field %q: %w", k, err)
|
||||||
|
}
|
||||||
|
t.fields[k] = n
|
||||||
|
}
|
||||||
|
if err := checkTokens(format, t.fields); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
rv, ok := m["repeat"]
|
||||||
|
if !ok {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
r, ok := rv.(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("repeat must be a number, got %T", rv)
|
||||||
|
}
|
||||||
|
if math.IsNaN(r) || math.IsInf(r, 0) || r < 1 || r != math.Trunc(r) {
|
||||||
|
return 0, fmt.Errorf("repeat must be a positive integer, got %v", rv)
|
||||||
|
}
|
||||||
|
if r > maxLen { // cap so a fat-fingered repeat can't build a multi-GB string
|
||||||
|
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, maxLen)
|
||||||
|
}
|
||||||
|
return int(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// weightOf reads a node's "weight" (default 1) from its raw JSON form. Only
|
||||||
|
// template objects carry weight; a present one must be finite and non-negative.
|
||||||
|
func weightOf(raw any) (float64, error) {
|
||||||
|
m, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
wv, ok := m["weight"]
|
||||||
|
if !ok {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
w, ok := wv.(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("weight must be a number, got %T", wv)
|
||||||
|
}
|
||||||
|
if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) {
|
||||||
|
return 0, fmt.Errorf("weight must be finite and non-negative, got %v", w)
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
package fakes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 skips shared nodes (and keeps this walk finite if data is cyclic);
|
||||||
|
// checkNoCycles, run next, is what rejects an actual reference cycle.
|
||||||
|
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 just the {..path} reference names among a format's field
|
||||||
|
// tokens (linkRefs binds each into the template's fields).
|
||||||
|
func refTokens(format string) []string {
|
||||||
|
var refs []string
|
||||||
|
for _, name := range fieldTokens(format) {
|
||||||
|
if isRef(name) {
|
||||||
|
refs = append(refs, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEdge is a child a node renders into, labelled by the token that reaches it
|
||||||
|
// (a field name, reference, or choice index) for a readable cycle report.
|
||||||
|
type renderEdge struct {
|
||||||
|
to node
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEdges lists the children rendering n recurses into, mirroring expand: a
|
||||||
|
// choice's items, and a template's field/reference tokens plus its calc operands.
|
||||||
|
// A literal or group renders nothing, so it has no edges.
|
||||||
|
func renderEdges(n node) []renderEdge {
|
||||||
|
switch n := n.(type) {
|
||||||
|
case *choice:
|
||||||
|
es := make([]renderEdge, len(n.items))
|
||||||
|
for i, it := range n.items {
|
||||||
|
es[i] = renderEdge{it, fmt.Sprintf("[%d]", i)}
|
||||||
|
}
|
||||||
|
return es
|
||||||
|
case *template:
|
||||||
|
var es []renderEdge
|
||||||
|
for _, name := range append(fieldTokens(n.format), calcOperands(n.format)...) {
|
||||||
|
if c, ok := n.fields[name]; ok {
|
||||||
|
es = append(es, renderEdge{c, name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return es
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNoCycles rejects a reference cycle: a node whose rendering can reach itself
|
||||||
|
// — directly, mutually, or through a chain — never terminates, so it must fail at
|
||||||
|
// New rather than stack-overflow at render. It is a depth-first walk of the render
|
||||||
|
// graph (renderEdges); grey marks nodes on the current path so a back-edge to one
|
||||||
|
// is the cycle, while black lets a shared node (a DAG, not a cycle) be skipped.
|
||||||
|
func checkNoCycles(root map[string]node) error {
|
||||||
|
const (
|
||||||
|
grey = 1
|
||||||
|
black = 2
|
||||||
|
)
|
||||||
|
color := map[node]int{}
|
||||||
|
var visit func(n node, path string) error
|
||||||
|
visit = func(n node, path string) error {
|
||||||
|
switch color[n] {
|
||||||
|
case grey:
|
||||||
|
return fmt.Errorf("reference cycle: %s", path)
|
||||||
|
case black:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color[n] = grey
|
||||||
|
for _, e := range renderEdges(n) {
|
||||||
|
if err := visit(e.to, path+" -> "+e.label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
color[n] = black
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for name, c := range root {
|
||||||
|
if err := visit(c, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -81,6 +81,13 @@ func TestReferenceErrors(t *testing.T) {
|
|||||||
"card": `{"format":"{..who.f}"}`,
|
"card": `{"format":"{..who.f}"}`,
|
||||||
},
|
},
|
||||||
"empty reference path": {"card": `{"format":"{..}"}`},
|
"empty reference path": {"card": `{"format":"{..}"}`},
|
||||||
|
// 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).
|
||||||
|
"direct cycle": {"a": `{"format":"x{..a}"}`},
|
||||||
|
"mutual cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..a}"}`},
|
||||||
|
"chain cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..c}"}`, "c": `{"format":"{..a}"}`},
|
||||||
|
// calc renders its operands, so a cycle through one must be caught too.
|
||||||
|
"calc operand cycle": {"x": `{"format":"{calc(y)}","y":[{"format":"{..x}"}]}`},
|
||||||
}
|
}
|
||||||
for name, files := range cases {
|
for name, files := range cases {
|
||||||
if _, err := New([]string{writeData(t, files)}); err == nil {
|
if _, err := New([]string{writeData(t, files)}); err == nil {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package fakes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rng is the randomness the renderer draws from. Passing it in keeps the render
|
||||||
|
// functions a pure core over an explicit effect; *rand.Rand satisfies it.
|
||||||
|
type rng interface {
|
||||||
|
IntN(n int) int
|
||||||
|
Float64() float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake generates a value for a dot path. Each segment descends one level: folder
|
||||||
|
// names and the category (JSON file) come first, then named fields within it,
|
||||||
|
// e.g. "sv_SE.address" or "sv_SE.address.street". Choices along the way are
|
||||||
|
// resolved at random. A path naming a folder (no value of its own) is an error.
|
||||||
|
func (f *Fakes) Fake(path string) (string, error) {
|
||||||
|
n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, "."))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, ok := n.(*group); ok {
|
||||||
|
return "", fmt.Errorf("fakes: %s names a folder, not a value", path)
|
||||||
|
}
|
||||||
|
return render(f.rand, n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// descend walks named fields, resolving choices it meets along the way. It is
|
||||||
|
// the one render-side step that can fail, because the path comes from the
|
||||||
|
// caller and may name a field that does not exist.
|
||||||
|
func descend(s *session, n node, segments []string) (node, error) {
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
switch n := n.(type) {
|
||||||
|
case *group:
|
||||||
|
child, ok := n.children[segments[0]]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no entry %q", segments[0])
|
||||||
|
}
|
||||||
|
return descend(s, child, segments[1:])
|
||||||
|
case *template:
|
||||||
|
child, ok := n.fields[segments[0]]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no field %q", segments[0])
|
||||||
|
}
|
||||||
|
return descend(s, child, segments[1:])
|
||||||
|
case *choice:
|
||||||
|
return descend(s, pick(s, n), segments) // a choice consumes no path segment
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// render evaluates a compiled node to a string. compile validates every node up
|
||||||
|
// front, so rendering a compiled tree cannot fail.
|
||||||
|
func render(s *session, n node) string {
|
||||||
|
switch n := n.(type) {
|
||||||
|
case literal:
|
||||||
|
return string(n)
|
||||||
|
case *choice:
|
||||||
|
return render(s, pick(s, n))
|
||||||
|
case *template:
|
||||||
|
if n.repeat == 1 {
|
||||||
|
return expand(s, n.format, n.fields)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < n.repeat; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(n.separator)
|
||||||
|
}
|
||||||
|
b.WriteString(expand(s, n.format, n.fields))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("fakes: uncompiled node %T", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick selects one item. Uniform choices are O(1); weighted choices are an
|
||||||
|
// O(log n) search over precomputed cumulative weights. compile guarantees a
|
||||||
|
// non-empty choice and a finite positive total, so the index is always in range.
|
||||||
|
func pick(r rng, c *choice) node {
|
||||||
|
if c.cum == nil {
|
||||||
|
return c.items[r.IntN(len(c.items))]
|
||||||
|
}
|
||||||
|
x := r.Float64() * c.cum[len(c.cum)-1]
|
||||||
|
i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > x })
|
||||||
|
return c.items[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// classChar randomises one character-class rune: '0' digit 0-9, '1' digit 1-9,
|
||||||
|
// 'A' letter A-Z, 'a' letter a-z.
|
||||||
|
func classChar(s *session, c rune) byte {
|
||||||
|
switch c {
|
||||||
|
case '0':
|
||||||
|
return byte('0' + s.IntN(10))
|
||||||
|
case '1':
|
||||||
|
return byte('1' + s.IntN(9))
|
||||||
|
case 'A':
|
||||||
|
return byte('A' + s.IntN(26))
|
||||||
|
default: // 'a'
|
||||||
|
return byte('a' + s.IntN(26))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expand renders a format string: literals verbatim, class chars randomised, a
|
||||||
|
// {name(args)} token via its builtin, any other {token} via resolve. checkTokens
|
||||||
|
// validated every token at compile time, so the scan cannot fail here (the
|
||||||
|
// eachToken error is unreachable and ignored).
|
||||||
|
func expand(s *session, format string, fields map[string]node) string {
|
||||||
|
var b strings.Builder
|
||||||
|
_ = eachToken(format, func(t ftoken) error {
|
||||||
|
switch t.kind {
|
||||||
|
case 'l':
|
||||||
|
b.WriteRune(t.r)
|
||||||
|
case 'c':
|
||||||
|
b.WriteByte(classChar(s, t.r))
|
||||||
|
case 'b':
|
||||||
|
if name, args, ok := funcCall(t.body); ok {
|
||||||
|
b.WriteString(builtins[name].call(s, b.String(), fields, args)) // b.String() is the output so far
|
||||||
|
} else {
|
||||||
|
b.WriteString(resolve(s, t.body, fields))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(s *session, token string, fields map[string]node) string {
|
||||||
|
names := strings.Split(token, "|")
|
||||||
|
return render(s, fields[names[s.IntN(len(names))]])
|
||||||
|
}
|
||||||
+67
-442
@@ -2,205 +2,75 @@ package fakes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// node is a compiled template element: literal, choice, or template. Compiling
|
// This file is the {token} grammar of a format string: how it is scanned
|
||||||
// JSON into these once (see compile) means rendering never re-inspects the raw
|
// (eachToken), how a function token is parsed (funcCall) and validated
|
||||||
// JSON or re-sums weights.
|
// (checkFunc, checkTokens), and the builtin contract its functions implement.
|
||||||
type node interface{ isNode() }
|
// render.go evaluates these tokens; node.go compiles the surrounding JSON;
|
||||||
|
// reference.go binds {..path} tokens across the tree.
|
||||||
|
|
||||||
// rng is the randomness the renderer draws from. Passing it in keeps the render
|
// ftoken is one unit of a scanned format string: a literal rune to emit, a class
|
||||||
// functions a pure core over an explicit effect; *rand.Rand satisfies it.
|
// char to randomise ('0' '1' 'A' 'a'), or the body of a {…} token.
|
||||||
type rng interface {
|
type ftoken struct {
|
||||||
IntN(n int) int
|
kind byte // 'l' literal rune, 'c' class char, 'b' brace body
|
||||||
Float64() float64
|
r rune // for kinds 'l' and 'c'
|
||||||
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// literal is emitted verbatim, never formatted.
|
// eachToken scans a format string once and calls fn for each unit, the single
|
||||||
type literal string
|
// source of truth for how '#' escapes and {…} braces are read — expand,
|
||||||
|
// checkTokens, fieldTokens and refTokens all drive off it so the grammar can't
|
||||||
func (literal) isNode() {}
|
// drift between the validator and the renderer. '#' escapes the next char to a
|
||||||
|
// literal ("#0" -> '0', "##" -> '#'); a '{' must reach a '}' (else an error); an
|
||||||
// group is a namespace of named children, built from a directory of JSON files
|
// unmatched '}' is an ordinary literal. The error stops the scan early.
|
||||||
// and subdirectories. It has no value of its own: descend into a named child by
|
func eachToken(format string, fn func(ftoken) error) error {
|
||||||
// dot path; rendering one is an error (see Fake).
|
|
||||||
type group struct{ children map[string]node }
|
|
||||||
|
|
||||||
func (*group) isNode() {}
|
|
||||||
|
|
||||||
// choice picks one of its items. cum holds cumulative weights for a weighted
|
|
||||||
// pick; when nil the choice is uniform and selection is O(1).
|
|
||||||
type choice struct {
|
|
||||||
items []node
|
|
||||||
cum []float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*choice) isNode() {}
|
|
||||||
|
|
||||||
// template renders a format string, substituting {tokens} from fields. repeat
|
|
||||||
// (default 1) renders that format that many times and joins the results with
|
|
||||||
// separator (default ""), each render an independent pick.
|
|
||||||
type template struct {
|
|
||||||
format string
|
|
||||||
fields map[string]node
|
|
||||||
repeat int
|
|
||||||
separator string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*template) isNode() {}
|
|
||||||
|
|
||||||
// Fake generates a value for a dot path. Each segment descends one level: folder
|
|
||||||
// names and the category (JSON file) come first, then named fields within it,
|
|
||||||
// e.g. "sv_SE.address" or "sv_SE.address.street". Choices along the way are
|
|
||||||
// resolved at random. A path naming a folder (no value of its own) is an error.
|
|
||||||
func (f *Fakes) Fake(path string) (string, error) {
|
|
||||||
n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, "."))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
|
||||||
}
|
|
||||||
if _, ok := n.(*group); ok {
|
|
||||||
return "", fmt.Errorf("fakes: %s names a folder, not a value", path)
|
|
||||||
}
|
|
||||||
return render(f.rand, n), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// descend walks named fields, resolving choices it meets along the way. It is
|
|
||||||
// the one render-side step that can fail, because the path comes from the
|
|
||||||
// caller and may name a field that does not exist.
|
|
||||||
func descend(s *session, n node, segments []string) (node, error) {
|
|
||||||
if len(segments) == 0 {
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
switch n := n.(type) {
|
|
||||||
case *group:
|
|
||||||
child, ok := n.children[segments[0]]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("no entry %q", segments[0])
|
|
||||||
}
|
|
||||||
return descend(s, child, segments[1:])
|
|
||||||
case *template:
|
|
||||||
child, ok := n.fields[segments[0]]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("no field %q", segments[0])
|
|
||||||
}
|
|
||||||
return descend(s, child, segments[1:])
|
|
||||||
case *choice:
|
|
||||||
return descend(s, pick(s, n), segments) // a choice consumes no path segment
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// render evaluates a compiled node to a string. compile validates every node up
|
|
||||||
// front, so rendering a compiled tree cannot fail.
|
|
||||||
func render(s *session, n node) string {
|
|
||||||
switch n := n.(type) {
|
|
||||||
case literal:
|
|
||||||
return string(n)
|
|
||||||
case *choice:
|
|
||||||
return render(s, pick(s, n))
|
|
||||||
case *template:
|
|
||||||
if n.repeat == 1 {
|
|
||||||
return expand(s, n.format, n.fields)
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for i := 0; i < n.repeat; i++ {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(n.separator)
|
|
||||||
}
|
|
||||||
b.WriteString(expand(s, n.format, n.fields))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
default:
|
|
||||||
panic(fmt.Sprintf("fakes: uncompiled node %T", n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pick selects one item. Uniform choices are O(1); weighted choices are an
|
|
||||||
// O(log n) search over precomputed cumulative weights. compile guarantees a
|
|
||||||
// non-empty choice and a finite positive total, so the index is always in range.
|
|
||||||
func pick(r rng, c *choice) node {
|
|
||||||
if c.cum == nil {
|
|
||||||
return c.items[r.IntN(len(c.items))]
|
|
||||||
}
|
|
||||||
x := r.Float64() * c.cum[len(c.cum)-1]
|
|
||||||
i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > x })
|
|
||||||
return c.items[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
// expand renders a format string. Character classes: '0' digit 0-9, '1' digit
|
|
||||||
// 1-9, 'A' letter A-Z, 'a' letter a-z. '#' escapes the next character to a
|
|
||||||
// literal ("#0" -> "0", "##" -> "#"). A "{name(args)}" token calls a builtin;
|
|
||||||
// any other "{token}" is substituted via resolve; every other rune is literal.
|
|
||||||
// checkTokens validated the braces and tokens at compile time, so this scan
|
|
||||||
// cannot fail.
|
|
||||||
func expand(s *session, format string, fields map[string]node) string {
|
|
||||||
var b strings.Builder
|
|
||||||
rs := []rune(format)
|
rs := []rune(format)
|
||||||
for i := 0; i < len(rs); i++ {
|
for i := 0; i < len(rs); i++ {
|
||||||
|
var t ftoken
|
||||||
switch c := rs[i]; c {
|
switch c := rs[i]; c {
|
||||||
case '#':
|
case '#':
|
||||||
|
t.kind, t.r = 'l', '#'
|
||||||
if i++; i < len(rs) {
|
if i++; i < len(rs) {
|
||||||
b.WriteRune(rs[i])
|
t.r = rs[i]
|
||||||
} else {
|
|
||||||
b.WriteRune('#')
|
|
||||||
}
|
}
|
||||||
case '0':
|
case '0', '1', 'A', 'a':
|
||||||
b.WriteByte(byte('0' + s.IntN(10)))
|
t.kind, t.r = 'c', c
|
||||||
case '1':
|
|
||||||
b.WriteByte(byte('1' + s.IntN(9)))
|
|
||||||
case 'A':
|
|
||||||
b.WriteByte(byte('A' + s.IntN(26)))
|
|
||||||
case 'a':
|
|
||||||
b.WriteByte(byte('a' + s.IntN(26)))
|
|
||||||
case '{':
|
case '{':
|
||||||
end := i + 1
|
end := i + 1
|
||||||
for rs[end] != '}' { // checkTokens guarantees a closing '}'
|
for end < len(rs) && rs[end] != '}' {
|
||||||
end++
|
end++
|
||||||
}
|
}
|
||||||
body := string(rs[i+1 : end])
|
if end >= len(rs) {
|
||||||
if name, args, ok := funcCall(body); ok {
|
return fmt.Errorf("unterminated '{' in %q", format)
|
||||||
if name == "calc" { // calc reads sibling fields, not just (rng, emitted)
|
|
||||||
b.WriteString(calcEval(s, args, fields))
|
|
||||||
} else {
|
|
||||||
b.WriteString(builtins[name].call(s, b.String(), args)) // b.String() is the output so far
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.WriteString(resolve(s, body, fields))
|
|
||||||
}
|
}
|
||||||
|
t.kind, t.body = 'b', string(rs[i+1:end])
|
||||||
i = end
|
i = end
|
||||||
default:
|
default:
|
||||||
b.WriteRune(c)
|
t.kind, t.r = 'l', c
|
||||||
|
}
|
||||||
|
if err := fn(t); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return b.String()
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
// 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(s *session, token string, fields map[string]node) string {
|
|
||||||
names := strings.Split(token, "|")
|
|
||||||
return render(s, fields[names[s.IntN(len(names))]])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// builtin is a format-string function invoked as {name(args)}. It receives the
|
// builtin is a format-string function invoked as {name(args)}. It receives the
|
||||||
// session (its rng, and the {seq()} counters), the output emitted so far in the
|
// session (its rng, and the {seq()} counters), the output emitted so far in the
|
||||||
// current expansion (for derivations such as a checksum over preceding digits),
|
// current expansion (for derivations such as a checksum over preceding digits),
|
||||||
// and its args. Almost all are pure over (rng, emitted, args) — no wall-clock, no
|
// the sibling fields (only calc reads them, to render its operands), and its args.
|
||||||
// crypto/rand — so seeding stays reproducible; a time-based id derives its time
|
// Almost all are pure over (rng, emitted, args) — no wall-clock, no crypto/rand —
|
||||||
// from the rng. seq is the one exception: it advances per-session counter state,
|
// so seeding stays reproducible; a time-based id derives its time from the rng.
|
||||||
// which is itself deterministic (1, 2, 3 …). arity is the exact arg count, or -1
|
// seq is the one exception: it advances per-session counter state, which is itself
|
||||||
// for variadic (then check does all the validation). The optional check validates
|
// deterministic (1, 2, 3 …). arity is the exact arg count, or -1 for variadic
|
||||||
// args at compile time (their values, beyond the count). The registry lives in
|
// (then check does all the validation). The optional check validates args at
|
||||||
// builtins.go.
|
// compile time (their values, beyond the count). The registry lives in builtins.go.
|
||||||
type builtin struct {
|
type builtin struct {
|
||||||
arity int
|
arity int
|
||||||
check func(args []string) error
|
check func(fields map[string]node, args []string) error
|
||||||
call func(s *session, emitted string, args []string) string
|
call func(s *session, emitted string, fields map[string]node, args []string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// funcCall splits a "{token}" body shaped name(args) into its parts; ok is false
|
// funcCall splits a "{token}" body shaped name(args) into its parts; ok is false
|
||||||
@@ -227,8 +97,9 @@ func splitArgs(s string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkFunc validates a function token at compile time: well-formed, naming a
|
// checkFunc validates a function token at compile time: well-formed, naming a
|
||||||
// known builtin, with the arg count that builtin takes.
|
// known builtin, with the arg count that builtin takes and args its check accepts.
|
||||||
func checkFunc(body string) error {
|
// fields is passed through for the one builtin (calc) that validates against them.
|
||||||
|
func checkFunc(body string, fields map[string]node) error {
|
||||||
name, args, ok := funcCall(body)
|
name, args, ok := funcCall(body)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("malformed function token {%s}", body)
|
return fmt.Errorf("malformed function token {%s}", body)
|
||||||
@@ -241,296 +112,50 @@ func checkFunc(body string) error {
|
|||||||
return fmt.Errorf("token {%s}: %s takes %d args, got %d", body, name, b.arity, len(args))
|
return fmt.Errorf("token {%s}: %s takes %d args, got %d", body, name, b.arity, len(args))
|
||||||
}
|
}
|
||||||
if b.check != nil {
|
if b.check != nil {
|
||||||
if err := b.check(args); err != nil {
|
if err := b.check(fields, args); err != nil {
|
||||||
return fmt.Errorf("token {%s}: %w", body, err)
|
return fmt.Errorf("token {%s}: %w", body, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// compile converts parsed JSON into a node tree, validating structure up front.
|
|
||||||
func compile(v any) (node, error) {
|
|
||||||
switch v := v.(type) {
|
|
||||||
case string:
|
|
||||||
return literal(v), nil
|
|
||||||
case []any:
|
|
||||||
return compileChoice(v)
|
|
||||||
case map[string]any:
|
|
||||||
return compileTemplate(v)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported node type %T", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func compileChoice(items []any) (node, error) {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil, fmt.Errorf("empty choice")
|
|
||||||
}
|
|
||||||
c := &choice{items: make([]node, len(items))}
|
|
||||||
cum := make([]float64, len(items))
|
|
||||||
var total float64
|
|
||||||
weighted := false
|
|
||||||
for i, raw := range items {
|
|
||||||
w, err := weightOf(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if w != 1 {
|
|
||||||
weighted = true
|
|
||||||
}
|
|
||||||
total += w
|
|
||||||
cum[i] = total
|
|
||||||
n, err := compile(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.items[i] = n
|
|
||||||
}
|
|
||||||
if weighted { // uniform choices skip the weight table and pick in O(1)
|
|
||||||
if total <= 0 || math.IsInf(total, 1) {
|
|
||||||
return nil, fmt.Errorf("choice weights must sum to a finite positive number, got %v", total)
|
|
||||||
}
|
|
||||||
c.cum = cum
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func compileTemplate(m map[string]any) (node, error) {
|
|
||||||
format, ok := m["format"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("template object missing string \"format\"")
|
|
||||||
}
|
|
||||||
repeat, err := repeatOf(m)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sep := ""
|
|
||||||
if sv, ok := m["separator"]; ok {
|
|
||||||
if sep, ok = sv.(string); !ok {
|
|
||||||
return nil, fmt.Errorf("separator must be a string, got %T", sv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t := &template{format: format, fields: make(map[string]node, len(m)), repeat: repeat, separator: sep}
|
|
||||||
for k, v := range m {
|
|
||||||
if k == "format" || k == "weight" || k == "repeat" || k == "separator" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, err := compile(v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("field %q: %w", k, err)
|
|
||||||
}
|
|
||||||
t.fields[k] = n
|
|
||||||
}
|
|
||||||
if err := checkTokens(format, t.fields); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTokens validates a format string the way expand scans it, so every
|
// checkTokens validates a format string the way expand scans it, so every
|
||||||
// "{token}" is balanced and names an existing field. This makes a typo'd or
|
// "{token}" is balanced and names an existing field (or a known function). This
|
||||||
// dangling reference a New-time error, never a random render-time one.
|
// makes a typo'd or dangling reference a New-time error, never a random
|
||||||
|
// render-time one.
|
||||||
func checkTokens(format string, fields map[string]node) error {
|
func checkTokens(format string, fields map[string]node) error {
|
||||||
rs := []rune(format)
|
return eachToken(format, func(t ftoken) error {
|
||||||
for i := 0; i < len(rs); i++ {
|
if t.kind != 'b' {
|
||||||
switch rs[i] {
|
return nil
|
||||||
case '#':
|
|
||||||
i++ // an escaped char is literal, never a token delimiter
|
|
||||||
case '{':
|
|
||||||
end := i + 1
|
|
||||||
for end < len(rs) && rs[end] != '}' {
|
|
||||||
end++
|
|
||||||
}
|
}
|
||||||
if end >= len(rs) {
|
if strings.IndexByte(t.body, '(') >= 0 { // a function token, not a field
|
||||||
return fmt.Errorf("unterminated '{' in %q", format)
|
return checkFunc(t.body, fields)
|
||||||
}
|
}
|
||||||
body := string(rs[i+1 : end])
|
for _, name := range strings.Split(t.body, "|") {
|
||||||
if strings.IndexByte(body, '(') >= 0 { // a function token, not a field
|
|
||||||
if name, args, ok := funcCall(body); ok && name == "calc" {
|
|
||||||
if err := checkCalc(args, fields); err != nil {
|
|
||||||
return fmt.Errorf("token {%s}: %w", body, err)
|
|
||||||
}
|
|
||||||
} else if err := checkFunc(body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, name := range strings.Split(body, "|") {
|
|
||||||
if isRef(name) {
|
if isRef(name) {
|
||||||
if name == refPrefix {
|
if name == refPrefix {
|
||||||
return fmt.Errorf("token {%s}: reference has no path", body)
|
return fmt.Errorf("token {%s}: reference has no path", t.body)
|
||||||
}
|
}
|
||||||
continue // a root reference; its target is checked at New (see linkRefs)
|
continue // a root reference; its target is checked at New (see linkRefs)
|
||||||
}
|
}
|
||||||
if _, ok := fields[name]; !ok {
|
if _, ok := fields[name]; !ok {
|
||||||
return fmt.Errorf("token {%s}: no field %q", body, name)
|
return fmt.Errorf("token {%s}: no field %q", t.body, name)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i = end
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// refPrefix marks a {..path} token: a reference to a node elsewhere in the data
|
// fieldTokens returns the field and reference names a format renders via {name}
|
||||||
// root rather than a sibling field. The path after it is the same dot path Fake
|
// or {a|..b} tokens (function tokens, which carry no field edges, are excluded).
|
||||||
// takes, resolved across every loaded directory (see linkRefs).
|
// These are exactly the child nodes expand's resolve recurses into.
|
||||||
const refPrefix = ".."
|
func fieldTokens(format string) []string {
|
||||||
|
var names []string
|
||||||
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
|
_ = eachToken(format, func(t ftoken) error {
|
||||||
|
if t.kind == 'b' && strings.IndexByte(t.body, '(') < 0 {
|
||||||
// linkRefs resolves every {..path} reference in the assembled tree, binding the
|
names = append(names, strings.Split(t.body, "|")...)
|
||||||
// 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
|
return nil
|
||||||
}
|
})
|
||||||
for _, c := range root {
|
return names
|
||||||
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) {
|
|
||||||
rv, ok := m["repeat"]
|
|
||||||
if !ok {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
r, ok := rv.(float64)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("repeat must be a number, got %T", rv)
|
|
||||||
}
|
|
||||||
if math.IsNaN(r) || math.IsInf(r, 0) || r < 1 || r != math.Trunc(r) {
|
|
||||||
return 0, fmt.Errorf("repeat must be a positive integer, got %v", rv)
|
|
||||||
}
|
|
||||||
return int(r), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// weightOf reads a node's "weight" (default 1) from its raw JSON form. Only
|
|
||||||
// template objects carry weight; a present one must be finite and non-negative.
|
|
||||||
func weightOf(raw any) (float64, error) {
|
|
||||||
m, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
wv, ok := m["weight"]
|
|
||||||
if !ok {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
w, ok := wv.(float64)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("weight must be a number, got %T", wv)
|
|
||||||
}
|
|
||||||
if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) {
|
|
||||||
return 0, fmt.Errorf("weight must be finite and non-negative, got %v", w)
|
|
||||||
}
|
|
||||||
return w, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user