Add repeat/separator templates; validate at compile time; support Go 1.22+ via GO_VERSION
This commit is contained in:
+5
-3
@@ -1,7 +1,9 @@
|
|||||||
# Reproducible test/build image. `docker build .` fails if vet or tests fail,
|
# 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,
|
# so it doubles as CI. Day-to-day, prefer `docker compose run` (bind-mounts
|
||||||
# no rebuilds). Pin matches the latest stable Go at time of writing.
|
# source, no rebuilds). GO_VERSION defaults to the latest stable Go; override it
|
||||||
FROM golang:1.26.4
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ format control we needed.
|
|||||||
go get github.com/Timewave-AB/fakes
|
go get github.com/Timewave-AB/fakes
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.26+.
|
Requires Go 1.22+ (for `math/rand/v2`).
|
||||||
|
|
||||||
## Usage
|
## 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:
|
**Format string.** Every character is literal except:
|
||||||
|
|
||||||
| Token | Expands to |
|
| 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.
|
`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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-1
@@ -8,9 +8,12 @@
|
|||||||
# Build caches persist in the `gocache` volume, so re-runs are fast and the
|
# 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
|
# host tree stays clean. Commands that rewrite source (fmt, tidy) keep your
|
||||||
# ownership when run with `--user "$(id -u):$(id -g)"`.
|
# 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
|
x-go: &go
|
||||||
image: golang:1.26.4
|
image: golang:${GO_VERSION:-1.26.4}
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
|||||||
+3
-23
@@ -22,7 +22,7 @@ func TestEscapeEdgeCases(t *testing.T) {
|
|||||||
`{"format":"x}y"}`: "x}y", // an unmatched } is literal (x, y aren't classes)
|
`{"format":"x}y"}`: "x}y", // an unmatched } is literal (x, y aren't classes)
|
||||||
}
|
}
|
||||||
for tmpl, want := range cases {
|
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)
|
t.Errorf("render(%s) = %q, want %q", tmpl, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ func TestEscapeEdgeCases(t *testing.T) {
|
|||||||
func TestMultibyteFormat(t *testing.T) {
|
func TestMultibyteFormat(t *testing.T) {
|
||||||
// Scanning is rune-aware: multibyte literals coexist with class chars and
|
// Scanning is rune-aware: multibyte literals coexist with class chars and
|
||||||
// tokens without corrupting indices.
|
// 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) {
|
if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) {
|
||||||
t.Fatalf("multibyte format = %q", got)
|
t.Fatalf("multibyte format = %q", got)
|
||||||
}
|
}
|
||||||
@@ -41,19 +41,13 @@ func TestAlternationThreeWay(t *testing.T) {
|
|||||||
f := engine(4)
|
f := engine(4)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for i := 0; i < 200; i++ {
|
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 {
|
if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 {
|
||||||
t.Fatalf("3-way alternation produced %v, want A, B and C", seen)
|
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) {
|
func TestNewErrors(t *testing.T) {
|
||||||
// Pointing New at a file (not a directory) fails.
|
// Pointing New at a file (not a directory) fails.
|
||||||
file := filepath.Join(t.TempDir(), "xx_XX")
|
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 ---
|
// --- deep path navigation ---
|
||||||
|
|
||||||
func TestDeepDottedPath(t *testing.T) {
|
func TestDeepDottedPath(t *testing.T) {
|
||||||
|
|||||||
@@ -74,7 +74,3 @@ func newRand(seed uint64, seeded bool) *rand.Rand {
|
|||||||
}
|
}
|
||||||
return rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
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,3 +1,3 @@
|
|||||||
module github.com/Timewave-AB/fakes
|
module github.com/Timewave-AB/fakes
|
||||||
|
|
||||||
go 1.26
|
go 1.22
|
||||||
|
|||||||
+149
-73
@@ -2,6 +2,7 @@ package fakes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -11,6 +12,13 @@ import (
|
|||||||
// JSON or re-sums weights.
|
// JSON or re-sums weights.
|
||||||
type node interface{ isNode() }
|
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.
|
// literal is emitted verbatim, never formatted.
|
||||||
type literal string
|
type literal string
|
||||||
|
|
||||||
@@ -25,10 +33,14 @@ type choice struct {
|
|||||||
|
|
||||||
func (*choice) isNode() {}
|
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 {
|
type template struct {
|
||||||
format string
|
format string
|
||||||
fields map[string]node
|
fields map[string]node
|
||||||
|
repeat int
|
||||||
|
separator string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*template) isNode() {}
|
func (*template) isNode() {}
|
||||||
@@ -43,19 +55,17 @@ func (f *Fakes) Fake(path string) (string, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("fakes: unknown category %q", segments[0])
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
||||||
}
|
}
|
||||||
s, err := f.render(n)
|
return render(f.rand, n), nil
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// descend walks named fields, resolving choices it meets along the way.
|
// descend walks named fields, resolving choices it meets along the way. It is
|
||||||
func (f *Fakes) descend(n node, segments []string) (node, error) {
|
// 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 {
|
if len(segments) == 0 {
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
@@ -65,58 +75,57 @@ func (f *Fakes) descend(n node, segments []string) (node, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("no field %q", segments[0])
|
return nil, fmt.Errorf("no field %q", segments[0])
|
||||||
}
|
}
|
||||||
return f.descend(child, segments[1:])
|
return descend(r, child, segments[1:])
|
||||||
case *choice:
|
case *choice:
|
||||||
chosen, err := f.pick(n)
|
return descend(r, pick(r, n), segments) // a choice consumes no path segment
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return f.descend(chosen, segments) // a choice consumes no path segment
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0])
|
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// render evaluates a node to a string.
|
// render evaluates a compiled node to a string. compile validates every node up
|
||||||
func (f *Fakes) render(n node) (string, error) {
|
// front, so rendering a compiled tree cannot fail.
|
||||||
|
func render(r rng, n node) string {
|
||||||
switch n := n.(type) {
|
switch n := n.(type) {
|
||||||
case literal:
|
case literal:
|
||||||
return string(n), nil
|
return string(n)
|
||||||
case *choice:
|
case *choice:
|
||||||
chosen, err := f.pick(n)
|
return render(r, pick(r, n))
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return f.render(chosen)
|
|
||||||
case *template:
|
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:
|
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
|
// pick selects one item. Uniform choices are O(1); weighted choices are an
|
||||||
// O(log n) search over precomputed cumulative weights.
|
// O(log n) search over precomputed cumulative weights. compile guarantees a
|
||||||
func (f *Fakes) pick(c *choice) (node, error) {
|
// non-empty choice and a finite positive total, so the index is always in range.
|
||||||
if len(c.items) == 0 {
|
func pick(r rng, c *choice) node {
|
||||||
return nil, fmt.Errorf("empty choice")
|
|
||||||
}
|
|
||||||
if c.cum == nil {
|
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]
|
x := r.Float64() * c.cum[len(c.cum)-1]
|
||||||
i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > r })
|
i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > x })
|
||||||
if i >= len(c.items) {
|
return c.items[i]
|
||||||
i = len(c.items) - 1
|
|
||||||
}
|
|
||||||
return c.items[i], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// expand renders a format string. Character classes: '0' digit 0-9, '1' digit
|
// 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
|
// 1-9, 'A' letter A-Z, 'a' letter a-z. '#' escapes the next character to a
|
||||||
// literal ("#0" -> "0", "##" -> "#"). "{token}" is substituted via resolve;
|
// literal ("#0" -> "0", "##" -> "#"). "{token}" is substituted via resolve;
|
||||||
// every other rune is literal.
|
// every other rune is literal. checkTokens validated the braces and tokens at
|
||||||
func (f *Fakes) expand(format string, fields map[string]node) (string, error) {
|
// compile time, so this scan cannot fail.
|
||||||
|
func expand(r rng, format string, fields map[string]node) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
rs := []rune(format)
|
rs := []rune(format)
|
||||||
for i := 0; i < len(rs); i++ {
|
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('#')
|
b.WriteRune('#')
|
||||||
}
|
}
|
||||||
case '0':
|
case '0':
|
||||||
b.WriteByte(byte('0' + f.intn(10)))
|
b.WriteByte(byte('0' + r.IntN(10)))
|
||||||
case '1':
|
case '1':
|
||||||
b.WriteByte(byte('1' + f.intn(9)))
|
b.WriteByte(byte('1' + r.IntN(9)))
|
||||||
case 'A':
|
case 'A':
|
||||||
b.WriteByte(byte('A' + f.intn(26)))
|
b.WriteByte(byte('A' + r.IntN(26)))
|
||||||
case 'a':
|
case 'a':
|
||||||
b.WriteByte(byte('a' + f.intn(26)))
|
b.WriteByte(byte('a' + r.IntN(26)))
|
||||||
case '{':
|
case '{':
|
||||||
end := i + 1
|
end := i + 1
|
||||||
for end < len(rs) && rs[end] != '}' {
|
for rs[end] != '}' { // checkTokens guarantees a closing '}'
|
||||||
end++
|
end++
|
||||||
}
|
}
|
||||||
if end >= len(rs) {
|
b.WriteString(resolve(r, string(rs[i+1:end]), fields))
|
||||||
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)
|
|
||||||
i = end
|
i = end
|
||||||
default:
|
default:
|
||||||
b.WriteRune(c)
|
b.WriteRune(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return b.String(), nil
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve evaluates a "{token}" body: one or more field names separated by '|',
|
// resolve renders a "{token}" body: one or more field names separated by '|',
|
||||||
// of which one is chosen at random and rendered.
|
// of which one is chosen at random. checkTokens guarantees every name exists.
|
||||||
func (f *Fakes) resolve(token string, fields map[string]node) (string, error) {
|
func resolve(r rng, token string, fields map[string]node) string {
|
||||||
names := strings.Split(token, "|")
|
names := strings.Split(token, "|")
|
||||||
name := names[f.intn(len(names))]
|
return render(r, fields[names[r.IntN(len(names))]])
|
||||||
child, ok := fields[name]
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("token {%s}: no field %q", token, name)
|
|
||||||
}
|
|
||||||
return f.render(child)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// compile converts parsed JSON into a node tree, validating structure up front.
|
// 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) {
|
func compileChoice(items []any) (node, error) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty choice")
|
||||||
|
}
|
||||||
c := &choice{items: make([]node, len(items))}
|
c := &choice{items: make([]node, len(items))}
|
||||||
cum := make([]float64, len(items))
|
cum := make([]float64, len(items))
|
||||||
var total float64
|
var total float64
|
||||||
weighted := false
|
weighted := false
|
||||||
for i, raw := range items {
|
for i, raw := range items {
|
||||||
w := weightOf(raw)
|
w, err := weightOf(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if w != 1 {
|
if w != 1 {
|
||||||
weighted = true
|
weighted = true
|
||||||
}
|
}
|
||||||
@@ -201,6 +204,9 @@ func compileChoice(items []any) (node, error) {
|
|||||||
c.items[i] = n
|
c.items[i] = n
|
||||||
}
|
}
|
||||||
if weighted { // uniform choices skip the weight table and pick in O(1)
|
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
|
c.cum = cum
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
@@ -211,9 +217,19 @@ func compileTemplate(m map[string]any) (node, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("template object missing string \"format\"")
|
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 {
|
for k, v := range m {
|
||||||
if k == "format" || k == "weight" {
|
if k == "format" || k == "weight" || k == "repeat" || k == "separator" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, err := compile(v)
|
n, err := compile(v)
|
||||||
@@ -222,15 +238,75 @@ func compileTemplate(m map[string]any) (node, error) {
|
|||||||
}
|
}
|
||||||
t.fields[k] = n
|
t.fields[k] = n
|
||||||
}
|
}
|
||||||
|
if err := checkTokens(format, t.fields); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// weightOf reads a node's "weight" (default 1) from its raw JSON form.
|
// checkTokens validates a format string the way expand scans it, so every
|
||||||
func weightOf(raw any) float64 {
|
// "{token}" is balanced and names an existing field. This makes a typo'd or
|
||||||
if m, ok := raw.(map[string]any); ok {
|
// dangling reference a New-time error, never a random render-time one.
|
||||||
if w, ok := m["weight"].(float64); ok {
|
func checkTokens(format string, fields map[string]node) error {
|
||||||
return w
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-33
@@ -30,18 +30,14 @@ func compiled(t *testing.T, s string) node {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(t *testing.T, f *Fakes, s string) string {
|
func mustRender(t *testing.T, f *Fakes, s string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := f.render(compiled(t, s))
|
return render(f.rand, compiled(t, s))
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("render %q: %v", s, err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLiteralStringIsVerbatim(t *testing.T) {
|
func TestLiteralStringIsVerbatim(t *testing.T) {
|
||||||
// A bare string is a literal, never formatted: 'a'/'A' must survive.
|
// 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)
|
t.Fatalf("literal = %q, want Malmö", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +52,7 @@ func TestCharacterClasses(t *testing.T) {
|
|||||||
f := engine(7)
|
f := engine(7)
|
||||||
for tmpl, re := range cases {
|
for tmpl, re := range cases {
|
||||||
for i := 0; i < 100; i++ {
|
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)
|
t.Fatalf("%s produced %q, want %s", tmpl, got, re)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,13 +61,13 @@ func TestCharacterClasses(t *testing.T) {
|
|||||||
|
|
||||||
func TestEscapeAndLiteralChars(t *testing.T) {
|
func TestEscapeAndLiteralChars(t *testing.T) {
|
||||||
// '#' escapes the next char; non-class chars (7, x, -) are literal.
|
// '#' 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)
|
t.Fatalf("escape = %q, want \"01Aa# x7-z\"", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenSubstitution(t *testing.T) {
|
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)
|
t.Fatalf("token = %q, want Eriksson", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +76,7 @@ func TestAlternationPicksOneField(t *testing.T) {
|
|||||||
f := engine(3)
|
f := engine(3)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for i := 0; i < 100; i++ {
|
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 {
|
if !seen["A"] || !seen["B"] || len(seen) != 2 {
|
||||||
t.Fatalf("alternation produced %v, want both A and B", seen)
|
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) {
|
func TestWeightZeroNeverChosen(t *testing.T) {
|
||||||
f := engine(5)
|
f := engine(5)
|
||||||
for i := 0; i < 200; i++ {
|
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)
|
t.Fatalf("weight-0 variant chosen: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +96,7 @@ func TestWeightSkewsDistribution(t *testing.T) {
|
|||||||
f := engine(9)
|
f := engine(9)
|
||||||
heavy := 0
|
heavy := 0
|
||||||
for i := 0; i < 1000; i++ {
|
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++
|
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) {
|
func TestRecursionHasNoDepthLimit(t *testing.T) {
|
||||||
// Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep.
|
// Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep.
|
||||||
tmpl := `"deep"`
|
tmpl := `"deep"`
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
tmpl = `{"format":"{a}","a":[` + tmpl + `]}`
|
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)
|
t.Fatalf("deep recursion = %q, want deep", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompileErrors(t *testing.T) {
|
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{
|
for _, bad := range []string{
|
||||||
`{"x":["Q"]}`, // object without "format"
|
`{"x":["Q"]}`, // object without "format"
|
||||||
`{"format":"{y}","x":1}`, // a field is a bare number
|
`{"format":"{y}","x":1}`, // a field is a bare number
|
||||||
`[1, 2]`, // a choice of numbers
|
`[1, 2]`, // a choice of numbers
|
||||||
`5`, // unsupported node type
|
`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 {
|
if _, err := compile(parse(t, bad)); err == nil {
|
||||||
t.Errorf("compile(%s) = nil error, want error", bad)
|
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) {
|
func TestFakePathNavigation(t *testing.T) {
|
||||||
f := engine(1)
|
f := engine(1)
|
||||||
f.categories = map[string]node{
|
f.categories = map[string]node{
|
||||||
|
|||||||
Reference in New Issue
Block a user