diff --git a/README.md b/README.md index bb6253a..5b54247 100644 --- a/README.md +++ b/README.md @@ -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}`; diff --git a/calc.go b/calc.go index b18114f..3ef18d1 100644 --- a/calc.go +++ b/calc.go @@ -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 { diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index 9e28fb4..505b6d7 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -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 diff --git a/data.go b/data.go index 6e0907c..0f3e110 100644 --- a/data.go +++ b/data.go @@ -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 } diff --git a/fejkdata.go b/fejkdata.go index 01eb871..d0c3dc7 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -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 } diff --git a/internal/builtinfunc/builtins.go b/internal/builtinfunc/builtins.go index d2f53ac..9d0315d 100644 --- a/internal/builtinfunc/builtins.go +++ b/internal/builtinfunc/builtins.go @@ -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) diff --git a/name.go b/name.go index 918aa28..d4e0512 100644 --- a/name.go +++ b/name.go @@ -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) } } diff --git a/node.go b/node.go index 553420d..fac45c2 100644 --- a/node.go +++ b/node.go @@ -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)