From 97e72988b1eed98572456c489c98b31ff4035891 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 8 Oct 2026 00:02:07 +0200 Subject: [PATCH] Refuse a calc over digits(0) or a repeat of 0, name an option's wrong kind as JSON does, type the name scope kinds, and compile only from a jsonvalue.Value --- README.md | 1 + calc.go | 7 +++-- datatype.go | 2 +- fejkdata_test.go | 6 ++--- inline.go | 8 +++--- internal/builtinfunc/builtins.go | 3 +++ internal/datafiles/datafiles.go | 18 +++---------- internal/datafiles/datafiles_test.go | 4 +-- internal/jsonvalue/jsonvalue.go | 39 ++++++++++++++++++++++++++++ name.go | 39 +++++++++++++++++++--------- node.go | 9 ++++--- struct.go | 23 ++++++++-------- table.go | 2 +- 13 files changed, 106 insertions(+), 55 deletions(-) create mode 100644 internal/jsonvalue/jsonvalue.go diff --git a/README.md b/README.md index a7d5f49..3e414f6 100644 --- a/README.md +++ b/README.md @@ -996,6 +996,7 @@ internal/datatype/ the datatype a record column holds; the root's DataType is an internal/drawstate/ the seeded randomness and the {seq()} counters a generator draws through internal/grammar/ how the template language is written: format tokens, paths and selectors, reference sigils, identifiers, calc syntax, and whether a string is a template or a path internal/invariant/ the one phrase every package panics with when an invariant breaks +internal/jsonvalue/ a decoded JSON value, its numbers kept as written, the one input a template compiles from internal/proven/ what a proof knows of a value, and the bounds a calc takes from its operands internal/rows/ a table's rows: the TSV, the options proved over it, the links between tables, row selection and draws, and the pin set one path or one named pick fixes data/ shipped data (JSON, and a TSV per table), embedded at build: locale folders, geo, misc diff --git a/calc.go b/calc.go index 3ef18d1..8339e7d 100644 --- a/calc.go +++ b/calc.go @@ -114,14 +114,17 @@ func constantValue(n grammar.CalcNode, operand func(string) []node) (float64, bo return 0, false } -// neverNumeric reports a node no render of which is a number: a null, fixed text that -// does not parse, or a choice of only such items. text is one such render. +// neverNumeric reports a node no render of which is a number: a null, a repeat of 0, fixed +// text that does not parse, or a choice of only such items. text is one such render. func neverNumeric(n node) (text string, never bool) { switch n := n.(type) { case *nullItem: return "", true case *template: lit, fixed := n.fixedText() + if n.repeat == 0 { + return "", true + } if !fixed || n.repeat != 1 { return "", false } diff --git a/datatype.go b/datatype.go index 0f94bd8..035563c 100644 --- a/datatype.go +++ b/datatype.go @@ -29,7 +29,7 @@ func datatypeOf(m map[string]any, pos position) (*DataType, error) { } name, ok := v.(string) if !ok { - return nil, fmt.Errorf("datatype must be a string, got %T", v) + return nil, fmt.Errorf("datatype must be a string, not %s", jsonKind(v)) } for d := DataTypeString; d < datatype.Count; d++ { if name != d.String() { diff --git a/fejkdata_test.go b/fejkdata_test.go index 1be0b22..392ff90 100644 --- a/fejkdata_test.go +++ b/fejkdata_test.go @@ -7,8 +7,8 @@ import ( "testing" "testing/fstest" - "github.com/larvit/fejkdata/internal/datafiles" "github.com/larvit/fejkdata/internal/drawstate" + "github.com/larvit/fejkdata/internal/jsonvalue" ) // newGenerator creates a generator over a single data directory, failing on @@ -102,9 +102,9 @@ func engine(seed uint64) *Generator { } // parse unmarshals a JSON template fragment into its dynamic form. -func parse(t *testing.T, s string) any { +func parse(t *testing.T, s string) jsonvalue.Value { t.Helper() - v, err := datafiles.DecodeJSON([]byte(s)) + v, err := jsonvalue.Decode([]byte(s)) if err != nil { t.Fatalf("parse %q: %v", s, err) } diff --git a/inline.go b/inline.go index 8e79f45..c9f4857 100644 --- a/inline.go +++ b/inline.go @@ -3,8 +3,8 @@ package fejkdata import ( "fmt" - "github.com/larvit/fejkdata/internal/datafiles" "github.com/larvit/fejkdata/internal/grammar" + "github.com/larvit/fejkdata/internal/jsonvalue" ) // Template is an inline template compiled, referenced and validated against a @@ -70,10 +70,10 @@ func compileInput(input string) (node, error) { // inputValue reads an inline template as the value compile takes: the JSON value it holds, the // whitespace around it dropped, or the input itself as a format string when it is not JSON. -func inputValue(input string) any { - raw, err := datafiles.DecodeJSON([]byte(input)) +func inputValue(input string) jsonvalue.Value { + raw, err := jsonvalue.Decode([]byte(input)) if err != nil { - return input + return jsonvalue.String(input) } return raw } diff --git a/internal/builtinfunc/builtins.go b/internal/builtinfunc/builtins.go index 9d0315d..9838c14 100644 --- a/internal/builtinfunc/builtins.go +++ b/internal/builtinfunc/builtins.go @@ -36,6 +36,9 @@ var builtins = withTransforms(map[string]builtin{ "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 { + if atoi(a[0]) == 0 { + return proven.Unproven("{" + token + "} prints nothing, which is no number") + } return proven.Printing(token, prints, proven.Bounded(0, math.Pow(10, float64(atoi(a[0])))-1, true)) }}, "upper": {arity: 1, checkArgs: countArg, prep: chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")}, diff --git a/internal/datafiles/datafiles.go b/internal/datafiles/datafiles.go index 6ae5b73..33cf7e1 100644 --- a/internal/datafiles/datafiles.go +++ b/internal/datafiles/datafiles.go @@ -2,8 +2,6 @@ package datafiles import ( - "bytes" - "encoding/json" "fmt" "io/fs" "os" @@ -11,6 +9,7 @@ import ( "strings" "github.com/larvit/fejkdata/internal/grammar" + "github.com/larvit/fejkdata/internal/jsonvalue" ) // Source is one tree to load: an fs.FS and the directory in it to start from. label @@ -35,7 +34,7 @@ func FS(fsys fs.FS, base string) Source { return Source{fsys: fsys, base: base} type Category struct { Folders []string Name string - JSON any + JSON jsonvalue.Value rows *rowsFiles } @@ -142,7 +141,7 @@ func (s Source) compileFile(dir []string, file string, rows *rowsFiles, compile if err != nil { return false, fmt.Errorf("%s: %w", s.labelled(full), err) } - raw, err := DecodeJSON(b) + raw, err := jsonvalue.Decode(b) if err != nil { return false, fmt.Errorf("%s: %w", s.labelled(full), err) } @@ -179,14 +178,3 @@ func (r *rowsFiles) read(name string) (string, error) { } return string(b), nil } - -// DecodeJSON decodes one JSON value, keeping each number as the json.Number it is written as. -func DecodeJSON(b []byte) (any, error) { - var v any - if !json.Valid(b) { - return nil, json.Unmarshal(b, &v) - } - dec := json.NewDecoder(bytes.NewReader(b)) - dec.UseNumber() - return v, dec.Decode(&v) -} diff --git a/internal/datafiles/datafiles_test.go b/internal/datafiles/datafiles_test.go index e419529..f93d99a 100644 --- a/internal/datafiles/datafiles_test.go +++ b/internal/datafiles/datafiles_test.go @@ -20,7 +20,7 @@ func walk(t *testing.T, src Source) ([]handed, error) { t.Helper() var got []handed err := src.Walk(func(c Category) error { - got = append(got, handed{c.Folders, c.Name, c.JSON}) + got = append(got, handed{c.Folders, c.Name, c.JSON.Any()}) return nil }) return got, err @@ -133,7 +133,7 @@ func TestLoadHandsOneCategoryAndItsRows(t *testing.T) { var got handed var rows string err := src.Load([]string{"sub"}, "t", func(c Category) error { - got = handed{c.Folders, c.Name, c.JSON} + got = handed{c.Folders, c.Name, c.JSON.Any()} var err error rows, err = c.ReadRows("t.tsv") return err diff --git a/internal/jsonvalue/jsonvalue.go b/internal/jsonvalue/jsonvalue.go new file mode 100644 index 0000000..1060ac6 --- /dev/null +++ b/internal/jsonvalue/jsonvalue.go @@ -0,0 +1,39 @@ +// Package jsonvalue holds a decoded JSON value whose numbers keep the text they are written in. +package jsonvalue + +import ( + "bytes" + "encoding/json" +) + +// Value is a JSON value that only Decode, String and Object make, so a template never compiles +// from a value encoding/json decoded with its numbers as float64. +type Value struct{ v any } + +// Decode decodes one JSON value, keeping each number as the json.Number it is written as. +func Decode(b []byte) (Value, error) { + var v any + if !json.Valid(b) { + return Value{}, json.Unmarshal(b, &v) + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + err := dec.Decode(&v) + return Value{v}, err +} + +// String is the JSON string s. +func String(s string) Value { return Value{s} } + +// Object is the JSON object holding fields. +func Object(fields map[string]Value) Value { + m := make(map[string]any, len(fields)) + for k, f := range fields { + m[k] = f.v + } + return Value{m} +} + +// Any is the value as encoding/json decodes it: a map[string]any, []any, string, json.Number, +// bool or nil. +func (v Value) Any() any { return v.v } diff --git a/name.go b/name.go index d4e0512..e8da2a8 100644 --- a/name.go +++ b/name.go @@ -111,22 +111,37 @@ func (sc *nameScope) bindAll(t *template, where string) error { // spelled names the scope in an error. func (sc *nameScope) spelled() string { - if sc.up == nil && sc.kind() != "repeat" { - return "outside any repeat" + if k := sc.kind(); k != scopeCategory { + return "in one " + k.String() } - return "in one " + sc.kind() + return "outside any repeat" } -// 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" +// scopeKind is what a name scope is: the category's own, a repeat's, or a choice's item's. +type scopeKind uint8 + +const ( + scopeCategory scopeKind = iota + scopeRepeat + scopeChoiceItem +) + +func (k scopeKind) String() string { + return [...]string{scopeCategory: "category", scopeRepeat: "repeat", scopeChoiceItem: "choice item"}[k] } -// ownScope is the scope t renders a frame of: a category's, once per render of its root, or a -// repeat's, once per iteration. It is nil where t owns no scope, or its scope binds no name. +func (sc *nameScope) kind() scopeKind { + switch t, isTemplate := sc.owner.(*template); { + case isTemplate && t.repeat > 1: + return scopeRepeat + case sc.up == nil: + return scopeCategory + } + return scopeChoiceItem +} + +// ownScope is the scope t renders a frame of: a category's, once per render of its root, a +// repeat's, once per iteration, or a choice item's, once per draw of the item. It is nil where t owns no scope, or its scope binds no name. func (t *template) ownScope() *nameScope { if sc := t.nameScope; sc != nil && sc.owner == node(t) && len(sc.order) > 0 { return sc @@ -190,7 +205,7 @@ func unanswered(where string, u unboundRead, seen *nameScope, scopes []*nameScop if at == "" { at = "an item of the root choice" } - if sc.kind() == "choice item" { + if sc.kind() == scopeChoiceItem { 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 fac45c2..9c720c1 100644 --- a/node.go +++ b/node.go @@ -9,6 +9,7 @@ import ( "github.com/larvit/fejkdata/internal/datafiles" "github.com/larvit/fejkdata/internal/grammar" "github.com/larvit/fejkdata/internal/invariant" + "github.com/larvit/fejkdata/internal/jsonvalue" ) // node is a compiled element of the namespace tree: a folder, choice, null, @@ -89,8 +90,8 @@ func (t *template) startOf(name string) node { // compile converts parsed JSON — a category or an inline template — into a node tree, // validating structure up front. -func compile(v any) (node, error) { - n, err := compileItem(v, atTop) +func compile(v jsonvalue.Value) (node, error) { + n, err := compileItem(v.Any(), atTop) if err != nil { return nil, err } @@ -100,7 +101,7 @@ func compile(v any) (node, error) { // compileCategory compiles a data file's value, which may be a table over a rows // file beside it. func compileCategory(c datafiles.Category) (node, error) { - if m, ok := c.JSON.(map[string]any); ok { + if m, ok := c.JSON.Any().(map[string]any); ok { if _, isTable := m["rows"]; isTable { return compileTable(m, c.Folders, c.Name, c.ReadRows) } @@ -276,7 +277,7 @@ func readOptions(m map[string]any, pos position) (templateOptions, error) { } if sv, ok := m["separator"]; ok { if o.separator, ok = sv.(string); !ok { - return o, fmt.Errorf("separator must be a string, got %T", sv) + return o, fmt.Errorf("separator must be a string, not %s", jsonKind(sv)) } } return o, nil diff --git a/struct.go b/struct.go index 1826866..986ac5b 100644 --- a/struct.go +++ b/struct.go @@ -12,6 +12,7 @@ import ( "github.com/larvit/fejkdata/internal/drawstate" "github.com/larvit/fejkdata/internal/grammar" "github.com/larvit/fejkdata/internal/invariant" + "github.com/larvit/fejkdata/internal/jsonvalue" "github.com/larvit/fejkdata/internal/proven" ) @@ -95,7 +96,7 @@ type structFields struct { *structCompile typ reflect.Type label string - tags map[string]any + tags map[string]jsonvalue.Value shape *structShape } @@ -105,7 +106,7 @@ func (sc *structCompile) compileShape(t reflect.Type, label string) (*structShap } sc.visiting[t] = true defer delete(sc.visiting, t) - c := &structFields{structCompile: sc, typ: t, label: label, tags: map[string]any{}, shape: &structShape{}} + c := &structFields{structCompile: sc, typ: t, label: label, tags: map[string]jsonvalue.Value{}, shape: &structShape{}} if err := c.gatherFields(t, nil); err != nil { return nil, err } @@ -214,25 +215,25 @@ func (c *structFields) nest(sf reflect.StructField, elem reflect.Type) error { // tagValue reads a field's fake tag as the value its column compiles from: an inline template // as written, or a path as the reference {/path}. -func tagValue(sf reflect.StructField, tag string) (any, error) { +func tagValue(sf reflect.StructField, tag string) (jsonvalue.Value, error) { if err := checkTaggedType(sf); err != nil { - return nil, err + return jsonvalue.Value{}, err } inline, err := grammar.IsTemplate(tag) switch { case err != nil: - return nil, err + return jsonvalue.Value{}, err case inline: return inputValue(tag), nil } path, err := grammar.CallerPath(tag) if err != nil { - return nil, err + return jsonvalue.Value{}, err } if err := grammar.CheckPathIdentifiers(path); err != nil { - return nil, err + return jsonvalue.Value{}, err } - return "{/" + path + "}", nil + return jsonvalue.String("{/" + path + "}"), nil } // checkTaggedType rejects a tagged field no column can fill. @@ -254,9 +255,9 @@ func checkTaggedType(sf reflect.StructField) error { // compileRecord compiles the tagged fields of t as one record, and proves each column holds // only what its field's Go type can. -func (s *structShape) compileRecord(root *folder, t reflect.Type, label string, tags map[string]any) error { - tags["format"] = "" - n, err := compile(tags) +func (s *structShape) compileRecord(root *folder, t reflect.Type, label string, tags map[string]jsonvalue.Value) error { + tags["format"] = jsonvalue.String("") + n, err := compile(jsonvalue.Object(tags)) if err != nil { return fmt.Errorf("%s: %w", label, err) } diff --git a/table.go b/table.go index 56b4202..63b683a 100644 --- a/table.go +++ b/table.go @@ -102,7 +102,7 @@ func readTableOptions(m map[string]any) (tableOptionValues, error) { } s, isString := v.(string) if !isString { - return o, fmt.Errorf("%s must be a string, got %T", k, v) + return o, fmt.Errorf("%s must be a string, not %s", k, jsonKind(v)) } if k != "format" && s == "" { return o, fmt.Errorf("%s names a column, so it cannot be empty; drop it", k)