From 1a15a139c8d29269233a765f1cef656c0f107063 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 09:39:18 +0200 Subject: [PATCH] Answer architecture round 1: refuse a name read inside the field it binds, check every scope's cycles, build a column read in one pass, drop the implicit struct pick for names across tags, and state the fresh-draw rule once --- AGENTS.md | 1 - CHANGELOG.md | 4 +-- README.md | 54 +++++++++++++++--------------- bench_test.go | 27 ++++++++------- data.go | 17 ++++------ data_test.go | 12 +++---- datatype.go | 2 +- docs/decisions.md | 14 +++----- graph.go | 12 +++++-- inline.go | 2 +- name.go | 26 +++++---------- name_test.go | 47 ++++++++++++++++++++++++-- namefence.go | 18 +++++++++- node.go | 2 +- perf_test.go | 3 +- pick.go | 30 ++++++++++++++++- draws_test.go => pick_test.go | 0 reference.go | 62 +++++++++++++++++------------------ draws.go => scope.go | 28 ---------------- struct.go | 54 ++++++------------------------ struct_test.go | 50 +++++++++++++++------------- table_test.go | 8 ++--- todo.md | 14 ++++++-- 23 files changed, 257 insertions(+), 230 deletions(-) rename draws_test.go => pick_test.go (100%) rename draws.go => scope.go (60%) diff --git a/AGENTS.md b/AGENTS.md index 53ad706..9b87031 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,6 @@ In [docs/decisions.md](docs/decisions.md): - `{a|b}` stays beside nested choices - Flags follow getopt_long - An argument is a template by its shape, not by a flag -- An inline template skips the cycle fence - An inline template that does not compile is misuse (exit 2), including a reference that resolves to nothing - A padded JSON argument is rejected, not trimmed - `FakeTemplate` and `NewTemplate` both stay diff --git a/CHANGELOG.md b/CHANGELOG.md index b95f4dd..f8ad6b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,8 @@ replacement, and each removed path, column or flag. - Every `{…}` draws afresh, and a name is the one way to keep a pick: `{p.a} {p.b}`, `{w} {uppercase(w)}` and `{/person.first} {/person.last}` are two draws each, which `{p as q}{q.a} {q.b}` and `{/person as n}{n.first} {n.last}` make one. `{x as n}` - binds a sibling field as well as a reference, and a struct's path tags into one - category read one pick of it. `drawGroup` is no longer an option, and `New` no longer + binds a sibling field as well as a reference, and a struct's tags share its names, + `{/sv_SE.person as p}{p.first}` beside `{p.sex}`. `drawGroup` is no longer an option, and `New` no longer refuses a read beside a path into what it reads, or reads of one table family that could disagree. - First release: the CLI, the library and the shipped data set. diff --git a/README.md b/README.md index ea2ed03..a56cd27 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,7 @@ it as nothing. A record is one render, so its columns read one pick of each [name](#names) its category binds: `"code": "{/currency as c}{c.code}"` beside `"symbol": "{c.symbol}"` is one -currency. Every other `{…}` draws afresh, so `{/currency.code}` and `{/currency.symbol}` -in two columns may name two currencies. +currency, while `{/currency.code}` and `{/currency.symbol}` in two columns may name two. A category never references itself: `{/users.first}` or a bare `{/users}` inside `users` describes a draw other than the fields beside it. Read a sibling as a field, and put a @@ -337,12 +336,12 @@ a column of one record, its tag a path or an inline template — told apart by [datatype](#datatype): a string, bool, integer or float kind, or a pointer to one, 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. Path tags reading into one category, two or more of them -and none selecting a row, read one pick of it, as a record's columns read one name: -`sv_SE.person.first` and `sv_SE.person.sex` describe one person, while an inline template -in a tag draws afresh. 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 it picks apart from its parent. `fake:"-"` leaves a +naming a kind that holds it. The tags are a record's columns, so they share its +[names](#names): `First` tagged `{/sv_SE.person as p}{p.first}` and `Sex` tagged `{p.sex}` +describe one person, while two path tags into `sv_SE.person` are two draws. 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 it picks apart from its +parent. `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; a type whose fields reach more than 1024 structs is refused, naming `fake:"-"` to cut it. The @@ -371,13 +370,16 @@ Every character is literal except a `{…}` token: | Token | Renders | |-------|---------| | `{name}` | the sibling field `name`, or the pick bound to the name ([Names](#names)) | -| `{x as name}` | nothing: binds one pick of `x`, a reference or a sibling field, to `name` ([Names](#names)) | +| `{x as name}` | nothing: binds one pick of `x`, a reference or a path into a sibling field, to `name` ([Names](#names)) | | `{name.field}` | `field` of a fresh draw of the sibling `name`, or of the pick bound to it ([Correlated fields](#correlated-fields)) | | `{a\|b}` | one of the named fields, even odds | | `{fn(args)}` | a builtin ([Functions](#functions)) | | `{/path}`, `{.path}`, `{..path}` | a node reached from the data root, this file's folder, or the folder above ([References](#references)) | | `{{`, `}}` | a literal `{` or `}` | +Each `{…}` makes a new pick, so `{w} {w}`, `{p.a} {p.b}` and `{/person.first} +{/person.last}` are two draws each. A [name](#names) keeps one pick for every read of it. + ```json "100 Main St, Apt {int(1,9)}{upper(1)} — tel {digits(3)}-{digits(4)}" ``` @@ -444,8 +446,8 @@ order.id: datatype integer: {digits(3)} prints text, not an integer order.id: datatype integer: "1{digits(2)}" is not one value; write one literal or one {float()}, {int()}, {seq()} or {calc()}, or read one ``` -A column of one reference alone to another record's column — `"score": "{/src.score}"` -— is that column: it takes the column's datatype and is null where the column is, and +A column of one reference or name read alone to another record's column — +`"score": "{/src.score}"`, or `"score": "{s.score}"` with `s` bound to `/src` — is that column: it takes the column's datatype and is null where the column is, and a struct field tagged `src.score` is nil there. A `datatype` of its own types the column's values where they prove it, and one restating the datatype it takes is refused. Any other read renders the column's text, a null as `""`. @@ -668,8 +670,7 @@ literals, sibling fields and [names](#names), each rendered then read as a numbe a second argument rounds to that many decimals. A hyphen is always subtraction, so a hyphenated field or name can't be an operand. -Each `{…}` draws afresh, so a calc computes from the values its format shows only where -both read one [name](#names): +A calc computes from the values its format shows where both read one [name](#names): ```json { "format": "{net as n}{qty as q}{n} x {q} = {calc(n * q, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } @@ -687,8 +688,8 @@ fail, except in a [typed column](#datatype), which must prove neither happens. `{lowercase(x)}`, `{uppercase(x)}` and `{ascii(x)}` rewrite the value of `x` — a field, a path, a `..path` or a [name](#names) — and nest. `ascii` folds Latin letters (`Åsa Öberg` -→ `Asa Oberg`) and drops any other non-ASCII rune. `x` draws afresh like any `{…}`, so an -email matches the name beside it where both read one name: +→ `Asa Oberg`) and drops any other non-ASCII rune. An email +matches the name beside it where both read one name: ```json { "format": "{person as p}{p.first} {p.last} <{lowercase(ascii(p.first))}.{lowercase(ascii(p.last))}@example.com>", @@ -714,9 +715,8 @@ without naming `sv_SE`: "Hej, {/en_US.person}!" ``` -Renders e.g. `Hej, Pat Smith!`. Each reference draws afresh, a path into a category -too: `{.person.first} {.person.last}` may name two people, and `{/misc.uuid} {/misc.uuid}` -is two draws. Bind the person to a [name](#names) to read one: `{.person as p}{p.first} +Renders e.g. `Hej, Pat Smith!`. `{.person.first} {.person.last}` may name two people, as +`{/misc.uuid} {/misc.uuid}` is two draws ([Format string](#format-string)). Bind the person to a [name](#names) to read one: `{.person as p}{p.first} {p.last}`. Rejected at `New`: a path that is unknown, names a folder, has no folder above, reads a field not every variant of a choice carries, or names the category the reference sits in, and a reference @@ -724,8 +724,8 @@ that leads back to its own value, directly, mutually or through a chain. ### Names -`{x as n}` binds one pick of `x`, a reference or a sibling field, to the name `n` and prints -nothing. `{n}` then +`{x as n}` binds one pick of `x`, a reference or a path into a sibling field, to the name `n` +and prints nothing. `{n}` then prints that pick, and `{n.path}` reads a path through it, so every read of `n` describes one row: @@ -754,6 +754,8 @@ Refused at `New`, each error naming what to write instead: and outside it; - a name that is a field or an option; - a binding of anything but a reference or a path into a field of the template binding it; +- a read of a name inside the field it binds, which would render itself: read it outside the + field; - a binding in a choice's item, which every other item would leave unbound; - a binding nothing reads, or one read once outside a `repeat` nested where it is bound, which the spelling it binds draws the same way: `{/word as w}{w}` is `{/word}`, and @@ -766,8 +768,8 @@ Refused at `New`, each error naming what to write instead: ### Correlated fields -`{name.field}` reads a path into a sibling, drawing it afresh as any `{…}` does. Several -tokens read one draw of it through a [name](#names): bind the sibling, and every read of +`{name.field}` reads a path into a sibling. Several tokens read one draw of it through a +[name](#names): bind the sibling, and every read of the name reads that one row — a locality and the postal code that really covers it: ```json @@ -780,8 +782,8 @@ the name reads that one row — a locality and the postal code that really cover ``` Renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm code beside -Tranås; each row's `weight` says how often it appears. A name keeps every level its -reads pass: `{p.geo.town.name} {p.geo.town.zip}` share the town. Every variant of a choice +Tranås; each row's `weight` says how often it appears. `{p.geo.town.name} +{p.geo.town.zip}` share the town too, as a name keeps every level its reads pass. Every variant of a choice on the path must carry the rest of it, so a row missing a field is named at load: ```text @@ -952,14 +954,14 @@ node.go the node model and JSON -> node compilation table.go tables: the rows TSV, its options and links, row selection and draws name.go names: {x as n} bindings, the scopes they live in, and the reads they answer namefence.go the load fences over names -pick.go a render's named picks: the frames of the name scopes rendering, and the reads kept under a name +pick.go a render's named picks: the frames of the name scopes rendering, the memo a pick keeps, and the reads kept under it +scope.go the scope a render reads names and rows through path.go the dotted-path walks with their selectors, and proving a path resolves render.go Fake, the recursive renderer (choices, format strings, expansions) and the reads its tokens make record.go records: Record, the JSON/CSV/SQL serializers, and their entry points struct.go structs: FakeStruct, fake tags, the one pick a struct's path tags into one category read, and a field's Go type as its column's datatype inline.go inline templates: Template, NewTemplate, FakeTemplate, IsTemplate, and their compile and link format.go the {token} grammar: scanning, tokens, operands, validation, compiling a format -draws.go the memo a named pick keeps its draws in, and the scope a render reads names and rows through pins.go the pin set: the table rows one path or one named pick fixes reference.go reference sigils, and resolving references across the tree graph.go the node graph: tree walks, containment and render edges, and the cycle and repeat fences diff --git a/bench_test.go b/bench_test.go index 9595833..8d94278 100644 --- a/bench_test.go +++ b/bench_test.go @@ -57,30 +57,29 @@ func BenchmarkRepeat(b *testing.B) { benchPath(b, dir, "many") } -// BenchmarkHeld measures a format that holds a head and reads two paths from it — -// what a correlated pair costs against BenchmarkUnheld, the same output drawn from -// two independent fields. -func BenchmarkHeld(b *testing.B) { - dir := tmpData(b, "addr", `{"format":"{place.postal-code} {place.locality}","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`) +// BenchmarkNamed measures a format that binds a field to a name and reads two paths through +// it — what a correlated pair costs against BenchmarkUnnamed, the same output drawn from two +// independent fields. +func BenchmarkNamed(b *testing.B) { + dir := tmpData(b, "addr", `{"format":"{place as p}{p.postal-code} {p.locality}","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`) benchPath(b, dir, "addr") } -func BenchmarkUnheld(b *testing.B) { +func BenchmarkUnnamed(b *testing.B) { dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}","postal-code":["1{digits(2)} {digits(2)}","573 {digits(2)}"],"locality":["Stockholm","Tranås"]}`) benchPath(b, dir, "addr") } -// BenchmarkHeldDeep reads two paths through an intermediate level, the shape the -// depth question is about. -func BenchmarkHeldDeep(b *testing.B) { - dir := tmpData(b, "addr", `{"format":"{p.addr.city} {p.addr.zip}","p":{"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":"1{digits(2)} {digits(2)}"}}}`) +// BenchmarkNamedDeep reads two paths through a name and an intermediate level. +func BenchmarkNamedDeep(b *testing.B) { + dir := tmpData(b, "addr", `{"format":"{p as q}{q.addr.city} {q.addr.zip}","p":{"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":"1{digits(2)} {digits(2)}"}}}`) benchPath(b, dir, "addr") } -// BenchmarkHeldWide reads ten paths from one row: the point where the maps an -// expansion holds outgrow a single bucket. -func BenchmarkHeldWide(b *testing.B) { - dir := tmpData(b, "row", `{"format":"{r.a}{r.b}{r.c}{r.d}{r.e}{r.f}{r.g}{r.h}{r.i}{r.j}","r":[ +// BenchmarkNamedWide reads ten paths through one name: the point where the maps a pick keeps +// outgrow a single bucket. +func BenchmarkNamedWide(b *testing.B) { + dir := tmpData(b, "row", `{"format":"{r as s}{s.a}{s.b}{s.c}{s.d}{s.e}{s.f}{s.g}{s.h}{s.i}{s.j}","r":[ {"format":"x","a":"1","b":"2","c":"3","d":"4","e":"5","f":"6","g":"7","h":"8","i":"9","j":"0"}, {"format":"y","a":"A","b":"B","c":"C","d":"D","e":"E","f":"F","g":"G","h":"H","i":"I","j":"J"}]}`) benchPath(b, dir, "row") diff --git a/data.go b/data.go index 18798d4..e583617 100644 --- a/data.go +++ b/data.go @@ -232,9 +232,8 @@ func inlineScope(n node, label string) nodeScope { // binding is one scope's way through bind, the one load pipeline; its fields are where // scopes differ. type binding struct { - scope nodeScope - link func() error - scopeFence func() error + scope nodeScope + link func() error // Set by a caller that proves the columns against their Go types itself. typedByGo bool } @@ -251,7 +250,6 @@ func categoryBinding(sites []categorySite, root map[string]node) binding { } return linkRefs(sites, root) }, - scopeFence: func() error { return checkNoCycles(sites) }, } } @@ -259,10 +257,8 @@ func (b binding) bind() error { if err := b.link(); err != nil { return err } - if b.scopeFence != nil { - if err := b.scopeFence(); err != nil { - return err - } + if err := checkNoCycles(b.scope); err != nil { + return err } if !b.typedByGo { if err := checkColumns(b.scope); err != nil { @@ -274,9 +270,8 @@ func (b binding) bind() error { // checkNodeFences runs the per-node fences every binding needs over a scope, each // over the whole scope before the next, so which of several broken nodes is reported -// does not depend on the walk. Its walks recurse unguarded, so the scope's cycles are -// refused first: categories by checkNoCycles, and an inline node closes none, since nothing -// references it. +// does not depend on the walk. Its walks recurse unguarded, so bind refuses the scope's cycles +// first. func checkNodeFences(s nodeScope) error { mem := renderCounts{} if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil { diff --git a/data_test.go b/data_test.go index df16b64..231cb92 100644 --- a/data_test.go +++ b/data_test.go @@ -330,8 +330,8 @@ func TestShippedSwedishAddress(t *testing.T) { } // TestShippedAddressIsOneNamedPick pins that an address's street, postal code, -// locality and region are one place in the geo tables, read through a name or a struct's -// tags, and that a reference beside the name is another address. +// locality and region are one place in the geo tables, read through a name in a template or +// across a struct's tags, and that a reference beside the name is another address. func TestShippedAddressIsOneNamedPick(t *testing.T) { f := newGenerator(t, "data", WithSeed(3)) for _, c := range []struct{ locale, country, template, largest string }{ @@ -354,16 +354,16 @@ func TestShippedAddressIsOneNamedPick(t *testing.T) { } if c.country == "SE" { var row struct { - Street string `fake:"sv_SE.address.street"` - PostalCode string `fake:"sv_SE.address.postal-code"` - Locality string `fake:"sv_SE.address.locality"` + Street string `fake:"{/sv_SE.address as a}{a.street}"` + PostalCode string `fake:"{a.postal-code}"` + Locality string `fake:"{a.locality}"` } for i := 0; i < 200; i++ { if err := f.FakeStruct(&row); err != nil { t.Fatal(err) } if got := row.Street + "|" + row.PostalCode + "|" + row.Locality + "|"; !places[got] { - t.Fatalf("sv_SE.address: struct tags %q are no street, postal code and locality of one place", got) + t.Fatalf("sv_SE.address: struct tags reading one name, %q, are no street, postal code and locality of one place", got) } } } diff --git a/datatype.go b/datatype.go index 9754938..79f4b45 100644 --- a/datatype.go +++ b/datatype.go @@ -77,7 +77,7 @@ func checkColumns(s nodeScope) error { items, _ := columnItems(column) for _, it := range items { if r := it.link.readsColumn; r != nil { - if err := check(r.a.head[1:], r.a.tail[0], r.column); err != nil { + if err := check(r.category, r.field, r.column); err != nil { return err } } diff --git a/docs/decisions.md b/docs/decisions.md index 898caff..1d223e4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -73,13 +73,6 @@ record. A folder-relative `{.name}` or `{..name}` is refused naming `{/name}`, s inline template sits in no folder. `IsTemplate` exports the rule, so the CLI, struct tags and any other caller read one. -## An inline template skips the cycle fence - -2026-09-03, Lilleman auf Larv. - -Each load proves what it loads 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. - ## An inline template that does not compile is misuse (exit 2), including a reference that resolves to nothing 2026-09-24, Lilleman auf Larv. @@ -305,9 +298,8 @@ not agree on one among themselves; each must only hold that type. 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 picks: path tags into one category, two or more of them and none selecting a row, read -one pick of it, as a category's record columns read one pick of each name (goal 4.1), while -an inline template in a tag draws afresh as any `{…}` does; a tagged field that another field hides is refused, not dropped. A named +its names: one tag may bind `{/person as p}{p.first}` and another read `{p.sex}`, while a +path tag draws afresh as `{/path}` does (goal 4.1); 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 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 @@ -474,6 +466,8 @@ table, `street`, `car` or `useragent`, carries no key and is only drawn. id by the postort's name, unique across Sweden as the key it replaces, give a new postort the next unused integer, and retire a removed or renamed one's id for good. +The code still takes a `name` option and selects by it; `todo.md` item 41 brings it to this decision. + ## A parent row with no child row is a load error 2026-09-17, Lilleman auf Larv. diff --git a/graph.go b/graph.go index fa52949..0a58860 100644 --- a/graph.go +++ b/graph.go @@ -166,14 +166,22 @@ func repeatCheck(path string, n node, mem renderCounts) error { // graph (renderEdges). Every node is a root: a field its parent's format never // renders is still reachable by dot path, so a cycle in one would otherwise reach // render and be fatal there. -func checkNoCycles(sites []categorySite) error { +func checkNoCycles(s nodeScope) error { const ( grey = 1 black = 2 ) + inScope := map[node]bool{} + _ = s(func(_ string, n node) error { + inScope[n] = true + return nil + }) color := map[node]int{} var visit func(n node, path string) error visit = func(n node, path string) error { + if !inScope[n] { + return nil // loaded before this scope and proven then, so it never reaches back into it + } switch color[n] { case grey: return fmt.Errorf("reference cycle: %s", path) @@ -189,5 +197,5 @@ func checkNoCycles(sites []categorySite) error { color[n] = black return nil } - return sitesScope(sites)(func(path string, n node) error { return visit(n, path) }) + return s(func(path string, n node) error { return visit(n, path) }) } diff --git a/inline.go b/inline.go index 2fb596f..fee06a0 100644 --- a/inline.go +++ b/inline.go @@ -194,7 +194,7 @@ func linkNodeRefs(scope nodeScope, root map[string]node) error { }); err != nil { return err } - for _, pass := range namePasses { + for _, pass := range linkPasses { if err := scope(func(path string, m node) error { if t, ok := m.(*template); ok { return pass(path, t) diff --git a/name.go b/name.go index cf82e57..2958304 100644 --- a/name.go +++ b/name.go @@ -20,7 +20,6 @@ type nameScope struct { type nameBinding struct { name string ref string // what it binds, as written: a reference, or a path into a field of binder - field bool // ref is a path into a field, so a read of the name stays in the category body string where string // the fields reaching the binding template, as a compile error spells them scope *nameScope @@ -43,6 +42,9 @@ type nameUse struct { operand, noRef, nested bool } +// bindsField reports whether b binds a path into a field, so a read of it stays in the category. +func (b *nameBinding) bindsField() bool { return !isRef(b.ref) } + func (sc *nameScope) lookup(name string) *nameBinding { for ; sc != nil; sc = sc.up { if b, ok := sc.bindings[name]; ok { @@ -111,7 +113,7 @@ func (sc *nameScope) bindAll(t *template, inChoice bool, where string) error { if b, twice := sc.bindings[tok.bound]; twice { return fmt.Errorf("%stoken {%s}: name %q is bound twice %s, by {%s} too; rename one", where, tok.body, tok.bound, sc.spelled(), b.body) } - b := &nameBinding{name: tok.bound, ref: tok.boundRef, field: !isRef(tok.boundRef), body: tok.body, where: where, scope: sc, index: len(sc.order), binder: t} + b := &nameBinding{name: tok.bound, ref: tok.boundRef, body: tok.body, where: where, scope: sc, index: len(sc.order), binder: t} if sc.bindings == nil { sc.bindings = map[string]*nameBinding{} } @@ -249,29 +251,19 @@ func namedReads(t *template, fn func(o *op, a *arm) error) error { return nil } -// namePasses run in order, each over every linked template before the next starts: a pass reads -// what the one before filled in. -var namePasses = []func(path string, t *template) error{linkNames, checkNameReads, checkCalcNames} +// linkPasses run in order once every template is linked, each over every template before the next +// starts: a pass reads what the one before filled in. +var linkPasses = []func(path string, t *template) error{linkNames, linkColumnRead, checkNameReads, checkCalcNames} // linkNames compiles t's reads of a name as paths from the head its binding's reference names, once // every template is linked, so each binder has resolved that reference. func linkNames(path string, t *template) error { - if err := namedReads(t, func(o *op, a *arm) error { + return namedReads(t, func(o *op, a *arm) error { if err := linkName(t, a); err != nil { return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err) } return nil - }); err != nil { - return err - } - if ops := t.compiled.ops; t.repeat == 1 && len(ops) == 1 && ops[0].kind == nameRead && len(ops[0].arms) == 1 && ops[0].arms[0].kind == namedRead { - a := ops[0].arms[0] - full := append(a.named.tail[:len(a.named.tail):len(a.named.tail)], a.tail...) - if column := recordColumn(a.named.head, full); column != nil { - t.link.readsColumn = &columnRead{a: a, column: column} - } - } - return nil + }) } func linkName(t *template, a *arm) error { diff --git a/name_test.go b/name_test.go index 0ddcd64..e0807fb 100644 --- a/name_test.go +++ b/name_test.go @@ -363,16 +363,57 @@ func TestANameIsATransformOperand(t *testing.T) { } } -func TestOneRenderReadsOneNameAcrossDrawGroups(t *testing.T) { +func TestOneRenderReadsOneNameAcrossNestedTemplates(t *testing.T) { dir := writeData(t, map[string]string{ "word": `["a","b","c","d","e","f","g","h"]`, "other": `{"format":"{x}","x":["1","2"]}`, - "cat": `{"format":"{/word as w}{a}","a":{"format":"{w}|{b}","b":{"format":"{w}{/other.x}","drawGroup":"g"}}}`, + "cat": `{"format":"{/word as w}{a}","a":{"format":"{w}|{b}","b":"{w}{/other.x}"}}`, }) f := newGenerator(t, dir, WithSeed(59)) for i := 0; i < 50; i++ { if got := fake(t, f, "cat.a"); got[0] != got[2] { - t.Fatalf("cat.a = %q, want one pick of w in both draw groups", got) + t.Fatalf("cat.a = %q, want one pick of w in both templates", got) } } } + +func TestAFieldBindingReadInsideItsFieldIsRefused(t *testing.T) { + f := engine(1) + for src, want := range map[string]string{ + `{"format":"{x as n}{n}{n}","x":"{n}"}`: `name "n" is read inside "x", the field it binds`, + `{"format":"{x as n}{n.a}{n.a}","x":{"format":"{a}","a":"{n}"}}`: `name "n" is read inside "x", the field it binds`, + `{"format":"{x as n}{y as m}{n}{n}{m}{m}","x":"{m}","y":"{n}"}`: "cycle", + } { + if _, err := f.NewTemplate(src); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("NewTemplate(%s) = %v, want it refused naming %s", src, err, want) + } + } +} + +func TestANameReadAloneIsTheRecordColumnItReads(t *testing.T) { + dir := writeData(t, map[string]string{ + "src": `{"format":"","score":[null,{"format":"{int(1,9)}","datatype":"integer"}],"code":["1","2"]}`, + "row": `{"format":"","a":"{/src.score as s}{s}","b":"{s}","c":"{/src as r}{r.score}","d":"{r.code}"}`, + }) + f := newGenerator(t, dir, WithSeed(3)) + nulls := 0 + for i := 0; i < 100; i++ { + r, err := f.FakeRecord("row") + if err != nil { + t.Fatal(err) + } + c := r.Columns() + if c[0].DataType != DataTypeInteger || c[2].DataType != DataTypeInteger || c[0].Value != c[1].Value || c[0].Null != c[1].Null { + t.Fatalf("%s: want a, b and c integer columns of src.score, a and b one pick", r.JSON()) + } + if c[0].Null { + nulls++ + } + } + if nulls == 0 || nulls == 100 { + t.Errorf("a was null %d times in 100 records, want both outcomes", nulls) + } + if _, err := f.NewRecordTemplate(`{"format":"","a":"{/src.score as s}{s}","b":"{s}"}`); err != nil { + t.Errorf("NewRecordTemplate = %v, want a name-read column accepted inline", err) + } +} diff --git a/namefence.go b/namefence.go index 6264eb5..443ca17 100644 --- a/namefence.go +++ b/namefence.go @@ -1,6 +1,7 @@ package fejkdata import ( + "errors" "fmt" "slices" "strings" @@ -18,6 +19,9 @@ func checkNameReads(path string, t *template) error { } } return namedReads(t, func(o *op, a *arm) error { + if a.named.bindsField() && rendersInside(a.named.head, t) { + return fmt.Errorf("%s: token {%s}: name %q is read inside %q, the field it binds, which would render itself; read the name outside that field", t.site.label(path), o.body, a.named.name, a.named.ref) + } for _, leaf := range a.leaves { if err := a.named.checkOnce(a.spelling, leaf, a.path); err != nil { return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err) @@ -27,11 +31,23 @@ func checkNameReads(path string, t *template) error { }) } +// rendersInside reports whether t sits in what n contains. +func rendersInside(n node, t *template) bool { + return eachNode(n, "", func(_ string, m node) error { + if m == t { + return errFound + } + return nil + }) != nil +} + +var errFound = errors.New("found") + // checkUses refuses a binding read once at a spot the bound spelling can stand: that spelling // draws the same way without the name. A field stands only beside its binding. func (b *nameBinding) checkUses() error { r := b.uses[0] - if len(b.uses) > 1 || r.nested || b.field && r.in != b.binder { + if len(b.uses) > 1 || r.nested || b.bindsField() && r.in != b.binder { return nil } spelling := b.ref diff --git a/node.go b/node.go index 2b9e39c..91635c7 100644 --- a/node.go +++ b/node.go @@ -92,7 +92,7 @@ func (s tableSite) label(path string) string { type templateLink struct { refs map[string]refBinding // each reference the format reads -> what it resolves to refHeads map[string]node // each refBinding.head -> the category it names - readsColumn *columnRead // set when the format is one reference alone reading a record's column + readsColumn *columnRead // set when the format is one reference or name read alone reading a record's column } func (*template) isNode() {} diff --git a/perf_test.go b/perf_test.go index 9a4e994..6306c6d 100644 --- a/perf_test.go +++ b/perf_test.go @@ -159,8 +159,7 @@ func TestNoStructAllocRegression(t *testing.T) { if err := f.FakeStruct(&v); err != nil { t.Fatal(err) } - // The three tags read one pick of x: its frame and memo cost two allocations. - const base = 7.0 + 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) } diff --git a/pick.go b/pick.go index cab51a4..92d8c44 100644 --- a/pick.go +++ b/pick.go @@ -9,6 +9,34 @@ type namedPick struct { pins pinSet } +// drawMemo is what a named pick has drawn: the variant each level was drawn as, so every path +// under it reads one variant; the value each read produced, by its path, so the same read written +// twice reads one value; the frames a read opens for the name scopes around where it lands +// (enter); and the rows drawn by each step down after a "..", by level. +type drawMemo struct { + variant map[string]node + value map[string]readValue + enteredFrames map[*nameScope]*pickFrame + steppedDown map[string]*pinSet +} + +// stepDownPins is the pins a path steps down into after stepping up to t: kept in m where there +// is one, so every path stepping down there reads one draw. +func (m *drawMemo) stepDownPins(pins *pinSet, t *table, levels []string, at int) *pinSet { + if m == nil { + return pins.above(t) + } + p, ok := m.steppedDown[levels[at]] + if !ok { + p = pins.above(t) + if m.steppedDown == nil { + m.steppedDown = map[string]*pinSet{} + } + m.steppedDown[levels[at]] = p + } + return p +} + // pickFrame is one render of a name scope: a pick per binding, each drawn on its first read. type pickFrame struct { scope *nameScope @@ -117,7 +145,7 @@ func readName(s *generatorState, sc renderScope, a arm) readValue { return r } leaf, pins := p.draw(s, a.named.head, a.steps, a.levels, a.path) - if a.named.field { + if a.named.bindsField() { return p.renderAt(s, leaf, pins, a.path, sc) } sc, mark := sc.enter(leaf, &p.memo) diff --git a/draws_test.go b/pick_test.go similarity index 100% rename from draws_test.go rename to pick_test.go diff --git a/reference.go b/reference.go index e37563c..11eccc7 100644 --- a/reference.go +++ b/reference.go @@ -76,7 +76,7 @@ func linkRefs(sites []categorySite, root map[string]node) error { }); err != nil { return err } - for _, pass := range namePasses { + for _, pass := range linkPasses { if err := eachTemplate(sites, func(_ categorySite, path string, t *template) error { return pass(path, t) }); err != nil { return err } @@ -98,9 +98,6 @@ func linkTemplate(folder []string, path, category string, t *template, root map[ } func compileArms(t *template) { - if r := t.link.readsColumn; r != nil { - compileArm(t, &r.a) - } for i := range t.compiled.ops { o := &t.compiled.ops[i] for j := range o.operands { @@ -154,46 +151,41 @@ func (t *template) resolveLink(folder []string, path, category string, root map[ link.refHeads[head] = target link.refs[name] = refBinding{head, tail} } - link.readsColumn = columnReadOf(t, link) return link, nil } // columnRead is a record's column read by a format of that one reference or name read alone, -// which is the column: it takes the column's datatype and null. +// which is the column: it takes the column's datatype and null. category and field name it. type columnRead struct { - a arm - column node + a arm + category, field string + column node } -func columnReadOf(t *template, link templateLink) *columnRead { - name, lone := loneRef(t.tokens) - if !lone || t.repeat != 1 { +// linkColumnRead sets the record's column t's format reads, where it is one reference or name +// read alone, once the names are linked. +func linkColumnRead(_ string, t *template) error { + ops := t.compiled.ops + if t.repeat != 1 || len(ops) != 1 || ops[0].kind != nameRead || len(ops[0].arms) != 1 { return nil } - a := splitArm(name, link.refs) - if column := recordColumn(link.refHeads[a.head], a.tail); column != nil { - return &columnRead{a: a, column: column} + a := ops[0].arms[0] + head, category, tail := t.head(a.head), a.head, a.tail + switch { + case a.kind == namedRead && !a.named.bindsField(): + ref := a.named.binder.link.refs[a.named.ref] + head, category = a.named.head, ref.head + tail = append(ref.tail[:len(ref.tail):len(ref.tail)], a.tail...) + case a.kind == namedRead || !isRef(a.head): + return nil + } + target, isTemplate := head.(*template) + if isTemplate && target.isRecord && len(tail) == 1 { + t.link.readsColumn = &columnRead{a: a, category: category[1:], field: tail[0], column: target.fields[tail[0]]} } return nil } -// recordColumn is the column tail names in head, nil where head is no record or tail no column of it. -func recordColumn(head node, tail []string) node { - target, isTemplate := head.(*template) - if !isTemplate || !target.isRecord || len(tail) != 1 { - return nil - } - return target.fields[tail[0]] -} - -// loneRef is the reference a format of one reference token and nothing else reads. -func loneRef(toks []formatToken) (string, bool) { - if len(toks) != 1 || toks[0].kind != nameRead || len(toks[0].names) != 1 || !isRef(toks[0].names[0]) { - return "", false - } - return toks[0].names[0], true -} - // eachTemplate calls fn once per template of the categories, with the category it sits // in and the dot path reaching it. func eachTemplate(sites []categorySite, fn func(s categorySite, path string, t *template) error) error { @@ -259,3 +251,11 @@ func refTokens(toks []formatToken) []string { } return refs } + +// loneRef is the reference a format of one reference token and nothing else reads. +func loneRef(toks []formatToken) (string, bool) { + if len(toks) != 1 || toks[0].kind != nameRead || len(toks[0].names) != 1 || !isRef(toks[0].names[0]) { + return "", false + } + return toks[0].names[0], true +} diff --git a/draws.go b/scope.go similarity index 60% rename from draws.go rename to scope.go index 614dac8..fe47556 100644 --- a/draws.go +++ b/scope.go @@ -1,33 +1,5 @@ package fejkdata -// drawMemo is what a named pick has drawn: the variant each level was drawn as, so every path -// under it reads one variant; the value each read produced, by its path, so the same read written -// twice reads one value; the frames a read opens for the name scopes around where it lands -// (enter); and the rows drawn by each step down after a "..", by level. -type drawMemo struct { - variant map[string]node - value map[string]readValue - enteredFrames map[*nameScope]*pickFrame - steppedDown map[string]*pinSet -} - -// stepDownPins is the pins a path steps down into after stepping up to t: kept in m where there -// is one, so every path stepping down there reads one draw. -func (m *drawMemo) stepDownPins(pins *pinSet, t *table, levels []string, at int) *pinSet { - if m == nil { - return pins.above(t) - } - p, ok := m.steppedDown[levels[at]] - if !ok { - p = pins.above(t) - if m.steppedDown == nil { - m.steppedDown = map[string]*pinSet{} - } - m.steppedDown[levels[at]] = p - } - return p -} - // readValue is what one read drew: its text, and whether it landed on a null. type readValue struct { text string diff --git a/struct.go b/struct.go index 81e1016..5af4d22 100644 --- a/struct.go +++ b/struct.go @@ -95,7 +95,6 @@ type structFields struct { typ reflect.Type label string tags map[string]any - paths map[string]string // each field tagged with a path, and the path shape *structShape } @@ -105,12 +104,11 @@ func (sc *structCompile) compileShape(t reflect.Type, label string) (*structShap } sc.visiting[t] = true defer delete(sc.visiting, t) - c := &structFields{structCompile: sc, typ: t, label: label, tags: map[string]any{}, paths: map[string]string{}, shape: &structShape{}} + c := &structFields{structCompile: sc, typ: t, label: label, tags: map[string]any{}, shape: &structShape{}} if err := c.gatherFields(t, nil); err != nil { return nil, err } if len(c.tags) > 0 { - bindShared(sc.root, c.tags, c.paths) if err := c.shape.compileRecord(sc.root, t, label, c.tags); err != nil { return nil, err } @@ -174,14 +172,11 @@ func (c *structFields) addTag(sf reflect.StructField, tag string) error { if visible, ok := c.typ.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.typ, sf.Index), sf.Name) } - v, path, err := tagValue(sf, tag) + v, err := tagValue(sf, tag) if err != nil { return fmt.Errorf("%s.%s: %w", c.label, sf.Name, err) } c.tags[sf.Name] = v - if path != "" { - c.paths[sf.Name] = path - } return nil } @@ -220,52 +215,22 @@ func (c *structFields) nest(sf reflect.StructField, elem reflect.Type) error { } // tagValue reads a field's fake tag as the value its column compiles from: an inline template -// as written, or a path as the reference {/path}, returning the path too. -func tagValue(sf reflect.StructField, tag string) (any, string, error) { +// 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 + return nil, err } inline, err := isTemplate(tag) switch { case err != nil: - return nil, "", err + return nil, err case inline: - v, err := inputValue(tag) - return v, "", err + return inputValue(tag) } if err := checkPathNames(tag); err != nil { - return nil, "", err + return nil, err } - return "{/" + tag + "}", tag, nil -} - -// bindShared has the fields whose paths read into one category, two or more of them and none -// selecting a row, read one pick of it through a name, as a record's columns read one pick. -func bindShared(root *folder, tags map[string]any, paths map[string]string) { - byCategory := map[string][]string{} - for _, field := range sortedNames(paths) { - segs, err := splitPath(paths[field]) - if err != nil || hasSelector(segs) { - continue - } - if _, i := folderAt(root, segs); i < len(segs) { - category := strings.Join(segs[:i+1], ".") - byCategory[category] = append(byCategory[category], field) - } - } - var format strings.Builder - for _, category := range sortedNames(byCategory) { - fields := byCategory[category] - if len(fields) < 2 { - continue - } - name := strings.ReplaceAll(category, ".", "-") - fmt.Fprintf(&format, "{/%s as %s}", category, name) - for _, field := range fields { - tags[field] = "{" + name + strings.TrimPrefix(paths[field], category) + "}" - } - } - tags["format"] = format.String() + return "{/" + tag + "}", nil } // checkTaggedType rejects a tagged field no column can fill. @@ -288,6 +253,7 @@ func checkTaggedType(sf reflect.StructField) error { // compileRecord compiles the tagged fields of t as one record, and proves each column holds // only what its field's Go type can. func (s *structShape) compileRecord(root *folder, t reflect.Type, label string, tags map[string]any) error { + tags["format"] = "" n, err := compile(tags) if err != nil { return fmt.Errorf("%s: %w", label, err) diff --git a/struct_test.go b/struct_test.go index 18bba3e..bec3cb3 100644 --- a/struct_test.go +++ b/struct_test.go @@ -8,18 +8,18 @@ import ( ) type structPlace struct { - City string `fake:"place.city"` - Zip string `fake:"place.zip"` + City string `fake:"{/place as p}{p.city}"` + Zip string `fake:"{p.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"` + Email string `fake:"{lowercase(p.first)}@example.com"` + First string `fake:"{/person as p}{p.first}"` Home structPlace ID int64 `fake:"{seq()}"` - Last string `fake:"person.last"` + Last string `fake:"{p.last}"` Level uint8 `fake:"{float(0,255,0)}"` Nick *string `fake:"[null,\"bo\"]"` Note string @@ -31,7 +31,7 @@ type structUser struct { } type structGiven struct { - First string `fake:"person.first"` + First string `fake:"{/person as p}{p.first}"` } type StructFamily struct { @@ -41,7 +41,7 @@ type StructFamily struct { type structEmployee struct { structGiven *StructFamily - Email string `fake:"{lowercase(/person.first)}@example.com"` + Email string `fake:"{lowercase(p.first)}@example.com"` } type structLink struct { @@ -118,25 +118,28 @@ func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) { } } -func digitOrNil(s string) bool { - return s == "nil" || len(s) == 1 && s >= "1" && s <= "9" -} - -func TestFakeStructPathTagsReadOnePickOfTheirCategory(t *testing.T) { +func TestFakeStructPathTagsDrawAfreshAndANameKeepsOnePick(t *testing.T) { f := structData(t) var v struct { - Leg string `fake:"trip.leg"` - To string `fake:"trip.leg.to"` - Name string `fake:"person"` - Last string `fake:"person.last"` + A string `fake:"trip.leg"` + B string `fake:"trip.leg"` + Leg string `fake:"{/trip as r}{r.leg}"` + To string `fake:"{r.leg.to}"` + Name string `fake:"{/person as p}{p}"` + Last string `fake:"{p.last}"` } + apart := false for i := 0; i < 100; i++ { if err := f.FakeStruct(&v); err != nil { t.Fatal(err) } if v.Leg != v.To || v.Name != "Ada "+v.Last && v.Name != "Bo "+v.Last { - t.Fatalf("%+v, want the tags into trip one leg, and into person one person", v) + t.Fatalf("%+v, want the reads of r one leg, and of p one person", v) } + apart = apart || v.A != v.B + } + if !apart { + t.Error("two tags of trip.leg drew one leg in 100 fills, want each path tag a draw of its own") } } @@ -157,8 +160,8 @@ func TestFakeStructFillsTaggedFields(t *testing.T) { switch { case !reflect.DeepEqual(u, twin): t.Fatalf("same seed diverged: %+v != %+v", u, twin) - case people[u.First] != u.Last || u.Email != "ada@example.com" && u.Email != "bo@example.com": - t.Fatalf("person fields %q %q %q, want one person across the path tags, and the template its own draw", u.First, u.Last, u.Email) + case people[u.First] != u.Last || u.Email != strings.ToLower(u.First)+"@example.com": + t.Fatalf("person fields %q %q %q, want one person read through p 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): @@ -180,14 +183,13 @@ 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 { - t.Fatalf("%+v, %+v: want the promoted fields one person, the embedded pointer allocated", e, e.StructFamily) + if e.StructFamily == nil || e.Last != "Lovelace" && e.Last != "Ek" || e.Email != strings.ToLower(e.First)+"@example.com" { + t.Fatalf("%+v, %+v: want a promoted field's name read by the struct's own, the embedded pointer allocated", e, e.StructFamily) } } var skipped struct { @@ -351,3 +353,7 @@ func TestFakeStructBoundsTheStructsATypeReaches(t *testing.T) { t.Errorf("a type reaching 1025 structs, the last embedded: FakeStruct = %v, want it refused naming the cap and fake:\"-\"", err) } } + +func digitOrNil(s string) bool { + return s == "nil" || len(s) == 1 && s >= "1" && s <= "9" +} diff --git a/table_test.go b/table_test.go index 5e8c8e5..09b6c8d 100644 --- a/table_test.go +++ b/table_test.go @@ -591,7 +591,7 @@ func TestTableFences(t *testing.T) { "a whole table's format selecting another row": map[string]string{"x.json": `{"format":"{v} {/y[1].w}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "y.json": `{"format":"{w}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tw\n1\tm\n2\tn\n", "r.json": `"{/x} {/y[2].w}"`}, "a table reaching its family through a template": with(geo(), map[string]string{"addr.json": `"{/locality.name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "a table reaching its family through a repeat": with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name} ","repeat":3}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), - "a selected row reaching its family's other row": with(geo(), map[string]string{"a.json": `"{/region[01].note}"`, "addr.json": `{"format":"{/region[12].name}","drawGroup":"g"}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), + "a selected row reaching its family's other row": with(geo(), map[string]string{"a.json": `"{/region[01].note}"`, "addr.json": `"{/region[12].name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "a cell repeating a reference": with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n{/region} {/region} {uppercase(/region)}\n"}), } for name, files := range accepted { @@ -899,9 +899,9 @@ func TestShippedTables(t *testing.T) { } } var row struct { - Idempotent bool `fake:"misc.httpmethod.idempotent"` - Method string `fake:"misc.httpmethod.method"` - Safe bool `fake:"misc.httpmethod.safe"` + Idempotent bool `fake:"{/misc.httpmethod as m}{m.idempotent}"` + Method string `fake:"{m.method}"` + Safe bool `fake:"{m.safe}"` } if err := newGenerator(t, "data", WithSeed(1)).FakeStruct(&row); err != nil { t.Fatalf("FakeStruct into a bool = %v, want the register's yes and no shipped as true and false", err) diff --git a/todo.md b/todo.md index e07b7dc..d5290a0 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 94` +`Next ID = 96` | Goal | W | |---|---| @@ -82,7 +82,7 @@ | 81 | 0.2.0 | decision | **Refuse at load every `calc` operand not proven numeric and every divisor not proven nonzero, in a string column too.** | 4 | 4 | 6 | 8 | 6.1, 4 | 20.6 | | 36 | 0.2.0 | | **Report every mistake a load finds in one error, as the decision "A load reports every mistake at once" states.** | 5 | 6 | 7 | 8 | 6 | 19.5 | | 88 | 0.2.0 | | **Add a 12-digit `YYYYMMDDNNNC` form of `sv_SE.personnummer` and `sv_SE.samordningsnummer`, the form Skatteverket's testpersonnummer series and many systems store.** | 2 | 3 | 6 | 6 | 4 | 19.4 | -| 41 | 0.2.0 | | **Key every table by one key column, as the decision "A table selects by one key column, a code, never a free-form name" states.** | 6 | 7 | 7 | 8 | 5.5 | 19.1 | +| 41 | 0.2.0 | decision | **Key every table by one key column, as the decision "A table selects by one key column, a code, never a free-form name" states.** | 6 | 7 | 7 | 8 | 5.5 | 19.1 | | 52 | 0.2.0 | | **Name the file a link error comes from beside its category path, `sub.x (/d/a/sub/x.json): …`.** | 1 | 2 | 5 | 7 | 6 | 19.0 | | 28 | 0.2.0 | decision | **Accept a middle name, and draw a `personnummer` whose sex matches a sex selected through a named pick.** | 4 | 4 | 6 | 7 | 4.1 | 18.9 | | 24 | 0.2.0 | defect | **Stop `misc.territory[EH].tld` rendering `.eh`, the one shipped TLD `misc.tld` does not hold.** | 1 | 1 | 4 | 7 | 4 | 18.6 | @@ -104,6 +104,7 @@ | 27 | 0.2.0 | | **Merge `en_US.ip` and `sv_SE.ip`, today byte-identical, into one `misc.ip`.** | 3 | 2 | 4 | 6 | 5.7 | 14.1 | | 22 | 0.2.0 | | **Give `url` and `email` a path that draws only domains nobody can register, keeping the wide set as the default.** | 3 | 4 | 7 | 8 | 16 | 14.0 | | 46 | 0.2.0 | | **Report the same error every load for a table with two bad options, and for a folder with two unnamed rows files.** | 2 | 2 | 3 | 7 | 6, 10 | 14.0 | +| 94 | 0.2.0 | | **Refuse a name whose every read passes one deeper level, naming the binding of that level: `{place as p}{p.geo.town.name} {p.geo.town.zip}` is `{place.geo.town as t}{t.name} {t.zip}`.** | 3 | 3 | 3 | 5 | 5.7 | 10.2 | | 14 | 0.3.0 | | **Spell `misc.creditcard`'s digit runs `{digits(n)}`.** | 2 | 2 | 4 | 6 | 5.7 | 15.1 | | 38 | 0.3.0 | defect | **Compare `calcParser.binary`'s operator as a rune.** | 1 | 1 | 3 | 6 | 6 | 13.8 | | 45 | 0.3.0 | | **Name the node a selector follows in `stepInto`'s refusal.** | 1 | 1 | 3 | 6 | 6 | 13.8 | @@ -127,6 +128,7 @@ | 37 | 0.3.0 | | **Rename `newRand` to `newGeneratorState` and `Generator.rand` to `state`.** | 1 | 1 | 1 | 5 | 3.3 | 9.8 | | 43 | 0.3.0 | | **Call `templateError` a compile failure in its doc.** | 1 | 1 | 1 | 5 | 3.3 | 9.8 | | 82 | 0.3.0 | principle | **Pass `newRand` its entropy source, so no test swaps the package's `randomBytes`.** | 1 | 1 | 1 | 5 | 3.2 | 9.8 | +| 95 | 0.3.0 | principle | **Hold a struct's columns and the field each fills in one slice.** | 1 | 2 | 1 | 5 | 3.2 | 9.5 | | 19 | 0.3.0 | | **Fill the 398 Swedish localities weighted 200 from SCB småorter.** | 2 | 4 | 4 | 6 | 14.1 | 9.2 | | 56 | 0.3.0 | defect | **Build on a manual run of `test.yml`.** | 1 | 1 | 1 | 4 | 1 | 8.8 | | 83 | 0.3.0 | principle | **Read a name binding's head and tail from its binder's link, and delete `linkBindings`.** | 4 | 3 | 1 | 6 | 3.2 | 8.1 | @@ -356,6 +358,10 @@ Require the path step to reach a sibling category. `readTableOptions` and `loadDir` return on the first in Go's map order. +### 94. Refuse a name whose every read passes one deeper level, naming the binding of that level: `{place as p}{p.geo.town.name} {p.geo.town.zip}` is `{place.geo.town as t}{t.name} {t.zip}`. + +Both spellings load and draw alike, against goal 5.7. Widen the read-once refusal in `namefence.go` to the longest prefix every read of a name shares. + ### 14. Spell `misc.creditcard`'s digit runs `{digits(n)}`. A `"repeat": 14` over `{digits(1)}` draws every 14-digit run with the odds `{digits(14)}` does. Both load, since neither has a part that changes nothing; the shipped data writes the obvious one. Re-pin seeded output in its own commit. @@ -432,6 +438,10 @@ Its doc says render failure, and it wraps what `NewTemplate` and `NewRecordTempl `fejkdata_test.go` swaps the global `randomBytes` to fail seeding, so every `New` in the package reads the failing source while that test runs. Technical principle: functional core, imperative shell. +### 95. Hold a struct's columns and the field each fills in one slice. + +`structShape.columns` and `structShape.fieldIndexes` pair up by index alone, against the technical principle "one owner per value". + ### 56. Build on a manual run of `test.yml`. `workflow_dispatch` leaves `github.event.before` empty, so the diff compares `HEAD` with itself and skips `docker build`.