313 lines
8.5 KiB
Go
313 lines
8.5 KiB
Go
package fakes
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// 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() }
|
|
|
|
// 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
|
|
|
|
func (literal) 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 category path. The first segment names a
|
|
// category (a JSON file); further dot-separated segments descend into named
|
|
// fields, e.g. "address" or "address.street". Choices along the way are
|
|
// resolved at random.
|
|
func (f *Fakes) Fake(path string) (string, error) {
|
|
segments := strings.Split(path, ".")
|
|
n, ok := f.categories[segments[0]]
|
|
if !ok {
|
|
return "", fmt.Errorf("fakes: unknown category %q", segments[0])
|
|
}
|
|
n, err := descend(f.rand, n, segments[1:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
|
}
|
|
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(r rng, n node, segments []string) (node, error) {
|
|
if len(segments) == 0 {
|
|
return n, nil
|
|
}
|
|
switch n := n.(type) {
|
|
case *template:
|
|
child, ok := n.fields[segments[0]]
|
|
if !ok {
|
|
return nil, fmt.Errorf("no field %q", segments[0])
|
|
}
|
|
return descend(r, child, segments[1:])
|
|
case *choice:
|
|
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 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)
|
|
case *choice:
|
|
return render(r, pick(r, n))
|
|
case *template:
|
|
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:
|
|
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", "##" -> "#"). "{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(r rng, format string, fields map[string]node) string {
|
|
var b strings.Builder
|
|
rs := []rune(format)
|
|
for i := 0; i < len(rs); i++ {
|
|
switch c := rs[i]; c {
|
|
case '#':
|
|
if i++; i < len(rs) {
|
|
b.WriteRune(rs[i])
|
|
} else {
|
|
b.WriteRune('#')
|
|
}
|
|
case '0':
|
|
b.WriteByte(byte('0' + r.IntN(10)))
|
|
case '1':
|
|
b.WriteByte(byte('1' + r.IntN(9)))
|
|
case 'A':
|
|
b.WriteByte(byte('A' + r.IntN(26)))
|
|
case 'a':
|
|
b.WriteByte(byte('a' + r.IntN(26)))
|
|
case '{':
|
|
end := i + 1
|
|
for rs[end] != '}' { // checkTokens guarantees a closing '}'
|
|
end++
|
|
}
|
|
b.WriteString(resolve(r, string(rs[i+1:end]), fields))
|
|
i = end
|
|
default:
|
|
b.WriteRune(c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// 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, "|")
|
|
return render(r, fields[names[r.IntN(len(names))]])
|
|
}
|
|
|
|
// 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
|
|
// "{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 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
|
|
}
|