Files
fejkdata/node.go
T

389 lines
12 KiB
Go

package fejkdata
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
)
// node is a compiled template element: a choice or a template. Compiling JSON
// into these once (see compile) means rendering never re-inspects the raw JSON or
// re-sums weights.
type node interface{ 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). shared is the set of
// relative dot paths every item can address, so descend and List both read the one
// answer to what a path may reach through this choice.
type choice struct {
items []node
cum []float64
shared map[string]bool
}
func (*choice) isNode() {}
// template renders a format string, substituting {tokens} from fields. A bare
// JSON string is a template with no 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
ops []op // format compiled once (see compileOps); what expand walks
grow int // minimum output size, to size the render buffer
fixed bool // no op varies, so every render is lit
lit string // the whole output when fixed
refs map[string]refBinding // each reference the format reads -> what it is bound to
// bound maps each field the format addresses by dotted path to one path token
// reading it, which is the half of an overlap the fences name. nil when the
// format takes no path.
bound map[string]string
// held is every name drawn once per expansion: the bound levels above, plus the
// siblings a {calc()} reads. nil when the format holds nothing (see expand).
held map[string]bool
}
func (*template) isNode() {}
// field is the node a path segment names; a binding is a render edge, not a field.
func (t *template) field(seg string) (node, bool) {
if isRef(seg) {
return nil, false
}
n, ok := t.fields[seg]
return n, ok
}
// compile converts parsed JSON into a node tree, validating structure up front.
// Only a choice's items carry a weight, so one here would be inert whatever its type.
func compile(v any) (node, error) {
if m, ok := v.(map[string]any); ok {
if _, weighted := m["weight"]; weighted {
return nil, fmt.Errorf("weight only skews a choice's items, so it has no effect here; it is an option and can never be a field")
}
}
return compileItem(v)
}
// compileItem compiles one node, allowing the weight a choice item may carry.
func compileItem(v any) (node, error) {
switch v := v.(type) {
case string:
return compileString(v)
case []any:
return compileChoice(v)
case map[string]any:
return compileTemplate(v)
default:
return nil, fmt.Errorf("a template value must be a string, a list or an object, not %s", jsonKind(v))
}
}
// jsonKind names a JSON value a template cannot hold, in the data format's own
// terms rather than the decoding library's.
func jsonKind(v any) string {
switch v.(type) {
case float64:
return "a number"
case bool:
return "a boolean"
case nil:
return "null"
}
return fmt.Sprintf("%T", v)
}
func compileString(s string) (node, error) {
if err := checkTokens(s, nil); err != nil {
return nil, err
}
t := &template{format: s, repeat: 1}
if err := t.compileFormat(); err != nil {
return nil, err
}
return t, nil
}
// compileFormat compiles the format into ops once every field is in place, and
// applies the fences that need the compiled reads.
func (t *template) compileFormat() error {
c := compileOps(t.format, t.refs)
t.ops, t.grow, t.bound, t.held = c.ops, c.grow, c.bound, c.held
t.fixed = true
for _, o := range t.ops {
if o.kind != 'l' {
t.fixed = false
}
}
if t.fixed && len(t.ops) == 1 {
t.lit = t.ops[0].lit
}
if err := checkNoOverlap(t.format, t.bound, t.refs); err != nil {
return err
}
return checkNoRepeatedRead(t.format, c, t.refs)
}
// compileInput compiles an inline template: a JSON value, or a bare format string
// when the input is not JSON. A JSON string literal and a bare string compile
// alike (both are a template with no fields); the other JSON scalars — a number,
// bool or null — are no template, so they are rejected here, as a data file that
// was one would be at load. Padding is where the two readings would disagree — a
// format string renders it, JSON drops it — so a padded JSON value is rejected
// naming the one that renders.
func compileInput(input string) (node, error) {
var raw any
if err := json.Unmarshal([]byte(input), &raw); err != nil {
return compile(input)
}
if trimmed := strings.TrimSpace(input); trimmed != input {
return nil, fmt.Errorf("a JSON template may not be padded with spaces, which a format string would render; write %s", trimmed)
}
return compile(raw)
}
func compileChoice(items []any) (node, error) {
if len(items) == 0 {
return nil, fmt.Errorf("empty choice")
}
if len(items) == 1 {
return nil, fmt.Errorf("a one-item choice is its item; write the item")
}
if err := checkNoRepeatedItem(items); err != nil {
return nil, err
}
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 := compileItem(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 math.IsInf(total, 1) {
return nil, fmt.Errorf("choice weights must sum to a finite number, got %v", total)
}
c.cum = cum
}
// Computed before linkRefs binds references into the items: a binding is keyed
// by a reference sigil, which paths skips, so the set is the same after.
c.shared = sharedPaths(c.items)
return c, nil
}
// checkNoRepeatedItem rejects a choice that lists one item twice: a pick is even
// over the items, so a repeat is a second spelling of weight. The error names the
// spelling that does skew a pick.
func checkNoRepeatedItem(items []any) error {
seen := make(map[string]int, len(items))
for i, raw := range items {
key, isString := raw.(string)
if !isString {
b, err := json.Marshal(raw)
if err != nil {
return err
}
key = "\x00" + string(b)
}
if j, dup := seen[key]; dup {
if s, isString := raw.(string); isString {
return fmt.Errorf("choice item %q is repeated; skew the odds with a weight instead: { \"format\": %q, \"weight\": 2 }", s, s)
}
return fmt.Errorf("choice item %d repeats item %d; skew the odds with a weight on one of them instead", i, j)
}
seen[string(key)] = i
}
return nil
}
func compileTemplate(m map[string]any) (node, error) {
o, err := readOptions(m)
if err != nil {
return nil, err
}
fields, err := compileFields(m)
if err != nil {
return nil, err
}
if len(fields) == 0 && o.repeat == 1 && !o.weighted {
return nil, fmt.Errorf("an object holding only a format is a string; write %q", o.format)
}
if err := checkTokens(o.format, fields); err != nil {
return nil, err
}
t := &template{format: o.format, fields: fields, repeat: o.repeat, separator: o.separator}
if err := t.compileFormat(); err != nil {
return nil, err
}
return t, nil
}
// templateOptions is what a template object's option keys say.
type templateOptions struct {
format string
repeat int
separator string
weighted bool
}
func readOptions(m map[string]any) (templateOptions, error) {
var o templateOptions
format, ok := m["format"].(string)
if !ok {
return o, fmt.Errorf("template object missing string \"format\"")
}
o.format = format
repeat, err := repeatOf(m)
if err != nil {
return o, err
}
o.repeat = repeat
if sv, ok := m["separator"]; ok {
if o.separator, ok = sv.(string); !ok {
return o, fmt.Errorf("separator must be a string, got %T", sv)
}
if repeat == 1 {
return o, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1")
}
if o.separator == "" {
return o, fmt.Errorf("separator \"\" is the default, so it has no effect; drop it")
}
}
_, o.weighted = m["weight"]
return o, nil
}
// compileFields compiles every non-option key of a template object, in name order
// so which of several bad fields is reported does not vary.
func compileFields(m map[string]any) (map[string]node, error) {
fields := make(map[string]node, len(m))
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if isOption(k) {
continue
}
if err := checkName(k); err != nil {
return nil, fmt.Errorf("field %w", err)
}
n, err := compile(m[k])
if err != nil {
return nil, fmt.Errorf("field %q: %w", k, err)
}
fields[k] = n
}
return fields, nil
}
// repeatOf reads a template's "repeat" (default 1): how many times its format
// is rendered and concatenated. A present one must be an integer above 1.
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 == 1 {
return 0, fmt.Errorf("repeat 1 is the default, so it has no effect; drop it")
}
if r > MaxRepeat { // caps the renders one repeat asks for; checkRepeatReach bounds what nested ones multiply to
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, MaxRepeat)
}
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 positive.
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 positive, got %v", w)
}
if w == 0 {
return 0, fmt.Errorf("weight 0 means the item is never drawn; remove the item instead")
}
if w == 1 {
return 0, fmt.Errorf("weight 1 is the default, so it has no effect; drop it")
}
return w, nil
}
// reservedInName is what a category, folder or field name may not contain: a dot
// separates the segments of a path, '|' the arms of a token, '(' opens a function
// call, braces delimit the token, '/' starts a reference, and brackets and a quote
// open a JSON value. A name carrying one is rejected where it is authored rather
// than where it would be unreachable.
const reservedInName = ".|({}/[]\""
// reservedList spells reservedInName for an error message, so the two cannot drift.
var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
// checkName rejects a name the dot path, {token} and JSON grammars cannot spell.
// Both a category or folder and a field go through it, so there is one answer to
// what a name may contain.
func checkName(name string) error {
if name == "" {
return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name)
}
if i := strings.IndexAny(name, reservedInName); i >= 0 {
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve",
name, name[i:i+1], reservedList)
}
return nil
}
// isOption reports whether a template key configures the node instead of naming a
// field. These four names can never be fields.
func isOption(name string) bool {
switch name {
case "format", "repeat", "separator", "weight":
return true
}
return false
}