Compare commits

...

3 Commits

14 changed files with 276 additions and 154 deletions
+18 -8
View File
@@ -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 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 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 `{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 a render of its own, in no group, so it draws anew, and a [group](#group) holds a
bare reference names no field and draws each time: `{/misc.uuid} {/misc.uuid}` is draw apart. A bare reference names no field and makes its own picks each time —
two draws. Rejected at `New`: a path that is `{/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 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, of a choice carries, and a reference that leads back to its own value, directly,
mutually or through a chain. mutually or through a chain.
@@ -453,8 +454,10 @@ mutually or through a chain.
### Group ### Group
A template may carry `group` to hold its reference draws apart: every reference path 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 it renders, however deep short of a `repeat`, reads the draw of that group, and the
group in a render read one draw. A nested `group` names another. 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 ```json
{ "format": "{payer} pays {payee}; signed {signature}", { "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 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 payer's draw, while the payee is drawn apart. Rejected at load, each naming nothing: a
default), one on a template that renders no reference path, and a path reading into a `group` of `""` (the default); one naming the group its template already draws in; one
level that carries 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 ### 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 `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 local to its own expansion, so a `first` column does not silently bind to a
`first` in the column next to it. `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 - **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 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 choice, errors. A tail may pass through a choice whose variants carry different
+12 -12
View File
@@ -28,10 +28,10 @@ func (s dataSource) name(p string) string {
} }
// loadData loads every source into one namespace tree and returns its root // loadData loads every source into one namespace tree and returns its root
// children. A directory becomes a group; each *.json file in it compiles to a node // children. A directory becomes a folder; each *.json file in it compiles to a node
// keyed by its base name (address.json -> "address"); each subdirectory becomes a // keyed by its base name (address.json -> "address"); each subdirectory becomes a
// nested group, so folders turn into dot-path segments. Sources merge left to right: // nested folder, so folders turn into dot-path segments. Sources merge left to right:
// matching groups merge by their children, and any other clash is won by the last // matching folders merge by their children, and any other clash is won by the last
// source loaded. Once merged, linkRefs binds every reference against the // source loaded. Once merged, linkRefs binds every reference against the
// final tree. // final tree.
func loadData(sources []dataSource) (map[string]node, error) { func loadData(sources []dataSource) (map[string]node, error) {
@@ -74,15 +74,15 @@ func loadData(sources []dataSource) (map[string]node, error) {
return root, nil return root, nil
} }
// loadDir compiles one directory into a group. fs.ReadDir yields entries sorted // loadDir compiles one directory into a folder. fs.ReadDir yields entries sorted
// by name, so the tree is built deterministically. Empty subdirectories (no JSON // by name, so the tree is built deterministically. Empty subdirectories (no JSON
// anywhere under them) are skipped rather than added as empty namespaces. // anywhere under them) are skipped rather than added as empty namespaces.
func loadDir(src dataSource, dir string) (*group, error) { func loadDir(src dataSource, dir string) (*folder, error) {
entries, err := fs.ReadDir(src.fsys, dir) entries, err := fs.ReadDir(src.fsys, dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", src.name(dir), err) return nil, fmt.Errorf("%s: %w", src.name(dir), err)
} }
g := &group{children: map[string]node{}} g := &folder{children: map[string]node{}}
for _, e := range entries { for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") { // hidden: a checkout or an editor's file, never data if strings.HasPrefix(e.Name(), ".") { // hidden: a checkout or an editor's file, never data
continue continue
@@ -99,8 +99,8 @@ func loadDir(src dataSource, dir string) (*group, error) {
return g, nil return g, nil
} }
// loadFolder adds a subdirectory as a nested group, unless nothing under it is data. // loadFolder adds a subdirectory as a nested folder, unless nothing under it is data.
func loadFolder(src dataSource, g *group, full, name string) error { func loadFolder(src dataSource, g *folder, full, name string) error {
child, err := loadDir(src, full) child, err := loadDir(src, full)
if err != nil { if err != nil {
return err return err
@@ -117,7 +117,7 @@ func loadFolder(src dataSource, g *group, full, name string) error {
// loadFile compiles a *.json file into a category named after it; any other file // loadFile compiles a *.json file into a category named after it; any other file
// is skipped. // is skipped.
func loadFile(src dataSource, g *group, full, file string) error { func loadFile(src dataSource, g *folder, full, file string) error {
if !strings.HasSuffix(file, ".json") { if !strings.HasSuffix(file, ".json") {
return nil return nil
} }
@@ -141,13 +141,13 @@ func loadFile(src dataSource, g *group, full, file string) error {
return nil return nil
} }
// mergeChildren overlays src onto dst. Two groups under the same key merge // mergeChildren overlays src onto dst. Two folders under the same key merge
// recursively (so locales/categories from several paths combine); every other // recursively (so locales/categories from several paths combine); every other
// key is replaced, making the last-loaded directory win on a conflict. // key is replaced, making the last-loaded directory win on a conflict.
func mergeChildren(dst, src map[string]node) { func mergeChildren(dst, src map[string]node) {
for k, v := range src { for k, v := range src {
if dg, ok := dst[k].(*group); ok { if dg, ok := dst[k].(*folder); ok {
if sg, ok := v.(*group); ok { if sg, ok := v.(*folder); ok {
mergeChildren(dg.children, sg.children) mergeChildren(dg.children, sg.children)
continue continue
} }
+146 -72
View File
@@ -31,10 +31,10 @@ func renderOnce(s *session, n node) string {
return render(s, n, drawScope{set: &set}) 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 { func (sc drawScope) in(t *template) drawScope {
if t.drawGroup != "" { if t.groupKey != "" {
sc.group = t.drawGroup sc.group = t.groupKey
} }
return sc return sc
} }
@@ -55,8 +55,9 @@ func (sc drawScope) draws() *draws {
return d return d
} }
// groupOf reads a template's "group" (default ""). // groupOf reads a template's "group" (default ""), which a repeat cannot carry: each iteration
func groupOf(m map[string]any) (string, error) { // renders in no group.
func groupOf(m map[string]any, repeat int) (string, error) {
v, ok := m["group"] v, ok := m["group"]
if !ok { if !ok {
return "", nil 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) return "", fmt.Errorf("group must be a string, got %T", v)
case name == "": case name == "":
return "", fmt.Errorf(`group "" is the default, so it has no effect; drop it`) 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 return name, nil
} }
// drawCheck fences each template of a scope as a render of its own, remembering which nodes // keyGroup keys t's group by the category t sits in, "" for an inline template, so a group name is
// read a reference path. // 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 { type drawCheck struct {
reads map[node]bool 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
t, ok := n.(*template) // a repeat.
switch { func (c *drawCheck) checkGroup(path string, n node) error {
case !ok: 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 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): func (c *drawCheck) checkDraws(path string, n node) error {
t, ok := n.(*template)
if !ok || !c.readsPath(t) {
return nil return nil
} }
w := newDrawWalk(nil) w := newDrawWalk(nil)
for _, e := range renderEdges(t) { 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 { if err := w.check(); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
@@ -97,14 +142,15 @@ func (c *drawCheck) check(path string, n node) error {
return nil 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 { func (c *drawCheck) readsPath(n node) bool {
if r, done := c.reads[n]; done { if r, done := c.reads[n]; done {
return r return r
} }
r := false r := false
for _, e := range renderEdges(n) { 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 r = true
break break
} }
@@ -116,8 +162,13 @@ func (c *drawCheck) readsPath(n node) bool {
return r return r
} }
// refRead is the reference an edge of n reads, and the node it is bound to; false when the func repeats(n node) bool {
// edge reads none. 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) { func refRead(n node, label string) (arm, node, bool) {
t, isTemplate := n.(*template) t, isTemplate := n.(*template)
if !isTemplate { if !isTemplate {
@@ -131,14 +182,13 @@ func refRead(n node, label string) (arm, node, bool) {
func checkColumnDraws(t *template, columns []string) error { func checkColumnDraws(t *template, columns []string) error {
w := newDrawWalk(t) w := newDrawWalk(t)
for _, name := range columns { 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() return w.check()
} }
// drawWalk gathers what one render reads through its draws and what it draws afresh, each by // drawWalk gathers what one render reads by reference and what it draws afresh, each by group, for
// group, for check to compare. record is set for a record's columns, which may not read the // check to compare. record is set for a record's columns, which may not read the record back.
// record back.
type drawWalk struct { type drawWalk struct {
record *template record *template
reads []pathRead reads []pathRead
@@ -148,20 +198,27 @@ type drawWalk struct {
err error 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. // 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 } 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 { type pathRead struct {
group string at drawAt
route drawRoute
a arm a arm
target node target node
} }
type freshDraw struct { type freshDraw struct {
group string at drawAt
route drawRoute
via string
n node n node
} }
@@ -180,88 +237,105 @@ func newDrawWalk(record *template) *drawWalk {
return &drawWalk{record: record, read: map[drawKey]bool{}, seen: map[drawVisit]bool{}} 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 // walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops
// what it draws belongs to that draw; a repeat renders over draws of its own, so the walk stops there. // there.
func (w *drawWalk) walk(n node, group string, route drawRoute, via string, held bool) { func (w *drawWalk) walk(n node, at drawAt) {
v := drawVisit{n, group, held} v := drawVisit{n, at.group, at.held}
if w.seen[v] || w.err != nil { if w.seen[v] || w.err != nil {
return return
} }
w.seen[v] = true w.seen[v] = true
if !held { if !at.held {
w.fresh = append(w.fresh, freshDraw{group, route, via, n}) w.fresh = append(w.fresh, freshDraw{at, n})
} }
t, isTemplate := n.(*template) if repeats(n) {
if isTemplate && t.repeat > 1 {
return return
} }
if isTemplate && t.drawGroup != "" { if t, isTemplate := n.(*template); isTemplate && t.groupKey != "" {
group = t.drawGroup at.group = t.groupKey
} }
for _, e := range renderEdges(n) { 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) a, target, reads := refRead(from, e.label)
switch { switch {
case !reads: case !reads:
w.walk(e.to, group, route, via, held) w.walk(e.to, at)
return
case w.record != nil && target == node(w.record): 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) 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)
case len(a.tail) > 0: return
if k := (drawKey{group, a.path}); !w.read[k] { }
if k := (drawKey{at.group, a.path}); !w.read[k] {
w.read[k] = true w.read[k] = true
w.reads = append(w.reads, pathRead{group, route, a, target}) w.reads = append(w.reads, pathRead{at, a, target})
}
w.walk(e.to, group, route, a.name, true)
default:
w.walk(e.to, group, route, a.name, false)
} }
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 // check refuses what one draw per reference path cannot answer for, reads compared in path order so
// another read renders, and a node drawn afresh beside a path whose draw holds it. // which pair is reported does not vary.
func (w *drawWalk) check() error { func (w *drawWalk) check() error {
if w.err != nil { if w.err != nil {
return w.err return w.err
} }
sort.SliceStable(w.reads, func(i, j int) bool { sort.SliceStable(w.reads, func(i, j int) bool {
if w.reads[i].group != w.reads[j].group { if w.reads[i].at.group != w.reads[j].at.group {
return w.reads[i].group < w.reads[j].group return w.reads[i].at.group < w.reads[j].at.group
} }
return w.reads[i].a.path < w.reads[j].a.path 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 i, level := range w.reads {
for _, into := range w.reads[i+1:] { for _, into := range w.reads[i+1:] {
if into.group == level.group && strings.HasPrefix(into.a.path, level.a.path+".") { if into.at.group == level.at.group && strings.HasPrefix(into.a.path, level.a.path+".") {
return overlap(level.route, level.a.name, into) 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 return nil
} }
func overlap(route drawRoute, ref string, into pathRead) error { // checkFreshDraws refuses a node drawn afresh where a reference path's draw holds it.
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)) 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 func overlapError(route drawRoute, ref string, into pathRead) error {
// that reference. 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 { func (r drawRoute) spelled(ref string) string {
if ref == "" || ref == r.label { if ref == "" || ref == r.label {
return r.spelling return r.spelling
+2 -2
View File
@@ -142,10 +142,10 @@ func (f *Generator) List() []string {
} }
// paths lists the dot paths addressable from n, relative to it, where "" is n // paths lists the dot paths addressable from n, relative to it, where "" is n
// itself. A group has no value of its own, so it contributes only its children's. // itself. A folder has no value of its own, so it contributes only its children's.
func paths(n node) []string { func paths(n node) []string {
switch n := n.(type) { switch n := n.(type) {
case *group: case *folder:
var out []string var out []string
for _, name := range sortedNames(n.children) { for _, name := range sortedNames(n.children) {
for _, p := range paths(n.children[name]) { for _, p := range paths(n.children[name]) {
+8 -4
View File
@@ -51,7 +51,7 @@ type namedNode struct {
func contained(n node) []namedNode { func contained(n node) []namedNode {
switch n := n.(type) { switch n := n.(type) {
case *group: case *folder:
return named(n.children) return named(n.children)
case *choice: case *choice:
out := make([]namedNode, len(n.items)) out := make([]namedNode, len(n.items))
@@ -69,7 +69,7 @@ func contained(n node) []namedNode {
// named skips a bound {/path} key: it is a render edge, not containment, so using // named skips a bound {/path} key: it is a render edge, not containment, so using
// it as a path segment would report a node under a path that does not reach it. Only // it as a path segment would report a node under a path that does not reach it. Only
// a template's fields hold bindings — loadDir skips a dot-prefixed entry, so a // a template's fields hold bindings — loadDir skips a dot-prefixed entry, so a
// group's children never carry the prefix — so this one skip serves both. // folder's children never carry the prefix — so this one skip serves both.
func named(m map[string]node) []namedNode { func named(m map[string]node) []namedNode {
out := make([]namedNode, 0, len(m)) out := make([]namedNode, 0, len(m))
for _, name := range sortedNames(m) { for _, name := range sortedNames(m) {
@@ -111,7 +111,7 @@ func (e renderEdge) reached() string {
// renderEdges lists the children rendering n recurses into, mirroring expand: a // renderEdges lists the children rendering n recurses into, mirroring expand: a
// choice's items, and a template's field/reference tokens plus its operands. A // choice's items, and a template's field/reference tokens plus its operands. A
// group renders nothing, so it has no edges. // folder renders nothing, so it has no edges.
func renderEdges(n node) []renderEdge { func renderEdges(n node) []renderEdge {
switch n := n.(type) { switch n := n.(type) {
case *choice: case *choice:
@@ -196,7 +196,11 @@ func checkRenders(s nodeScope) error {
if err := s(heldCheck); err != nil { if err := s(heldCheck); err != nil {
return err 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 err
} }
return s((&valueProof{}).checkDatatype) return s((&valueProof{}).checkDatatype)
+15 -15
View File
@@ -6,11 +6,12 @@ import (
"strings" "strings"
) )
// heldCheck rejects every route to a held name except the ones that read its draw. // heldCheck rejects every route to a held sibling name except the ones that read its
// An expansion holds one draw of that name; anything else that renders it draws // 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 // again, and the two disagree. checkNoOverlap settles the spellings within one format
// format (a token, an operand); this settles the rest — a reference, whether it // (a token, an operand); this settles the rest — a reference, whether it sits in that
// sits in that format or in anything the format renders, however deep. // 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 { func heldCheck(path string, n node) error {
t, ok := n.(*template) t, ok := n.(*template)
if !ok || len(t.held) == 0 { 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) readers := boundReaders(t.format, t.bound, t.refs)
for _, head := range heldHeads(t) { for _, head := range heldHeads(t) {
if _, isPath := t.bound[head]; isPath && isRef(head) { 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 { if err := checkHeadHeld(t, head, readers); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
@@ -178,12 +179,11 @@ func renders(n node, want, seen map[node]bool) bool {
return false return false
} }
// checkNoOverlap rejects a format that both renders a level and reads a path into // checkNoOverlap rejects a format that both renders a sibling level and reads a path
// it — {p} beside {p.first}, {p.addr} beside {p.addr.city}, {.p} beside // into it — {p} beside {p.first}, {p.addr} beside {p.addr.city}. The path reads the
// {/sv_SE.p.first}. The path reads the level's held draw while rendering the level // level's held draw while rendering the level expands it afresh, so their values would
// expands it afresh, so their values would disagree. Reads are compared by their // disagree. Reads are compared in sorted order, so which pair is reported does not
// one spelling, in sorted order, so which pair is reported depends neither on how // depend on where the tokens sit.
// a reference was written nor on where the tokens sit.
func checkNoOverlap(format string, bound map[string]string, refs map[string]refBinding) error { func checkNoOverlap(format string, bound map[string]string, refs map[string]refBinding) error {
names := boundReaders(format, bound, refs) names := boundReaders(format, bound, refs)
// Stable over one format-order scan, so two readers of one name (a token and a // 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. // spelling, and how to name it.
type reader struct{ name, path, label string } 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 // 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. // 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 { 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 { if fn, _, isFunc := funcCall(t.body); isFunc {
for _, operand := range tokenOperands(t.body) { for _, operand := range tokenOperands(t.body) {
a := splitArm(operand, refs) 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)}) names = append(names, reader{a.name, a.path, fmt.Sprintf("%s operand %q", fn, operand)})
} }
} }
return nil return nil
} }
for _, a := range splitArms(t.body, refs) { 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 + "}"}) names = append(names, reader{a.name, a.path, "token {" + a.name + "}"})
} }
} }
+4
View File
@@ -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 // bindInline links an inline node's references against root and runs check over it, naming its
// nodes from label. // nodes from label.
func bindInline(n node, label string, root map[string]node, check func(nodeScope) error) error { 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) scope := inlineScope(n, label)
if err := linkNodeRefs(scope, root); err != nil { if err := linkNodeRefs(scope, root); err != nil {
return err return err
@@ -151,6 +154,7 @@ func linkNodeRefs(scope nodeScope, root map[string]node) error {
if !ok { if !ok {
return nil return nil
} }
t.keyGroup("")
for _, name := range refTokens(t.format) { for _, name := range refTokens(t.format) {
sigil, rest, err := refShape(name) sigil, rest, err := refShape(name)
if err != nil { if err != nil {
+2 -1
View File
@@ -108,8 +108,9 @@ func TestFakeTemplateErrors(t *testing.T) {
{`name: {/no.such.path}`, "no entry"}, {`name: {/no.such.path}`, "no entry"},
{`name: {..nope}`, "write {/nope}"}, {`name: {..nope}`, "write {/nope}"},
{`{"format":"x"}`, "is a string"}, {`{"format":"x"}`, "is a string"},
{`{/misc.country} {/misc.country.alpha2}`, "renders a level"}, {`{/misc.country} {/misc.country.alpha2}`, "renders a level that {/misc.country.alpha2} reads a path into; name the fields you want instead, or draw them apart with a group"},
{`{"format":"{/misc.country.alpha2} {x}","x":"{/misc.country}"}`, "reads a path into"}, {`{"format":"{/misc.country.alpha2} {x}","x":"{/misc.country}"}`, "reads a path into"},
{`{"format":"{/misc.country.alpha2}","group":"g"}`, "nothing can reference"},
} { } {
_, err := f.FakeTemplate(c.input) _, err := f.FakeTemplate(c.input)
if err == nil || !strings.Contains(err.Error(), c.want) { if err == nil || !strings.Contains(err.Error(), c.want) {
+10 -6
View File
@@ -13,12 +13,12 @@ import (
// re-sums weights. // re-sums weights.
type node interface{ isNode() } type node interface{ isNode() }
// group is a namespace of named children, built from a directory of JSON files // folder is a namespace of named children, built from a directory of JSON files
// and subdirectories. It has no value of its own: descend into a named child by // and subdirectories. It has no value of its own: descend into a named child by
// dot path; rendering one is an error (see Fake). // dot path; rendering one is an error (see Fake).
type group struct{ children map[string]node } type folder struct{ children map[string]node }
func (*group) isNode() {} func (*folder) isNode() {}
// choice picks one of its items. cum holds cumulative weights for a weighted // choice picks one of its items. cum holds cumulative weights for a weighted
// pick; when nil the choice is uniform and selection is O(1). shared is the set of // pick; when nil the choice is uniform and selection is O(1). shared is the set of
@@ -62,7 +62,8 @@ type template struct {
fromString bool // written as a JSON string rather than an object 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 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 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() {} 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 { if err := checkTokens(o.format, fields); err != nil {
return nil, err 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 { if err := t.compileFormat(); err != nil {
return nil, err 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 { if o.datatype, err = datatypeOf(m, pos); err != nil {
return o, err return o, err
} }
if o.group, err = groupOf(m); err != nil { if o.group, err = groupOf(m, repeat); err != nil {
return o, err return o, err
} }
if sv, ok := m["separator"]; ok { if sv, ok := m["separator"]; ok {
+5 -1
View File
@@ -57,9 +57,13 @@ func TestAGroupThatSplitsNothingIsRejected(t *testing.T) {
for src, want := range map[string]string{ for src, want := range map[string]string{
`{"format":"{x}","x":{"format":"{y}","y":["a","b"],"group":"g"}}`: `group "g" splits nothing`, `{"format":"{x}","x":{"format":"{y}","y":["a","b"],"group":"g"}}`: `group "g" splits nothing`,
`{"format":"{x}","x":{"format":"{/word}","group":"g"}}`: `group "g" splits nothing`, `{"format":"{x}","x":{"format":"{/word}","group":"g"}}`: `group "g" splits nothing`,
`{"format":"{x}","x":{"format":"{r}","group":"g","r":{"format":"{/word.w}","repeat":2}}}`: `group "g" splits nothing`,
`{"format":"{x}","x":{"format":"{y}","y":"{/word.w}","repeat":2,"group":"g"}}`: `group "g" on a repeat`,
`{"format":"{x}","x":{"format":"{/word.w} {y}","group":"g","y":{"format":"{/word.w}!","group":"g"}}}`: `"y" names group "g", the group this template draws in already`,
`{"format":"{/word.w}","group":"g"}`: "", `{"format":"{/word.w}","group":"g"}`: "",
`{"format":"{x}","x":{"format":"{/mail}","group":"g"}}`: "", `{"format":"{x}","x":{"format":"{/mail}","group":"g"}}`: "",
`{"format":"{x}","x":{"format":"{y}","y":"{/word.w}","repeat":2,"group":"g"}}`: "", `{"format":"{x}","x":{"format":"{/word.w} {y}","group":"g","y":{"format":"{/word.w}!","group":"h"}}}`: "",
`{"format":"{x}","x":{"format":"{/word.w} {r}","group":"g","r":{"format":"{y}","repeat":2,"y":{"format":"{/word.w}","group":"g"}}}}`: "",
} { } {
files["cat"] = src files["cat"] = src
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))) _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files)))
+3 -3
View File
@@ -16,7 +16,7 @@ type pathWalk struct {
leaf func(n node) error leaf func(n node) error
} }
// walkPath descends tail from n: a group or template by its next segment, a // walkPath descends tail from n: a folder or template by its next segment, a
// choice by w.choice, which consumes no segment. A missing segment is an error, // choice by w.choice, which consumes no segment. A missing segment is an error,
// so no walk reaches past what the data holds. A table-shaped dispatch, one case // so no walk reaches past what the data holds. A table-shaped dispatch, one case
// per node kind, kept whole on purpose. // per node kind, kept whole on purpose.
@@ -28,7 +28,7 @@ func walkPath(n node, tail []string, w pathWalk) error {
return nil return nil
} }
switch n := n.(type) { switch n := n.(type) {
case *group: case *folder:
child, ok := n.children[tail[0]] child, ok := n.children[tail[0]]
if !ok { if !ok {
return fmt.Errorf("no entry %q", tail[0]) return fmt.Errorf("no entry %q", tail[0])
@@ -105,7 +105,7 @@ func checkPath(n node, tail []string, level string) error {
switch { switch {
case t.repeat > 1: case t.repeat > 1:
return fmt.Errorf("the level %q carries a repeat, which a path reading one draw of it cannot apply", name) 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 fmt.Errorf("the level %q carries a group, which a path reading into it cannot apply", name)
} }
return nil return nil
+11 -10
View File
@@ -72,6 +72,7 @@ func refSegments(name string, folder []string) ([]string, error) {
// error, never a random render-time one. // error, never a random render-time one.
func linkRefs(root map[string]node) error { func linkRefs(root map[string]node) error {
return eachTemplate(root, func(folder []string, path string, t *template) 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) return linkTemplateRefs(folder, path, t, root)
}) })
} }
@@ -162,17 +163,17 @@ func eachTemplate(root map[string]node, fn func(folder []string, path string, t
} }
return nil return nil
} }
var inFolder func(folder []string, children map[string]node) error var inFolder func(dir []string, children map[string]node) error
inFolder = func(folder []string, children map[string]node) error { inFolder = func(dir []string, children map[string]node) error {
for _, name := range sortedNames(children) { for _, name := range sortedNames(children) {
path := join(strings.Join(folder, "."), name) path := join(strings.Join(dir, "."), name)
if g, ok := children[name].(*group); ok { if g, ok := children[name].(*folder); ok {
if err := inFolder(append(folder[:len(folder):len(folder)], name), g.children); err != nil { if err := inFolder(append(dir[:len(dir):len(dir)], name), g.children); err != nil {
return err return err
} }
continue continue
} }
if err := inCategory(folder, path, children[name]); err != nil { if err := inCategory(dir, path, children[name]); err != nil {
return err return err
} }
} }
@@ -183,13 +184,13 @@ func eachTemplate(root map[string]node, fn func(folder []string, path string, t
// resolveCategory walks a dotted path through the folders to the category it // resolveCategory walks a dotted path through the folders to the category it
// names, returning that head, the node, and the tail left to read into it. A // names, returning that head, the node, and the tail left to read into it. A
// descent of its own rather than a walkPath: it walks groups only and returns // descent of its own rather than a walkPath: it walks folders only and returns
// where they end, not a leaf. // where they end, not a leaf.
func resolveCategory(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { func resolveCategory(root map[string]node, segments []string) (head []string, target node, tail []string, err error) {
var n node = &group{children: root} var n node = &folder{children: root}
i := 0 i := 0
for ; i < len(segments); i++ { for ; i < len(segments); i++ {
g, ok := n.(*group) g, ok := n.(*folder)
if !ok { if !ok {
break break
} }
@@ -199,7 +200,7 @@ func resolveCategory(root map[string]node, segments []string) (head []string, ta
} }
n = child n = child
} }
if _, ok := n.(*group); ok { if _, ok := n.(*folder); ok {
return nil, nil, nil, fmt.Errorf("names a folder, not a value") return nil, nil, nil, fmt.Errorf("names a folder, not a value")
} }
return segments[:i], n, segments[i:], nil return segments[:i], n, segments[i:], nil
+23 -3
View File
@@ -261,8 +261,9 @@ func TestReferenceOverlapIsRejected(t *testing.T) {
"sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":` + p + `}`, "sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":` + p + `}`,
"sibling fields reading a level and a path into it": `{"format":"{a} {b}","a":"{/cat.p}","b":"{/cat.p.first}","p":` + p + `}`, "sibling fields reading a level and a path into it": `{"format":"{a} {b}","a":"{/cat.p}","b":"{/cat.p.first}","p":` + p + `}`,
"a field rendering the level a nested reference reads into": `{"format":"{x} {p}","x":"{/cat.p.first}","p":` + p + `}`, "a field rendering the level a nested reference reads into": `{"format":"{x} {p}","x":"{/cat.p.first}","p":` + p + `}`,
"a bare reference beside a path into what it never renders": `{"format":"{a} {b}","a":"{/other}","b":"{/other.p.first}"}`,
} { } {
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file, "other": `{"format":"x","p":` + p + `}`})))
if err == nil || !strings.Contains(err.Error(), "reads a path into") { if err == nil || !strings.Contains(err.Error(), "reads a path into") {
t.Errorf("%s: New = %v, want the overlap rejected", name, err) t.Errorf("%s: New = %v, want the overlap rejected", name, err)
} }
@@ -285,14 +286,27 @@ func onePerson(name string) bool {
func TestAReferencePathIsOneDrawPerRender(t *testing.T) { func TestAReferencePathIsOneDrawPerRender(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"apart": `{"format":"{a} & {b}","a":{"format":"{/person.first} {/person.last}","group":"x"},"b":{"format":"{/person.first} {/person.last}","group":"y"}}`, "apart": `{"format":"{a} & {b}","a":{"format":"{/person.first} {/person.last}","group":"x"},"b":{"format":"{/person.first} {/person.last}","group":"y"}}`,
"caller": `{"format":"{a} & {b}","a":{"format":"{/person.first} {/person.last}","group":"x"},"b":"{/pay}"}`,
"contact": `{"format":"{first} {last} <{email}>","email":"{lowercase(/person.first)}.{lowercase(/person.last)}@example.com","first":"{/person.first}","last":"{/person.last}"}`, "contact": `{"format":"{first} {last} <{email}>","email":"{lowercase(/person.first)}.{lowercase(/person.last)}@example.com","first":"{/person.first}","last":"{/person.last}"}`,
"iterations": `{"format":"{/person.first} {r}","group":"outer","r":{"format":"{a}={b}","repeat":3,"separator":",","a":"{/person.first}","b":{"format":"{/person.first}","group":"outer"}}}`,
"nested": `{"format":"{/person.first} {inner}","inner":"{/person.last}"}`, "nested": `{"format":"{/person.first} {inner}","inner":"{/person.last}"}`,
"pair": `{"format":"{a} & {b}","a":"{/person.first} {/person.last}","b":"{/person.first} {/person.last}"}`, "pair": `{"format":"{a} & {b}","a":"{/person.first} {/person.last}","b":"{/person.first} {/person.last}"}`,
"pay": `{"format":"{p}","p":{"format":"{/person.first} {/person.last}","group":"x"}}`,
"person": drawPeople, "person": drawPeople,
}) })
f := newGenerator(t, dir, WithSeed(1)) f := newGenerator(t, dir, WithSeed(1))
apart := false apart, local, noGroup := false, false, false
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
a, b, _ := strings.Cut(fake(t, f, "caller"), " & ")
if !onePerson(a) || !onePerson(b) {
t.Fatalf("caller = %q & %q, want each one person", a, b)
}
local = local || a != b
_, iterations, _ := strings.Cut(fake(t, f, "iterations"), " ")
for _, pair := range strings.Split(iterations, ",") {
a, b, _ := strings.Cut(pair, "=")
noGroup = noGroup || a != b
}
if got := fake(t, f, "nested"); !onePerson(got) { if got := fake(t, f, "nested"); !onePerson(got) {
t.Fatalf("nested = %q, want a nested template's path one draw with its parent's", got) t.Fatalf("nested = %q, want a nested template's path one draw with its parent's", got)
} }
@@ -309,7 +323,7 @@ func TestAReferencePathIsOneDrawPerRender(t *testing.T) {
if !onePerson(c[1].Value+" "+c[2].Value) || c[0].Value != strings.ToLower(c[1].Value)+"."+strings.ToLower(c[2].Value)+"@example.com" { if !onePerson(c[1].Value+" "+c[2].Value) || c[0].Value != strings.ToLower(c[1].Value)+"."+strings.ToLower(c[2].Value)+"@example.com" {
t.Fatalf("contact record %s, want first, last and email one person", r.JSON()) t.Fatalf("contact record %s, want first, last and email one person", r.JSON())
} }
a, b, _ := strings.Cut(fake(t, f, "pair"), " & ") a, b, _ = strings.Cut(fake(t, f, "pair"), " & ")
if !onePerson(a) || a != b { if !onePerson(a) || a != b {
t.Fatalf("pair = %q & %q, want both fields one person", a, b) t.Fatalf("pair = %q & %q, want both fields one person", a, b)
} }
@@ -322,6 +336,12 @@ func TestAReferencePathIsOneDrawPerRender(t *testing.T) {
if !apart { if !apart {
t.Error("groups x and y drew one person in 100 renders, want a draw each") t.Error("groups x and y drew one person in 100 renders, want a draw each")
} }
if !local {
t.Error("group x in caller and group x in the pay it references drew one person in 100 renders; a group name is local to its category")
}
if !noGroup {
t.Error("an iteration's plain read and its group outer always agreed; a repeat iteration renders in no group, so they are a draw each")
}
} }
func TestRelativeReferences(t *testing.T) { func TestRelativeReferences(t *testing.T) {
+6 -6
View File
@@ -20,11 +20,11 @@ type rng interface {
func (f *Generator) Fake(path string) (string, error) { func (f *Generator) Fake(path string) (string, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, ".")) n, err := descend(f.rand, &folder{children: f.categories}, strings.Split(path, "."))
if err != nil { if err != nil {
return "", fmt.Errorf("fejkdata: %s: %w", path, err) return "", fmt.Errorf("fejkdata: %s: %w", path, err)
} }
if _, ok := n.(*group); ok { if _, ok := n.(*folder); ok {
return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path) return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path)
} }
return renderOnce(f.rand, n), nil return renderOnce(f.rand, n), nil
@@ -72,7 +72,7 @@ func render(s *session, n node, sc drawScope) string {
if i > 0 { if i > 0 {
b.WriteString(n.separator) b.WriteString(n.separator)
} }
b.WriteString(expandAnew(s, n, sc.group)) b.WriteString(expandAnew(s, n))
} }
return b.String() return b.String()
default: 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. // render's loop, its draw set would move to the heap.
// //
//go:noinline //go:noinline
func expandAnew(s *session, t *template, group string) string { func expandAnew(s *session, t *template) string {
var set drawSet 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 // pick selects one item. Uniform choices are O(1); weighted choices are an