Add repeat/separator templates; validate at compile time; support Go 1.22+ via GO_VERSION

This commit is contained in:
lilleman
2026-06-08 21:11:58 +02:00
parent 72154502d7
commit 3e1cd3ee2a
8 changed files with 241 additions and 139 deletions
+5 -3
View File
@@ -1,7 +1,9 @@
# Reproducible test/build image. `docker build .` fails if vet or tests fail,
# so it doubles as CI. Day-to-day, prefer the Makefile (bind-mounts source,
# no rebuilds). Pin matches the latest stable Go at time of writing.
FROM golang:1.26.4
# so it doubles as CI. Day-to-day, prefer `docker compose run` (bind-mounts
# source, no rebuilds). GO_VERSION defaults to the latest stable Go; override it
# to test the lowest supported version: docker build --build-arg GO_VERSION=1.22 .
ARG GO_VERSION=1.26.4
FROM golang:${GO_VERSION}
WORKDIR /app
+25 -1
View File
@@ -23,7 +23,7 @@ format control we needed.
go get github.com/Timewave-AB/fakes
```
Requires Go 1.26+.
Requires Go 1.22+ (for `math/rand/v2`).
## Usage
@@ -119,6 +119,22 @@ within a choice:
]
```
Only template (object) nodes carry `weight` — a bare string or nested array in a
choice always counts as `1`. Weights are checked when you create the faker: a
negative, non-numeric, or all-zero set is rejected at `New`, so a typo fails
fast instead of silently skewing output.
**Repeat.** A template node may carry a `repeat` (default `1`) to render its
`format` that many times — each render an independent pick — joined by
`separator` (default `""`):
```json
{ "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] }
```
This yields e.g. `bar foo baz`. `repeat` must be a positive integer and
`separator` a string, both checked at `New`.
**Format string.** Every character is literal except:
| Token | Expands to |
@@ -193,6 +209,14 @@ docker compose run --rm --user "$(id -u):$(id -g)" tidy # go mod tidy
`docker build .` runs `go vet` and the tests, so it works as a CI gate too.
Tests run against the latest Go by default. Set `GO_VERSION` to check the lowest
supported version too:
```sh
GO_VERSION=1.22 docker compose run --rm test # lowest supported
docker compose run --rm test # latest
```
## Layout
```
+4 -1
View File
@@ -8,9 +8,12 @@
# Build caches persist in the `gocache` volume, so re-runs are fast and the
# host tree stays clean. Commands that rewrite source (fmt, tidy) keep your
# ownership when run with `--user "$(id -u):$(id -g)"`.
#
# GO_VERSION picks the toolchain image (default: latest stable). Test the lowest
# supported Go with: GO_VERSION=1.22 docker compose run --rm test
x-go: &go
image: golang:1.26.4
image: golang:${GO_VERSION:-1.26.4}
working_dir: /app
volumes:
- .:/app
+3 -23
View File
@@ -22,7 +22,7 @@ func TestEscapeEdgeCases(t *testing.T) {
`{"format":"x}y"}`: "x}y", // an unmatched } is literal (x, y aren't classes)
}
for tmpl, want := range cases {
if got := render(t, f, tmpl); got != want {
if got := mustRender(t, f, tmpl); got != want {
t.Errorf("render(%s) = %q, want %q", tmpl, got, want)
}
}
@@ -31,7 +31,7 @@ func TestEscapeEdgeCases(t *testing.T) {
func TestMultibyteFormat(t *testing.T) {
// Scanning is rune-aware: multibyte literals coexist with class chars and
// tokens without corrupting indices.
got := render(t, engine(2), `{"format":"Öster{x}-0å","x":["väg"]}`)
got := mustRender(t, engine(2), `{"format":"Öster{x}-0å","x":["väg"]}`)
if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) {
t.Fatalf("multibyte format = %q", got)
}
@@ -41,19 +41,13 @@ func TestAlternationThreeWay(t *testing.T) {
f := engine(4)
seen := map[string]bool{}
for i := 0; i < 200; i++ {
seen[render(t, f, `{"format":"{a|b|c}","a":["A"],"b":["B"],"c":["C"]}`)] = true
seen[mustRender(t, f, `{"format":"{a|b|c}","a":["A"],"b":["B"],"c":["C"]}`)] = true
}
if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 {
t.Fatalf("3-way alternation produced %v, want A, B and C", seen)
}
}
func TestEmptyChoiceErrors(t *testing.T) {
if _, err := engine(1).render(compiled(t, `[]`)); err == nil {
t.Fatal("render([]) = nil error, want empty-choice error")
}
}
func TestNewErrors(t *testing.T) {
// Pointing New at a file (not a directory) fails.
file := filepath.Join(t.TempDir(), "xx_XX")
@@ -69,20 +63,6 @@ func TestNewErrors(t *testing.T) {
}
}
func TestFakeSurfacesErrors(t *testing.T) {
f := engine(1)
f.categories = map[string]node{
"bad": compiled(t, `[{"format":"{y}","x":["Q"]}]`), // token names a missing field -> render error
"empty": compiled(t, `[]`), // empty choice met on a path -> descend error
}
if _, err := f.Fake("bad"); err == nil {
t.Error("Fake(bad) = nil error, want render error")
}
if _, err := f.Fake("empty.foo"); err == nil {
t.Error("Fake(empty.foo) = nil error, want empty-choice error")
}
}
// --- deep path navigation ---
func TestDeepDottedPath(t *testing.T) {
-4
View File
@@ -74,7 +74,3 @@ func newRand(seed uint64, seeded bool) *rand.Rand {
}
return rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
}
// intn returns a random int in [0, n). It panics for n <= 0, matching the
// stdlib contract.
func (f *Fakes) intn(n int) int { return f.rand.IntN(n) }
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Timewave-AB/fakes
go 1.26
go 1.22
+147 -71
View File
@@ -2,6 +2,7 @@ package fakes
import (
"fmt"
"math"
"sort"
"strings"
)
@@ -11,6 +12,13 @@ import (
// JSON or re-sums weights.
type node interface{ isNode() }
// 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
}
// literal is emitted verbatim, never formatted.
type literal string
@@ -25,10 +33,14 @@ type choice struct {
func (*choice) isNode() {}
// template renders a format string, substituting {tokens} from fields.
// 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() {}
@@ -43,19 +55,17 @@ func (f *Fakes) Fake(path string) (string, error) {
if !ok {
return "", fmt.Errorf("fakes: unknown category %q", segments[0])
}
n, err := f.descend(n, segments[1:])
n, err := descend(f.rand, n, segments[1:])
if err != nil {
return "", fmt.Errorf("fakes: %s: %w", path, err)
}
s, err := f.render(n)
if err != nil {
return "", fmt.Errorf("fakes: %s: %w", path, err)
}
return s, nil
return render(f.rand, n), nil
}
// descend walks named fields, resolving choices it meets along the way.
func (f *Fakes) descend(n node, segments []string) (node, error) {
// 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(r rng, n node, segments []string) (node, error) {
if len(segments) == 0 {
return n, nil
}
@@ -65,58 +75,57 @@ func (f *Fakes) descend(n node, segments []string) (node, error) {
if !ok {
return nil, fmt.Errorf("no field %q", segments[0])
}
return f.descend(child, segments[1:])
return descend(r, child, segments[1:])
case *choice:
chosen, err := f.pick(n)
if err != nil {
return nil, err
}
return f.descend(chosen, segments) // a choice consumes no path segment
return descend(r, pick(r, 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 node to a string.
func (f *Fakes) render(n node) (string, error) {
// render evaluates a compiled node to a string. compile validates every node up
// front, so rendering a compiled tree cannot fail.
func render(r rng, n node) string {
switch n := n.(type) {
case literal:
return string(n), nil
return string(n)
case *choice:
chosen, err := f.pick(n)
if err != nil {
return "", err
}
return f.render(chosen)
return render(r, pick(r, n))
case *template:
return f.expand(n.format, n.fields)
if n.repeat == 1 {
return expand(r, 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(r, n.format, n.fields))
}
return b.String()
default:
return "", fmt.Errorf("unsupported node %T", n)
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.
func (f *Fakes) pick(c *choice) (node, error) {
if len(c.items) == 0 {
return nil, fmt.Errorf("empty choice")
}
// 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[f.intn(len(c.items))], nil
return c.items[r.IntN(len(c.items))]
}
r := f.rand.Float64() * c.cum[len(c.cum)-1]
i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > r })
if i >= len(c.items) {
i = len(c.items) - 1
}
return c.items[i], nil
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", "##" -> "#"). "{token}" is substituted via resolve;
// every other rune is literal.
func (f *Fakes) expand(format string, fields map[string]node) (string, error) {
// every other rune is literal. checkTokens validated the braces and tokens at
// compile time, so this scan cannot fail.
func expand(r rng, format string, fields map[string]node) string {
var b strings.Builder
rs := []rune(format)
for i := 0; i < len(rs); i++ {
@@ -128,44 +137,32 @@ func (f *Fakes) expand(format string, fields map[string]node) (string, error) {
b.WriteRune('#')
}
case '0':
b.WriteByte(byte('0' + f.intn(10)))
b.WriteByte(byte('0' + r.IntN(10)))
case '1':
b.WriteByte(byte('1' + f.intn(9)))
b.WriteByte(byte('1' + r.IntN(9)))
case 'A':
b.WriteByte(byte('A' + f.intn(26)))
b.WriteByte(byte('A' + r.IntN(26)))
case 'a':
b.WriteByte(byte('a' + f.intn(26)))
b.WriteByte(byte('a' + r.IntN(26)))
case '{':
end := i + 1
for end < len(rs) && rs[end] != '}' {
for rs[end] != '}' { // checkTokens guarantees a closing '}'
end++
}
if end >= len(rs) {
return "", fmt.Errorf("unterminated '{' in %q", format)
}
s, err := f.resolve(string(rs[i+1:end]), fields)
if err != nil {
return "", err
}
b.WriteString(s)
b.WriteString(resolve(r, string(rs[i+1:end]), fields))
i = end
default:
b.WriteRune(c)
}
}
return b.String(), nil
return b.String()
}
// resolve evaluates a "{token}" body: one or more field names separated by '|',
// of which one is chosen at random and rendered.
func (f *Fakes) resolve(token string, fields map[string]node) (string, error) {
// resolve renders a "{token}" body: one or more field names separated by '|',
// of which one is chosen at random. checkTokens guarantees every name exists.
func resolve(r rng, token string, fields map[string]node) string {
names := strings.Split(token, "|")
name := names[f.intn(len(names))]
child, ok := fields[name]
if !ok {
return "", fmt.Errorf("token {%s}: no field %q", token, name)
}
return f.render(child)
return render(r, fields[names[r.IntN(len(names))]])
}
// compile converts parsed JSON into a node tree, validating structure up front.
@@ -183,12 +180,18 @@ func compile(v any) (node, error) {
}
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 := weightOf(raw)
w, err := weightOf(raw)
if err != nil {
return nil, err
}
if w != 1 {
weighted = true
}
@@ -201,6 +204,9 @@ func compileChoice(items []any) (node, error) {
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
@@ -211,9 +217,19 @@ func compileTemplate(m map[string]any) (node, error) {
if !ok {
return nil, fmt.Errorf("template object missing string \"format\"")
}
t := &template{format: format, fields: make(map[string]node, len(m))}
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" {
if k == "format" || k == "weight" || k == "repeat" || k == "separator" {
continue
}
n, err := compile(v)
@@ -222,15 +238,75 @@ func compileTemplate(m map[string]any) (node, error) {
}
t.fields[k] = n
}
if err := checkTokens(format, t.fields); err != nil {
return nil, err
}
return t, nil
}
// weightOf reads a node's "weight" (default 1) from its raw JSON form.
func weightOf(raw any) float64 {
if m, ok := raw.(map[string]any); ok {
if w, ok := m["weight"].(float64); ok {
return w
// 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
// dangling reference a New-time error, never a random render-time one.
func checkTokens(format string, fields map[string]node) error {
rs := []rune(format)
for i := 0; i < len(rs); i++ {
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])
for _, name := range strings.Split(body, "|") {
if _, ok := fields[name]; !ok {
return fmt.Errorf("token {%s}: no field %q", body, name)
}
}
return 1
i = end
}
}
return 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)
}
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
}
+50 -29
View File
@@ -30,18 +30,14 @@ func compiled(t *testing.T, s string) node {
return n
}
func render(t *testing.T, f *Fakes, s string) string {
func mustRender(t *testing.T, f *Fakes, s string) string {
t.Helper()
out, err := f.render(compiled(t, s))
if err != nil {
t.Fatalf("render %q: %v", s, err)
}
return out
return render(f.rand, compiled(t, s))
}
func TestLiteralStringIsVerbatim(t *testing.T) {
// A bare string is a literal, never formatted: 'a'/'A' must survive.
if got := render(t, engine(1), `"Malmö"`); got != "Malmö" {
if got := mustRender(t, engine(1), `"Malmö"`); got != "Malmö" {
t.Fatalf("literal = %q, want Malmö", got)
}
}
@@ -56,7 +52,7 @@ func TestCharacterClasses(t *testing.T) {
f := engine(7)
for tmpl, re := range cases {
for i := 0; i < 100; i++ {
if got := render(t, f, tmpl); !re.MatchString(got) {
if got := mustRender(t, f, tmpl); !re.MatchString(got) {
t.Fatalf("%s produced %q, want %s", tmpl, got, re)
}
}
@@ -65,13 +61,13 @@ func TestCharacterClasses(t *testing.T) {
func TestEscapeAndLiteralChars(t *testing.T) {
// '#' escapes the next char; non-class chars (7, x, -) are literal.
if got := render(t, engine(1), `{"format":"#0#1#A#a## x7-z"}`); got != "01Aa# x7-z" {
if got := mustRender(t, engine(1), `{"format":"#0#1#A#a## x7-z"}`); got != "01Aa# x7-z" {
t.Fatalf("escape = %q, want \"01Aa# x7-z\"", got)
}
}
func TestTokenSubstitution(t *testing.T) {
if got := render(t, engine(1), `{"format":"{x}sson","x":["Erik"]}`); got != "Eriksson" {
if got := mustRender(t, engine(1), `{"format":"{x}sson","x":["Erik"]}`); got != "Eriksson" {
t.Fatalf("token = %q, want Eriksson", got)
}
}
@@ -80,7 +76,7 @@ func TestAlternationPicksOneField(t *testing.T) {
f := engine(3)
seen := map[string]bool{}
for i := 0; i < 100; i++ {
seen[render(t, f, `{"format":"{a|b}","a":["A"],"b":["B"]}`)] = true
seen[mustRender(t, f, `{"format":"{a|b}","a":["A"],"b":["B"]}`)] = true
}
if !seen["A"] || !seen["B"] || len(seen) != 2 {
t.Fatalf("alternation produced %v, want both A and B", seen)
@@ -90,7 +86,7 @@ func TestAlternationPicksOneField(t *testing.T) {
func TestWeightZeroNeverChosen(t *testing.T) {
f := engine(5)
for i := 0; i < 200; i++ {
if got := render(t, f, `[{"format":"X","weight":0},"Y"]`); got != "Y" {
if got := mustRender(t, f, `[{"format":"X","weight":0},"Y"]`); got != "Y" {
t.Fatalf("weight-0 variant chosen: %q", got)
}
}
@@ -100,7 +96,7 @@ func TestWeightSkewsDistribution(t *testing.T) {
f := engine(9)
heavy := 0
for i := 0; i < 1000; i++ {
if render(t, f, `[{"format":"H","weight":10},{"format":"L"}]`) == "H" {
if mustRender(t, f, `[{"format":"H","weight":10},{"format":"L"}]`) == "H" {
heavy++
}
}
@@ -109,24 +105,63 @@ func TestWeightSkewsDistribution(t *testing.T) {
}
}
func TestRepeatRendersFormatNTimes(t *testing.T) {
// repeat N concatenates N independent renders of the format ('x','y' are
// literal, not character classes).
if got := mustRender(t, engine(1), `{"format":"xy","repeat":3}`); got != "xyxyxy" {
t.Fatalf("repeat literal = %q, want xyxyxy", got)
}
// separator joins renders (N-1 of them, no trailing one).
if got := mustRender(t, engine(1), `{"format":"xy","repeat":3,"separator":"-"}`); got != "xy-xy-xy" {
t.Fatalf("repeat with separator = %q, want xy-xy-xy", got)
}
f, re := engine(7), regexp.MustCompile(`^[0-9]{4}$`)
seen := map[string]bool{}
for i := 0; i < 50; i++ {
got := mustRender(t, f, `{"format":"0","repeat":4}`)
if !re.MatchString(got) {
t.Fatalf("repeat-4 = %q, want 4 digits", got)
}
seen[got] = true
}
if len(seen) < 2 {
t.Fatalf("repeat never varied across renders: %v", seen)
}
}
func TestRecursionHasNoDepthLimit(t *testing.T) {
// Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep.
tmpl := `"deep"`
for i := 0; i < 50; i++ {
tmpl = `{"format":"{a}","a":[` + tmpl + `]}`
}
if got := render(t, engine(1), tmpl); got != "deep" {
if got := mustRender(t, engine(1), tmpl); got != "deep" {
t.Fatalf("deep recursion = %q, want deep", got)
}
}
func TestCompileErrors(t *testing.T) {
// Structural problems are caught up front, at compile/New time.
// Every structural problem is caught up front, at compile/New time, never
// deferred to a random render that happens to hit the bad branch.
for _, bad := range []string{
`{"x":["Q"]}`, // object without "format"
`{"format":"{y}","x":1}`, // a field is a bare number
`[1, 2]`, // a choice of numbers
`5`, // unsupported node type
`[]`, // empty choice
`{"format":"{x"}`, // unterminated brace
`{"format":"{y}","x":["Q"]}`, // token names a missing field
`{"format":"{}"}`, // empty token name
`{"format":"{a|}","a":["Q"]}`, // empty alternation segment
`[{"format":"A","weight":-1},{"format":"B"}]`, // negative weight
`[{"format":"A","weight":0},{"format":"B","weight":0}]`, // weights sum to zero
`[{"format":"A","weight":1e308},{"format":"B","weight":1e308}]`, // weights overflow to +Inf
`[{"format":"A","weight":"heavy"}]`, // non-numeric weight
`{"format":"x","repeat":0}`, // repeat below 1
`{"format":"x","repeat":-2}`, // negative repeat
`{"format":"x","repeat":1.5}`, // non-integer repeat
`{"format":"x","repeat":"two"}`, // non-numeric repeat
`{"format":"x","repeat":2,"separator":5}`, // non-string separator
} {
if _, err := compile(parse(t, bad)); err == nil {
t.Errorf("compile(%s) = nil error, want error", bad)
@@ -134,20 +169,6 @@ func TestCompileErrors(t *testing.T) {
}
}
func TestRenderErrors(t *testing.T) {
// These compile, but fail when rendered.
f := engine(1)
for _, bad := range []string{
`{"format":"{x"}`, // unterminated brace
`{"format":"{y}","x":["Q"]}`, // token names a missing field
`[]`, // empty choice
} {
if _, err := f.render(compiled(t, bad)); err == nil {
t.Errorf("render(%s) = nil error, want error", bad)
}
}
}
func TestFakePathNavigation(t *testing.T) {
f := engine(1)
f.categories = map[string]node{