909596ec2d
The operand order was written out in three places; it now lives at calcVars, which fixes it, and the other two point there. Dropped the note explaining why ')' is not reserved, which said what the set is not rather than what it is, and shortened reservedList's doc to less than its body. The note on held now states where it reaches instead of arguing against a design this repo no longer contains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
8.9 KiB
Go
285 lines
8.9 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() }
|
|
|
|
// literal is emitted verbatim, never formatted.
|
|
type literal string
|
|
|
|
func (literal) 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. 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
|
|
// 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() {}
|
|
|
|
// 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 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 := 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 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
|
|
}
|
|
if len(c.items) > 1 {
|
|
// Safe to precompute: a choice's items come from one file, so no group can
|
|
// appear inside one, and neither mergeChildren nor linkRefs can reach in.
|
|
c.shared = sharedPaths(c.items)
|
|
}
|
|
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)
|
|
}
|
|
if repeat == 1 {
|
|
return nil, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1")
|
|
}
|
|
}
|
|
t := &template{format: format, fields: make(map[string]node, len(m)), repeat: repeat, separator: sep}
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys) // so which of several bad fields is reported does not vary
|
|
for _, k := range keys {
|
|
if isOption(k) {
|
|
continue
|
|
}
|
|
if isRef(k) {
|
|
return nil, fmt.Errorf("field %q starts with %q, which is reserved for {..path} bindings", k, refPrefix)
|
|
}
|
|
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)
|
|
}
|
|
t.fields[k] = n
|
|
}
|
|
if err := checkTokens(format, t.fields); err != nil {
|
|
return nil, err
|
|
}
|
|
t.ops, t.grow, t.bound, t.held = compileOps(format)
|
|
if err := checkNoOverlap(format, t.bound); err != nil {
|
|
return nil, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// checkPath reports whether a token's dotted tail can address a node whichever way
|
|
// the draw goes, by the reachability rule descend applies — a multi-variant choice
|
|
// must carry the whole remaining path in the set every variant shares — plus the
|
|
// rules a held draw adds, which descend has no need of: a level a path reads may
|
|
// not carry a repeat, and each variant answers for that itself. So a path that
|
|
// validates here resolves on every render, and a typo is a New-time error.
|
|
func checkPath(n node, tail []string, level string) error {
|
|
if len(tail) == 0 {
|
|
return nil
|
|
}
|
|
switch n := n.(type) {
|
|
case *template:
|
|
if n.repeat > 1 {
|
|
return fmt.Errorf("the level %q carries a repeat, which a path reading one draw of it cannot apply", level)
|
|
}
|
|
child, ok := n.fields[tail[0]]
|
|
if !ok {
|
|
return fmt.Errorf("no field %q", tail[0])
|
|
}
|
|
return checkPath(child, tail[1:], level+"."+tail[0])
|
|
case *choice:
|
|
if len(n.items) > 1 {
|
|
if want := strings.Join(tail, "."); !n.shared[want] {
|
|
return unreachableInChoice(n, want)
|
|
}
|
|
// Reachability is settled; each variant still answers for itself, so a
|
|
// rule about the level (its repeat) holds behind a choice as in front.
|
|
for _, item := range n.items {
|
|
if err := checkPath(item, tail, level); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return checkPath(n.items[0], tail, level)
|
|
case literal:
|
|
return fmt.Errorf("a plain string has no field %q", tail[0])
|
|
default:
|
|
return fmt.Errorf("cannot descend into %T at %q", n, tail[0])
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
if r > maxLen { // cap so a fat-fingered repeat can't build a multi-GB string
|
|
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, maxLen)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 and '}' ends the token. A name carrying one is reachable by no format, so it
|
|
// is rejected where it is authored rather than at the token that cannot reach it.
|
|
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 and {token} 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 i := strings.IndexAny(name, reservedInName); i >= 0 {
|
|
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path and {token} 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
|
|
}
|