diff --git a/.gitignore b/.gitignore index bb3531d..71fd48f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -# Coverage output +.claude *.out todo.md \ No newline at end of file diff --git a/README.md b/README.md index 0198688..eeaacdb 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,18 @@ fakes -data-path ./data/sv_SE -data-path ./mydata word # layer dirs; the last w fakes -seed 42 -data-path ./data/sv_SE address fakes -repeat 3 -data-path ./data/sv_SE person # three values, one per line fakes -repeat 3 -separator ', ' -data-path ./data/sv_SE word # nät, barn, sol +fakes -data-path ./data/sv_SE -list # every path this data offers ``` -`-data-path` is repeatable (last wins a name clash) and the path comes last, after -the flags. `-repeat N` renders the path N times — each an independent draw — joined -by `-separator` (default a newline, so values land one per line). +`-data-path` is repeatable (last wins a name clash) and the path comes last — +all flags must precede it. `-repeat N` renders the path N times — each an +independent draw — joined by `-separator` (default a newline, so values land one +per line). Not sure what a data set offers? `-list` prints every path you can ask +for; `-version` prints the build version. Without installing, run it from a checkout with `go run ./cmd/fakes …`. Exit -codes: `0` success, `1` runtime error (missing dir, unknown path), `2` misuse. +codes: `0` success (including `-list`, `-version`, `-h`), `1` runtime error +(missing dir, unknown path), `2` misuse. ### Generating a file from a custom template @@ -136,6 +140,9 @@ bv, _ := b.Fake("person") av == bv // true ``` +`f.List()` returns the sorted paths the loaded data offers — the categories, their +dotted fields and folder segments (what the CLI's `-list` prints). + A `*Fakes` is **not** safe for concurrent use — create one per goroutine. ## Data @@ -248,7 +255,9 @@ There are four kinds. **Derivations** read the digits emitted so far, so put the after their payload; **generators** read only the rng, so they stand alone; one **session counter** (`seq`) advances state held on the faker; and one **computation** (`calc`) evaluates arithmetic over sibling fields. Arguments are -validated at `New` (a bad count, range, country, or expression fails fast). +validated at `New` (a bad count, range, country, or expression fails fast); a +length, count or decimal place beyond a sane maximum is rejected there too, so a +fat-fingered `hex(2000000000)` can't try to allocate gigabytes at render. | Function | Kind | Emits | |----------|------|-------| @@ -292,7 +301,8 @@ hyphenated field name can't be an operand. The expression is checked at `New` renders `19.99 x 3 = 59.97`. Each name is rendered where it appears, so a field shown *and* used in a `calc` is drawn twice — keep a shared operand in a single-value field if the two must agree. A field that doesn't render to a number -yields `NaN`, which prints rather than failing the render. +yields `NaN`, and a division by zero yields `Inf`; both print rather than failing +the render. **References.** A `{..path}` token renders a node from the **data root** instead of a sibling field — the dot path is the one `Fake` takes, resolved across every @@ -305,8 +315,9 @@ data dirs: renders e.g. `Hej, Pat Smith!`. References are bound when you create the faker, so a path that is unknown, names a folder, or steps through a multi-variant choice -fails at `New`. A reference must not lead back to its own value (directly or -through a chain), or rendering won't terminate. +fails at `New`. A reference that leads back to its own value (directly, mutually, +or through a chain) is a cycle that would never finish rendering, so it too is +rejected at `New`. **Format string.** Every character is literal except: @@ -324,6 +335,11 @@ through a chain), or rendering won't terminate. `{a|b}` renders one of the sibling fields `a` or `b`, chosen at random; an arm may be a `{..path}` reference too (`{name|..en_US.person}`). +Inside a `format`, `0 1 A a` are **always** character classes — so a fixed digit +or letter must be escaped (`#1`, `#A`) or it becomes random. A format of +`100 Main St` renders e.g. `506 mdin street`, not `100 Main St`. For a value with +no tokens at all, use a bare string node (`"100 Main St"`), emitted verbatim. + **Putting it together** (`person.json`): ```json @@ -396,12 +412,15 @@ docker compose run --rm test # latest ## Layout ``` -fakes.go Fakes, New, options, seeding -template.go Fake, the recursive renderer (choices, format strings, paths) +fakes.go Fakes, New, List, options, seeding +node.go the node model and JSON -> node compilation +render.go Fake and the recursive renderer (choices, format strings, paths) +template.go the {token} grammar: scanning, function tokens, validation +reference.go {..path} binding across the tree, and cycle detection builtins.go the {name()} function registry and its implementations calc.go the {calc()} arithmetic evaluator: parser, eval, validation data.go data loading: folders/files -> namespace tree, multi-path merge -cmd/fakes/ the `fakes` CLI (New + Fake over stdout) +cmd/fakes/ the `fakes` CLI (New + Fake/List over stdout) data/ shipped data (JSON): locale folders + a misc folder ``` diff --git a/builtins.go b/builtins.go index c62d2a1..1ffd2db 100644 --- a/builtins.go +++ b/builtins.go @@ -3,10 +3,19 @@ package fakes import ( "encoding/base64" "fmt" + "math" "strconv" "strings" ) +// maxLen caps generator output lengths (hex, nanoid, base64) and maxDecimals caps +// float/calc decimal places, so a fat-fingered or overflowing argument fails at +// New instead of trying to allocate gigabytes — or panicking — at render. +const ( + maxLen = 1 << 20 // 1,048,576 chars/bytes + maxDecimals = 1024 +) + // builtins is the registry of {name(args)} functions. Two kinds: derivations read // the digits emitted so far in the current expansion (luhn, mod11, ean — place // them after their payload); generators read only the rng (uuid, ulid, ...). All @@ -15,26 +24,31 @@ import ( // the wall clock. Add a builtin only for what data can't express: a random v4 // UUID already ships as data (data/misc/uuid.json), so the builtin is v7. var builtins = map[string]builtin{ - "luhn": {arity: 0, call: func(_ *session, e string, _ []string) string { return string(rune('0' + luhnCheck(e))) }}, - "mod11": {arity: 0, call: func(_ *session, e string, _ []string) string { return mod11Check(e) }}, - "ean": {arity: 0, call: func(_ *session, e string, _ []string) string { return eanCheck(e) }}, - "uuid": {arity: 0, call: func(s *session, _ string, _ []string) string { return uuidV7(s) }}, - "ulid": {arity: 0, call: func(s *session, _ string, _ []string) string { return ulid(s) }}, - "objectid": {arity: 0, call: func(s *session, _ string, _ []string) string { return randHex(s, 24) }}, - "nanoid": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { return nanoid(s, atoi(a[0])) }}, - "hex": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { return randHex(s, atoi(a[0])) }}, - "base64": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { + "luhn": {arity: 0, call: func(_ *session, e string, _ map[string]node, _ []string) string { + return string(rune('0' + luhnCheck(e))) + }}, + "mod11": {arity: 0, call: func(_ *session, e string, _ map[string]node, _ []string) string { return mod11Check(e) }}, + "ean": {arity: 0, call: func(_ *session, e string, _ map[string]node, _ []string) string { return eanCheck(e) }}, + "uuid": {arity: 0, call: func(s *session, _ string, _ map[string]node, _ []string) string { return uuidV7(s) }}, + "ulid": {arity: 0, call: func(s *session, _ string, _ map[string]node, _ []string) string { return ulid(s) }}, + "objectid": {arity: 0, call: func(s *session, _ string, _ map[string]node, _ []string) string { return randHex(s, 24) }}, + "nanoid": {arity: 1, check: posIntArg, call: func(s *session, _ string, _ map[string]node, a []string) string { return nanoid(s, atoi(a[0])) }}, + "hex": {arity: 1, check: posIntArg, call: func(s *session, _ string, _ map[string]node, a []string) string { return randHex(s, atoi(a[0])) }}, + "base64": {arity: 1, check: posIntArg, call: func(s *session, _ string, _ map[string]node, a []string) string { return base64.StdEncoding.EncodeToString(randBytes(s, atoi(a[0]))) }}, - "int": {arity: 2, check: intRangeArgs, call: func(s *session, _ string, a []string) string { + "int": {arity: 2, check: intRangeArgs, call: func(s *session, _ string, _ map[string]node, a []string) string { return strconv.Itoa(atoi(a[0]) + s.IntN(atoi(a[1])-atoi(a[0])+1)) }}, "float": {arity: 3, check: floatArgs, call: floatCall}, - "iban": {arity: 1, check: ibanArg, call: func(s *session, _ string, a []string) string { return iban(s, a[0]) }}, + "iban": {arity: 1, check: ibanArg, call: func(s *session, _ string, _ map[string]node, a []string) string { return iban(s, a[0]) }}, + // calc is the one builtin that reads the sibling fields (to render its operands); + // every other ignores them. 1 or 2 args: the expression and an optional decimals. + "calc": {arity: -1, check: checkCalc, call: calcCall}, // seq is the one stateful builtin: a per-session counter from 1, advancing on // each call. An optional name selects an independent counter; no name uses the // default one. Deterministic by construction, so a seeded faker stays stable. - "seq": {arity: -1, check: seqArg, call: func(s *session, _ string, a []string) string { + "seq": {arity: -1, check: seqArg, call: func(s *session, _ string, _ map[string]node, a []string) string { key := "" if len(a) == 1 { key = a[0] @@ -64,14 +78,18 @@ func randHex(r rng, n int) string { return string(b) } -func posIntArg(a []string) error { - if n, err := strconv.Atoi(a[0]); err != nil || n < 1 { +func posIntArg(_ map[string]node, a []string) error { + n, err := strconv.Atoi(a[0]) + if err != nil || n < 1 { return fmt.Errorf("count %q must be a positive integer", a[0]) } + if n > maxLen { + return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen) + } return nil } -func intRangeArgs(a []string) error { +func intRangeArgs(_ map[string]node, a []string) error { lo, e1 := strconv.Atoi(a[0]) hi, e2 := strconv.Atoi(a[1]) if e1 != nil || e2 != nil { @@ -80,10 +98,13 @@ func intRangeArgs(a []string) error { if lo > hi { return fmt.Errorf("int(min,max): min %d > max %d", lo, hi) } + if uint64(hi)-uint64(lo) >= uint64(math.MaxInt64) { // span hi-lo+1 would overflow int -> IntN panic + return fmt.Errorf("int(min,max): range %d..%d is too wide", lo, hi) + } return nil } -func floatArgs(a []string) error { +func floatArgs(_ map[string]node, a []string) error { lo, e1 := strconv.ParseFloat(a[0], 64) hi, e2 := strconv.ParseFloat(a[1], 64) dp, e3 := strconv.Atoi(a[2]) @@ -93,19 +114,22 @@ func floatArgs(a []string) error { if lo > hi { return fmt.Errorf("float(min,max,dp): min %v > max %v", lo, hi) } - if dp < 0 { - return fmt.Errorf("float(min,max,dp): decimals %d < 0", dp) + if math.IsInf(hi-lo, 0) { // an overflowing span would render as "+Inf" + return fmt.Errorf("float(min,max,dp): range %v..%v is too wide", lo, hi) + } + if dp < 0 || dp > maxDecimals { + return fmt.Errorf("float(min,max,dp): decimals %d out of range 0..%d", dp, maxDecimals) } return nil } -func floatCall(s *session, _ string, a []string) string { +func floatCall(s *session, _ string, _ map[string]node, a []string) string { lo, _ := strconv.ParseFloat(a[0], 64) hi, _ := strconv.ParseFloat(a[1], 64) return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', atoi(a[2]), 64) } -func seqArg(a []string) error { +func seqArg(_ map[string]node, a []string) error { if len(a) > 1 { return fmt.Errorf("seq takes at most one name, got %d args", len(a)) } @@ -231,7 +255,7 @@ func eanCheck(s string) string { // a left-to-right derivation; it generates the whole value instead. var ibanLen = map[string]int{"BE": 16, "DE": 22, "DK": 18, "ES": 24, "FI": 18, "NO": 15, "SE": 24} -func ibanArg(a []string) error { +func ibanArg(_ map[string]node, a []string) error { if _, ok := ibanLen[a[0]]; !ok { return fmt.Errorf("iban(%q): unsupported country code", a[0]) } diff --git a/builtins_test.go b/builtins_test.go index cd42624..41b3f2d 100644 --- a/builtins_test.go +++ b/builtins_test.go @@ -122,6 +122,31 @@ func TestBuiltinSeqPerSession(t *testing.T) { } } +// TestBuiltinArgLimits pins the New-time guards that keep a fat-fingered or +// overflowing argument from panicking or OOM-ing at render. Each must be rejected +// at compile, not detonate when the template is drawn. +func TestBuiltinArgLimits(t *testing.T) { + bad := []string{ + `{"format":"{int(-9223372036854775808,9223372036854775807)}"}`, // span overflows int -> IntN panic + `{"format":"{hex(2000000000)}"}`, // ~2 GB string + `{"format":"{nanoid(2000000000)}"}`, + `{"format":"{base64(2000000000)}"}`, + `{"format":"{float(-1e308,1e308,2)}"}`, // span is +Inf + `{"format":"{float(0,1,100000)}"}`, // decimals -> huge string + `{"format":"{calc(1+1,100000)}"}`, // calc decimals -> huge string + `{"format":"{x}","x":["1"],"repeat":2000000000}`, // repeat -> multi-GB join + } + for _, tmpl := range bad { + if _, err := compile(parse(t, tmpl)); err == nil { + t.Errorf("compile(%s) = nil error, want a limit rejection", tmpl) + } + } + // Ordinary values still compile. + if _, err := compile(parse(t, `{"format":"{hex(16)} {int(-5,5)} {float(0,1,3)} {calc(1+1,2)}"}`)); err != nil { + t.Errorf("compile of normal args failed: %v", err) + } +} + func TestBuiltinIBAN(t *testing.T) { wantLen := map[string]int{"SE": 24, "DE": 22, "NO": 15} f := engine(1) diff --git a/calc.go b/calc.go index 3a115e3..f858052 100644 --- a/calc.go +++ b/calc.go @@ -10,9 +10,9 @@ import ( // calc is the {calc(expr[, dp])} token: an arithmetic expression over number // literals and sibling-field names (rendered, then parsed as numbers), with -// + - * /, unary minus and parentheses. Unlike a builtin it reads the field -// environment, so expand and checkTokens route it here rather than the builtins -// registry. The value prints in minimal decimal form, or rounded to dp decimals +// + - * /, unary minus and parentheses. It is a registry builtin like any other +// {name(args)} function — the one whose check and call read the sibling fields (to +// render its operands). The value prints in minimal decimal form, or rounded to dp // when given. A field that doesn't render to a number becomes NaN, which // propagates and prints as "NaN" — visible, never a render error. The expression // is re-parsed each render, mirroring how expand re-scans the format; checkCalc @@ -58,8 +58,10 @@ func (n calcBin) eval(s *session, fields map[string]node) float64 { } // checkCalc validates a calc token at compile time: a parseable expression whose -// operands all name existing fields, and an optional non-negative integer dp. -func checkCalc(args []string, fields map[string]node) error { +// operands all name existing fields, and an optional non-negative integer dp. It +// is a builtin check (fields first), so calc dispatches through the registry like +// every other {name(args)} function. +func checkCalc(fields map[string]node, args []string) error { if len(args) < 1 || len(args) > 2 { return fmt.Errorf("calc takes an expression and an optional decimals count, got %d args", len(args)) } @@ -73,17 +75,18 @@ func checkCalc(args []string, fields map[string]node) error { } } if len(args) == 2 { - if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 { - return fmt.Errorf("calc decimals %q must be a non-negative integer", args[1]) + if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 || dp > maxDecimals { + return fmt.Errorf("calc decimals %q must be an integer in 0..%d", args[1], maxDecimals) } } return nil } -// calcEval renders a calc token. checkCalc proved the expression parses and the +// calcCall renders a calc token. checkCalc proved the expression parses and the // decimals arg is valid, so neither step here can fail. dp -1 prints the minimal -// form; a given dp rounds to that many places. -func calcEval(s *session, args []string, fields map[string]node) string { +// form; a given dp rounds to that many places. The emitted-so-far arg is unused — +// calc reads sibling fields, not the preceding output. +func calcCall(s *session, _ string, fields map[string]node, args []string) string { expr, _ := parseCalc(args[0]) dp := -1 if len(args) == 2 { @@ -92,6 +95,25 @@ func calcEval(s *session, args []string, fields map[string]node) string { return strconv.FormatFloat(expr.eval(s, fields), 'f', dp, 64) } +// calcOperands lists the sibling-field names every {calc(...)} token in a format +// reads, so cycle detection sees the field edges calc renders through (a function +// token otherwise carries no field edge). +func calcOperands(format string) []string { + var names []string + _ = eachToken(format, func(t ftoken) error { + if t.kind != 'b' { + return nil + } + if name, args, ok := funcCall(t.body); ok && name == "calc" && len(args) >= 1 { + if expr, err := parseCalc(args[0]); err == nil { + names = append(names, calcVars(expr)...) + } + } + return nil + }) + return names +} + // calcVars lists the field names an expression references, for the existence // check in checkCalc. func calcVars(n calcNode) []string { diff --git a/cmd/fakes/main.go b/cmd/fakes/main.go index d8608ee..78a166c 100644 --- a/cmd/fakes/main.go +++ b/cmd/fakes/main.go @@ -10,10 +10,12 @@ package main import ( + "errors" "flag" "fmt" "io" "os" + "runtime/debug" "strings" "github.com/Timewave-AB/fakes" @@ -25,7 +27,11 @@ const usage = `Usage: fakes -data-path D [-data-path D]... [-seed N] [-repeat N] a category, or a dotted path into one (person, person.last) -seed N seed for reproducible output -repeat N render the path N times (default 1) - -separator S string between repeated values (default newline)` + -separator S string between repeated values (default newline) + -list list the paths the data offers, then exit + -version print the version, then exit + +Flags must come before .` // stringList collects a repeatable string flag, preserving order. type stringList []string @@ -45,19 +51,24 @@ func run(args []string, stdout, stderr io.Writer) int { seed := fs.Uint64("seed", 0, "seed for reproducible output") repeat := fs.Int("repeat", 1, "render the path this many times") sep := fs.String("separator", "\n", "string between repeated values") + list := fs.Bool("list", false, "list the paths the data offers, then exit") + showVersion := fs.Bool("version", false, "print the version, then exit") var dirs stringList fs.Var(&dirs, "data-path", "a data directory to load (repeatable)") if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { // -h/-help already printed usage + return 0 + } return 2 } - if len(dirs) == 0 || fs.NArg() != 1 { + if *showVersion { + fmt.Fprintln(stdout, "fakes "+buildVersion()) + return 0 + } + if len(dirs) == 0 { fs.Usage() return 2 } - if *repeat < 1 { - fmt.Fprintln(stderr, "repeat must be a positive integer") - return 2 - } var opts []fakes.Option fs.Visit(func(fl *flag.Flag) { @@ -66,6 +77,26 @@ func run(args []string, stdout, stderr io.Writer) int { } }) + if *list { + f, err := fakes.New(dirs, opts...) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + for _, p := range f.List() { + fmt.Fprintln(stdout, p) + } + return 0 + } + if fs.NArg() != 1 { + fs.Usage() + return 2 + } + if *repeat < 1 { + fmt.Fprintln(stderr, "repeat must be a positive integer") + return 2 + } + path := fs.Arg(0) f, err := fakes.New(dirs, opts...) if err != nil { @@ -82,3 +113,13 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, strings.Join(vals, *sep)) return 0 } + +// buildVersion reports the module version stamped into the binary by `go install` +// (or "devel" for a local build), read from the build info — no version constant +// to bump, no extra dependency. +func buildVersion() string { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" { + return info.Main.Version + } + return "devel" +} diff --git a/cmd/fakes/main_test.go b/cmd/fakes/main_test.go index 3dddd9e..95b187f 100644 --- a/cmd/fakes/main_test.go +++ b/cmd/fakes/main_test.go @@ -145,6 +145,42 @@ func TestRunUnknownCategoryFails(t *testing.T) { } } +func TestRunList(t *testing.T) { + // -list prints the discoverable paths and exits 0, no positional path needed. + code, out, errb := runOut("-data-path", svSE, "-list") + if code != 0 { + t.Fatalf("run = %d, stderr=%q", code, errb) + } + for _, want := range []string{"person", "person.last", "address"} { + if !strings.Contains(out, want) { + t.Errorf("list output missing %q:\n%s", want, out) + } + } +} + +func TestRunHelpExitsZero(t *testing.T) { + // -h/-help is success (0), not misuse (2), so scripts under `set -e` survive. + for _, h := range []string{"-h", "-help"} { + code, _, errb := runOut(h) + if code != 0 { + t.Errorf("%s = %d, want 0", h, code) + } + if !strings.Contains(errb, "Usage") { + t.Errorf("%s: stderr = %q, want usage", h, errb) + } + } +} + +func TestRunVersion(t *testing.T) { + code, out, errb := runOut("-version") + if code != 0 { + t.Fatalf("-version = %d, stderr=%q", code, errb) + } + if strings.TrimSpace(out) == "" { + t.Error("-version: want a version on stdout") + } +} + func TestRunMissingDirFails(t *testing.T) { code, _, errb := runOut("-data-path", "../../data/nope", "person") if code != 1 { diff --git a/data.go b/data.go index 302c8af..ed49ca5 100644 --- a/data.go +++ b/data.go @@ -30,6 +30,9 @@ func loadData(paths []string) (map[string]node, error) { if err := linkRefs(root); err != nil { return nil, err } + if err := checkNoCycles(root); err != nil { + return nil, err + } return root, nil } diff --git a/fakes.go b/fakes.go index e6ff76d..16ba3e7 100644 --- a/fakes.go +++ b/fakes.go @@ -21,6 +21,7 @@ import ( "encoding/binary" "fmt" "math/rand/v2" + "sort" ) // Fakes generates fake data from a loaded namespace tree. Create one with [New]. @@ -75,6 +76,50 @@ func New(paths []string, opts ...Option) (*Fakes, error) { return &Fakes{rand: newRand(c.seed, c.seeded), categories: cats}, nil } +// List returns the sorted dotted paths Fake can render: every category, the +// dotted fields within a template, and folder segments — descending transparently +// through single-variant choices the way a reference does. A multi-variant choice +// is one path (its pick is random); its items are not separately addressable. It's +// the discoverable map of what a loaded data set offers, powering the CLI's -list. +func (f *Fakes) List() []string { + var out []string + var walk func(prefix string, n node) + walk = func(prefix string, n node) { + switch n := n.(type) { + case *group: + for name, c := range n.children { + walk(join(prefix, name), c) + } + case *choice: + if len(n.items) == 1 { // a single-variant choice is a transparent wrapper + walk(prefix, n.items[0]) + return + } + out = append(out, prefix) + case *template: + out = append(out, prefix) + for name, c := range n.fields { + if isRef(name) { // a bound {..path} reference, not an authored field + continue + } + walk(join(prefix, name), c) + } + case literal: + out = append(out, prefix) + } + } + walk("", &group{children: f.categories}) + sort.Strings(out) + return out +} + +func join(prefix, name string) string { + if prefix == "" { + return name + } + return prefix + "." + name +} + func newRand(seed uint64, seeded bool) *session { r := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) if !seeded { diff --git a/loading_test.go b/loading_test.go index 780d9f6..4b2a847 100644 --- a/loading_test.go +++ b/loading_test.go @@ -3,9 +3,25 @@ package fakes import ( "os" "path/filepath" + "reflect" "testing" ) +// TestList pins the discoverable paths: every category, its dotted fields, and +// folder segments — descending transparently through single-variant choices. +func TestList(t *testing.T) { + dir := writeData(t, map[string]string{ + "person": `{"format":"{first} {last}","first":["A"],"last":["B"]}`, + "word": `["x", "y"]`, + "geo/city": `["Z"]`, + }) + got := newFakes(t, dir, WithSeed(1)).List() + want := []string{"geo.city", "person", "person.first", "person.last", "word"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("List() = %v, want %v", got, want) + } +} + // writeData builds a temp data directory from a map of relative path (without // ".json") -> file content, creating parent folders as needed. The directory's // name carries no meaning anymore, so callers pick any layout they like — diff --git a/node.go b/node.go new file mode 100644 index 0000000..6abcead --- /dev/null +++ b/node.go @@ -0,0 +1,164 @@ +package fakes + +import ( + "fmt" + "math" +) + +// 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). +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() {} + +// 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 +} + +// 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 +} diff --git a/reference.go b/reference.go new file mode 100644 index 0000000..57691fe --- /dev/null +++ b/reference.go @@ -0,0 +1,180 @@ +package fakes + +import ( + "fmt" + "strings" +) + +// refPrefix marks a {..path} token: a reference to a node elsewhere in the data +// root rather than a sibling field. The path after it is the same dot path Fake +// takes, resolved across every loaded directory (see linkRefs). +const refPrefix = ".." + +func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) } + +// linkRefs resolves every {..path} reference in the assembled tree, binding the +// target node into the referring template's fields under the token's key so the +// ordinary resolver renders it like a sibling. It runs once, after all data is +// merged, so a reference sees the final (override-resolved) tree. A path that is +// unknown, names a folder, or steps through a multi-variant choice fails here, +// keeping a bad reference a New-time error, never a random render-time one. The +// seen set skips shared nodes (and keeps this walk finite if data is cyclic); +// checkNoCycles, run next, is what rejects an actual reference cycle. +func linkRefs(root map[string]node) error { + seen := map[node]bool{} + var visit func(node) error + visit = func(n node) error { + if n == nil || seen[n] { + return nil + } + seen[n] = true + switch n := n.(type) { + case *group: + for _, c := range n.children { + if err := visit(c); err != nil { + return err + } + } + case *choice: + for _, it := range n.items { + if err := visit(it); err != nil { + return err + } + } + case *template: + for _, name := range refTokens(n.format) { + target, err := lookup(root, strings.Split(name[len(refPrefix):], ".")) + if err != nil { + return fmt.Errorf("reference {%s}: %w", name, err) + } + n.fields[name] = target + } + for _, c := range n.fields { + if err := visit(c); err != nil { + return err + } + } + } + return nil + } + for _, c := range root { + if err := visit(c); err != nil { + return err + } + } + return nil +} + +// lookup finds the single node a reference path names, walking groups and +// template fields by segment and descending a single-variant choice as a +// transparent wrapper. A missing segment, a folder target, or a step through a +// multi-variant choice (which has no one value to bind) is an error. +func lookup(root map[string]node, segments []string) (node, error) { + var n node = &group{children: root} + for i := 0; i < len(segments); i++ { + switch c := n.(type) { + case *group: + child, ok := c.children[segments[i]] + if !ok { + return nil, fmt.Errorf("no entry %q", segments[i]) + } + n = child + case *template: + child, ok := c.fields[segments[i]] + if !ok { + return nil, fmt.Errorf("no field %q", segments[i]) + } + n = child + case *choice: + if len(c.items) != 1 { + return nil, fmt.Errorf("%q steps through a %d-way choice", segments[i], len(c.items)) + } + n, i = c.items[0], i-1 // a choice consumes no segment; reprocess it unwrapped + default: + return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[i]) + } + } + if _, ok := n.(*group); ok { + return nil, fmt.Errorf("names a folder, not a value") + } + return n, nil +} + +// refTokens returns just the {..path} reference names among a format's field +// tokens (linkRefs binds each into the template's fields). +func refTokens(format string) []string { + var refs []string + for _, name := range fieldTokens(format) { + if isRef(name) { + refs = append(refs, name) + } + } + return refs +} + +// renderEdge is a child a node renders into, labelled by the token that reaches it +// (a field name, reference, or choice index) for a readable cycle report. +type renderEdge struct { + to node + label string +} + +// renderEdges lists the children rendering n recurses into, mirroring expand: a +// choice's items, and a template's field/reference tokens plus its calc operands. +// A literal or group renders nothing, so it has no edges. +func renderEdges(n node) []renderEdge { + switch n := n.(type) { + case *choice: + es := make([]renderEdge, len(n.items)) + for i, it := range n.items { + es[i] = renderEdge{it, fmt.Sprintf("[%d]", i)} + } + return es + case *template: + var es []renderEdge + for _, name := range append(fieldTokens(n.format), calcOperands(n.format)...) { + if c, ok := n.fields[name]; ok { + es = append(es, renderEdge{c, name}) + } + } + return es + default: + return nil + } +} + +// checkNoCycles rejects a reference cycle: a node whose rendering can reach itself +// — directly, mutually, or through a chain — never terminates, so it must fail at +// New rather than stack-overflow at render. It is a depth-first walk of the render +// graph (renderEdges); grey marks nodes on the current path so a back-edge to one +// is the cycle, while black lets a shared node (a DAG, not a cycle) be skipped. +func checkNoCycles(root map[string]node) error { + const ( + grey = 1 + black = 2 + ) + color := map[node]int{} + var visit func(n node, path string) error + visit = func(n node, path string) error { + switch color[n] { + case grey: + return fmt.Errorf("reference cycle: %s", path) + case black: + return nil + } + color[n] = grey + for _, e := range renderEdges(n) { + if err := visit(e.to, path+" -> "+e.label); err != nil { + return err + } + } + color[n] = black + return nil + } + for name, c := range root { + if err := visit(c, name); err != nil { + return err + } + } + return nil +} diff --git a/reference_test.go b/reference_test.go index ddda49b..e53acaa 100644 --- a/reference_test.go +++ b/reference_test.go @@ -81,6 +81,13 @@ func TestReferenceErrors(t *testing.T) { "card": `{"format":"{..who.f}"}`, }, "empty reference path": {"card": `{"format":"{..}"}`}, + // A reference that leads back to its own value never terminates at render, + // so New must reject the cycle up front (direct, mutual, or chained). + "direct cycle": {"a": `{"format":"x{..a}"}`}, + "mutual cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..a}"}`}, + "chain cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..c}"}`, "c": `{"format":"{..a}"}`}, + // calc renders its operands, so a cycle through one must be caught too. + "calc operand cycle": {"x": `{"format":"{calc(y)}","y":[{"format":"{..x}"}]}`}, } for name, files := range cases { if _, err := New([]string{writeData(t, files)}); err == nil { diff --git a/render.go b/render.go new file mode 100644 index 0000000..7b10568 --- /dev/null +++ b/render.go @@ -0,0 +1,140 @@ +package fakes + +import ( + "fmt" + "sort" + "strings" +) + +// 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 +} + +// Fake generates a value for a dot path. Each segment descends one level: folder +// names and the category (JSON file) come first, then named fields within it, +// e.g. "sv_SE.address" or "sv_SE.address.street". Choices along the way are +// resolved at random. A path naming a folder (no value of its own) is an error. +func (f *Fakes) Fake(path string) (string, error) { + n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, ".")) + if err != nil { + return "", fmt.Errorf("fakes: %s: %w", path, err) + } + if _, ok := n.(*group); ok { + return "", fmt.Errorf("fakes: %s names a folder, not a value", path) + } + 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(s *session, n node, segments []string) (node, error) { + if len(segments) == 0 { + return n, nil + } + switch n := n.(type) { + case *group: + child, ok := n.children[segments[0]] + if !ok { + return nil, fmt.Errorf("no entry %q", segments[0]) + } + return descend(s, child, segments[1:]) + case *template: + child, ok := n.fields[segments[0]] + if !ok { + return nil, fmt.Errorf("no field %q", segments[0]) + } + return descend(s, child, segments[1:]) + case *choice: + return descend(s, pick(s, 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(s *session, n node) string { + switch n := n.(type) { + case literal: + return string(n) + case *choice: + return render(s, pick(s, n)) + case *template: + if n.repeat == 1 { + return expand(s, 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(s, 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] +} + +// classChar randomises one character-class rune: '0' digit 0-9, '1' digit 1-9, +// 'A' letter A-Z, 'a' letter a-z. +func classChar(s *session, c rune) byte { + switch c { + case '0': + return byte('0' + s.IntN(10)) + case '1': + return byte('1' + s.IntN(9)) + case 'A': + return byte('A' + s.IntN(26)) + default: // 'a' + return byte('a' + s.IntN(26)) + } +} + +// expand renders a format string: literals verbatim, class chars randomised, a +// {name(args)} token via its builtin, any other {token} via resolve. checkTokens +// validated every token at compile time, so the scan cannot fail here (the +// eachToken error is unreachable and ignored). +func expand(s *session, format string, fields map[string]node) string { + var b strings.Builder + _ = eachToken(format, func(t ftoken) error { + switch t.kind { + case 'l': + b.WriteRune(t.r) + case 'c': + b.WriteByte(classChar(s, t.r)) + case 'b': + if name, args, ok := funcCall(t.body); ok { + b.WriteString(builtins[name].call(s, b.String(), fields, args)) // b.String() is the output so far + } else { + b.WriteString(resolve(s, t.body, fields)) + } + } + return nil + }) + return b.String() +} + +// resolve renders a "{token}" body: one or more names separated by '|', one +// picked at random. A name is a sibling field or a {..path} reference, which +// linkRefs bound into fields too; checkTokens and linkRefs guarantee both exist. +func resolve(s *session, token string, fields map[string]node) string { + names := strings.Split(token, "|") + return render(s, fields[names[s.IntN(len(names))]]) +} diff --git a/template.go b/template.go index fa59c96..389032e 100644 --- a/template.go +++ b/template.go @@ -2,205 +2,75 @@ 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() } +// This file is the {token} grammar of a format string: how it is scanned +// (eachToken), how a function token is parsed (funcCall) and validated +// (checkFunc, checkTokens), and the builtin contract its functions implement. +// render.go evaluates these tokens; node.go compiles the surrounding JSON; +// reference.go binds {..path} tokens across the tree. -// 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 +// ftoken is one unit of a scanned format string: a literal rune to emit, a class +// char to randomise ('0' '1' 'A' 'a'), or the body of a {…} token. +type ftoken struct { + kind byte // 'l' literal rune, 'c' class char, 'b' brace body + r rune // for kinds 'l' and 'c' + body string } -// 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). -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 dot path. Each segment descends one level: folder -// names and the category (JSON file) come first, then named fields within it, -// e.g. "sv_SE.address" or "sv_SE.address.street". Choices along the way are -// resolved at random. A path naming a folder (no value of its own) is an error. -func (f *Fakes) Fake(path string) (string, error) { - n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, ".")) - if err != nil { - return "", fmt.Errorf("fakes: %s: %w", path, err) - } - if _, ok := n.(*group); ok { - return "", fmt.Errorf("fakes: %s names a folder, not a value", path) - } - 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(s *session, n node, segments []string) (node, error) { - if len(segments) == 0 { - return n, nil - } - switch n := n.(type) { - case *group: - child, ok := n.children[segments[0]] - if !ok { - return nil, fmt.Errorf("no entry %q", segments[0]) - } - return descend(s, child, segments[1:]) - case *template: - child, ok := n.fields[segments[0]] - if !ok { - return nil, fmt.Errorf("no field %q", segments[0]) - } - return descend(s, child, segments[1:]) - case *choice: - return descend(s, pick(s, 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(s *session, n node) string { - switch n := n.(type) { - case literal: - return string(n) - case *choice: - return render(s, pick(s, n)) - case *template: - if n.repeat == 1 { - return expand(s, 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(s, 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", "##" -> "#"). A "{name(args)}" token calls a builtin; -// any other "{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(s *session, format string, fields map[string]node) string { - var b strings.Builder +// eachToken scans a format string once and calls fn for each unit, the single +// source of truth for how '#' escapes and {…} braces are read — expand, +// checkTokens, fieldTokens and refTokens all drive off it so the grammar can't +// drift between the validator and the renderer. '#' escapes the next char to a +// literal ("#0" -> '0', "##" -> '#'); a '{' must reach a '}' (else an error); an +// unmatched '}' is an ordinary literal. The error stops the scan early. +func eachToken(format string, fn func(ftoken) error) error { rs := []rune(format) for i := 0; i < len(rs); i++ { + var t ftoken switch c := rs[i]; c { case '#': + t.kind, t.r = 'l', '#' if i++; i < len(rs) { - b.WriteRune(rs[i]) - } else { - b.WriteRune('#') + t.r = rs[i] } - case '0': - b.WriteByte(byte('0' + s.IntN(10))) - case '1': - b.WriteByte(byte('1' + s.IntN(9))) - case 'A': - b.WriteByte(byte('A' + s.IntN(26))) - case 'a': - b.WriteByte(byte('a' + s.IntN(26))) + case '0', '1', 'A', 'a': + t.kind, t.r = 'c', c case '{': end := i + 1 - for rs[end] != '}' { // checkTokens guarantees a closing '}' + for end < len(rs) && rs[end] != '}' { end++ } - body := string(rs[i+1 : end]) - if name, args, ok := funcCall(body); ok { - if name == "calc" { // calc reads sibling fields, not just (rng, emitted) - b.WriteString(calcEval(s, args, fields)) - } else { - b.WriteString(builtins[name].call(s, b.String(), args)) // b.String() is the output so far - } - } else { - b.WriteString(resolve(s, body, fields)) + if end >= len(rs) { + return fmt.Errorf("unterminated '{' in %q", format) } + t.kind, t.body = 'b', string(rs[i+1:end]) i = end default: - b.WriteRune(c) + t.kind, t.r = 'l', c + } + if err := fn(t); err != nil { + return err } } - return b.String() -} - -// resolve renders a "{token}" body: one or more names separated by '|', one -// picked at random. A name is a sibling field or a {..path} reference, which -// linkRefs bound into fields too; checkTokens and linkRefs guarantee both exist. -func resolve(s *session, token string, fields map[string]node) string { - names := strings.Split(token, "|") - return render(s, fields[names[s.IntN(len(names))]]) + return nil } // builtin is a format-string function invoked as {name(args)}. It receives the // session (its rng, and the {seq()} counters), the output emitted so far in the // current expansion (for derivations such as a checksum over preceding digits), -// and its args. Almost all are pure over (rng, emitted, args) — no wall-clock, no -// crypto/rand — so seeding stays reproducible; a time-based id derives its time -// from the rng. seq is the one exception: it advances per-session counter state, -// which is itself deterministic (1, 2, 3 …). arity is the exact arg count, or -1 -// for variadic (then check does all the validation). The optional check validates -// args at compile time (their values, beyond the count). The registry lives in -// builtins.go. +// the sibling fields (only calc reads them, to render its operands), and its args. +// Almost all are pure over (rng, emitted, args) — no wall-clock, no crypto/rand — +// so seeding stays reproducible; a time-based id derives its time from the rng. +// seq is the one exception: it advances per-session counter state, which is itself +// deterministic (1, 2, 3 …). arity is the exact arg count, or -1 for variadic +// (then check does all the validation). The optional check validates args at +// compile time (their values, beyond the count). The registry lives in builtins.go. type builtin struct { arity int - check func(args []string) error - call func(s *session, emitted string, args []string) string + check func(fields map[string]node, args []string) error + call func(s *session, emitted string, fields map[string]node, args []string) string } // funcCall splits a "{token}" body shaped name(args) into its parts; ok is false @@ -227,8 +97,9 @@ func splitArgs(s string) []string { } // checkFunc validates a function token at compile time: well-formed, naming a -// known builtin, with the arg count that builtin takes. -func checkFunc(body string) error { +// known builtin, with the arg count that builtin takes and args its check accepts. +// fields is passed through for the one builtin (calc) that validates against them. +func checkFunc(body string, fields map[string]node) error { name, args, ok := funcCall(body) if !ok { return fmt.Errorf("malformed function token {%s}", body) @@ -241,296 +112,50 @@ func checkFunc(body string) error { return fmt.Errorf("token {%s}: %s takes %d args, got %d", body, name, b.arity, len(args)) } if b.check != nil { - if err := b.check(args); err != nil { + if err := b.check(fields, args); err != nil { return fmt.Errorf("token {%s}: %w", body, err) } } return nil } -// 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. +// "{token}" is balanced and names an existing field (or a known function). 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]) - if strings.IndexByte(body, '(') >= 0 { // a function token, not a field - if name, args, ok := funcCall(body); ok && name == "calc" { - if err := checkCalc(args, fields); err != nil { - return fmt.Errorf("token {%s}: %w", body, err) - } - } else if err := checkFunc(body); err != nil { - return err - } - } else { - for _, name := range strings.Split(body, "|") { - if isRef(name) { - if name == refPrefix { - return fmt.Errorf("token {%s}: reference has no path", body) - } - continue // a root reference; its target is checked at New (see linkRefs) - } - if _, ok := fields[name]; !ok { - return fmt.Errorf("token {%s}: no field %q", body, name) - } - } - } - i = end - } - } - return nil -} - -// refPrefix marks a {..path} token: a reference to a node elsewhere in the data -// root rather than a sibling field. The path after it is the same dot path Fake -// takes, resolved across every loaded directory (see linkRefs). -const refPrefix = ".." - -func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) } - -// linkRefs resolves every {..path} reference in the assembled tree, binding the -// target node into the referring template's fields under the token's key so the -// ordinary resolver renders it like a sibling. It runs once, after all data is -// merged, so a reference sees the final (override-resolved) tree. A path that is -// unknown, names a folder, or steps through a multi-variant choice fails here, -// keeping a bad reference a New-time error, never a random render-time one. The -// seen set both skips shared nodes and stops a cyclic reference looping the walk. -func linkRefs(root map[string]node) error { - seen := map[node]bool{} - var visit func(node) error - visit = func(n node) error { - if n == nil || seen[n] { + return eachToken(format, func(t ftoken) error { + if t.kind != 'b' { return nil } - seen[n] = true - switch n := n.(type) { - case *group: - for _, c := range n.children { - if err := visit(c); err != nil { - return err + if strings.IndexByte(t.body, '(') >= 0 { // a function token, not a field + return checkFunc(t.body, fields) + } + for _, name := range strings.Split(t.body, "|") { + if isRef(name) { + if name == refPrefix { + return fmt.Errorf("token {%s}: reference has no path", t.body) } + continue // a root reference; its target is checked at New (see linkRefs) } - case *choice: - for _, it := range n.items { - if err := visit(it); err != nil { - return err - } - } - case *template: - for _, name := range refTokens(n.format) { - target, err := lookup(root, strings.Split(name[len(refPrefix):], ".")) - if err != nil { - return fmt.Errorf("reference {%s}: %w", name, err) - } - n.fields[name] = target - } - for _, c := range n.fields { - if err := visit(c); err != nil { - return err - } + if _, ok := fields[name]; !ok { + return fmt.Errorf("token {%s}: no field %q", t.body, name) } } return nil - } - for _, c := range root { - if err := visit(c); err != nil { - return err + }) +} + +// fieldTokens returns the field and reference names a format renders via {name} +// or {a|..b} tokens (function tokens, which carry no field edges, are excluded). +// These are exactly the child nodes expand's resolve recurses into. +func fieldTokens(format string) []string { + var names []string + _ = eachToken(format, func(t ftoken) error { + if t.kind == 'b' && strings.IndexByte(t.body, '(') < 0 { + names = append(names, strings.Split(t.body, "|")...) } - } - return nil -} - -// lookup finds the single node a reference path names, walking groups and -// template fields by segment and descending a single-variant choice as a -// transparent wrapper. A missing segment, a folder target, or a step through a -// multi-variant choice (which has no one value to bind) is an error. -func lookup(root map[string]node, segments []string) (node, error) { - var n node = &group{children: root} - for i := 0; i < len(segments); i++ { - switch c := n.(type) { - case *group: - child, ok := c.children[segments[i]] - if !ok { - return nil, fmt.Errorf("no entry %q", segments[i]) - } - n = child - case *template: - child, ok := c.fields[segments[i]] - if !ok { - return nil, fmt.Errorf("no field %q", segments[i]) - } - n = child - case *choice: - if len(c.items) != 1 { - return nil, fmt.Errorf("%q steps through a %d-way choice", segments[i], len(c.items)) - } - n, i = c.items[0], i-1 // a choice consumes no segment; reprocess it unwrapped - default: - return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[i]) - } - } - if _, ok := n.(*group); ok { - return nil, fmt.Errorf("names a folder, not a value") - } - return n, nil -} - -// refTokens returns the reference names ({..path}, including ones inside a -// {a|..path} alternation) used in a format string, scanning braces the way -// expand does. Function tokens carry no references. -func refTokens(format string) []string { - var refs []string - rs := []rune(format) - for i := 0; i < len(rs); i++ { - switch rs[i] { - case '#': - i++ - case '{': - end := i + 1 - for end < len(rs) && rs[end] != '}' { - end++ - } - if end >= len(rs) { - return refs // checkTokens already rejected the unterminated brace - } - if body := string(rs[i+1 : end]); strings.IndexByte(body, '(') < 0 { - for _, name := range strings.Split(body, "|") { - if isRef(name) { - refs = append(refs, name) - } - } - } - i = end - } - } - return refs -} - -// 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 + return nil + }) + return names }