138bd21d83
A field named "" was reachable by exactly one spelling, a bare {} token,
and by no dot path: List hid it and Fake could not ask for it. checkName
rejects it now, for the reason it rejects a dot — a name no path can reach.
That also makes the empty-segment rule unconditional. {a.} was rejected
because a field really named "" would otherwise have made it resolve; no
data can carry one, so the rule now guards a shape that cannot exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
9.0 KiB
Go
288 lines
9.0 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 name == "" {
|
|
return fmt.Errorf("%q is empty, so no dot path can reach 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 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
|
|
}
|