diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3f359..56596d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,10 @@ replacement, and each removed path, column or flag. `repeat` binds picks again on every iteration. Refused at `New`: a name bound twice outside any `repeat`, twice in one `repeat`, or both inside a `repeat` and outside it; a name that is a field or an option; a binding of anything but a reference; a binding in a - choice's item; a binding nothing reads, or one read once whole, which the reference - spells; a path through a name that selects a row; a read of a name rendering a field - twice where a path through the name reads it; and a binding in a table. A field, column, + choice's item; a binding nothing reads, or one read once, which the reference spells; a + path through a name that selects a row; a read of a name rendering a field twice where a + path through the name reads it; reads of a name in two draw groups, or inside a + `repeat`, where what it names reads a reference path; and a binding in a table. A field, column, category or folder name may not contain ` as `. - First release: the CLI, the library and the shipped data set. - Table categories: a category JSON naming a `rows` TSV beside it, with the options diff --git a/README.md b/README.md index 10db0ff..049ba02 100644 --- a/README.md +++ b/README.md @@ -711,8 +711,8 @@ row: Renders e.g. `🇳🇴 Norway: capital Oslo, calling code +47`. A name keeps each level a `{n.path}` reads, and what it lands on, so `{n}` and `{n.path}` agree, and a [transform](#transforms) of `n.path` reads the same value. Everything else under the name draws -as it would anywhere: `{w}-{w}` in the category `n` names draws `w` twice, so `{n.w}` beside -`{n}` is refused there, since it could read either draw. +as it would anywhere. So if the category `n` names has the format `{w}-{w}`, it draws `w` +twice, and `{n.w}` beside `{n}` is refused, since it could read either draw. A name is a pick of its own: `{t.name}` and `{/misc.territory.name}` beside it are two draws. A reference path the picked category reads is still held for the render as usual, so two names @@ -724,13 +724,21 @@ category reads it, and a record's columns read one pick. Each render of the cate anew, and so does each bare reference to it. A name a `repeat` binds picks again on every iteration, and one bound outside the repeat keeps its pick on every line. -Rejected at load: a name bound twice outside any `repeat`, twice in one `repeat`, or both -inside a `repeat` and outside it; a name that is a field or an option; a binding of anything -but a reference; a binding in a choice's item, which every other item would leave unbound; a -binding nothing reads, or one read once whole outside a `repeat`, which the reference spells; -a path through a name that selects a row, which the reference selects instead; a read of a name -rendering a field twice where a path through the name reads it; and a binding in a table's -format or cell. A field, column, category or folder name may not contain ` as `. +Rejected at load, each error naming what to write instead: + +- a name bound twice outside any `repeat`, twice in one `repeat`, or both inside a `repeat` + and outside it; +- a name that is a field or an option; +- a binding of anything but a reference; +- a binding in a choice's item, which every other item would leave unbound; +- a binding nothing reads, or read once outside a `repeat`, which the reference spells: + `{/word as w}{w}` is `{/word}`, and `{/word as w}{w.x}` is `{/word.x}`; +- a path through a name that selects a row; +- a read of a name rendering a field twice, where a path through the name reads it; +- reads of a name in two draw groups, or inside a `repeat`, where what it names reads a + reference path, which each draw group and iteration draws apart; +- a binding in a table's format or cell; +- a field, column, category or folder name containing ` as `. ### Draw group diff --git a/docs/decisions.md b/docs/decisions.md index 286ea38..e9ff0d6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -893,7 +893,7 @@ a struct's fields as one. ## A name lives in the category binding it, or in the repeat binding it, and is drawn on its first read -2026-10-03, larv-review. Goals 5.3 and 5.6. +2026-10-03, larv-review in PR #145, for Lilleman auf Larv. Goals 5.3 and 5.6. `bindNames` scopes a name at compile, and a render keeps one `pickFrame` per scope. One field binds a name where another reads it, `"first": "{/person as p}{p.first}"` beside `"last": "{p.last}"`, so the scope cannot be the template holding the binding; the category @@ -913,5 +913,8 @@ nowhere else. A pick keeps only the levels a read of the name addresses, so a template draws the same way under a name as anywhere else (goal 5.2). A reference read under a pick draws in the draw group of the read of the name, the group the draw fences survey it in, so two names bound to one -category share what it reads by reference path. `todo.md` item 4 ends that: once every `{…}` -draws afresh, nothing is held for the render. +category share what it reads by reference path. Where what a name names reads a reference +path, its reads stay in one draw group and outside any repeat: a reference path is held per +draw group and per iteration, so what the pick kept and what it renders afresh would come from +two draws of it. `todo.md` item 4 ends both: once every `{…}` draws afresh, nothing is held for +the render. diff --git a/format.go b/format.go index 8bf18e6..c175ce0 100644 --- a/format.go +++ b/format.go @@ -215,13 +215,12 @@ func parseChecked(format string, fields map[string]node) ([]formatToken, []unbou } // unboundRead is a token reading a head no field holds, which only a name bound around the -// template can answer; err is the refusal where none does. whole is a read of the name itself, -// no path into it, and operand a builtin's read of it. +// template can answer; err is the refusal where none does. tail is the path read into the name, +// "" for the name itself, operand marks a builtin's read of it, and body is the token's. type unboundRead struct { - head string - whole bool - operand bool - err error + head, tail, body string + operand bool + err error } // checkTokens proves every parsed token names an existing field or a known function, @@ -280,7 +279,7 @@ func checkReads(t formatToken, fields map[string]node, operands bool) ([]unbound return nil, err } a := splitArm(name, nil) - unbound = append(unbound, unboundRead{head: a.head, whole: len(a.tail) == 0, operand: operands, err: err}) + unbound = append(unbound, unboundRead{head: a.head, tail: joinSegments(a.tail), body: t.body, operand: operands, err: err}) } return unbound, nil } diff --git a/name.go b/name.go index 03f7cc8..e07d119 100644 --- a/name.go +++ b/name.go @@ -35,10 +35,12 @@ type nameBinding struct { addressed map[string]string } -// nameUse is one read of a name: whole, with no path into it, an operand of a builtin, or in a -// repeat nested inside the name's scope. +// nameUse is one read of a name: the path it reads into the name, "" for the name itself; whether +// a builtin reads it as an operand; the draw group its template renders in; and whether it sits in +// a repeat nested inside the name's scope. type nameUse struct { - whole, operand, nested bool + tail, group string + operand, nested bool } func (sc *nameScope) lookup(name string) *nameBinding { @@ -82,11 +84,11 @@ func bindNames(root node) error { for _, sc := range scopes[1:] { for _, b := range sc.order { if outer := sc.up.lookup(b.name); outer != nil { - return fmt.Errorf("%stoken {%s}: name %q is bound outside this repeat already, by {%s}; rename one", b.where, b.body, b.name, outer.body) + return fmt.Errorf("%stoken {%s}: name %q is bound outside this repeat too, by {%s}; rename one", b.where, b.body, b.name, outer.body) } } } - if err := resolveReads(root, "", scopes); err != nil { + if err := resolveReads(root, "", "", scopes); err != nil { return err } for _, sc := range scopes { @@ -105,7 +107,7 @@ func (sc *nameScope) bindAll(t *template, inChoice bool, where string) error { continue } if inChoice { - return fmt.Errorf("%stoken {%s}: a choice's item binds no name, since every other item would leave it unbound; bind it outside the choice", where, tok.body) + return fmt.Errorf("%stoken {%s}: a choice's item binds no name, since every other item would leave it unbound; bind it outside the choice, or move the item into a category of its own and reference that", where, tok.body) } if b, twice := sc.bindings[tok.bound]; twice { return fmt.Errorf("%stoken {%s}: name %q is bound twice %s, by {%s} too; rename one", where, tok.body, tok.bound, sc.spelled(), b.body) @@ -122,7 +124,7 @@ func (sc *nameScope) bindAll(t *template, inChoice bool, where string) error { // spelled names the scope in an error. func (sc *nameScope) spelled() string { - if sc.up == nil { + if t, isTemplate := sc.owner.(*template); sc.up == nil && !(isTemplate && t.repeat > 1) { return "outside any repeat" } return "in one repeat" @@ -136,10 +138,13 @@ func (sc *nameScope) settle() error { if len(b.uses) == 0 { return fmt.Errorf("%stoken {%s}: nothing reads name %q; drop the token", b.where, b.body, b.name) } - if r := b.uses[0]; len(b.uses) == 1 && r.whole && !r.nested { - spelling := "{" + b.ref + "}" - if r.operand { - spelling = b.ref + if r := b.uses[0]; len(b.uses) == 1 && !r.nested { + spelling := b.ref + if r.tail != "" { + spelling += "." + r.tail + } + if !r.operand { + spelling = "{" + spelling + "}" } return fmt.Errorf("%stoken {%s}: name %q is read once, so it keeps nothing; write %s where it is read, and drop the token", b.where, b.body, b.name, spelling) } @@ -168,11 +173,14 @@ func eachContained(n node, where string, fn func(c node, where string) error) er // resolveReads answers each unbound read under n with a name its template sees, fields before the // format as compile reports them, and refuses a field spelling a name. scopes are every scope of // the category, which a refusal searches for a name bound where the read cannot see it. -func resolveReads(n node, where string, scopes []*nameScope) error { - if err := eachContained(n, where, func(c node, where string) error { return resolveReads(c, where, scopes) }); err != nil { +func resolveReads(n node, where, group string, scopes []*nameScope) error { + t, isTemplate := n.(*template) + if isTemplate && t.drawGroup != "" { + group = t.drawGroup + } + if err := eachContained(n, where, func(c node, where string) error { return resolveReads(c, where, group, scopes) }); err != nil { return err } - t, isTemplate := n.(*template) if !isTemplate { return nil } @@ -188,21 +196,32 @@ func resolveReads(n node, where string, scopes []*nameScope) error { for _, u := range t.unbound { b := t.nameScope.lookup(u.head) if b == nil { - return fmt.Errorf("%s%w%s", where, u.err, boundElsewhere(u.head, scopes)) + return unresolved(where, u, t.nameScope, scopes) } - b.uses = append(b.uses, nameUse{whole: u.whole, operand: u.operand, nested: t.nameScope != b.scope}) + b.uses = append(b.uses, nameUse{tail: u.tail, group: group, operand: u.operand, nested: t.nameScope != b.scope}) } return nil } -// boundElsewhere names the repeat binding name, for a read outside it. -func boundElsewhere(name string, scopes []*nameScope) string { - for _, sc := range scopes { - if b, ok := sc.bindings[name]; ok { - return fmt.Sprintf("; name %q is bound inside the repeat at %s, which a read outside the repeat cannot see", name, strings.TrimSuffix(b.where, ": ")) +// unresolved is the refusal of u, a read no field or name answers: naming the names the read sees, +// else the repeat binding the name where the read cannot see it. +func unresolved(where string, u unboundRead, seen *nameScope, scopes []*nameScope) error { + var visible []string + for sc := seen; sc != nil; sc = sc.up { + for _, b := range sc.order { + visible = append(visible, b.name) } } - return "" + if len(visible) > 0 { + sort.Strings(visible) + return fmt.Errorf("%stoken {%s}: no field or name %q; the names bound here are %v", where, u.body, u.head, visible) + } + for _, sc := range scopes { + if b, ok := sc.bindings[u.head]; ok { + return fmt.Errorf("%s%w; name %q is bound inside the repeat at %s, which a read outside the repeat cannot see; bind it outside the repeat to read one pick on every line", where, u.err, u.head, strings.TrimSuffix(b.where, ": ")) + } + } + return fmt.Errorf("%s%w", where, u.err) } // isName reports whether a head t reads is a name: neither a reference nor a field. @@ -253,7 +272,7 @@ func linkNames(path string, t *template) error { func linkName(t *template, a *arm) error { b := t.nameScope.lookup(a.head) if hasSelector(a.tail) { - return fmt.Errorf("a path through name %q selects no row; select it in the reference %q binds", a.head, a.head) + return fmt.Errorf("a path through name %q selects no row; read it directly, {%s.%s}, or bind the row to a name of its own", a.head, b.ref, joinSegments(a.tail)) } full := append(b.tail[:len(b.tail):len(b.tail)], a.tail...) if err := checkPathResolves(b.head, full, a.head); err != nil { @@ -281,6 +300,14 @@ func linkName(t *template, a *arm) error { // a path through the name reads one draw of it: the pick keeps one draw there, and the two // renders would disagree with it. func checkNameReads(path string, t *template) error { + for _, tok := range t.tokens { + if tok.kind != nameBind { + continue + } + if err := t.nameScope.bindings[tok.bound].checkUses(); err != nil { + return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), tok.body, err) + } + } return namedReads(t, func(o *op, a *arm) error { for _, leaf := range a.leaves { if err := a.named.checkOnce(a.spelling, leaf, a.path); err != nil { @@ -291,6 +318,59 @@ func checkNameReads(path string, t *template) error { }) } +// checkUses refuses reads of b in two draw groups, or inside a repeat, where what b names reads a +// reference path: that path is held per draw group and per iteration, so what the pick kept and +// what it renders afresh would come from two draws of it. todo.md item 4 ends the hold. +func (b *nameBinding) checkUses() error { + if !readsHeld(b.head, map[node]bool{}) { + return nil + } + for _, u := range b.uses { + if u.nested { + return fmt.Errorf("name %q is read inside a repeat, and what it names reads a reference path, which each iteration draws apart; bind the name inside the repeat", b.name) + } + if u.group != b.uses[0].group { + return fmt.Errorf("name %q is read in two draw groups, %s and %s, and what it names reads a reference path, which each draw group draws apart; read the name in one draw group", b.name, groupSpelling(b.uses[0].group), groupSpelling(u.group)) + } + } + return nil +} + +func groupSpelling(group string) string { + if group == "" { + return "the unnamed one" + } + return fmt.Sprintf("%q", group) +} + +// readsHeld reports whether rendering n, or any field under it, can read a reference path. +func readsHeld(n node, seen map[node]bool) bool { + if seen[n] { + return false + } + seen[n] = true + if t, isTemplate := n.(*template); isTemplate { + for _, o := range t.compiled.ops { + for _, a := range append(o.arms[:len(o.arms):len(o.arms)], o.operands...) { + if a.kind == refPathRead { + return true + } + } + } + } + for _, c := range contained(n) { + if readsHeld(c.node, seen) { + return true + } + } + for _, e := range renderEdges(n) { + if readsHeld(e.to, seen) { + return true + } + } + return false +} + // checkOnce walks n, rendering at key under b's pick, into each level a read of b addresses. func (b *nameBinding) checkOnce(read string, n node, key string) error { switch n := n.(type) { @@ -329,7 +409,7 @@ func (b *nameBinding) checkTemplateOnce(read string, t *template, key string) er sort.Strings(heads) for _, head := range heads { if by, kept := b.addressed[join(key, head)]; kept && fresh[head] > 1 { - return fmt.Errorf("{%s} renders field %q twice, so {%s} cannot say which draw it reads; drop one of them", read, head, by) + return fmt.Errorf("{%s} renders field %q twice, so {%s} cannot say which draw it reads; drop {%s} or {%s}", read, head, by, read, by) } } for _, a := range into { diff --git a/name_test.go b/name_test.go index 856c42a..a2c6089 100644 --- a/name_test.go +++ b/name_test.go @@ -212,7 +212,7 @@ func TestNameErrors(t *testing.T) { `no field "w"; name "w" is bound inside the repeat at field "x"`}, {"padded", map[string]string{"word": `["a","b"]`, "card": `"{/word as w}{w}"`}, `write {/word as w}`}, - {"selector after a name", map[string]string{"region.json": nameTables()["region.json"], "region.tsv": nameTables()["region.tsv"], "municipality.json": nameTables()["municipality.json"], "municipality.tsv": nameTables()["municipality.tsv"], "card": `"{/region as r}{r.municipality[0180].name}"`}, + {"selector after a name", map[string]string{"region.json": nameTables()["region.json"], "region.tsv": nameTables()["region.tsv"], "municipality.json": nameTables()["municipality.json"], "municipality.tsv": nameTables()["municipality.tsv"], "card": `"{/region as r}{r.name} {r.municipality[0180].name}"`}, `read it directly, {/region.municipality[0180].name}`}, {"in a table's format", map[string]string{"word": `["a","b"]`, "t.json": `{"format":"{/word as w}{w}","rows":"t.tsv","key":"k"}`, "t.tsv": "k\nx\ny\n"}, "a table binds no name"},