diff --git a/Dockerfile b/Dockerfile index cc1e323..51d7df9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 90cf8d6..c379e88 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/compose.yaml b/compose.yaml index f101449..2522384 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/edge_test.go b/edge_test.go index 3b2cf75..879016e 100644 --- a/edge_test.go +++ b/edge_test.go @@ -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) { diff --git a/fakes.go b/fakes.go index c101180..3cf9488 100644 --- a/fakes.go +++ b/fakes.go @@ -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) } diff --git a/go.mod b/go.mod index c623d15..5f92fb6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/Timewave-AB/fakes -go 1.26 +go 1.22 diff --git a/template.go b/template.go index e880f6a..074d483 100644 --- a/template.go +++ b/template.go @@ -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 + 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) + } + } + i = end } } - return 1 + 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 } diff --git a/template_test.go b/template_test.go index 229a9a7..202074b 100644 --- a/template_test.go +++ b/template_test.go @@ -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 + `{"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{