Refuse a name read across draw groups or in a repeat where it reads a reference path, or read once; name the spelling in each refusal

This commit is contained in:
2026-10-03 12:08:40 +02:00
committed by Lillem4n
parent 05cf531339
commit 740f2bc3bd
6 changed files with 138 additions and 47 deletions
+4 -3
View File
@@ -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
+17 -9
View File
@@ -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
+6 -3
View File
@@ -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.
+6 -7
View File
@@ -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
}
+104 -24
View File
@@ -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 {
+1 -1
View File
@@ -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"},