Load a generator with no data, a repeat or count of 0, and a name a choice's item binds for that item's own reads

This commit is contained in:
2026-10-07 23:49:25 +02:00
committed by Lillem4n
parent 1e159fdf3a
commit f036f859d6
8 changed files with 48 additions and 53 deletions
+6 -4
View File
@@ -435,7 +435,7 @@ number, and a choice whose every weight is `0`.
### Repeat
A template may carry `repeat` (a positive integer, default `1`) to render its format
A template may carry `repeat` (an integer of 0 or more, default `1`; `0` renders nothing) to render its format
that many times — each an independent draw — joined by `separator` (default `""`):
```json
@@ -632,7 +632,7 @@ namespace too.
A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad
count, range, country or expression fails fast; an integer may carry a sign or leading
zeros (`+5` and `05` are `5`); bounds are finite; and a length, count or decimal place beyond a sane maximum is rejected, so a
zeros (`+5` and `05` are `5`); a count of `0` renders nothing; bounds are finite; and a length, count or decimal place beyond a sane maximum is rejected, so a
fat-fingered `hex(2000000000)` never tries to allocate gigabytes. Every builtin draws only from the seed — a
time-based id takes its timestamp from the rng, not the clock — so seeded output
stays reproducible.
@@ -764,7 +764,8 @@ and so are two names bound to one category.
A name is drawn on its first read, and lives in the category binding it: any field of the
category may read it, and a record's columns read one pick. Each render of the category picks
anew, and so does each bare reference to it. A name a `repeat` binds picks again on every
iteration, and one bound outside the repeat keeps its pick on every line.
iteration, and one bound outside the repeat keeps its pick on every line. A name a choice's
item binds lives in that item, and picks anew each time the item is drawn.
Refused at `New`, each error naming what to write instead:
@@ -774,7 +775,8 @@ Refused at `New`, each error naming what to write instead:
- a binding of anything but a reference, or a field of the template binding it or a path into
one;
- a read of a name inside the field bound to it: read it outside that field;
- a binding in a choice's item, which every other item would leave unbound;
- a read outside a choice's item of a name bound inside it, which every other item would
leave unbound;
- a path through a name that selects a row;
- `{n}` beside `{n.w}` where `n`'s category reads `w` twice, as in `{w}-{w}` or
`{w.a} {w.b}`;
+2 -2
View File
@@ -91,7 +91,7 @@ func constantValue(n grammar.CalcNode, operand func(string) []node) (float64, bo
return 0, false
}
t, ok := nodes[0].(*template)
if !ok || t.repeat > 1 {
if !ok || t.repeat != 1 {
return 0, false
}
lit, fixed := t.fixedText()
@@ -122,7 +122,7 @@ func neverNumeric(n node) (text string, never bool) {
return "", true
case *template:
lit, fixed := n.fixedText()
if !fixed || n.repeat > 1 {
if !fixed || n.repeat != 1 {
return "", false
}
if _, err := strconv.ParseFloat(strings.TrimSpace(lit), 64); err != nil {
-3
View File
@@ -571,9 +571,6 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
}
f, err := fejkdata.New(in.options()...)
if errors.Is(err, fejkdata.ErrNoData) {
return misuse(stderr, errors.New("--no-shipped-data needs at least one --data-path"))
}
if err != nil {
fmt.Fprintln(stderr, err)
return 1
-4
View File
@@ -1,7 +1,6 @@
package fejkdata
import (
"fmt"
"strings"
"github.com/larvit/fejkdata/internal/datafiles"
@@ -16,9 +15,6 @@ func loadData(sources []datafiles.Source) (map[string]node, error) {
}
mergeChildren(root, g.children)
}
if len(root) == 0 {
return nil, fmt.Errorf("no .json data found")
}
if err := categoryPipeline(categorySites(&folder{children: root}), root).run(); err != nil {
return nil, err
}
+2 -9
View File
@@ -3,7 +3,6 @@ package fejkdata
import (
crand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io/fs"
"reflect"
@@ -20,9 +19,6 @@ const MaxRepeat = 1 << 20
var _ [^uint(0)>>63 - 1]struct{} // docs/decisions.md#64-bit-targets-only
// ErrNoData is returned by New when no source is loaded at all.
var ErrNoData = errors.New("no data: WithoutShippedData needs at least one WithDataPath or WithDataFS")
// Generator generates fake data from a namespace tree. Create one with [New].
// It is safe for concurrent use; a seeded sequence is reproducible only when drawn
// from one goroutine.
@@ -73,8 +69,8 @@ func WithoutShippedData() Option {
// New builds a generator from the shipped data set and the options' sources, merged
// in order with the last winning a name clash. Each JSON file becomes a category
// named after the file (address.json -> "address") and each subdirectory a
// namespace segment. It errors on a missing directory, invalid JSON, invalid data,
// or no data at all. With only the shipped data, a category loads on the first call
// namespace segment. It errors on a missing directory, invalid JSON or invalid data; a
// generator with no data at all renders templates that read none. With only the shipped data, a category loads on the first call
// reaching it; with WithDataPath or WithDataFS, every category loads here.
func New(opts ...Option) (*Generator, error) {
c := config{shipped: true}
@@ -102,9 +98,6 @@ func (c config) load() (folder, error) {
sources = append(sources, shippedSource)
}
sources = append(sources, c.sources...)
if len(sources) == 0 {
return folder{}, ErrNoData
}
cats, err := loadData(sources)
return folder{children: cats}, err
}
+9 -9
View File
@@ -33,14 +33,14 @@ var builtins = withTransforms(map[string]builtin{
"ean": {arity: 0, prep: derive(eanCheck)},
"uuid": {arity: 0, prep: sample(uuidV7)},
"ulid": {arity: 0, prep: sample(ulid)},
"nanoid": {arity: 1, checkArgs: posIntArg, prep: chars(nanoidAlphabet)},
"hex": {arity: 1, checkArgs: posIntArg, prep: chars(hexDigits)},
"digits": {arity: 1, checkArgs: posIntArg, prep: chars("0123456789"), prints: datatype.String, proveNumber: func(token string, prints datatype.DataType, a []string) proven.Facts {
"nanoid": {arity: 1, checkArgs: countArg, prep: chars(nanoidAlphabet)},
"hex": {arity: 1, checkArgs: countArg, prep: chars(hexDigits)},
"digits": {arity: 1, checkArgs: countArg, prep: chars("0123456789"), prints: datatype.String, proveNumber: func(token string, prints datatype.DataType, a []string) proven.Facts {
return proven.Printing(token, prints, proven.Bounded(0, math.Pow(10, float64(atoi(a[0])))-1, true))
}},
"upper": {arity: 1, checkArgs: posIntArg, prep: chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")},
"lower": {arity: 1, checkArgs: posIntArg, prep: chars("abcdefghijklmnopqrstuvwxyz")},
"base64": {arity: 1, checkArgs: posIntArg, prep: func(a []string) Call {
"upper": {arity: 1, checkArgs: countArg, prep: chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")},
"lower": {arity: 1, checkArgs: countArg, prep: chars("abcdefghijklmnopqrstuvwxyz")},
"base64": {arity: 1, checkArgs: countArg, prep: func(a []string) Call {
n := atoi(a[0])
return func(s *drawstate.State, _ string, _ []string) string {
return base64.StdEncoding.EncodeToString(randBytes(s, n))
@@ -158,7 +158,7 @@ func intArg(s string) (int, error) {
}
return n, nil
}
func posIntArg(a []string) error {
func countArg(a []string) error {
n, err := intArg(a[0])
if errors.Is(err, strconv.ErrRange) {
return fmt.Errorf("count %q exceeds the maximum %d", a[0], maxLen)
@@ -166,8 +166,8 @@ func posIntArg(a []string) error {
if err != nil {
return fmt.Errorf("count %w", err)
}
if n < 1 {
return fmt.Errorf("count %q must be positive", a[0])
if n < 0 {
return fmt.Errorf("count %q must not be negative", a[0])
}
if n > maxLen {
return fmt.Errorf("count %d exceeds the maximum %d", n, maxLen)
+26 -19
View File
@@ -9,12 +9,12 @@ import (
"github.com/larvit/fejkdata/internal/grammar"
)
// nameScope is where a name is bound: a category, or one iteration of a repeat inside it. A
// token sees its scope's names and every enclosing scope's, so one field may bind a name and
// another read it.
// nameScope is where a name is bound: a category, one iteration of a repeat inside it, or one
// render of a choice's item. A token sees its scope's names and every enclosing scope's, so one
// field may bind a name and another read it.
type nameScope struct {
up *nameScope
owner node // the category's root, or the repeat
owner node // the category's root, the repeat, or the choice's item
bindings map[string]*nameBinding
order []*nameBinding
}
@@ -56,28 +56,27 @@ func (sc *nameScope) lookup(name string) *nameBinding {
}
// bindNames gives every template of a compiled category or inline template the scope its
// names live in, and refuses a name bound twice along one chain of scopes, a binding in a
// choice's item, a name a field spells too, and a read no field or name answers.
// names live in, and refuses a name bound twice along one chain of scopes, a name a field spells
// too, and a read no field or name answers.
func bindNames(root node) error {
var scopes []*nameScope
var gather func(n node, scope *nameScope, inChoice bool, where string) error
gather = func(n node, scope *nameScope, inChoice bool, where string) error {
if t, isTemplate := n.(*template); n == root || isTemplate && t.repeat > 1 {
var gather func(n node, scope *nameScope, item bool, where string) error
gather = func(n node, scope *nameScope, item bool, where string) error {
if t, isTemplate := n.(*template); n == root || isTemplate && (t.repeat > 1 || item) {
scope = &nameScope{up: scope, owner: n}
scopes = append(scopes, scope)
inChoice = false
}
switch t := n.(type) {
case *template:
t.nameScope = scope
if err := scope.bindAll(t, inChoice, where); err != nil {
if err := scope.bindAll(t, where); err != nil {
return err
}
case *choice:
t.nameScope = scope
}
_, isChoice := n.(*choice)
return eachContained(n, where, func(c node, where string) error { return gather(c, scope, inChoice || isChoice, where) })
return eachContained(n, where, func(c node, where string) error { return gather(c, scope, isChoice, where) })
}
if err := gather(root, nil, false, ""); err != nil {
return err
@@ -85,21 +84,18 @@ func bindNames(root node) error {
for _, sc := range scopes[1:] {
for _, b := range sc.order {
if outer := sc.up.lookup(b.name); outer != nil {
return fmt.Errorf("%stoken {%s}: name %q is bound outside this repeat too, by {%s}; rename one", b.where, b.body, b.name, outer.body)
return fmt.Errorf("%stoken {%s}: name %q is bound outside this %s too, by {%s}; rename one", b.where, b.body, b.name, sc.kind(), outer.body)
}
}
}
return answerReads(root, scopes)
}
func (sc *nameScope) bindAll(t *template, inChoice bool, where string) error {
func (sc *nameScope) bindAll(t *template, where string) error {
for _, tok := range t.tokens {
if tok.Kind != grammar.NameBind {
continue
}
if inChoice {
return fmt.Errorf("%stoken {%s}: a choice's item binds no name, since every other item would leave it unbound; bind it outside the choice, or move the item into a category of its own and reference that", where, tok.Body)
}
if b, twice := sc.bindings[tok.Bound]; twice {
return fmt.Errorf("%stoken {%s}: name %q is bound twice %s, by {%s} too; rename one", where, tok.Body, tok.Bound, sc.spelled(), b.body)
}
@@ -115,10 +111,18 @@ func (sc *nameScope) bindAll(t *template, inChoice bool, where string) error {
// spelled names the scope in an error.
func (sc *nameScope) spelled() string {
if t, isTemplate := sc.owner.(*template); sc.up == nil && !(isTemplate && t.repeat > 1) {
if sc.up == nil && sc.kind() != "repeat" {
return "outside any repeat"
}
return "in one repeat"
return "in one " + sc.kind()
}
// kind names what a scope inside the category's is: a repeat, or a choice's item.
func (sc *nameScope) kind() string {
if t, isTemplate := sc.owner.(*template); isTemplate && t.repeat > 1 {
return "repeat"
}
return "choice item"
}
// ownScope is the scope t renders a frame of: a category's, once per render of its root, or a
@@ -186,6 +190,9 @@ func unanswered(where string, u unboundRead, seen *nameScope, scopes []*nameScop
if at == "" {
at = "an item of the root choice"
}
if sc.kind() == "choice item" {
return fmt.Errorf("%s%w; name %q is bound at %s, inside an item of a choice, which a read outside that item cannot see; bind it outside the choice", where, u.err, u.head, at)
}
return fmt.Errorf("%s%w; name %q is bound at %s, inside a repeat, which a read outside the repeat cannot see; bind it outside the repeat to read one pick on every line", where, u.err, u.head, at)
}
}
+3 -3
View File
@@ -301,7 +301,7 @@ func compileFields(m map[string]any, pos position) (map[string]node, error) {
}
// 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.
// is rendered and concatenated. A present one must be an integer of 0 or more.
func repeatOf(m map[string]any) (int, error) {
rv, ok := m["repeat"]
if !ok {
@@ -311,8 +311,8 @@ func repeatOf(m map[string]any) (int, error) {
if !ok {
return 0, fmt.Errorf("repeat must be a number, not %s", jsonKind(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 math.IsNaN(r) || math.IsInf(r, 0) || r < 0 || r != math.Trunc(r) {
return 0, fmt.Errorf("repeat must be an integer of 0 or more, got %v", rv)
}
if r > MaxRepeat { // caps the renders one repeat asks for; repeatCheck bounds what nested ones multiply to
return 0, fmt.Errorf("repeat %v exceeds the maximum %d", rv, MaxRepeat)