From d4ff3b242ee3924426dd1f16a0df2a37c0002d3e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 16:23:00 +0200 Subject: [PATCH 01/10] Tests: FakeStruct fills tagged fields as one record, draws a nested struct apart, refuses a tag its field cannot hold, and compiles a type once --- perf_test.go | 19 +++++ struct_test.go | 187 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 struct_test.go diff --git a/perf_test.go b/perf_test.go index 0ee6dcb..ab92408 100644 --- a/perf_test.go +++ b/perf_test.go @@ -75,6 +75,25 @@ func TestNoRecordAllocRegression(t *testing.T) { } } +func TestNoStructAllocRegression(t *testing.T) { + f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{"x.json": {Data: []byte(`{"format":"","a":"x","b":"y","c":"z"}`)}})) + if err != nil { + t.Fatal(err) + } + var v struct { + A string `fake:"x.a"` + B string `fake:"x.b"` + C string `fake:"x.c"` + } + if err := f.FakeStruct(&v); err != nil { + t.Fatal(err) + } + const base = 5.0 + if allocs := testing.AllocsPerRun(10000, func() { f.FakeStruct(&v) }); allocs > base*1.10 { + t.Errorf("FakeStruct: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); compiling the type per call is the usual cause", allocs, base*1.10, base) + } +} + func BenchmarkNestedDepth25(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(25)), "deep") } func BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") } func BenchmarkWideTokens100(b *testing.B) { diff --git a/struct_test.go b/struct_test.go new file mode 100644 index 0000000..3fbaa37 --- /dev/null +++ b/struct_test.go @@ -0,0 +1,187 @@ +package fejkdata + +import ( + "reflect" + "strings" + "testing" +) + +type structPlace struct { + City string `fake:"place.city"` + Zip string `fake:"place.zip"` +} + +type structUser struct { + Active bool `fake:"[\"true\",\"false\"]"` + Age uint8 `fake:"{int(18,99)}"` + Email string `fake:"{lowercase(/person.first)}@example.com"` + First string `fake:"person.first"` + Home structPlace + ID int64 `fake:"{seq()}"` + Last string `fake:"person.last"` + Nick *string `fake:"[null,\"bo\"]"` + Note string + Rank *int `fake:"{\"format\":\"{r}\",\"r\":[\"1\",\"2\"]}"` + Score float32 `fake:"{float(0,1,2)}"` + Work *structPlace + hidden structPlace +} + +type structLink struct { + Name string `fake:"person.first"` + Next *structLink +} + +func structData(t *testing.T) *Generator { + t.Helper() + return newGenerator(t, writeData(t, map[string]string{ + "person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`, + "place": `[{"format":"{city}","city":"Stockholm","zip":"111 22"},{"format":"{city}","city":"Tranås","zip":"573 31"}]`, + "trip": `{"format":"","leg":[{"format":"{to}","to":"Oslo"},{"format":"{to}","to":"Rome"}]}`, + }), WithSeed(1)) +} + +func TestFakeStructFillsTaggedFields(t *testing.T) { + a, b := structData(t), structData(t) + people := map[string]string{"Ada": "Lovelace", "Bo": "Ek"} + zips := map[string]string{"Stockholm": "111 22", "Tranås": "573 31"} + actives, nils := 0, 0 + for i := 0; i < 100; i++ { + u, twin := structUser{Note: "keep"}, structUser{Note: "keep"} + if err := a.FakeStruct(&u); err != nil { + t.Fatal(err) + } + if err := b.FakeStruct(&twin); err != nil { + t.Fatal(err) + } + switch { + case !reflect.DeepEqual(u, twin): + t.Fatalf("same seed diverged: %+v != %+v", u, twin) + case people[u.First] != u.Last || u.Email != strings.ToLower(u.First)+"@example.com": + t.Fatalf("person fields %q %q %q, want one person drawn across the struct", u.First, u.Last, u.Email) + case zips[u.Home.City] != u.Home.Zip || u.Work == nil || zips[u.Work.City] != u.Work.Zip: + t.Fatalf("places %+v, %+v, want each nested struct one place, the pointer allocated", u.Home, u.Work) + case u.ID != int64(i+1) || u.Age < 18 || u.Age > 99 || u.Score < 0 || u.Score > 1 || u.Rank == nil || (*u.Rank != 1 && *u.Rank != 2): + t.Fatalf("typed fields %+v, want each the value its tag draws", u) + case u.Nick != nil && *u.Nick != "bo", u.Note != "keep", u.hidden != (structPlace{}): + t.Fatalf("%+v: want Nick nil or bo, and the untagged fields left as they were", u) + } + if u.Active { + actives++ + } + if u.Nick == nil { + nils++ + } + } + if actives == 0 || actives == 100 || nils == 0 || nils == 100 { + t.Errorf("100 draws gave %d active and %d nil nicks, want both outcomes of each", actives, nils) + } +} + +func TestFakeStructDrawsANestedStructApart(t *testing.T) { + f := structData(t) + for i := 0; i < 100; i++ { + var trip struct{ From, To structPlace } + if err := f.FakeStruct(&trip); err != nil { + t.Fatal(err) + } + if trip.From.City != trip.To.City { + return + } + } + t.Error("From and To drew one place in 100 trips; a nested struct is a record of its own, so each draws apart") +} + +func TestFakeStructLeavesAPointerBackAlone(t *testing.T) { + var l structLink + if err := structData(t).FakeStruct(&l); err != nil || l.Name == "" || l.Next != nil { + t.Errorf("FakeStruct = %v, %+v; want Name filled and Next, a pointer back to the struct being filled, left nil", err, l) + } +} + +func TestFakeStructErrors(t *testing.T) { + f := structData(t) + for _, c := range []struct { + v any + want string + }{ + {structUser{}, "fills a struct through a non-nil pointer, got fejkdata.structUser"}, + {(*structUser)(nil), "through a non-nil pointer"}, + {new(int), "through a non-nil pointer"}, + {nil, "through a non-nil pointer"}, + {&struct{ A string }{}, "has no fake tags"}, + {&struct { + a string `fake:"person.first"` + }{}, ".a: unexported"}, + {&struct { + A []string `fake:"person.first"` + }{}, ".A: a fake tag fills a string, bool, integer or float field, or a pointer to one, not []string"}, + {&struct { + A **int `fake:"{int(1,9)}"` + }{}, "not **int"}, + {&struct { + A structPlace `fake:"place"` + }{}, ".A: a struct field fills from the tags on its own fields"}, + {&struct { + A int `fake:"{\"format\":\"{int(1,9)}\",\"datatype\":\"integer\"}"` + }{}, `its Go type int sets the datatype; drop "datatype"`}, + {&struct { + A int `fake:"[null,\"{int(1,9)}\"]"` + }{}, "can draw null, which int cannot hold; make it *int"}, + {&struct { + A int `fake:"{digits(3)}"` + }{}, ".A (int): {digits(3)} prints text, not an integer"}, + {&struct { + A int `fake:"person.first"` + }{}, `"Ada" is not an integer`}, + {&struct { + A int8 `fake:"{int(0,300)}"` + }{}, `"{int(0,300)}" is not proven within int8`}, + {&struct { + A uint `fake:"{int(-1,5)}"` + }{}, `"{int(-1,5)}" is not proven within uint`}, + {&struct { + A float32 `fake:"[\"1\",\"1e39\"]"` + }{}, `"1e39" is not proven within float32`}, + {&struct { + A bool `fake:"{int(0,1)}"` + }{}, "prints an integer, not a boolean"}, + {&struct { + A string `fake:"nope.x"` + }{}, `no entry "nope"`}, + {&struct { + A string `fake:"{/person.first}"` + }{}, `write fake:"person.first"`}, + {&struct { + A string `fake:"\"{/person.first}\""` + }{}, `write fake:"person.first"`}, + {&struct { + A string `fake:"a|b"` + }{}, `contains "|"`}, + {&struct { + A string `fake:""` + }{}, "is empty"}, + {&struct { + A string `fake:"[abc]"` + }{}, `holds a "["`}, + {&struct { + A string `fake:"{.person.first}"` + }{}, "write {/person.first}"}, + {&struct { + A string `fake:"{x}"` + }{}, `no field "x"`}, + {&struct { + A string `fake:"trip.leg"` + B string `fake:"trip.leg.to"` + }{}, "reads a path into"}, + {&struct { + Trip struct { + A int `fake:"{digits(3)}"` + } + }{}, ".Trip.A (int): {digits(3)} prints text"}, + } { + if err := f.FakeStruct(c.v); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("FakeStruct(%T) = %v, want an error containing %q", c.v, err, c.want) + } + } +} -- 2.52.0 From c0b176a8390b9f424a06394b599e7bd98443be33 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 16:23:10 +0200 Subject: [PATCH 02/10] Struct-filling: FakeStruct fills fake-tagged fields as a record, each field's Go type its datatype, and IsTemplate owns the path-or-template shape rule --- README.md | 38 +++++- cmd/fejkdata/main.go | 18 +-- datatype.go | 33 +++-- fejkdata.go | 2 + graph.go | 4 +- inline.go | 58 ++++++-- struct.go | 312 +++++++++++++++++++++++++++++++++++++++++++ todo.md | 5 - 8 files changed, 424 insertions(+), 46 deletions(-) create mode 100644 struct.go diff --git a/README.md b/README.md index 2cda366..f6d64b0 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ v = t.Fake() // render many times, r, err := f.FakeRecord("users") // one record: each field a column s := r.JSON() // {"first":"Ada","last":"Lovelace"} r, err = f.FakeRecordTemplate(`{"format":"{x}","x":["a","b"]}`) // compile + render inline +err = f.FakeStruct(&user) // fill a struct's fake:"…" tagged fields +ok, err := fejkdata.IsTemplate(arg) // an inline template by its shape, else a path ``` | Option | | @@ -170,6 +172,28 @@ A `*Record` carries its columns via `Columns()` — each a `Column` of `Name`, CLI's `--format` writes. `FakeRecord` and `FakeRecordTemplate` take a record; a path or template that is not one — a bare string, a choice, or a folder — errors. +```go +type User struct { + ID int64 `fake:"{seq()}"` + Last string `fake:"sv_SE.person.last"` + Age uint8 `fake:"{int(18,99)}"` + Nick *string `fake:"[null, \"{/sv_SE.username}\"]"` + Home Address // filled from Address's own tags +} +``` + +`FakeStruct` fills a struct through a pointer: each exported field tagged `fake:"…"` is +a column of one record, its tag a path or an inline template — told apart by +`IsTemplate`, as the CLI tells an argument — and its Go type the column's +[datatype](#datatype): a string, bool, integer or float kind, or a pointer to one, +which a [`null`](#null) item leaves nil. A value the kind cannot hold, such as +`{int(0,300)}` in a `uint8`, is refused as a typed column's is. A struct field, or a +pointer to one, fills from its own tags as a record of its own, so its references draw +apart from its parent's. Untagged fields keep their values, and so does a pointer back +to a struct already being filled. The first call for a type compiles its tags and +reports what they get wrong: a tag holding only `{/path}` names the path to write, and +a `datatype` in a tag names the Go type that already sets it. + A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only when drawn from one goroutine. Changing how a value is composed shifts the seeded stream for that value and everything drawn after it. @@ -473,7 +497,8 @@ tokens add cost in proportion to the output. one is a load error naming the right one. 3. **Every mistake is a load error** — `New` rejects the data and `NewTemplate` the inline template; on a loaded generator `Fake` fails only for an unknown - path, and `Template.Fake` cannot fail at all. + path, `FakeStruct` only for a type its tags do not describe, and + `Template.Fake` cannot fail at all. 4. **Zero to a value in one command** — `go install`, then `fejkdata sv_SE.person`: no checkout, no flag. Flags are GNU-form (`--seed 42`, `-n 3`) in any position; the first custom template needs no escape and no option. @@ -579,13 +604,17 @@ tokens add cost in proportion to the output. - **Samples say what they emit, transforms what they do.** `{upper(2)}` is two letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on whether the argument looks like a number. -- **A record is a template seen as columns, not a second schema format.** A +- **A record is a template seen as columns; a Go struct is the one second schema.** A template's `format` composes its fields into one string; `FakeRecord` and `--format` project the same fields as columns. Two views of one dataset, so a record author writes the same JSON they already know, and a column is the same field `Fake` renders by dotted path. The `format` is inert to a record — a record-only template writes `"format": ""` — but it is compiled and fenced, so - a template that loads renders as whichever shape is asked for. + a template that loads renders as whichever shape is asked for. `FakeStruct` takes + its columns from a struct instead, because a Go caller has already written that + schema: the fields name the columns and their types are the datatypes, so a tag + says only what to draw, and a `datatype` in it would be a second spelling of the + type. - **A record shares one reference draw per category.** Two columns that reference one category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of it, so a record's facts agree the way a template's [correlated @@ -674,7 +703,8 @@ node.go the node model and JSON -> node compilation path.go the dotted-path walk, and proving a path resolves render.go Fake and the recursive renderer (choices, format strings, expansions) record.go records: Record, the JSON/CSV/SQL serializers, and their entry points -inline.go inline templates: Template, NewTemplate, FakeTemplate, and their compile and link +struct.go structs: FakeStruct, fake tags, and a field's Go type as its column's datatype +inline.go inline templates: Template, NewTemplate, FakeTemplate, IsTemplate, and their compile and link template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format hold.go the hold: one draw per expansion for paths and operands, and its fences reference.go reference sigils, and binding references across the tree diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index 36b605f..e005fc5 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -9,7 +9,6 @@ package main import ( "bufio" - "encoding/json" "errors" "fmt" "io" @@ -415,20 +414,13 @@ const ( argTemplate ) -// classify reads what a positional argument names by its shape: a { token, or a -// JSON object, array or string, is an inline template; anything else is a path. +// classify reads what a positional argument names by its shape (see fejkdata.IsTemplate). func classify(arg string) (argKind, error) { - if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) { - return argTemplate, nil + inline, err := fejkdata.IsTemplate(arg) + if inline { + return argTemplate, err } - if i := strings.IndexAny(arg, `[]}"`); i >= 0 { - return argPath, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) - } - return argPath, nil -} - -func isJSONStart(arg string) bool { - return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) + return argPath, err } func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/datatype.go b/datatype.go index 03bdb7f..6ddc484 100644 --- a/datatype.go +++ b/datatype.go @@ -67,19 +67,7 @@ func datatypeOf(m map[string]any, pos position) (DataType, error) { // columnDatatype is the datatype a column's items declare. They must agree, since a // column holds one; a column only ever null is a string. func columnDatatype(n node) (DataType, error) { - var items []*template - var collect func(node) - collect = func(n node) { - switch n := n.(type) { - case *choice: - for _, it := range n.items { - collect(it) - } - case *template: - items = append(items, n) - } - } - collect(n) + items, _ := columnItems(n) if len(items) == 0 { return DataTypeString, nil } @@ -91,6 +79,25 @@ func columnDatatype(n node) (DataType, error) { return items[0].datatype, nil } +// columnItems is a column's template items, its choices unwrapped, and whether one is null. +func columnItems(n node) (items []*template, nullable bool) { + var collect func(node) + collect = func(n node) { + switch n := n.(type) { + case *choice: + for _, it := range n.items { + collect(it) + } + case *template: + items = append(items, n) + case *null: + nullable = true + } + } + collect(n) + return items, nullable +} + // disagreement names the fix for two items of one column declaring different datatypes. func disagreement(a, b *template) error { typed, bare := a, b diff --git a/fejkdata.go b/fejkdata.go index 35cd73e..24bcf7e 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -21,6 +21,7 @@ import ( "io/fs" "math/rand/v2" "os" + "reflect" "sort" "sync" ) @@ -46,6 +47,7 @@ type Generator struct { rand *session categories map[string]node records map[node]recordShape + structs map[reflect.Type]structResult } // session is one generator's mutable render state: the seeded rng plus the {seq()} diff --git a/graph.go b/graph.go index f8361e1..07fd20f 100644 --- a/graph.go +++ b/graph.go @@ -173,8 +173,8 @@ func treeScope(root map[string]node) nodeScope { return func(fn func(path string, n node) error) error { return walkNodes(root, fn) } } -func inlineScope(n node) nodeScope { - return func(fn func(path string, m node) error) error { return eachNode(n, "template", fn) } +func inlineScope(n node, label string) nodeScope { + return func(fn func(path string, m node) error) error { return eachNode(n, label, fn) } } // checkScope runs the per-node fences over a scope, each over the whole scope diff --git a/inline.go b/inline.go index 91f6686..92dcdd7 100644 --- a/inline.go +++ b/inline.go @@ -32,11 +32,7 @@ func (f *Generator) NewTemplate(input string) (*Template, error) { if err != nil { return nil, fmt.Errorf("fejkdata: %w", err) } - scope := inlineScope(n) - if err := linkNodeRefs(scope, f.categories); err != nil { - return nil, fmt.Errorf("fejkdata: %w", err) - } - if err := checkScope(scope); err != nil { + if err := bindInline(n, "template", f.categories); err != nil { return nil, fmt.Errorf("fejkdata: %w", err) } return &Template{g: f, n: n}, nil @@ -53,17 +49,61 @@ func (f *Generator) FakeTemplate(input string) (string, error) { return t.Fake(), nil } -// compileInput compiles an inline template: a JSON value, or a bare format string -// when the input is not JSON. +// IsTemplate reports whether arg is an inline template rather than a path, by its shape: a { +// token, or a JSON object, array or string, is a template, and anything else is a path. A +// name never holds a bracket, a brace or a quote, so an arg holding one that is not valid +// JSON names neither, and errors. +func IsTemplate(arg string) (bool, error) { + inline, err := isTemplate(arg) + if err != nil { + return false, fmt.Errorf("fejkdata: %w", err) + } + return inline, nil +} + +func isTemplate(arg string) (bool, error) { + if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) { + return true, nil + } + if i := strings.IndexAny(arg, `[]}"`); i >= 0 { + return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) + } + return false, nil +} + +func isJSONStart(arg string) bool { + return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) +} + func compileInput(input string) (node, error) { + v, err := inputValue(input) + if err != nil { + return nil, err + } + return compile(v) +} + +// inputValue reads an inline template as the value compile takes: the JSON value it holds, or +// the input itself as a format string when it is not JSON. +func inputValue(input string) (any, error) { var raw any if err := json.Unmarshal([]byte(input), &raw); err != nil { - return compile(input) + return input, nil } if trimmed := strings.TrimSpace(input); trimmed != input { return nil, fmt.Errorf("a JSON template may not be padded with spaces, which a format string would render; write %s", trimmed) } - return compile(raw) + return raw, nil +} + +// bindInline links an inline node's references against root and runs the fences over it, +// naming its nodes from label. +func bindInline(n node, label string, root map[string]node) error { + scope := inlineScope(n, label) + if err := linkNodeRefs(scope, root); err != nil { + return err + } + return checkScope(scope) } // linkNodeRefs binds the references in an inline node's templates against the diff --git a/struct.go b/struct.go new file mode 100644 index 0000000..19a05c2 --- /dev/null +++ b/struct.go @@ -0,0 +1,312 @@ +package fejkdata + +import ( + "errors" + "fmt" + "math" + "reflect" + "strconv" + "strings" +) + +// FakeStruct fills the struct v points to. Each exported field tagged `fake:"…"` is a +// column of one record: its tag a path or an inline template, told apart as [IsTemplate] +// tells them, and its Go type the column's datatype. A struct field, or a pointer to one, +// fills from its own tags as a record of its own. The first call for a type compiles its +// tags, so a later call for that type fails only as the first did. +func (f *Generator) FakeStruct(v any) error { + p := reflect.ValueOf(v) + if p.Kind() != reflect.Pointer || p.IsNil() || p.Elem().Kind() != reflect.Struct { + return fmt.Errorf("fejkdata: FakeStruct fills a struct through a non-nil pointer, got %T", v) + } + f.mu.Lock() + defer f.mu.Unlock() + shape, err := f.structShapeOf(p.Elem().Type()) + if err != nil { + return fmt.Errorf("fejkdata: %w", err) + } + shape.fill(f.rand, p.Elem()) + return nil +} + +// structResult is what compiling a struct type settled: its shape, or why it cannot be filled. +type structResult struct { + shape *structShape + err error +} + +// structShapeOf compiles a struct type once and remembers the answer. Callers hold the +// generator's lock. +func (f *Generator) structShapeOf(t reflect.Type) (*structShape, error) { + if r, done := f.structs[t]; done { + return r.shape, r.err + } + shape, err := compileStruct(f.categories, t, t.String(), map[reflect.Type]bool{}) + if err == nil && shape.empty() { + err = fmt.Errorf("%s has no fake tags, so nothing to fill", t) + } + if f.structs == nil { + f.structs = map[reflect.Type]structResult{} + } + f.structs[t] = structResult{shape, err} + return shape, err +} + +// structShape is a struct type compiled to fill: its tagged fields as one record, the field +// index each column fills, and the struct fields carrying tags of their own. +type structShape struct { + record *template + columns []Column + fields []int + nested []nestedStruct +} + +// nestedStruct is a struct field, or a pointer to one, filled as a record of its own. +type nestedStruct struct { + index int + shape *structShape +} + +func (s *structShape) empty() bool { return s.record == nil && len(s.nested) == 0 } + +// compileStruct compiles struct type t, naming its fields from label. visiting holds the types +// compiling above t, so a pointer back to one is left alone rather than filled without end. +func compileStruct(root map[string]node, t reflect.Type, label string, visiting map[reflect.Type]bool) (*structShape, error) { + visiting[t] = true + defer delete(visiting, t) + shape := &structShape{} + tags := map[string]any{} + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + tag, tagged := sf.Tag.Lookup("fake") + if !tagged { + if err := shape.addNested(root, sf, label, visiting); err != nil { + return nil, err + } + continue + } + v, err := tagValue(sf, tag) + if err != nil { + return nil, fmt.Errorf("%s.%s: %w", label, sf.Name, err) + } + tags[sf.Name] = v + } + if len(tags) > 0 { + if err := shape.compileRecord(root, t, label, tags); err != nil { + return nil, err + } + } + return shape, nil +} + +// addNested adds an untagged exported struct field, or a pointer to one, that carries tags. +func (s *structShape) addNested(root map[string]node, sf reflect.StructField, label string, visiting map[reflect.Type]bool) error { + t := sf.Type + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if !sf.IsExported() || t.Kind() != reflect.Struct || visiting[t] { + return nil + } + nested, err := compileStruct(root, t, label+"."+sf.Name, visiting) + if err != nil || nested.empty() { + return err + } + s.nested = append(s.nested, nestedStruct{sf.Index[0], nested}) + return nil +} + +// 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) { + if err := checkTaggedType(sf); err != nil { + return nil, err + } + inline, err := isTemplate(tag) + if err != nil { + return nil, err + } + if !inline { + for _, seg := range strings.Split(tag, ".") { + if err := checkName(seg); err != nil { + return nil, fmt.Errorf("path %w", err) + } + } + return "{/" + tag + "}", nil + } + v, err := inputValue(tag) + if s, isString := v.(string); isString && isLoneReference(s) { + path := s[2 : len(s)-1] + return nil, fmt.Errorf("%s is the path %s written as a template; write fake:%q", s, path, path) + } + return v, err +} + +// isLoneReference reports whether a format is one {/path} token alone, which a path tag spells. +func isLoneReference(format string) bool { + return len(format) > len("{/}") && strings.HasPrefix(format, "{/") && strings.HasSuffix(format, "}") && + !strings.ContainsAny(format[1:len(format)-1], "{}|(") +} + +// checkTaggedType rejects a tagged field no column can fill. +func checkTaggedType(sf reflect.StructField) error { + elem := sf.Type + if elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + _, holds := columnKinds[elem.Kind()] + switch { + case !sf.IsExported(): + return errors.New("unexported, so its fake tag cannot fill it") + case holds: + return nil + case elem.Kind() == reflect.Struct: + return errors.New("a struct field fills from the tags on its own fields; drop this one") + } + return fmt.Errorf("a fake tag fills a string, bool, integer or float field, or a pointer to one, not %s", sf.Type) +} + +// 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 map[string]node, t reflect.Type, label string, tags map[string]any) error { + tags["format"] = "" + n, err := compile(tags) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if err := bindInline(n, label, root); err != nil { + return err + } + record, columns, err := recordOf(n) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + proof := &valueProof{} + s.fields = make([]int, len(columns)) + for i, c := range columns { + sf, _ := t.FieldByName(c.Name) + if c.DataType != DataTypeString { + return fmt.Errorf("%s.%s: its Go type %s sets the datatype; drop \"datatype\"", label, c.Name, sf.Type) + } + if err := proof.checkField(label+"."+c.Name, sf.Type, record.fields[c.Name]); err != nil { + return err + } + s.fields[i] = sf.Index[0] + } + s.record, s.columns = record, columns + return nil +} + +// columnKind is what a field of one Go kind holds: the datatype its text proves as, and the +// range its value stays in. +type columnKind struct { + datatype DataType + lo, hi float64 +} + +var columnKinds = map[reflect.Kind]columnKind{ + reflect.Bool: {DataTypeBoolean, -math.MaxFloat64, math.MaxFloat64}, + reflect.Float32: {DataTypeNumber, -math.MaxFloat32, math.MaxFloat32}, + reflect.Float64: {DataTypeNumber, -math.MaxFloat64, math.MaxFloat64}, + reflect.Int: {DataTypeInteger, math.MinInt, math.MaxInt}, + reflect.Int16: {DataTypeInteger, math.MinInt16, math.MaxInt16}, + reflect.Int32: {DataTypeInteger, math.MinInt32, math.MaxInt32}, + reflect.Int64: {DataTypeInteger, math.MinInt64, math.MaxInt64}, + reflect.Int8: {DataTypeInteger, math.MinInt8, math.MaxInt8}, + reflect.String: {DataTypeString, -math.MaxFloat64, math.MaxFloat64}, + reflect.Uint: {DataTypeInteger, 0, math.MaxUint}, + reflect.Uint16: {DataTypeInteger, 0, math.MaxUint16}, + reflect.Uint32: {DataTypeInteger, 0, math.MaxUint32}, + reflect.Uint64: {DataTypeInteger, 0, math.MaxUint64}, + reflect.Uint8: {DataTypeInteger, 0, math.MaxUint8}, +} + +// checkField rejects a column some render of which a field of Go type ft cannot hold: a null +// outside a pointer, or a value its kind's datatype or range refuses. +func (p *valueProof) checkField(label string, ft reflect.Type, column node) error { + items, nullable := columnItems(column) + elem := ft + if ft.Kind() == reflect.Pointer { + elem = ft.Elem() + } else if nullable { + return fmt.Errorf("%s: its tag can draw null, which %s cannot hold; make it *%s", label, ft, ft) + } + kind := columnKinds[elem.Kind()] + if kind.datatype == DataTypeString { + return nil + } + for _, it := range items { + v := p.of(it) + reason := v.not[kind.datatype] + if reason == "" && (v.lo < kind.lo || v.hi > kind.hi) { + reason = fmt.Sprintf("%q is not proven within %s", it.format, elem.Kind()) + } + if reason != "" { + return fmt.Errorf("%s (%s): %s", label, ft, reason) + } + } + return nil +} + +// fill draws the record into v's tagged fields, then each nested struct as a record of its own. +func (s *structShape) fill(sess *session, v reflect.Value) { + if s.record != nil { + for i, c := range renderRecord(sess, s.record, s.columns).columns { + setColumn(v.Field(s.fields[i]), c) + } + } + for _, n := range s.nested { + field := v.Field(n.index) + if field.Kind() == reflect.Pointer { + if field.IsNil() { + field.Set(reflect.New(field.Type().Elem())) + } + field = field.Elem() + } + n.shape.fill(sess, field) + } +} + +// setColumn writes a drawn column into its field: a null as a nil pointer, a value through a +// fresh pointer or straight into the field. +func setColumn(field reflect.Value, c Column) { + if field.Kind() != reflect.Pointer { + setText(field, c.Value) + return + } + if c.Null { + field.SetZero() + return + } + value := reflect.New(field.Type().Elem()) + setText(value.Elem(), c.Value) + field.Set(value) +} + +// setText parses text into a field of one of columnKinds, which checkField proved it parses as. +func setText(field reflect.Value, text string) { + var err error + switch kind := columnKinds[field.Kind()]; { + case kind.datatype == DataTypeString: + field.SetString(text) + case kind.datatype == DataTypeBoolean: + var b bool + b, err = strconv.ParseBool(text) + field.SetBool(b) + case kind.datatype == DataTypeNumber: + var x float64 + x, err = strconv.ParseFloat(text, field.Type().Bits()) + field.SetFloat(x) + case field.CanInt(): + var n int64 + n, err = strconv.ParseInt(text, 10, field.Type().Bits()) + field.SetInt(n) + default: + var n uint64 + n, err = strconv.ParseUint(text, 10, field.Type().Bits()) + field.SetUint(n) + } + if err != nil { + panic(fmt.Sprintf("fejkdata: %q reached a %s field unproven: %v", text, field.Type(), err)) + } +} diff --git a/todo.md b/todo.md index f252672..282f2dd 100644 --- a/todo.md +++ b/todo.md @@ -6,11 +6,6 @@ The record API lands first, so the data update can use it. ### Record API -- Struct-filling — fill a Go struct from `fake:"…"` tags holding a path or an - inline template, for parity with gofakeit and go-faker. The field's Go type is - the column type, through the same conversion and load checks as typed columns, - and a nested struct is its own draw group. Revise the Decision "A record is a - template seen as columns, not a second schema format" with that reason. - Draw groups — references into one category share one draw per render (one record, or one `Fake`) in both views; each `repeat` iteration draws anew, and a bare reference draws each time. An option naming a draw group splits a render -- 2.52.0 From a548abcc02b3d17c80f3ada9c7a4ff321aa34ba8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 16:53:56 +0200 Subject: [PATCH 03/10] Tests: embedded fields join their struct's record, fake:"-" leaves a struct unfilled, integer ranges round inward, and a lone reference is refused as a path --- cmd/fejkdata/main_test.go | 47 +++++++++------------------- inline_test.go | 38 +++++++++++++++++++++++ struct_test.go | 64 +++++++++++++++++++++++++++++++++------ 3 files changed, 106 insertions(+), 43 deletions(-) diff --git a/cmd/fejkdata/main_test.go b/cmd/fejkdata/main_test.go index fc2c485..2e17b5c 100644 --- a/cmd/fejkdata/main_test.go +++ b/cmd/fejkdata/main_test.go @@ -305,42 +305,22 @@ func TestRunShippedDataByDefault(t *testing.T) { } func TestClassify(t *testing.T) { - for arg, want := range map[string]argKind{ - "sv_SE.person": argPath, - "person.last": argPath, - "name: {x}": argTemplate, // a { token: a path can never carry a brace - `{"format":"x"}`: argTemplate, - `["a","b"]`: argTemplate, // a JSON array carries no brace - `[1, 2]`: argTemplate, - ` ["a","b"]`: argTemplate, // padding is the template's own error, not a shape verdict - `"hello"`: argTemplate, // a JSON string, the spelling a format-only object names - } { - got, err := classify(arg) - if err != nil || got != want { + for arg, want := range map[string]argKind{"sv_SE.person": argPath, "name: {x}": argTemplate} { + if got, err := classify(arg); err != nil || got != want { t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want) } } - for arg, want := range map[string]string{ - "[abc]": `holds a "["`, - "[abc].field": `holds a "["`, - "x[1]": `holds a "["`, - "a]b": `holds a "]"`, - "a}b": `holds a "}"`, - `"abc`: `holds a "\""`, - `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it - } { - _, err := classify(arg) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("classify(%q) = %v; want it rejected naming %s", arg, err, want) - } + if _, err := classify("[abc]"); err == nil || !strings.Contains(err.Error(), `holds a "["`) { + t.Errorf("classify([abc]) = %v; want it rejected naming the bracket", err) } } func TestUsageReferencesResolve(t *testing.T) { for _, token := range regexp.MustCompile(`\{/[^}]+\}`).FindAllString(usage, -1) { - code, out, errb := runOut("--seed", "1", token) + path := token[2 : len(token)-1] + code, out, errb := runOut("--seed", "1", path) if code != 0 || strings.TrimSpace(out) == "" { - t.Errorf("usage advertises %s: run = %d, %q, stderr %q", token, code, out, errb) + t.Errorf("usage advertises %s: run %s = %d, %q, stderr %q", token, path, code, out, errb) } } } @@ -373,12 +353,13 @@ func TestRunInlineTemplate(t *testing.T) { func TestRunTemplateMisuse(t *testing.T) { for arg, want := range map[string]string{ - "{bad": "unterminated", - "[red,green]": "names no template either", - `{"format":"x"}`: "is a string", - "{/no.such.path}": "no entry", - "x[1]": "names no template either", - ` ["a","b"] `: "may not be padded", + "{bad": "unterminated", + "[red,green]": "names no template either", + `{"format":"x"}`: "is a string", + "name: {/no.such.path}": "no entry", + "{/sv_SE.person}": "write sv_SE.person", + "x[1]": "names no template either", + ` ["a","b"] `: "may not be padded", } { code, out, errb := runOut("--seed", "1", arg) if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") || !strings.Contains(errb, want) { diff --git a/inline_test.go b/inline_test.go index f44e164..bb5b7f5 100644 --- a/inline_test.go +++ b/inline_test.go @@ -150,6 +150,44 @@ func TestNewTemplateReusable(t *testing.T) { } } +func TestIsTemplate(t *testing.T) { + for arg, want := range map[string]bool{ + "sv_SE.person": false, + "person.last": false, + "name: {x}": true, + `{"format":"x"}`: true, + `["a","b"]`: true, + `[1, 2]`: true, + ` ["a","b"]`: true, // padding is the template's own error, not a shape verdict + `"hello"`: true, + "{/a}{/b}": true, + "{/a|/b}": true, + "{uppercase(/a)}": true, + "{{/a}}": true, + `"{/a} x"`: true, + } { + if got, err := IsTemplate(arg); err != nil || got != want { + t.Errorf("IsTemplate(%q) = %v, %v; want %v", arg, got, err, want) + } + } + for arg, want := range map[string]string{ + "[abc]": `holds a "["`, + "[abc].field": `holds a "["`, + "x[1]": `holds a "["`, + "a]b": `holds a "]"`, + "a}b": `holds a "}"`, + `"abc`: `holds a "\""`, + `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it + "{/sv_SE.person.last}": "{/sv_SE.person.last} is the path sv_SE.person.last written as a template; write sv_SE.person.last", + `"{/sv_SE.person}"`: "write sv_SE.person", + "{.person.last}": "write person.last", + } { + if _, err := IsTemplate(arg); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("IsTemplate(%q) = %v; want it rejected naming %s", arg, err, want) + } + } +} + func TestFakeTemplateRepeatBound(t *testing.T) { f := shipped(t) _, err := f.FakeTemplate(`{"format":"{x}","repeat":200,"x":{"format":"{y}","repeat":200,"y":{"format":"z","repeat":200}}}`) diff --git a/struct_test.go b/struct_test.go index 3fbaa37..7e5c18b 100644 --- a/struct_test.go +++ b/struct_test.go @@ -19,14 +19,30 @@ type structUser struct { Home structPlace ID int64 `fake:"{seq()}"` Last string `fake:"person.last"` + Level uint8 `fake:"{float(0,255,0)}"` Nick *string `fake:"[null,\"bo\"]"` Note string - Rank *int `fake:"{\"format\":\"{r}\",\"r\":[\"1\",\"2\"]}"` - Score float32 `fake:"{float(0,1,2)}"` + Rank *int `fake:"{\"format\":\"{r}\",\"r\":[\"1\",\"2\"]}"` + Score float32 `fake:"{float(0,1,2)}"` + Skip *structPlace `fake:"-"` Work *structPlace hidden structPlace } +type structGiven struct { + First string `fake:"person.first"` +} + +type StructFamily struct { + Last string `fake:"person.last"` +} + +type structEmployee struct { + structGiven + *StructFamily + Email string `fake:"{lowercase(/person.first)}@example.com"` +} + type structLink struct { Name string `fake:"person.first"` Next *structLink @@ -63,7 +79,7 @@ func TestFakeStructFillsTaggedFields(t *testing.T) { t.Fatalf("places %+v, %+v, want each nested struct one place, the pointer allocated", u.Home, u.Work) case u.ID != int64(i+1) || u.Age < 18 || u.Age > 99 || u.Score < 0 || u.Score > 1 || u.Rank == nil || (*u.Rank != 1 && *u.Rank != 2): t.Fatalf("typed fields %+v, want each the value its tag draws", u) - case u.Nick != nil && *u.Nick != "bo", u.Note != "keep", u.hidden != (structPlace{}): + case u.Nick != nil && *u.Nick != "bo", u.Note != "keep", u.hidden != (structPlace{}), u.Skip != nil: t.Fatalf("%+v: want Nick nil or bo, and the untagged fields left as they were", u) } if u.Active { @@ -78,6 +94,20 @@ func TestFakeStructFillsTaggedFields(t *testing.T) { } } +func TestFakeStructFillsEmbeddedFieldsIntoItsRecord(t *testing.T) { + f := structData(t) + people := map[string]string{"Ada": "Lovelace", "Bo": "Ek"} + for i := 0; i < 100; i++ { + var e structEmployee + if err := f.FakeStruct(&e); err != nil { + t.Fatal(err) + } + if e.StructFamily == nil || people[e.First] != e.Last || e.Email != strings.ToLower(e.First)+"@example.com" { + t.Fatalf("%+v, %+v: want the promoted fields one person with the struct's own, the embedded pointer allocated", e, e.StructFamily) + } + } +} + func TestFakeStructDrawsANestedStructApart(t *testing.T) { f := structData(t) for i := 0; i < 100; i++ { @@ -136,13 +166,16 @@ func TestFakeStructErrors(t *testing.T) { }{}, `"Ada" is not an integer`}, {&struct { A int8 `fake:"{int(0,300)}"` - }{}, `"{int(0,300)}" is not proven within int8`}, + }{}, `"{int(0,300)}" can reach 300, past int8; make it int64`}, {&struct { A uint `fake:"{int(-1,5)}"` - }{}, `"{int(-1,5)}" is not proven within uint`}, + }{}, `"{int(-1,5)}" can reach -1, past uint; make it int64`}, {&struct { A float32 `fake:"[\"1\",\"1e39\"]"` - }{}, `"1e39" is not proven within float32`}, + }{}, `"1e39" can reach 1e+39, past float32; make it float64`}, + {&struct { + A int32 `fake:"{seq()}"` + }{}, `"{seq()}" can reach 9.223372036854776e+18, past int32; make it int64`}, {&struct { A bool `fake:"{int(0,1)}"` }{}, "prints an integer, not a boolean"}, @@ -151,10 +184,10 @@ func TestFakeStructErrors(t *testing.T) { }{}, `no entry "nope"`}, {&struct { A string `fake:"{/person.first}"` - }{}, `write fake:"person.first"`}, + }{}, "is the path person.first written as a template; write person.first"}, {&struct { A string `fake:"\"{/person.first}\""` - }{}, `write fake:"person.first"`}, + }{}, "is the path person.first written as a template; write person.first"}, {&struct { A string `fake:"a|b"` }{}, `contains "|"`}, @@ -165,8 +198,19 @@ func TestFakeStructErrors(t *testing.T) { A string `fake:"[abc]"` }{}, `holds a "["`}, {&struct { - A string `fake:"{.person.first}"` + A string `fake:"{.person.first} x"` }{}, "write {/person.first}"}, + {&struct { + A string `fake:"/person.first"` + }{}, "write person.first"}, + {&struct { + A string `fake:"-"` + }{}, `fake:"-" leaves a struct field unfilled`}, + {&struct{ *structGiven }{}, "an embedded pointer to an unexported type"}, + {&struct { + structGiven + First string `fake:"person.last"` + }{}, "struct.structGiven.First: hidden by another field named First"}, {&struct { A string `fake:"{x}"` }{}, `no field "x"`}, @@ -178,7 +222,7 @@ func TestFakeStructErrors(t *testing.T) { Trip struct { A int `fake:"{digits(3)}"` } - }{}, ".Trip.A (int): {digits(3)} prints text"}, + }{}, ": struct.Trip.A (int): {digits(3)} prints text"}, } { if err := f.FakeStruct(c.v); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("FakeStruct(%T) = %v, want an error containing %q", c.v, err, c.want) -- 2.52.0 From e6bd4c59724f7851d9510efe7303e913e9e580b7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 16:54:04 +0200 Subject: [PATCH 04/10] Fill embedded fields into their struct's record, add fake:"-", round integer ranges inward naming a wider kind, and refuse a lone reference in IsTemplate --- README.md | 40 +++++--- inline.go | 24 ++++- struct.go | 268 +++++++++++++++++++++++++++++++++++++----------------- todo.md | 5 +- 4 files changed, 237 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index f6d64b0..4f36582 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ a JSON object, array or string, or that carries a `{` token, is instead an spot. Its tokens reach the data by reference from the root — `{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike available. An inline template sits in no folder, so the folder-relative `{.name}` -and `{..name}` are rejected naming the root spelling. A path never contains a -brace, a bracket or a quote, so the two cannot collide (see +and `{..name}` are rejected naming the root spelling, and one reference alone — +`{/sv_SE.person}` — is the path written as a template, rejected naming the path. A +path never contains a brace, a bracket or a quote, so the two cannot collide (see [Decisions](#decisions)). | Flag | | @@ -186,13 +187,15 @@ type User struct { a column of one record, its tag a path or an inline template — told apart by `IsTemplate`, as the CLI tells an argument — and its Go type the column's [datatype](#datatype): a string, bool, integer or float kind, or a pointer to one, -which a [`null`](#null) item leaves nil. A value the kind cannot hold, such as -`{int(0,300)}` in a `uint8`, is refused as a typed column's is. A struct field, or a -pointer to one, fills from its own tags as a record of its own, so its references draw -apart from its parent's. Untagged fields keep their values, and so does a pointer back -to a struct already being filled. The first call for a type compiles its tags and -reports what they get wrong: a tag holding only `{/path}` names the path to write, and -a `datatype` in a tag names the Go type that already sets it. +which a [`null`](#null) item leaves nil. An integer stays within int64 whatever its +kind, and a value the kind cannot hold, such as `{int(0,300)}` in a `uint8`, is refused +naming a kind that holds it. The fields an embedded struct promotes are columns of the +same record; a named struct field, or a pointer to one, fills from its own tags as a +record of its own, so its references draw apart from its parent's, and `fake:"-"` +leaves it unfilled. Untagged fields keep their values, and so does a pointer back to a +struct already being filled. The first call for a type compiles its tags and reports +what they get wrong, with the same error on every later call; a `datatype` in a tag +names the Go type that already sets it. A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only when drawn from one goroutine. Changing how a value is composed shifts the seeded @@ -497,8 +500,8 @@ tokens add cost in proportion to the output. one is a load error naming the right one. 3. **Every mistake is a load error** — `New` rejects the data and `NewTemplate` the inline template; on a loaded generator `Fake` fails only for an unknown - path, `FakeStruct` only for a type its tags do not describe, and - `Template.Fake` cannot fail at all. + path, `FakeStruct` only for a non-struct argument or a type its tags do not + describe, with the same error every call, and `Template.Fake` cannot fail at all. 4. **Zero to a value in one command** — `go install`, then `fejkdata sv_SE.person`: no checkout, no flag. Flags are GNU-form (`--seed 42`, `-n 3`) in any position; the first custom template needs no escape and no option. @@ -535,7 +538,10 @@ tokens add cost in proportion to the output. whole — though only a leading one could collide — keeps one simple name rule instead of a leading-position special case. The JSON string is what makes the library's own advice reachable: the error for an object holding only a format - names `"…"`, and that spelling has to work where it is printed. + names `"…"`, and that spelling has to work where it is printed. One reference alone, + `{/users}`, is refused naming the path `users`: both render the same text, and only + the path names a record. `IsTemplate` exports the rule, so the CLI, struct tags and + any other caller read one. - **An inline template skips the cycle fence, and only that one.** `New` proves the loaded tree acyclic, an inline node is a finite tree of its own, and nothing in the tree can reference it, so no render of it reaches itself. Every other fence @@ -615,6 +621,16 @@ tokens add cost in proportion to the output. schema: the fields name the columns and their types are the datatypes, so a tag says only what to draw, and a `datatype` in it would be a second spelling of the type. +- **A struct's records follow Go's field access, and compile on first use.** The + fields an embedded struct promotes are the struct's own — `e.First`, as + `encoding/json` and SQL mappers read them — so they are columns of its record and + share its draws; a tagged field that another field hides is refused, not dropped. A + named struct field is another entity and a record of its own; `fake:"-"` leaves it + unfilled, whatever a category named `-` holds, and a pointer back to a struct + already being filled is left alone, since filling it would never end. `New` cannot + see a caller's types, so the first `FakeStruct` for a type compiles its tags and the + answer, error included, is kept per type: a test's first call is its load, and no + `NewStruct` handle is needed, as the cache already compiles once. - **A record shares one reference draw per category.** Two columns that reference one category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of it, so a record's facts agree the way a template's [correlated diff --git a/inline.go b/inline.go index 92dcdd7..7dac749 100644 --- a/inline.go +++ b/inline.go @@ -52,7 +52,8 @@ func (f *Generator) FakeTemplate(input string) (string, error) { // IsTemplate reports whether arg is an inline template rather than a path, by its shape: a { // token, or a JSON object, array or string, is a template, and anything else is a path. A // name never holds a bracket, a brace or a quote, so an arg holding one that is not valid -// JSON names neither, and errors. +// JSON names neither, and errors; so does a template of one reference alone, which is a path +// written as a template. func IsTemplate(arg string) (bool, error) { inline, err := isTemplate(arg) if err != nil { @@ -63,6 +64,9 @@ func IsTemplate(arg string) (bool, error) { func isTemplate(arg string) (bool, error) { if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) { + if path, lone := loneReference(arg); lone { + return false, fmt.Errorf("%s is the path %s written as a template; write %s", arg, path, path) + } return true, nil } if i := strings.IndexAny(arg, `[]}"`); i >= 0 { @@ -75,6 +79,24 @@ func isJSONStart(arg string) bool { return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) } +// loneReference is the path a template spells when it is one reference token and nothing else. +func loneReference(arg string) (string, bool) { + format := arg + if strings.HasPrefix(arg, `"`) && json.Unmarshal([]byte(arg), &format) != nil { + return "", false + } + var units []ftoken + if eachToken(format, func(t ftoken) error { units = append(units, t); return nil }) != nil || len(units) != 1 { + return "", false + } + body := units[0].body + if units[0].kind != 'b' || !isRef(body) || strings.ContainsAny(body, "|(") { + return "", false + } + _, path, err := refShape(body) + return path, err == nil +} + func compileInput(input string) (node, error) { v, err := inputValue(input) if err != nil { diff --git a/struct.go b/struct.go index 19a05c2..92c8885 100644 --- a/struct.go +++ b/struct.go @@ -5,15 +5,17 @@ import ( "fmt" "math" "reflect" + "slices" "strconv" "strings" ) // FakeStruct fills the struct v points to. Each exported field tagged `fake:"…"` is a // column of one record: its tag a path or an inline template, told apart as [IsTemplate] -// tells them, and its Go type the column's datatype. A struct field, or a pointer to one, -// fills from its own tags as a record of its own. The first call for a type compiles its -// tags, so a later call for that type fails only as the first did. +// tells them, and its Go type the column's datatype. The fields an embedded struct promotes +// are columns of that record too, while a named struct field, or a pointer to one, fills +// from its own tags as a record of its own. The first call for a type compiles its tags, +// so a later call for that type fails only as the first did. func (f *Generator) FakeStruct(v any) error { p := reflect.ValueOf(v) if p.Kind() != reflect.Pointer || p.IsNil() || p.Elem().Kind() != reflect.Struct { @@ -41,7 +43,11 @@ func (f *Generator) structShapeOf(t reflect.Type) (*structShape, error) { if r, done := f.structs[t]; done { return r.shape, r.err } - shape, err := compileStruct(f.categories, t, t.String(), map[reflect.Type]bool{}) + label := t.Name() + if label == "" { + label = "struct" + } + shape, err := compileStruct(f.categories, t, label, map[reflect.Type]bool{}) if err == nil && shape.empty() { err = fmt.Errorf("%s has no fake tags, so nothing to fill", t) } @@ -53,66 +59,138 @@ func (f *Generator) structShapeOf(t reflect.Type) (*structShape, error) { } // structShape is a struct type compiled to fill: its tagged fields as one record, the field -// index each column fills, and the struct fields carrying tags of their own. +// index path each column fills, and the struct fields carrying tags of their own. type structShape struct { record *template columns []Column - fields []int + fields [][]int nested []nestedStruct } -// nestedStruct is a struct field, or a pointer to one, filled as a record of its own. +// nestedStruct is a named struct field, or a pointer to one, filled as a record of its own. type nestedStruct struct { - index int + index []int shape *structShape } func (s *structShape) empty() bool { return s.record == nil && len(s.nested) == 0 } -// compileStruct compiles struct type t, naming its fields from label. visiting holds the types -// compiling above t, so a pointer back to one is left alone rather than filled without end. +// structFields gathers what one struct type fills: its tagged fields, those its embedded +// structs promote included, as the tags of one record, and its named struct fields as nested +// records. visiting holds the types compiling or embedded above, so a pointer back to one is +// left alone rather than filled without end. +type structFields struct { + root map[string]node + t reflect.Type + label string + visiting map[reflect.Type]bool + tags map[string]any + shape *structShape +} + +// compileStruct compiles struct type t, naming its fields from label. func compileStruct(root map[string]node, t reflect.Type, label string, visiting map[reflect.Type]bool) (*structShape, error) { visiting[t] = true defer delete(visiting, t) - shape := &structShape{} - tags := map[string]any{} - for i := 0; i < t.NumField(); i++ { - sf := t.Field(i) - tag, tagged := sf.Tag.Lookup("fake") - if !tagged { - if err := shape.addNested(root, sf, label, visiting); err != nil { - return nil, err - } - continue - } - v, err := tagValue(sf, tag) - if err != nil { - return nil, fmt.Errorf("%s.%s: %w", label, sf.Name, err) - } - tags[sf.Name] = v + c := &structFields{root: root, t: t, label: label, visiting: visiting, tags: map[string]any{}, shape: &structShape{}} + if err := c.walk(t, nil); err != nil { + return nil, err } - if len(tags) > 0 { - if err := shape.compileRecord(root, t, label, tags); err != nil { + if len(c.tags) > 0 { + if err := c.shape.compileRecord(root, t, label, c.tags); err != nil { return nil, err } } - return shape, nil + return c.shape, nil } -// addNested adds an untagged exported struct field, or a pointer to one, that carries tags. -func (s *structShape) addNested(root map[string]node, sf reflect.StructField, label string, visiting map[reflect.Type]bool) error { - t := sf.Type +// walk gathers the fields of struct type t, which sits at index within c.t. +func (c *structFields) walk(t reflect.Type, index []int) error { + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + sf.Index = append(index[:len(index):len(index)], i) + if err := c.field(sf); err != nil { + return err + } + } + return nil +} + +func (c *structFields) field(sf reflect.StructField) error { + tag, tagged := sf.Tag.Lookup("fake") + elem := structOf(sf.Type) + switch { + case tagged && tag == "-": + if elem == nil { + return fmt.Errorf(`%s.%s: fake:"-" leaves a struct field unfilled, and any other untagged field keeps its value already; drop the tag`, c.label, sf.Name) + } + return nil + case tagged: + return c.column(sf, tag) + case elem == nil || c.visiting[elem]: + return nil + case sf.Anonymous: + return c.embed(sf, elem) + case sf.IsExported(): + return c.nest(sf, elem) + } + return nil +} + +// structOf is the struct type a field holds, by value or through a pointer; nil when none. +func structOf(t reflect.Type) reflect.Type { if t.Kind() == reflect.Pointer { t = t.Elem() } - if !sf.IsExported() || t.Kind() != reflect.Struct || visiting[t] { + if t.Kind() != reflect.Struct { return nil } - nested, err := compileStruct(root, t, label+"."+sf.Name, visiting) + return t +} + +// column adds a tagged field's tag to the record, refusing one another field hides. +func (c *structFields) column(sf reflect.StructField, tag string) error { + if visible, ok := c.t.FieldByName(sf.Name); !ok || !slices.Equal(visible.Index, sf.Index) { + return fmt.Errorf("%s.%s: hidden by another field named %s, so its fake tag cannot fill it; rename one", c.label, fieldPath(c.t, sf.Index), sf.Name) + } + v, err := tagValue(sf, tag) + if err != nil { + return fmt.Errorf("%s.%s: %w", c.label, sf.Name, err) + } + c.tags[sf.Name] = v + return nil +} + +// fieldPath names the field at index within t through each struct it is embedded in. +func fieldPath(t reflect.Type, index []int) string { + names := make([]string, len(index)) + for i := range index { + names[i] = t.FieldByIndex(index[:i+1]).Name + } + return strings.Join(names, ".") +} + +// embed gathers the fields an embedded struct promotes into c's record. +func (c *structFields) embed(sf reflect.StructField, elem reflect.Type) error { + c.visiting[elem] = true + defer delete(c.visiting, elem) + tags, nested := len(c.tags), len(c.shape.nested) + if err := c.walk(elem, sf.Index); err != nil { + return err + } + if sf.Type.Kind() == reflect.Pointer && !sf.IsExported() && (len(c.tags) > tags || len(c.shape.nested) > nested) { + return fmt.Errorf("%s.%s: an embedded pointer to an unexported type cannot be allocated, so the tags beneath it cannot fill; embed %s by value", c.label, fieldPath(c.t, sf.Index), elem) + } + return nil +} + +// nest adds a named struct field, or a pointer to one, that carries tags as a record of its own. +func (c *structFields) nest(sf reflect.StructField, elem reflect.Type) error { + nested, err := compileStruct(c.root, elem, c.label+"."+sf.Name, c.visiting) if err != nil || nested.empty() { return err } - s.nested = append(s.nested, nestedStruct{sf.Index[0], nested}) + c.shape.nested = append(c.shape.nested, nestedStruct{sf.Index, nested}) return nil } @@ -123,45 +201,35 @@ func tagValue(sf reflect.StructField, tag string) (any, error) { return nil, err } inline, err := isTemplate(tag) - if err != nil { + switch { + case err != nil: return nil, err + case inline: + return inputValue(tag) + case strings.HasPrefix(tag, "/"): + return nil, fmt.Errorf("path %q starts with /, and every path starts at the root already; write %s", tag, tag[1:]) } - if !inline { - for _, seg := range strings.Split(tag, ".") { - if err := checkName(seg); err != nil { - return nil, fmt.Errorf("path %w", err) - } + for _, seg := range strings.Split(tag, ".") { + if err := checkName(seg); err != nil { + return nil, fmt.Errorf("path %w", err) } - return "{/" + tag + "}", nil } - v, err := inputValue(tag) - if s, isString := v.(string); isString && isLoneReference(s) { - path := s[2 : len(s)-1] - return nil, fmt.Errorf("%s is the path %s written as a template; write fake:%q", s, path, path) - } - return v, err -} - -// isLoneReference reports whether a format is one {/path} token alone, which a path tag spells. -func isLoneReference(format string) bool { - return len(format) > len("{/}") && strings.HasPrefix(format, "{/") && strings.HasSuffix(format, "}") && - !strings.ContainsAny(format[1:len(format)-1], "{}|(") + return "{/" + tag + "}", nil } // checkTaggedType rejects a tagged field no column can fill. func checkTaggedType(sf reflect.StructField) error { - elem := sf.Type - if elem.Kind() == reflect.Pointer { - elem = elem.Elem() + _, holds := columnKinds[sf.Type.Kind()] + if sf.Type.Kind() == reflect.Pointer { + _, holds = columnKinds[sf.Type.Elem().Kind()] } - _, holds := columnKinds[elem.Kind()] switch { case !sf.IsExported(): return errors.New("unexported, so its fake tag cannot fill it") case holds: return nil - case elem.Kind() == reflect.Struct: - return errors.New("a struct field fills from the tags on its own fields; drop this one") + case structOf(sf.Type) != nil: + return errors.New(`a struct field fills from the tags on its own fields; drop this one, or write fake:"-" to leave it unfilled`) } return fmt.Errorf("a fake tag fills a string, bool, integer or float field, or a pointer to one, not %s", sf.Type) } @@ -182,7 +250,7 @@ func (s *structShape) compileRecord(root map[string]node, t reflect.Type, label return fmt.Errorf("%s: %w", label, err) } proof := &valueProof{} - s.fields = make([]int, len(columns)) + s.fields = make([][]int, len(columns)) for i, c := range columns { sf, _ := t.FieldByName(c.Name) if c.DataType != DataTypeString { @@ -191,34 +259,44 @@ func (s *structShape) compileRecord(root map[string]node, t reflect.Type, label if err := proof.checkField(label+"."+c.Name, sf.Type, record.fields[c.Name]); err != nil { return err } - s.fields[i] = sf.Index[0] + s.fields[i] = sf.Index } s.record, s.columns = record, columns return nil } -// columnKind is what a field of one Go kind holds: the datatype its text proves as, and the -// range its value stays in. +// columnKind is what a field of one Go kind holds: the datatype its text proves as, the range a +// number of it stays in, and the kind to name when a value can pass that range. type columnKind struct { datatype DataType lo, hi float64 + wider reflect.Kind } var columnKinds = map[reflect.Kind]columnKind{ - reflect.Bool: {DataTypeBoolean, -math.MaxFloat64, math.MaxFloat64}, - reflect.Float32: {DataTypeNumber, -math.MaxFloat32, math.MaxFloat32}, - reflect.Float64: {DataTypeNumber, -math.MaxFloat64, math.MaxFloat64}, - reflect.Int: {DataTypeInteger, math.MinInt, math.MaxInt}, - reflect.Int16: {DataTypeInteger, math.MinInt16, math.MaxInt16}, - reflect.Int32: {DataTypeInteger, math.MinInt32, math.MaxInt32}, - reflect.Int64: {DataTypeInteger, math.MinInt64, math.MaxInt64}, - reflect.Int8: {DataTypeInteger, math.MinInt8, math.MaxInt8}, - reflect.String: {DataTypeString, -math.MaxFloat64, math.MaxFloat64}, - reflect.Uint: {DataTypeInteger, 0, math.MaxUint}, - reflect.Uint16: {DataTypeInteger, 0, math.MaxUint16}, - reflect.Uint32: {DataTypeInteger, 0, math.MaxUint32}, - reflect.Uint64: {DataTypeInteger, 0, math.MaxUint64}, - reflect.Uint8: {DataTypeInteger, 0, math.MaxUint8}, + reflect.Bool: {datatype: DataTypeBoolean}, + reflect.Float32: {DataTypeNumber, -math.MaxFloat32, math.MaxFloat32, reflect.Float64}, + reflect.Float64: {DataTypeNumber, -math.MaxFloat64, math.MaxFloat64, reflect.Float64}, + reflect.Int: {DataTypeInteger, math.MinInt, math.MaxInt, reflect.Int64}, + reflect.Int16: {DataTypeInteger, math.MinInt16, math.MaxInt16, reflect.Int64}, + reflect.Int32: {DataTypeInteger, math.MinInt32, math.MaxInt32, reflect.Int64}, + reflect.Int64: {DataTypeInteger, math.MinInt64, math.MaxInt64, reflect.Int64}, + reflect.Int8: {DataTypeInteger, math.MinInt8, math.MaxInt8, reflect.Int64}, + reflect.String: {datatype: DataTypeString}, + reflect.Uint: {DataTypeInteger, 0, math.MaxUint, reflect.Int64}, + reflect.Uint16: {DataTypeInteger, 0, math.MaxUint16, reflect.Int64}, + reflect.Uint32: {DataTypeInteger, 0, math.MaxUint32, reflect.Int64}, + reflect.Uint64: {DataTypeInteger, 0, math.MaxUint64, reflect.Int64}, + reflect.Uint8: {DataTypeInteger, 0, math.MaxUint8, reflect.Int64}, +} + +// reach is the least and greatest value v proves a field of this kind receives. An integer +// prints whole, so its bounds round inward. +func (k columnKind) reach(v proven) (lo, hi float64) { + if k.datatype == DataTypeInteger { + return math.Ceil(v.lo), math.Floor(v.hi) + } + return v.lo, v.hi } // checkField rejects a column some render of which a field of Go type ft cannot hold: a null @@ -237,13 +315,19 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro } for _, it := range items { v := p.of(it) - reason := v.not[kind.datatype] - if reason == "" && (v.lo < kind.lo || v.hi > kind.hi) { - reason = fmt.Sprintf("%q is not proven within %s", it.format, elem.Kind()) - } - if reason != "" { + if reason := v.not[kind.datatype]; reason != "" { return fmt.Errorf("%s (%s): %s", label, ft, reason) } + if kind.datatype == DataTypeBoolean { + continue + } + if lo, hi := kind.reach(v); lo < kind.lo || hi > kind.hi { + past := hi + if lo < kind.lo { + past = lo + } + return fmt.Errorf("%s (%s): %q can reach %s, past %s; make it %s", label, ft, it.format, strconv.FormatFloat(past, 'g', -1, 64), elem.Kind(), kind.wider) + } } return nil } @@ -252,11 +336,11 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro func (s *structShape) fill(sess *session, v reflect.Value) { if s.record != nil { for i, c := range renderRecord(sess, s.record, s.columns).columns { - setColumn(v.Field(s.fields[i]), c) + setColumn(fieldAt(v, s.fields[i]), c) } } for _, n := range s.nested { - field := v.Field(n.index) + field := fieldAt(v, n.index) if field.Kind() == reflect.Pointer { if field.IsNil() { field.Set(reflect.New(field.Type().Elem())) @@ -267,6 +351,20 @@ func (s *structShape) fill(sess *session, v reflect.Value) { } } +// fieldAt is v's field at index, allocating each nil embedded pointer on the way. +func fieldAt(v reflect.Value, index []int) reflect.Value { + for _, i := range index { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + v = v.Field(i) + } + return v +} + // setColumn writes a drawn column into its field: a null as a nil pointer, a value through a // fresh pointer or straight into the field. func setColumn(field reflect.Value, c Column) { diff --git a/todo.md b/todo.md index 282f2dd..1e4124b 100644 --- a/todo.md +++ b/todo.md @@ -19,8 +19,9 @@ The record API lands first, so the data update can use it. - two bare `{/sv_SE.word}` → two words - Reference inheritance — settle whether a column that is exactly one reference to another record's column, like `{/src.score}`, takes that column's datatype and - null. Today a null there writes `""`, and a typed column reading it is refused. - Settle before draw groups and the data update. + null. Today a null there writes `""`, a `*T` struct field reading it gets `""` + rather than nil, and a typed column reading it is refused. Settle before draw + groups and the data update. ### Data -- 2.52.0 From 7cb6928995c92ed5141fccaab410b99f1283d175 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 17:11:10 +0200 Subject: [PATCH 05/10] Tests: fake:"-" leaves an embedded struct unfilled, a leading / is refused naming the path, and a name may not be - --- inline_test.go | 1 + loading_test.go | 4 ++++ struct_test.go | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/inline_test.go b/inline_test.go index bb5b7f5..0ee9a8c 100644 --- a/inline_test.go +++ b/inline_test.go @@ -181,6 +181,7 @@ func TestIsTemplate(t *testing.T) { "{/sv_SE.person.last}": "{/sv_SE.person.last} is the path sv_SE.person.last written as a template; write sv_SE.person.last", `"{/sv_SE.person}"`: "write sv_SE.person", "{.person.last}": "write person.last", + "/sv_SE.person": "write sv_SE.person", } { if _, err := IsTemplate(arg); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("IsTemplate(%q) = %v; want it rejected naming %s", arg, err, want) diff --git a/loading_test.go b/loading_test.go index 681fe67..bd947af 100644 --- a/loading_test.go +++ b/loading_test.go @@ -298,6 +298,10 @@ func TestNewErrors(t *testing.T) { map[string]string{"a(b/cat": `"1"`}, `folder "a(b" contains "("`, }, + "field name a struct tag reserves": { + map[string]string{"a": `{"format":"{x}","x":"1","-":"2"}`}, + `field "-" is reserved`, + }, // A repeated arm skews an alternation, which weight is the spelling for. "repeated alternation arm": { map[string]string{"a": `{"format":"{x|x}","x":"1"}`}, diff --git a/struct_test.go b/struct_test.go index 7e5c18b..eb348d2 100644 --- a/struct_test.go +++ b/struct_test.go @@ -106,6 +106,13 @@ func TestFakeStructFillsEmbeddedFieldsIntoItsRecord(t *testing.T) { t.Fatalf("%+v, %+v: want the promoted fields one person with the struct's own, the embedded pointer allocated", e, e.StructFamily) } } + var skipped struct { + structGiven `fake:"-"` + Email string `fake:"{lowercase(/person.first)}@example.com"` + } + if err := f.FakeStruct(&skipped); err != nil || skipped.First != "" || skipped.Email == "" { + t.Errorf("FakeStruct = %v, %+v; want the embedded struct under fake:\"-\" left unfilled beside the filled field", err, skipped) + } } func TestFakeStructDrawsANestedStructApart(t *testing.T) { -- 2.52.0 From bbe6c5b1d98a8bbe03cd00d223cef65c8e2869ab Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 17:11:20 +0200 Subject: [PATCH 06/10] Fold the range check into columnKind.past, refuse a leading / in IsTemplate, reserve the name -, and document fake:"-" on an embedded struct --- README.md | 22 +++++++++++----------- inline.go | 7 +++++-- node.go | 6 +++++- struct.go | 35 ++++++++++++++++++----------------- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4f36582..e611941 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ spot. Its tokens reach the data by reference from the root — `{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike available. An inline template sits in no folder, so the folder-relative `{.name}` and `{..name}` are rejected naming the root spelling, and one reference alone — -`{/sv_SE.person}` — is the path written as a template, rejected naming the path. A -path never contains a brace, a bracket or a quote, so the two cannot collide (see -[Decisions](#decisions)). +`{/sv_SE.person}` — is the path written as a template, rejected naming the path, as is +a path written `/sv_SE.person`. A path never contains a brace, a bracket or a quote, +so the two cannot collide (see [Decisions](#decisions)). | Flag | | |------|--| @@ -191,9 +191,9 @@ which a [`null`](#null) item leaves nil. An integer stays within int64 whatever kind, and a value the kind cannot hold, such as `{int(0,300)}` in a `uint8`, is refused naming a kind that holds it. The fields an embedded struct promotes are columns of the same record; a named struct field, or a pointer to one, fills from its own tags as a -record of its own, so its references draw apart from its parent's, and `fake:"-"` -leaves it unfilled. Untagged fields keep their values, and so does a pointer back to a -struct already being filled. The first call for a type compiles its tags and reports +record of its own, so its references draw apart from its parent's. `fake:"-"` leaves a +struct field, embedded or named, or a pointer to one, unfilled. Untagged fields keep +their values, and so does a pointer back to a struct already being filled. The first call for a type compiles its tags and reports what they get wrong, with the same error on every later call; a `datatype` in a tag names the Go type that already sets it. @@ -209,8 +209,8 @@ work with no data on disk. A directory is a namespace: each JSON file is a category named after the file, each subdirectory a dot-path segment, so `mydata/sv_SE/person.json` is `sv_SE.person` and replaces the shipped one. Sources merge in order; matching folders combine, any other clash is won by the -last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`; -dot-prefixed entries are skipped, so a data directory can also be a checkout. +last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`, nor be +`-`, which a struct tag reserves; dot-prefixed entries are skipped, so a data directory can also be a checkout. Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`, `person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`, @@ -625,9 +625,9 @@ tokens add cost in proportion to the output. fields an embedded struct promotes are the struct's own — `e.First`, as `encoding/json` and SQL mappers read them — so they are columns of its record and share its draws; a tagged field that another field hides is refused, not dropped. A - named struct field is another entity and a record of its own; `fake:"-"` leaves it - unfilled, whatever a category named `-` holds, and a pointer back to a struct - already being filled is left alone, since filling it would never end. `New` cannot + named struct field is another entity and a record of its own. `fake:"-"` leaves a + struct field, embedded or named, unfilled, so no name may be `-`; a pointer back to + a struct already being filled is left alone, since filling it would never end. `New` cannot see a caller's types, so the first `FakeStruct` for a type compiles its tags and the answer, error included, is kept per type: a test's first call is its load, and no `NewStruct` handle is needed, as the cache already compiles once. diff --git a/inline.go b/inline.go index 7dac749..1e29542 100644 --- a/inline.go +++ b/inline.go @@ -52,8 +52,8 @@ func (f *Generator) FakeTemplate(input string) (string, error) { // IsTemplate reports whether arg is an inline template rather than a path, by its shape: a { // token, or a JSON object, array or string, is a template, and anything else is a path. A // name never holds a bracket, a brace or a quote, so an arg holding one that is not valid -// JSON names neither, and errors; so does a template of one reference alone, which is a path -// written as a template. +// JSON names neither, and errors; so do a template of one reference alone, which is a path +// written as a template, and a path written with a leading /. func IsTemplate(arg string) (bool, error) { inline, err := isTemplate(arg) if err != nil { @@ -72,6 +72,9 @@ func isTemplate(arg string) (bool, error) { if i := strings.IndexAny(arg, `[]}"`); i >= 0 { return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) } + if len(arg) > 1 && strings.HasPrefix(arg, "/") { + return false, fmt.Errorf("path %s starts with /, and every path starts at the root already; write %s", arg, arg[1:]) + } return false, nil } diff --git a/node.go b/node.go index cef1bc7..2a44c01 100644 --- a/node.go +++ b/node.go @@ -378,13 +378,17 @@ const reservedInName = ".|({}/[]\"" // reservedList spells reservedInName for an error message, so the two cannot drift. var reservedList = strings.Join(strings.Split(reservedInName, ""), " ") -// checkName rejects a name the dot path, {token} and JSON grammars cannot spell. +// checkName rejects a name the dot path, {token} and JSON grammars cannot spell, or a struct +// tag cannot read. // Both a category or folder and a field go through it, so there is one answer to // what a name may contain. func checkName(name string) error { if name == "" { return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name) } + if name == "-" { + return fmt.Errorf(`%q is reserved: the struct tag fake:"-" leaves a field unfilled, so no tag could read it; rename it`, name) + } if i := strings.IndexAny(name, reservedInName); i >= 0 { return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve", name, name[i:i+1], reservedList) diff --git a/struct.go b/struct.go index 92c8885..380223b 100644 --- a/struct.go +++ b/struct.go @@ -206,8 +206,6 @@ func tagValue(sf reflect.StructField, tag string) (any, error) { return nil, err case inline: return inputValue(tag) - case strings.HasPrefix(tag, "/"): - return nil, fmt.Errorf("path %q starts with /, and every path starts at the root already; write %s", tag, tag[1:]) } for _, seg := range strings.Split(tag, ".") { if err := checkName(seg); err != nil { @@ -290,13 +288,23 @@ var columnKinds = map[reflect.Kind]columnKind{ reflect.Uint8: {DataTypeInteger, 0, math.MaxUint8, reflect.Int64}, } -// reach is the least and greatest value v proves a field of this kind receives. An integer -// prints whole, so its bounds round inward. -func (k columnKind) reach(v proven) (lo, hi float64) { - if k.datatype == DataTypeInteger { - return math.Ceil(v.lo), math.Floor(v.hi) +// past is the bound of v a field of this kind cannot hold, if either is. An integer prints +// whole, so its bounds round inward first. +func (k columnKind) past(v proven) (float64, bool) { + lo, hi := v.lo, v.hi + switch k.datatype { + case DataTypeString, DataTypeBoolean: + return 0, false + case DataTypeInteger: + lo, hi = math.Ceil(lo), math.Floor(hi) } - return v.lo, v.hi + switch { + case lo < k.lo: + return lo, true + case hi > k.hi: + return hi, true + } + return 0, false } // checkField rejects a column some render of which a field of Go type ft cannot hold: a null @@ -318,15 +326,8 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro if reason := v.not[kind.datatype]; reason != "" { return fmt.Errorf("%s (%s): %s", label, ft, reason) } - if kind.datatype == DataTypeBoolean { - continue - } - if lo, hi := kind.reach(v); lo < kind.lo || hi > kind.hi { - past := hi - if lo < kind.lo { - past = lo - } - return fmt.Errorf("%s (%s): %q can reach %s, past %s; make it %s", label, ft, it.format, strconv.FormatFloat(past, 'g', -1, 64), elem.Kind(), kind.wider) + if bound, over := kind.past(v); over { + return fmt.Errorf("%s (%s): %q can reach %s, past %s; make it %s", label, ft, it.format, strconv.FormatFloat(bound, 'g', -1, 64), elem.Kind(), kind.wider) } } return nil -- 2.52.0 From ef0000043685647b4fb93b7e46f49b25d0da1416 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 17:35:04 +0200 Subject: [PATCH 07/10] Tests: a type's records are capped at 1024, a lone reference is found through padding and a format-only object, null clears a preset pointer, and errors repeat per call --- inline_test.go | 25 ++++++++++++++----------- shipped_data_test.go | 7 +++++++ struct_test.go | 37 +++++++++++++++++++++++++++++-------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/inline_test.go b/inline_test.go index 0ee9a8c..4a1abaf 100644 --- a/inline_test.go +++ b/inline_test.go @@ -171,17 +171,20 @@ func TestIsTemplate(t *testing.T) { } } for arg, want := range map[string]string{ - "[abc]": `holds a "["`, - "[abc].field": `holds a "["`, - "x[1]": `holds a "["`, - "a]b": `holds a "]"`, - "a}b": `holds a "}"`, - `"abc`: `holds a "\""`, - `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it - "{/sv_SE.person.last}": "{/sv_SE.person.last} is the path sv_SE.person.last written as a template; write sv_SE.person.last", - `"{/sv_SE.person}"`: "write sv_SE.person", - "{.person.last}": "write person.last", - "/sv_SE.person": "write sv_SE.person", + "[abc]": `holds a "["`, + "[abc].field": `holds a "["`, + "x[1]": `holds a "["`, + "a]b": `holds a "]"`, + "a}b": `holds a "}"`, + `"abc`: `holds a "\""`, + `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it + "{/sv_SE.person.last}": "{/sv_SE.person.last} is the path sv_SE.person.last written as a template; write sv_SE.person.last", + `"{/sv_SE.person}"`: "write sv_SE.person", + "{.person.last}": "write person.last", + "/sv_SE.person": "write sv_SE.person", + ` "{/sv_SE.person}"`: "write sv_SE.person", + `{"format":"{/sv_SE.person}"}`: "write sv_SE.person", + "//sv_SE.person": "write sv_SE.person", } { if _, err := IsTemplate(arg); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("IsTemplate(%q) = %v; want it rejected naming %s", arg, err, want) diff --git a/shipped_data_test.go b/shipped_data_test.go index 193a96b..7b37f3e 100644 --- a/shipped_data_test.go +++ b/shipped_data_test.go @@ -118,6 +118,13 @@ func TestFakeIsSafeForConcurrentUse(t *testing.T) { return } tmpl.Fake() + var u struct { + Last string `fake:"sv_SE.person.last"` + } + if err := f.FakeStruct(&u); err != nil { + t.Error(err) + return + } } }() } diff --git a/struct_test.go b/struct_test.go index eb348d2..a74d9cf 100644 --- a/struct_test.go +++ b/struct_test.go @@ -63,7 +63,8 @@ func TestFakeStructFillsTaggedFields(t *testing.T) { zips := map[string]string{"Stockholm": "111 22", "Tranås": "573 31"} actives, nils := 0, 0 for i := 0; i < 100; i++ { - u, twin := structUser{Note: "keep"}, structUser{Note: "keep"} + oldNick, oldTwinNick := "old", "old" + u, twin := structUser{Nick: &oldNick, Note: "keep"}, structUser{Nick: &oldTwinNick, Note: "keep"} if err := a.FakeStruct(&u); err != nil { t.Fatal(err) } @@ -79,7 +80,7 @@ func TestFakeStructFillsTaggedFields(t *testing.T) { t.Fatalf("places %+v, %+v, want each nested struct one place, the pointer allocated", u.Home, u.Work) case u.ID != int64(i+1) || u.Age < 18 || u.Age > 99 || u.Score < 0 || u.Score > 1 || u.Rank == nil || (*u.Rank != 1 && *u.Rank != 2): t.Fatalf("typed fields %+v, want each the value its tag draws", u) - case u.Nick != nil && *u.Nick != "bo", u.Note != "keep", u.hidden != (structPlace{}), u.Skip != nil: + case u.Nick != nil && *u.Nick != "bo", u.Note != "keep", u.hidden != (structPlace{}), u.Skip != nil, oldNick != "old": t.Fatalf("%+v: want Nick nil or bo, and the untagged fields left as they were", u) } if u.Active { @@ -173,16 +174,16 @@ func TestFakeStructErrors(t *testing.T) { }{}, `"Ada" is not an integer`}, {&struct { A int8 `fake:"{int(0,300)}"` - }{}, `"{int(0,300)}" can reach 300, past int8; make it int64`}, + }{}, `"{int(0,300)}" is not proven within int8; make it int64`}, {&struct { A uint `fake:"{int(-1,5)}"` - }{}, `"{int(-1,5)}" can reach -1, past uint; make it int64`}, + }{}, `"{int(-1,5)}" is not proven within uint; make it int64`}, {&struct { A float32 `fake:"[\"1\",\"1e39\"]"` - }{}, `"1e39" can reach 1e+39, past float32; make it float64`}, + }{}, `"1e39" is not proven within float32; make it float64`}, {&struct { A int32 `fake:"{seq()}"` - }{}, `"{seq()}" can reach 9.223372036854776e+18, past int32; make it int64`}, + }{}, `"{seq()}" is not proven within int32; make it int64`}, {&struct { A bool `fake:"{int(0,1)}"` }{}, "prints an integer, not a boolean"}, @@ -213,7 +214,7 @@ func TestFakeStructErrors(t *testing.T) { {&struct { A string `fake:"-"` }{}, `fake:"-" leaves a struct field unfilled`}, - {&struct{ *structGiven }{}, "an embedded pointer to an unexported type"}, + {&struct{ *structGiven }{}, "an unexported embedded pointer field cannot be set"}, {&struct { structGiven First string `fake:"person.last"` @@ -231,8 +232,28 @@ func TestFakeStructErrors(t *testing.T) { } }{}, ": struct.Trip.A (int): {digits(3)} prints text"}, } { - if err := f.FakeStruct(c.v); err == nil || !strings.Contains(err.Error(), c.want) { + err := f.FakeStruct(c.v) + if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("FakeStruct(%T) = %v, want an error containing %q", c.v, err, c.want) + continue + } + if again := f.FakeStruct(c.v); again == nil || again.Error() != err.Error() { + t.Errorf("FakeStruct(%T) again = %v, want the first call's error, %v", c.v, again, err) + } + } +} + +func TestFakeStructBoundsTheRecordsATypeReaches(t *testing.T) { + f := structData(t) + tree := reflect.TypeOf(structPlace{}) + for depth := 1; depth <= 10; depth++ { + tree = reflect.StructOf([]reflect.StructField{{Name: "L", Type: reflect.PointerTo(tree)}, {Name: "R", Type: reflect.PointerTo(tree)}}) + err := f.FakeStruct(reflect.New(tree).Interface()) + switch { + case depth < 10 && err != nil: + t.Fatalf("a tree %d deep, %d records: FakeStruct = %v, want it filled", depth, 1<<(depth+1)-1, err) + case depth == 10 && (err == nil || !strings.Contains(err.Error(), "more than 1024 records") || !strings.Contains(err.Error(), `fake:"-"`)): + t.Errorf("a tree 10 deep, 2047 records: FakeStruct = %v, want it refused naming the cap and fake:\"-\"", err) } } } -- 2.52.0 From e4ebfdb28d1e484a55ca9cb066c20026f85cb64d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 17:35:15 +0200 Subject: [PATCH 08/10] Cap a struct type's records at 1024, find a lone reference in the value a template holds, word range refusals as unproven, and scope the lone-reference Decision to arguments and tags --- README.md | 8 ++--- cmd/fejkdata/main.go | 2 +- inline.go | 19 +++++++---- struct.go | 76 +++++++++++++++++++++++--------------------- 4 files changed, 57 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index e611941..61108b1 100644 --- a/README.md +++ b/README.md @@ -538,10 +538,10 @@ tokens add cost in proportion to the output. whole — though only a leading one could collide — keeps one simple name rule instead of a leading-position special case. The JSON string is what makes the library's own advice reachable: the error for an object holding only a format - names `"…"`, and that spelling has to work where it is printed. One reference alone, - `{/users}`, is refused naming the path `users`: both render the same text, and only - the path names a record. `IsTemplate` exports the rule, so the CLI, struct tags and - any other caller read one. + names `"…"`, and that spelling has to work where it is printed. An argument or struct + tag of one reference alone, `{/users}`, is refused naming the path `users`: both + render the same text, and only the path names a record. `IsTemplate` exports the + rule, so the CLI, struct tags and any other caller read one. - **An inline template skips the cycle fence, and only that one.** `New` proves the loaded tree acyclic, an inline node is a finite tree of its own, and nothing in the tree can reference it, so no render of it reaches itself. Every other fence diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index e005fc5..bb95e71 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -418,7 +418,7 @@ const ( func classify(arg string) (argKind, error) { inline, err := fejkdata.IsTemplate(arg) if inline { - return argTemplate, err + return argTemplate, nil } return argPath, err } diff --git a/inline.go b/inline.go index 1e29542..ffd8789 100644 --- a/inline.go +++ b/inline.go @@ -72,8 +72,8 @@ func isTemplate(arg string) (bool, error) { if i := strings.IndexAny(arg, `[]}"`); i >= 0 { return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) } - if len(arg) > 1 && strings.HasPrefix(arg, "/") { - return false, fmt.Errorf("path %s starts with /, and every path starts at the root already; write %s", arg, arg[1:]) + if path := strings.TrimLeft(arg, "/"); path != arg && path != "" { + return false, fmt.Errorf("path %s starts with /, and every path starts at the root already; write %s", arg, path) } return false, nil } @@ -82,14 +82,19 @@ func isJSONStart(arg string) bool { return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) } -// loneReference is the path a template spells when it is one reference token and nothing else. +// loneReference is the path a template spells when the value it holds — a format string, a +// JSON string, or an object holding only a format — is one reference token and nothing else. func loneReference(arg string) (string, bool) { - format := arg - if strings.HasPrefix(arg, `"`) && json.Unmarshal([]byte(arg), &format) != nil { - return "", false + var raw any + if json.Unmarshal([]byte(arg), &raw) != nil { + raw = arg } + if m, isObject := raw.(map[string]any); isObject && len(m) == 1 { + raw = m["format"] + } + format, isString := raw.(string) var units []ftoken - if eachToken(format, func(t ftoken) error { units = append(units, t); return nil }) != nil || len(units) != 1 { + if !isString || eachToken(format, func(t ftoken) error { units = append(units, t); return nil }) != nil || len(units) != 1 { return "", false } body := units[0].body diff --git a/struct.go b/struct.go index 380223b..692bda1 100644 --- a/struct.go +++ b/struct.go @@ -31,12 +31,15 @@ func (f *Generator) FakeStruct(v any) error { return nil } -// structResult is what compiling a struct type settled: its shape, or why it cannot be filled. type structResult struct { shape *structShape err error } +// maxStructRecords caps the records one struct type fills, which pointers between struct +// types multiply along every path. +const maxStructRecords = 1 << 10 + // structShapeOf compiles a struct type once and remembers the answer. Callers hold the // generator's lock. func (f *Generator) structShapeOf(t reflect.Type) (*structShape, error) { @@ -47,7 +50,8 @@ func (f *Generator) structShapeOf(t reflect.Type) (*structShape, error) { if label == "" { label = "struct" } - shape, err := compileStruct(f.categories, t, label, map[reflect.Type]bool{}) + sc := &structCompile{root: f.categories, visiting: map[reflect.Type]bool{}, records: maxStructRecords} + shape, err := sc.record(t, label) if err == nil && shape.empty() { err = fmt.Errorf("%s has no fake tags, so nothing to fill", t) } @@ -75,29 +79,38 @@ type nestedStruct struct { func (s *structShape) empty() bool { return s.record == nil && len(s.nested) == 0 } -// structFields gathers what one struct type fills: its tagged fields, those its embedded -// structs promote included, as the tags of one record, and its named struct fields as nested -// records. visiting holds the types compiling or embedded above, so a pointer back to one is -// left alone rather than filled without end. -type structFields struct { +// structCompile is what compiling one struct type shares across the records it reaches: the +// loaded tree, the types compiling or embedded above, so a pointer back to one is left alone +// rather than filled without end, and how many more records it may build. +type structCompile struct { root map[string]node - t reflect.Type - label string visiting map[reflect.Type]bool - tags map[string]any - shape *structShape + records int } -// compileStruct compiles struct type t, naming its fields from label. -func compileStruct(root map[string]node, t reflect.Type, label string, visiting map[reflect.Type]bool) (*structShape, error) { - visiting[t] = true - defer delete(visiting, t) - c := &structFields{root: root, t: t, label: label, visiting: visiting, tags: map[string]any{}, shape: &structShape{}} +// structFields gathers what one struct type fills: its tagged fields, those its embedded +// structs promote included, as the tags of one record, and its named struct fields as nested +// records. +type structFields struct { + *structCompile + t reflect.Type + label string + tags map[string]any + shape *structShape +} + +func (sc *structCompile) record(t reflect.Type, label string) (*structShape, error) { + if sc.records--; sc.records < 0 { + return nil, fmt.Errorf(`%s: the struct fields reach more than %d records; leave a pointer unfilled with fake:"-"`, label, maxStructRecords) + } + sc.visiting[t] = true + defer delete(sc.visiting, t) + c := &structFields{structCompile: sc, t: t, label: label, tags: map[string]any{}, shape: &structShape{}} if err := c.walk(t, nil); err != nil { return nil, err } if len(c.tags) > 0 { - if err := c.shape.compileRecord(root, t, label, c.tags); err != nil { + if err := c.shape.compileRecord(sc.root, t, label, c.tags); err != nil { return nil, err } } @@ -170,7 +183,6 @@ func fieldPath(t reflect.Type, index []int) string { return strings.Join(names, ".") } -// embed gathers the fields an embedded struct promotes into c's record. func (c *structFields) embed(sf reflect.StructField, elem reflect.Type) error { c.visiting[elem] = true defer delete(c.visiting, elem) @@ -179,14 +191,13 @@ func (c *structFields) embed(sf reflect.StructField, elem reflect.Type) error { return err } if sf.Type.Kind() == reflect.Pointer && !sf.IsExported() && (len(c.tags) > tags || len(c.shape.nested) > nested) { - return fmt.Errorf("%s.%s: an embedded pointer to an unexported type cannot be allocated, so the tags beneath it cannot fill; embed %s by value", c.label, fieldPath(c.t, sf.Index), elem) + return fmt.Errorf("%s.%s: an unexported embedded pointer field cannot be set, so the tags beneath it cannot fill; embed %s by value", c.label, fieldPath(c.t, sf.Index), elem) } return nil } -// nest adds a named struct field, or a pointer to one, that carries tags as a record of its own. func (c *structFields) nest(sf reflect.StructField, elem reflect.Type) error { - nested, err := compileStruct(c.root, elem, c.label+"."+sf.Name, c.visiting) + nested, err := c.record(elem, c.label+"."+sf.Name) if err != nil || nested.empty() { return err } @@ -264,7 +275,7 @@ func (s *structShape) compileRecord(root map[string]node, t reflect.Type, label } // columnKind is what a field of one Go kind holds: the datatype its text proves as, the range a -// number of it stays in, and the kind to name when a value can pass that range. +// number of it stays in, and the kind to name when a value is not proven within that range. type columnKind struct { datatype DataType lo, hi float64 @@ -288,23 +299,16 @@ var columnKinds = map[reflect.Kind]columnKind{ reflect.Uint8: {DataTypeInteger, 0, math.MaxUint8, reflect.Int64}, } -// past is the bound of v a field of this kind cannot hold, if either is. An integer prints +// holds reports whether a field of this kind holds every value v proves. An integer prints // whole, so its bounds round inward first. -func (k columnKind) past(v proven) (float64, bool) { - lo, hi := v.lo, v.hi +func (k columnKind) holds(v proven) bool { switch k.datatype { case DataTypeString, DataTypeBoolean: - return 0, false + return true case DataTypeInteger: - lo, hi = math.Ceil(lo), math.Floor(hi) + return math.Ceil(v.lo) >= k.lo && math.Floor(v.hi) <= k.hi } - switch { - case lo < k.lo: - return lo, true - case hi > k.hi: - return hi, true - } - return 0, false + return v.lo >= k.lo && v.hi <= k.hi } // checkField rejects a column some render of which a field of Go type ft cannot hold: a null @@ -326,8 +330,8 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro if reason := v.not[kind.datatype]; reason != "" { return fmt.Errorf("%s (%s): %s", label, ft, reason) } - if bound, over := kind.past(v); over { - return fmt.Errorf("%s (%s): %q can reach %s, past %s; make it %s", label, ft, it.format, strconv.FormatFloat(bound, 'g', -1, 64), elem.Kind(), kind.wider) + if !kind.holds(v) { + return fmt.Errorf("%s (%s): %q is not proven within %s; make it %s", label, ft, it.format, elem.Kind(), kind.wider) } } return nil -- 2.52.0 From c212c6bef45b06bcaba1d9f1244c3e3696faf62b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 17:50:30 +0200 Subject: [PATCH 09/10] Tests: the struct cap sits exactly at 1024 and counts embedded structs, and path advice never names a spelling that fails --- inline_test.go | 3 +++ struct_test.go | 23 +++++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/inline_test.go b/inline_test.go index 4a1abaf..b3a7f9d 100644 --- a/inline_test.go +++ b/inline_test.go @@ -185,6 +185,9 @@ func TestIsTemplate(t *testing.T) { ` "{/sv_SE.person}"`: "write sv_SE.person", `{"format":"{/sv_SE.person}"}`: "write sv_SE.person", "//sv_SE.person": "write sv_SE.person", + "{//sv_SE.person}": "{//sv_SE.person} is the path sv_SE.person written as a template; write sv_SE.person", + "/sv_SE/person": `path "sv_SE/person" contains "/"`, + "{/-}": `path "-" is reserved`, } { if _, err := IsTemplate(arg); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("IsTemplate(%q) = %v; want it rejected naming %s", arg, err, want) diff --git a/struct_test.go b/struct_test.go index a74d9cf..7332a32 100644 --- a/struct_test.go +++ b/struct_test.go @@ -243,17 +243,20 @@ func TestFakeStructErrors(t *testing.T) { } } -func TestFakeStructBoundsTheRecordsATypeReaches(t *testing.T) { +func TestFakeStructBoundsTheStructsATypeReaches(t *testing.T) { f := structData(t) - tree := reflect.TypeOf(structPlace{}) - for depth := 1; depth <= 10; depth++ { + place := reflect.TypeOf(structPlace{}) + tree := place + for depth := 1; depth <= 8; depth++ { tree = reflect.StructOf([]reflect.StructField{{Name: "L", Type: reflect.PointerTo(tree)}, {Name: "R", Type: reflect.PointerTo(tree)}}) - err := f.FakeStruct(reflect.New(tree).Interface()) - switch { - case depth < 10 && err != nil: - t.Fatalf("a tree %d deep, %d records: FakeStruct = %v, want it filled", depth, 1<<(depth+1)-1, err) - case depth == 10 && (err == nil || !strings.Contains(err.Error(), "more than 1024 records") || !strings.Contains(err.Error(), `fake:"-"`)): - t.Errorf("a tree 10 deep, 2047 records: FakeStruct = %v, want it refused naming the cap and fake:\"-\"", err) - } + } + fields := []reflect.StructField{{Name: "L", Type: reflect.PointerTo(tree)}, {Name: "R", Type: reflect.PointerTo(tree)}, {Name: "P", Type: place}} + if err := f.FakeStruct(reflect.New(reflect.StructOf(fields)).Interface()); err != nil { + t.Fatalf("a type reaching 1024 structs: FakeStruct = %v, want it filled", err) + } + embedded := reflect.StructField{Name: "StructFamily", Type: reflect.TypeOf(StructFamily{}), Anonymous: true} + err := f.FakeStruct(reflect.New(reflect.StructOf(append(fields, embedded))).Interface()) + if err == nil || !strings.Contains(err.Error(), "more than 1024 structs") || !strings.Contains(err.Error(), `leave a struct field unfilled with fake:"-"`) { + t.Errorf("a type reaching 1025 structs, the last embedded: FakeStruct = %v, want it refused naming the cap and fake:\"-\"", err) } } -- 2.52.0 From 3cd3f8f3175bf3f281bc8db02eefad3fe7755a31 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 17:50:42 +0200 Subject: [PATCH 10/10] Spend the struct cap on embedded structs too, word it for any struct field, and route path advice through the name check --- README.md | 7 ++++--- inline.go | 15 +++++++++++++-- node.go | 10 ++++++++++ struct.go | 33 ++++++++++++++++++++------------- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 61108b1..ed89bcb 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,10 @@ naming a kind that holds it. The fields an embedded struct promotes are columns same record; a named struct field, or a pointer to one, fills from its own tags as a record of its own, so its references draw apart from its parent's. `fake:"-"` leaves a struct field, embedded or named, or a pointer to one, unfilled. Untagged fields keep -their values, and so does a pointer back to a struct already being filled. The first call for a type compiles its tags and reports -what they get wrong, with the same error on every later call; a `datatype` in a tag -names the Go type that already sets it. +their values, and so does a pointer back to a struct already being filled; a type +whose fields reach more than 1024 structs is refused, naming `fake:"-"` to cut it. The +first call for a type compiles its tags and reports what they get wrong, with the same +error on every later call; a `datatype` in a tag names the Go type that already sets it. A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only when drawn from one goroutine. Changing how a value is composed shifts the seeded diff --git a/inline.go b/inline.go index ffd8789..ca693dc 100644 --- a/inline.go +++ b/inline.go @@ -65,7 +65,7 @@ func IsTemplate(arg string) (bool, error) { func isTemplate(arg string) (bool, error) { if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) { if path, lone := loneReference(arg); lone { - return false, fmt.Errorf("%s is the path %s written as a template; write %s", arg, path, path) + return false, pathAdvice(path, fmt.Sprintf("%s is the path %s written as a template", arg, path)) } return true, nil } @@ -73,7 +73,7 @@ func isTemplate(arg string) (bool, error) { return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) } if path := strings.TrimLeft(arg, "/"); path != arg && path != "" { - return false, fmt.Errorf("path %s starts with /, and every path starts at the root already; write %s", arg, path) + return false, pathAdvice(path, fmt.Sprintf("path %s starts with /, and every path starts at the root already", arg)) } return false, nil } @@ -82,6 +82,14 @@ func isJSONStart(arg string) bool { return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) } +// pathAdvice refuses a spelling of path, naming path to write, or why no name can spell it. +func pathAdvice(path, refusal string) error { + if err := checkPathNames(path); err != nil { + return err + } + return fmt.Errorf("%s; write %s", refusal, path) +} + // loneReference is the path a template spells when the value it holds — a format string, a // JSON string, or an object holding only a format — is one reference token and nothing else. func loneReference(arg string) (string, bool) { @@ -101,6 +109,9 @@ func loneReference(arg string) (string, bool) { if units[0].kind != 'b' || !isRef(body) || strings.ContainsAny(body, "|(") { return "", false } + if strings.HasPrefix(body, "/") { + body = "/" + strings.TrimLeft(body, "/") + } _, path, err := refShape(body) return path, err == nil } diff --git a/node.go b/node.go index 2a44c01..56f84a5 100644 --- a/node.go +++ b/node.go @@ -396,6 +396,16 @@ func checkName(name string) error { return nil } +// checkPathNames rejects a dotted path with a segment no name may be. +func checkPathNames(path string) error { + for _, seg := range strings.Split(path, ".") { + if err := checkName(seg); err != nil { + return fmt.Errorf("path %w", err) + } + } + return nil +} + // isOption reports whether a template key configures the node instead of naming a // field. These names can never be fields. func isOption(name string) bool { diff --git a/struct.go b/struct.go index 692bda1..727ae75 100644 --- a/struct.go +++ b/struct.go @@ -36,9 +36,8 @@ type structResult struct { err error } -// maxStructRecords caps the records one struct type fills, which pointers between struct -// types multiply along every path. -const maxStructRecords = 1 << 10 +// maxStructs caps the structs compiling one type walks through its fields. +const maxStructs = 1 << 10 // structShapeOf compiles a struct type once and remembers the answer. Callers hold the // generator's lock. @@ -50,7 +49,7 @@ func (f *Generator) structShapeOf(t reflect.Type) (*structShape, error) { if label == "" { label = "struct" } - sc := &structCompile{root: f.categories, visiting: map[reflect.Type]bool{}, records: maxStructRecords} + sc := &structCompile{root: f.categories, visiting: map[reflect.Type]bool{}, structs: maxStructs} shape, err := sc.record(t, label) if err == nil && shape.empty() { err = fmt.Errorf("%s has no fake tags, so nothing to fill", t) @@ -79,13 +78,13 @@ type nestedStruct struct { func (s *structShape) empty() bool { return s.record == nil && len(s.nested) == 0 } -// structCompile is what compiling one struct type shares across the records it reaches: the +// structCompile is what compiling one struct type shares across the structs it reaches: the // loaded tree, the types compiling or embedded above, so a pointer back to one is left alone -// rather than filled without end, and how many more records it may build. +// rather than filled without end, and how many more structs it may walk. type structCompile struct { root map[string]node visiting map[reflect.Type]bool - records int + structs int } // structFields gathers what one struct type fills: its tagged fields, those its embedded @@ -100,8 +99,8 @@ type structFields struct { } func (sc *structCompile) record(t reflect.Type, label string) (*structShape, error) { - if sc.records--; sc.records < 0 { - return nil, fmt.Errorf(`%s: the struct fields reach more than %d records; leave a pointer unfilled with fake:"-"`, label, maxStructRecords) + if err := sc.spend(label); err != nil { + return nil, err } sc.visiting[t] = true defer delete(sc.visiting, t) @@ -117,6 +116,13 @@ func (sc *structCompile) record(t reflect.Type, label string) (*structShape, err return c.shape, nil } +func (sc *structCompile) spend(label string) error { + if sc.structs--; sc.structs >= 0 { + return nil + } + return fmt.Errorf(`%s: the struct fields reach more than %d structs; leave a struct field unfilled with fake:"-"`, label, maxStructs) +} + // walk gathers the fields of struct type t, which sits at index within c.t. func (c *structFields) walk(t reflect.Type, index []int) error { for i := 0; i < t.NumField(); i++ { @@ -184,6 +190,9 @@ func fieldPath(t reflect.Type, index []int) string { } func (c *structFields) embed(sf reflect.StructField, elem reflect.Type) error { + if err := c.spend(c.label + "." + fieldPath(c.t, sf.Index)); err != nil { + return err + } c.visiting[elem] = true defer delete(c.visiting, elem) tags, nested := len(c.tags), len(c.shape.nested) @@ -218,10 +227,8 @@ func tagValue(sf reflect.StructField, tag string) (any, error) { case inline: return inputValue(tag) } - for _, seg := range strings.Split(tag, ".") { - if err := checkName(seg); err != nil { - return nil, fmt.Errorf("path %w", err) - } + if err := checkPathNames(tag); err != nil { + return nil, err } return "{/" + tag + "}", nil } -- 2.52.0