Updates from reviewers

This commit is contained in:
lilleman
2026-06-09 10:03:53 +02:00
parent cee2f35a2c
commit 5b6480a094
15 changed files with 845 additions and 498 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Coverage output .claude
*.out *.out
todo.md todo.md
+30 -11
View File
@@ -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
View File
@@ -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])
} }
+25
View File
@@ -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)
+32 -10
View File
@@ -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
View File
@@ -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"
}
+36
View File
@@ -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 {
+3
View File
@@ -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
} }
+45
View File
@@ -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 {
+16
View File
@@ -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 —
+164
View File
@@ -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
View File
@@ -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
}
+7
View File
@@ -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 {
+140
View File
@@ -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))]])
}
+74 -449
View File
@@ -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] {
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) {
return fmt.Errorf("unterminated '{' in %q", format)
}
body := string(rs[i+1 : end])
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 name == refPrefix {
return fmt.Errorf("token {%s}: reference has no path", body)
}
continue // a root reference; its target is checked at New (see linkRefs)
}
if _, ok := fields[name]; !ok {
return fmt.Errorf("token {%s}: no field %q", body, name)
}
}
}
i = end
}
}
return nil
}
// refPrefix marks a {..path} token: a reference to a node elsewhere in the data
// root rather than a sibling field. The path after it is the same dot path Fake
// takes, resolved across every loaded directory (see linkRefs).
const refPrefix = ".."
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
// linkRefs resolves every {..path} reference in the assembled tree, binding the
// target node into the referring template's fields under the token's key so the
// ordinary resolver renders it like a sibling. It runs once, after all data is
// merged, so a reference sees the final (override-resolved) tree. A path that is
// unknown, names a folder, or steps through a multi-variant choice fails here,
// keeping a bad reference a New-time error, never a random render-time one. The
// seen set both skips shared nodes and stops a cyclic reference looping the walk.
func linkRefs(root map[string]node) error {
seen := map[node]bool{}
var visit func(node) error
visit = func(n node) error {
if n == nil || seen[n] {
return nil return nil
} }
seen[n] = true if strings.IndexByte(t.body, '(') >= 0 { // a function token, not a field
switch n := n.(type) { return checkFunc(t.body, fields)
case *group: }
for _, c := range n.children { for _, name := range strings.Split(t.body, "|") {
if err := visit(c); err != nil { if isRef(name) {
return err if name == refPrefix {
return fmt.Errorf("token {%s}: reference has no path", t.body)
} }
continue // a root reference; its target is checked at New (see linkRefs)
} }
case *choice: if _, ok := fields[name]; !ok {
for _, it := range n.items { return fmt.Errorf("token {%s}: no field %q", t.body, name)
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 { }
if err := visit(c); err != nil {
return err // fieldTokens returns the field and reference names a format renders via {name}
// or {a|..b} tokens (function tokens, which carry no field edges, are excluded).
// These are exactly the child nodes expand's resolve recurses into.
func fieldTokens(format string) []string {
var names []string
_ = eachToken(format, func(t ftoken) error {
if t.kind == 'b' && strings.IndexByte(t.body, '(') < 0 {
names = append(names, strings.Split(t.body, "|")...)
} }
} return nil
return nil })
} return names
// 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
} }