From d3f430bf53b9909a7922344efa558a9e99020a8c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 23:31:48 +0200 Subject: [PATCH] Render a repeat iteration in no group, refuse a group that names nothing, key group names by category, and own reference overlap in one fence --- README.md | 26 ++++-- draw.go | 222 ++++++++++++++++++++++++++++++++++----------------- graph.go | 6 +- hold.go | 30 +++---- inline.go | 4 + node.go | 10 ++- path.go | 2 +- reference.go | 1 + render.go | 8 +- 9 files changed, 203 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index bf21f7c..3147a1c 100644 --- a/README.md +++ b/README.md @@ -443,9 +443,10 @@ Renders e.g. `Hej, Pat Smith!`. A reference path into a category is held like a record — rather than one format: `{.person.femalefirst} {.person.last}` name one person, as do the same two references in sibling fields or a nested template, and `{lowercase(.person.femalefirst)}` reads that same draw. Each `repeat` iteration is -a render of its own, so it draws anew, and a [group](#group) holds a draw apart. A -bare reference names no field and draws each time: `{/misc.uuid} {/misc.uuid}` is -two draws. Rejected at `New`: a path that is +a render of its own, in no group, so it draws anew, and a [group](#group) holds a +draw apart. A bare reference names no field and makes its own picks each time — +`{/misc.uuid} {/misc.uuid}` is two draws — while the reference paths inside what it +renders still read the render's draws. Rejected at `New`: a path that is unknown, names a folder, has no folder above, or reads a field not every variant of a choice carries, and a reference that leads back to its own value, directly, mutually or through a chain. @@ -453,8 +454,10 @@ mutually or through a chain. ### Group A template may carry `group` to hold its reference draws apart: every reference path -it renders, however deep, reads the draw of that group, and the templates naming one -group in a render read one draw. A nested `group` names another. +it renders, however deep short of a `repeat`, reads the draw of that group, and the +templates of one category naming one group read one draw. A group name is local to +its category, so a category another one references never joins its groups by name; +the unnamed group spans them all. ```json { "format": "{payer} pays {payee}; signed {signature}", @@ -464,9 +467,11 @@ group in a render read one draw. A nested `group` names another. ``` Renders e.g. `Sara Eriksson pays Ebba Lind; signed Eriksson`: the signature reads the -payer's draw, while the payee is drawn apart. Rejected at load: a `group` of `""` (the -default), one on a template that renders no reference path, and a path reading into a -level that carries one. +payer's draw, while the payee is drawn apart. Rejected at load, each naming nothing: a +`group` of `""` (the default); one naming the group its template already draws in; one +on a template that renders no reference path short of a `repeat`, on a `repeat` itself — +each iteration renders in no group — or on an inline template's root, which nothing +references. So is a path reading into a level that carries a `group`. ### Correlated fields @@ -673,6 +678,11 @@ tokens add cost in proportion to the output. `person` rather than two copies of it. Only references share: a sibling field is local to its own expansion, so a `first` column does not silently bind to a `first` in the column next to it. +- **A group name is local to its category.** A category's groups are its own + entities, so a caller naming a group the same way never joins them by accident, + and renaming a group inside one file changes no render elsewhere. The unnamed + group still spans categories, since facts that belong together across categories + must agree. - **A record's column set is fixed before the first draw.** Only a category-level template is a record: a path descending into a field, or naming a folder or a choice, errors. A tail may pass through a choice whose variants carry different diff --git a/draw.go b/draw.go index 43f323d..39b2f73 100644 --- a/draw.go +++ b/draw.go @@ -31,10 +31,10 @@ func renderOnce(s *session, n node) string { return render(s, n, drawScope{set: &set}) } -// in is the scope t renders in: the group it names, else its caller's. +// in is the scope t renders in: its group where it names one, else its caller's. func (sc drawScope) in(t *template) drawScope { - if t.drawGroup != "" { - sc.group = t.drawGroup + if t.groupKey != "" { + sc.group = t.groupKey } return sc } @@ -55,8 +55,9 @@ func (sc drawScope) draws() *draws { return d } -// groupOf reads a template's "group" (default ""). -func groupOf(m map[string]any) (string, error) { +// groupOf reads a template's "group" (default ""), which a repeat cannot carry: each iteration +// renders in no group. +func groupOf(m map[string]any, repeat int) (string, error) { v, ok := m["group"] if !ok { return "", nil @@ -67,29 +68,73 @@ func groupOf(m map[string]any) (string, error) { return "", fmt.Errorf("group must be a string, got %T", v) case name == "": return "", fmt.Errorf(`group "" is the default, so it has no effect; drop it`) + case repeat > 1: + return "", fmt.Errorf("group %q on a repeat names nothing, since each iteration is a render of its own; drop it", name) } return name, nil } -// drawCheck fences each template of a scope as a render of its own, remembering which nodes -// read a reference path. +// keyGroup keys t's group by the category t sits in, "" for an inline template, so a group name is +// local to its category. +func (t *template) keyGroup(category string) { + if t.group != "" { + t.groupKey = category + "/" + t.group + } +} + +// checkNestedGroup refuses a template beneath one drawing in group that names group again, short of +// a repeat or another group. +func checkNestedGroup(fields map[string]node, group string) error { + if group == "" { + return nil + } + var walk func(path string, n node) error + walk = func(path string, n node) error { + t, isTemplate := n.(*template) + switch { + case isTemplate && t.group == group: + return fmt.Errorf("%q names group %q, the group this template draws in already; drop it", path, group) + case isTemplate && (t.group != "" || t.repeat > 1): + return nil + } + for _, c := range contained(n) { + if err := walk(join(path, c.name), c.node); err != nil { + return err + } + } + return nil + } + for _, name := range sortedNames(fields) { + if err := walk(name, fields[name]); err != nil { + return err + } + } + return nil +} + +// drawCheck fences each template of a scope as a render of its own, remembering which nodes read a +// reference path. type drawCheck struct { reads map[node]bool } -func (c *drawCheck) check(path string, n node) error { +// checkGroup refuses a group that splits nothing: one whose render reads no reference path short of +// a repeat. +func (c *drawCheck) checkGroup(path string, n node) error { + if t, ok := n.(*template); ok && t.group != "" && !c.readsPath(t) { + return fmt.Errorf("%s: group %q splits nothing, since nothing it renders reads a reference path; drop it", path, t.group) + } + return nil +} + +func (c *drawCheck) checkDraws(path string, n node) error { t, ok := n.(*template) - switch { - case !ok: - return nil - case !c.readsPath(t) && t.drawGroup != "": - return fmt.Errorf("%s: group %q splits nothing, since nothing it renders reads a reference path; drop it", path, t.drawGroup) - case !c.readsPath(t): + if !ok || !c.readsPath(t) { return nil } w := newDrawWalk(nil) for _, e := range renderEdges(t) { - w.edge(t, e, t.drawGroup, drawRoute{e.reached(), e.label}, "", false) + w.edge(t, e, drawAt{group: t.groupKey, route: drawRoute{e.reached(), e.label}}) } if err := w.check(); err != nil { return fmt.Errorf("%s: %w", path, err) @@ -97,14 +142,15 @@ func (c *drawCheck) check(path string, n node) error { return nil } -// readsPath reports whether rendering n reads a reference path, however deep. +// readsPath reports whether rendering n reads a reference path, short of a repeat, which renders over +// draws of its own. func (c *drawCheck) readsPath(n node) bool { if r, done := c.reads[n]; done { return r } r := false for _, e := range renderEdges(n) { - if a, _, isRef := refRead(n, e.label); (isRef && len(a.tail) > 0) || c.readsPath(e.to) { + if a, _, isRef := refRead(n, e.label); (isRef && len(a.tail) > 0) || (!repeats(e.to) && c.readsPath(e.to)) { r = true break } @@ -116,8 +162,13 @@ func (c *drawCheck) readsPath(n node) bool { return r } -// refRead is the reference an edge of n reads, and the node it is bound to; false when the -// edge reads none. +func repeats(n node) bool { + t, isTemplate := n.(*template) + return isTemplate && t.repeat > 1 +} + +// refRead is the reference an edge of n reads, and the node it is bound to; false when the edge +// reads none. func refRead(n node, label string) (arm, node, bool) { t, isTemplate := n.(*template) if !isTemplate { @@ -131,14 +182,13 @@ func refRead(n node, label string) (arm, node, bool) { func checkColumnDraws(t *template, columns []string) error { w := newDrawWalk(t) for _, name := range columns { - w.walk(t.fields[name], t.drawGroup, drawRoute{spelling: fmt.Sprintf("column %q", name)}, "", false) + w.walk(t.fields[name], drawAt{group: t.groupKey, route: drawRoute{spelling: fmt.Sprintf("column %q", name)}}) } return w.check() } -// drawWalk gathers what one render reads through its draws and what it draws afresh, each by -// group, for check to compare. record is set for a record's columns, which may not read the -// record back. +// drawWalk gathers what one render reads by reference and what it draws afresh, each by group, for +// check to compare. record is set for a record's columns, which may not read the record back. type drawWalk struct { record *template reads []pathRead @@ -148,21 +198,28 @@ type drawWalk struct { err error } +// drawAt is where a walk stands: the group it draws in, how the render's root reached it, the +// reference it last crossed, and whether it renders inside a reference path's draw. +type drawAt struct { + group string + route drawRoute + via string + held bool +} + // drawRoute is how a render reaches a draw: as its author spells it, and the root edge's label. type drawRoute struct{ spelling, label string } +// pathRead is one reference a render reads: a path, or a bare reference with no tail. type pathRead struct { - group string - route drawRoute + at drawAt a arm target node } type freshDraw struct { - group string - route drawRoute - via string - n node + at drawAt + n node } type drawVisit struct { @@ -180,88 +237,105 @@ func newDrawWalk(record *template) *drawWalk { return &drawWalk{record: record, read: map[drawKey]bool{}, seen: map[drawVisit]bool{}} } -// walk follows what rendering n renders. held says n renders inside a reference path's draw, so -// what it draws belongs to that draw; a repeat renders over draws of its own, so the walk stops there. -func (w *drawWalk) walk(n node, group string, route drawRoute, via string, held bool) { - v := drawVisit{n, group, held} +// walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops +// there. +func (w *drawWalk) walk(n node, at drawAt) { + v := drawVisit{n, at.group, at.held} if w.seen[v] || w.err != nil { return } w.seen[v] = true - if !held { - w.fresh = append(w.fresh, freshDraw{group, route, via, n}) + if !at.held { + w.fresh = append(w.fresh, freshDraw{at, n}) } - t, isTemplate := n.(*template) - if isTemplate && t.repeat > 1 { + if repeats(n) { return } - if isTemplate && t.drawGroup != "" { - group = t.drawGroup + if t, isTemplate := n.(*template); isTemplate && t.groupKey != "" { + at.group = t.groupKey } for _, e := range renderEdges(n) { - w.edge(n, e, group, route, via, held) + w.edge(n, e, at) } } -func (w *drawWalk) edge(from node, e renderEdge, group string, route drawRoute, via string, held bool) { +func (w *drawWalk) edge(from node, e renderEdge, at drawAt) { a, target, reads := refRead(from, e.label) switch { case !reads: - w.walk(e.to, group, route, via, held) + w.walk(e.to, at) + return case w.record != nil && target == node(w.record): - w.err = fmt.Errorf("%s reads {%s}, which points back at this record; a column cannot read another column — move the shared value into its own category and reference that", route.spelling, a.name) - case len(a.tail) > 0: - if k := (drawKey{group, a.path}); !w.read[k] { - w.read[k] = true - w.reads = append(w.reads, pathRead{group, route, a, target}) - } - w.walk(e.to, group, route, a.name, true) - default: - w.walk(e.to, group, route, a.name, false) + w.err = fmt.Errorf("%s reads {%s}, which points back at this record; a column cannot read another column — move the shared value into its own category and reference that", at.route.spelling, a.name) + return } + if k := (drawKey{at.group, a.path}); !w.read[k] { + w.read[k] = true + w.reads = append(w.reads, pathRead{at, a, target}) + } + at.via, at.held = a.name, len(a.tail) > 0 + w.walk(e.to, at) } -// check refuses what one draw per reference path cannot answer for: a path read into a level -// another read renders, and a node drawn afresh beside a path whose draw holds it. +// check refuses what one draw per reference path cannot answer for, reads compared in path order so +// which pair is reported does not vary. func (w *drawWalk) check() error { if w.err != nil { return w.err } sort.SliceStable(w.reads, func(i, j int) bool { - if w.reads[i].group != w.reads[j].group { - return w.reads[i].group < w.reads[j].group + if w.reads[i].at.group != w.reads[j].at.group { + return w.reads[i].at.group < w.reads[j].at.group } return w.reads[i].a.path < w.reads[j].a.path }) - pins := map[drawKey]pathRead{} + if err := w.checkOverlaps(); err != nil { + return err + } + return w.checkFreshDraws() +} + +// checkOverlaps refuses a read of a level beside a path another read takes into it. +func (w *drawWalk) checkOverlaps() error { for i, level := range w.reads { for _, into := range w.reads[i+1:] { - if into.group == level.group && strings.HasPrefix(into.a.path, level.a.path+".") { - return overlap(level.route, level.a.name, into) + if into.at.group == level.at.group && strings.HasPrefix(into.a.path, level.a.path+".") { + return overlapError(level.at.route, level.a.name, into) } } - held := map[node]bool{} - coverPath(level.target, level.a.tail, held) - for n := range held { - if _, pinned := pins[drawKey{level.group, n}]; !pinned { - pins[drawKey{level.group, n}] = level - } - } - } - for _, f := range w.fresh { - if r, pinned := pins[drawKey{f.group, f.n}]; pinned { - return overlap(f.route, f.via, r) - } } return nil } -func overlap(route drawRoute, ref string, into pathRead) error { - return fmt.Errorf("%s renders a level that %s reads a path into; name the fields you want instead, or draw them apart with a group", route.spelled(ref), into.route.spelled(into.a.name)) +// checkFreshDraws refuses a node drawn afresh where a reference path's draw holds it. +func (w *drawWalk) checkFreshDraws() error { + pins := map[drawKey]pathRead{} + for _, r := range w.reads { + if len(r.a.tail) == 0 { + continue + } + held := map[node]bool{} + coverPath(r.target, r.a.tail, held) + for n := range held { + if _, pinned := pins[drawKey{r.at.group, n}]; !pinned { + pins[drawKey{r.at.group, n}] = r + } + } + } + for _, f := range w.fresh { + if r, pinned := pins[drawKey{f.at.group, f.n}]; pinned { + return overlapError(f.at.route, f.at.via, r) + } + } + return nil } -// spelled names the route, and the reference it reaches a draw by where its root edge is not -// that reference. +func overlapError(route drawRoute, ref string, into pathRead) error { + return fmt.Errorf("%s renders a level that %s reads a path into; name the fields you want instead, or draw them apart with a group", route.spelled(ref), into.at.route.spelled(into.a.name)) +} + +// spelled names the route, and the reference it reaches a draw by where its root edge is not that +// reference. func (r drawRoute) spelled(ref string) string { if ref == "" || ref == r.label { return r.spelling diff --git a/graph.go b/graph.go index 06d525f..9e51a59 100644 --- a/graph.go +++ b/graph.go @@ -196,7 +196,11 @@ func checkRenders(s nodeScope) error { if err := s(heldCheck); err != nil { return err } - if err := s((&drawCheck{}).check); err != nil { + fence := &drawCheck{} + if err := s(fence.checkGroup); err != nil { + return err + } + if err := s(fence.checkDraws); err != nil { return err } return s((&valueProof{}).checkDatatype) diff --git a/hold.go b/hold.go index 82b8951..591e823 100644 --- a/hold.go +++ b/hold.go @@ -6,11 +6,12 @@ import ( "strings" ) -// heldCheck rejects every route to a held name except the ones that read its draw. -// An expansion holds one draw of that name; anything else that renders it draws -// again, and the two disagree. checkNoOverlap settles the spellings within one -// format (a token, an operand); this settles the rest — a reference, whether it -// sits in that format or in anything the format renders, however deep. +// heldCheck rejects every route to a held sibling name except the ones that read its +// draw. An expansion holds one draw of that name; anything else that renders it draws +// again, and the two disagree. checkNoOverlap settles the spellings within one format +// (a token, an operand); this settles the rest — a reference, whether it sits in that +// format or in anything the format renders, however deep. A reference path is held +// for the whole render instead, which drawCheck fences. func heldCheck(path string, n node) error { t, ok := n.(*template) if !ok || len(t.held) == 0 { @@ -19,7 +20,7 @@ func heldCheck(path string, n node) error { readers := boundReaders(t.format, t.bound, t.refs) for _, head := range heldHeads(t) { if _, isPath := t.bound[head]; isPath && isRef(head) { - continue // held for the whole render, which drawCheck fences + continue } if err := checkHeadHeld(t, head, readers); err != nil { return fmt.Errorf("%s: %w", path, err) @@ -178,12 +179,11 @@ func renders(n node, want, seen map[node]bool) bool { return false } -// checkNoOverlap rejects a format that both renders a level and reads a path into -// it — {p} beside {p.first}, {p.addr} beside {p.addr.city}, {.p} beside -// {/sv_SE.p.first}. The path reads the level's held draw while rendering the level -// expands it afresh, so their values would disagree. Reads are compared by their -// one spelling, in sorted order, so which pair is reported depends neither on how -// a reference was written nor on where the tokens sit. +// checkNoOverlap rejects a format that both renders a sibling level and reads a path +// into it — {p} beside {p.first}, {p.addr} beside {p.addr.city}. The path reads the +// level's held draw while rendering the level expands it afresh, so their values would +// disagree. Reads are compared in sorted order, so which pair is reported does not +// depend on where the tokens sit. func checkNoOverlap(format string, bound map[string]string, refs map[string]refBinding) error { names := boundReaders(format, bound, refs) // Stable over one format-order scan, so two readers of one name (a token and a @@ -203,7 +203,7 @@ func checkNoOverlap(format string, bound map[string]string, refs map[string]refB // spelling, and how to name it. type reader struct{ name, path, label string } -// boundReaders lists every way a format reaches a bound field, in the order the +// boundReaders lists every way a format reaches a bound sibling field, in the order the // format writes them. An operand renders its field, so it names a level exactly // as a token does; one scan finds both, which is what puts them in one order. func boundReaders(format string, bound map[string]string, refs map[string]refBinding) []reader { @@ -215,14 +215,14 @@ func boundReaders(format string, bound map[string]string, refs map[string]refBin if fn, _, isFunc := funcCall(t.body); isFunc { for _, operand := range tokenOperands(t.body) { a := splitArm(operand, refs) - if _, isBound := bound[a.key]; isBound { + if _, isBound := bound[a.key]; isBound && !isRef(a.key) { names = append(names, reader{a.name, a.path, fmt.Sprintf("%s operand %q", fn, operand)}) } } return nil } for _, a := range splitArms(t.body, refs) { - if _, isBound := bound[a.key]; isBound { + if _, isBound := bound[a.key]; isBound && !isRef(a.key) { names = append(names, reader{a.name, a.path, "token {" + a.name + "}"}) } } diff --git a/inline.go b/inline.go index 8da83e1..98207d7 100644 --- a/inline.go +++ b/inline.go @@ -136,6 +136,9 @@ func inputValue(input string) (any, error) { // bindInline links an inline node's references against root and runs check over it, naming its // nodes from label. func bindInline(n node, label string, root map[string]node, check func(nodeScope) error) error { + if t, isTemplate := n.(*template); isTemplate && t.group != "" { + return fmt.Errorf("%s: group %q names nothing, since nothing can reference an inline template; drop it", label, t.group) + } scope := inlineScope(n, label) if err := linkNodeRefs(scope, root); err != nil { return err @@ -151,6 +154,7 @@ func linkNodeRefs(scope nodeScope, root map[string]node) error { if !ok { return nil } + t.keyGroup("") for _, name := range refTokens(t.format) { sigil, rest, err := refShape(name) if err != nil { diff --git a/node.go b/node.go index 5374aa2..4d02360 100644 --- a/node.go +++ b/node.go @@ -62,7 +62,8 @@ type template struct { fromString bool // written as a JSON string rather than an object readsColumn *columnRead // set when the format is one reference alone reading a record's column record bool // compiled at the top without a repeat, so its fields are record columns - drawGroup string // the group its render reads reference paths in; "" keeps its caller's + group string // the group it draws in, as written; "" keeps its caller's + groupKey string // group keyed by its category once linked, what a render reads its reference paths under } func (*template) isNode() {} @@ -248,7 +249,10 @@ func compileTemplate(m map[string]any, pos position) (node, error) { if err := checkTokens(o.format, fields); err != nil { return nil, err } - t := &template{format: o.format, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, drawGroup: o.group, record: fieldPos == inColumn} + if err := checkNestedGroup(fields, o.group); err != nil { + return nil, err + } + t := &template{format: o.format, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, group: o.group, record: fieldPos == inColumn} if err := t.compileFormat(); err != nil { return nil, err } @@ -280,7 +284,7 @@ func readOptions(m map[string]any, pos position) (templateOptions, error) { if o.datatype, err = datatypeOf(m, pos); err != nil { return o, err } - if o.group, err = groupOf(m); err != nil { + if o.group, err = groupOf(m, repeat); err != nil { return o, err } if sv, ok := m["separator"]; ok { diff --git a/path.go b/path.go index f997eed..247560a 100644 --- a/path.go +++ b/path.go @@ -105,7 +105,7 @@ func checkPath(n node, tail []string, level string) error { switch { case t.repeat > 1: return fmt.Errorf("the level %q carries a repeat, which a path reading one draw of it cannot apply", name) - case t.drawGroup != "": + case t.group != "": return fmt.Errorf("the level %q carries a group, which a path reading into it cannot apply", name) } return nil diff --git a/reference.go b/reference.go index 46f8751..319e0f5 100644 --- a/reference.go +++ b/reference.go @@ -72,6 +72,7 @@ func refSegments(name string, folder []string) ([]string, error) { // error, never a random render-time one. func linkRefs(root map[string]node) error { return eachTemplate(root, func(folder []string, path string, t *template) error { + t.keyGroup(strings.Join(strings.Split(path, ".")[:len(folder)+1], ".")) return linkTemplateRefs(folder, path, t, root) }) } diff --git a/render.go b/render.go index 90a74fb..b29894c 100644 --- a/render.go +++ b/render.go @@ -72,7 +72,7 @@ func render(s *session, n node, sc drawScope) string { if i > 0 { b.WriteString(n.separator) } - b.WriteString(expandAnew(s, n, sc.group)) + b.WriteString(expandAnew(s, n)) } return b.String() default: @@ -80,13 +80,13 @@ func render(s *session, n node, sc drawScope) string { } } -// expandAnew expands one repeat iteration of t as a render of its own, in group. Inlined into +// expandAnew expands one repeat iteration of t as a render of its own, in no group. Inlined into // render's loop, its draw set would move to the heap. // //go:noinline -func expandAnew(s *session, t *template, group string) string { +func expandAnew(s *session, t *template) string { var set drawSet - return expand(s, t, drawScope{set: &set, group: group}) + return expand(s, t, drawScope{set: &set}) } // pick selects one item. Uniform choices are O(1); weighted choices are an