Add {..path} references: render a node from elsewhere in the merged data root

This commit is contained in:
lilleman
2026-06-09 00:11:17 +02:00
parent 8db65ac598
commit 18de1b7004
4 changed files with 246 additions and 4 deletions
+134 -2
View File
@@ -175,8 +175,9 @@ func expand(r rng, format string, fields map[string]node) string {
return b.String()
}
// resolve renders a "{token}" body: one or more field names separated by '|',
// of which one is chosen at random. checkTokens guarantees every name exists.
// resolve renders a "{token}" body: one or more names separated by '|', one
// picked at random. A name is a sibling field or a {..path} reference, which
// linkRefs bound into fields too; checkTokens and linkRefs guarantee both exist.
func resolve(r rng, token string, fields map[string]node) string {
names := strings.Split(token, "|")
return render(r, fields[names[r.IntN(len(names))]])
@@ -366,6 +367,12 @@ func checkTokens(format string, fields map[string]node) error {
}
} else {
for _, name := range strings.Split(body, "|") {
if isRef(name) {
if name == refPrefix {
return fmt.Errorf("token {%s}: reference has no path", body)
}
continue // a root reference; its target is checked at New (see linkRefs)
}
if _, ok := fields[name]; !ok {
return fmt.Errorf("token {%s}: no field %q", body, name)
}
@@ -377,6 +384,131 @@ func checkTokens(format string, fields map[string]node) error {
return nil
}
// refPrefix marks a {..path} token: a reference to a node elsewhere in the data
// root rather than a sibling field. The path after it is the same dot path Fake
// takes, resolved across every loaded directory (see linkRefs).
const refPrefix = ".."
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
// linkRefs resolves every {..path} reference in the assembled tree, binding the
// target node into the referring template's fields under the token's key so the
// ordinary resolver renders it like a sibling. It runs once, after all data is
// merged, so a reference sees the final (override-resolved) tree. A path that is
// unknown, names a folder, or steps through a multi-variant choice fails here,
// keeping a bad reference a New-time error, never a random render-time one. The
// seen set both skips shared nodes and stops a cyclic reference looping the walk.
func linkRefs(root map[string]node) error {
seen := map[node]bool{}
var visit func(node) error
visit = func(n node) error {
if n == nil || seen[n] {
return nil
}
seen[n] = true
switch n := n.(type) {
case *group:
for _, c := range n.children {
if err := visit(c); err != nil {
return err
}
}
case *choice:
for _, it := range n.items {
if err := visit(it); err != nil {
return err
}
}
case *template:
for _, name := range refTokens(n.format) {
target, err := lookup(root, strings.Split(name[len(refPrefix):], "."))
if err != nil {
return fmt.Errorf("reference {%s}: %w", name, err)
}
n.fields[name] = target
}
for _, c := range n.fields {
if err := visit(c); err != nil {
return err
}
}
}
return nil
}
for _, c := range root {
if err := visit(c); err != nil {
return err
}
}
return nil
}
// lookup finds the single node a reference path names, walking groups and
// template fields by segment and descending a single-variant choice as a
// transparent wrapper. A missing segment, a folder target, or a step through a
// multi-variant choice (which has no one value to bind) is an error.
func lookup(root map[string]node, segments []string) (node, error) {
var n node = &group{children: root}
for i := 0; i < len(segments); i++ {
switch c := n.(type) {
case *group:
child, ok := c.children[segments[i]]
if !ok {
return nil, fmt.Errorf("no entry %q", segments[i])
}
n = child
case *template:
child, ok := c.fields[segments[i]]
if !ok {
return nil, fmt.Errorf("no field %q", segments[i])
}
n = child
case *choice:
if len(c.items) != 1 {
return nil, fmt.Errorf("%q steps through a %d-way choice", segments[i], len(c.items))
}
n, i = c.items[0], i-1 // a choice consumes no segment; reprocess it unwrapped
default:
return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[i])
}
}
if _, ok := n.(*group); ok {
return nil, fmt.Errorf("names a folder, not a value")
}
return n, nil
}
// refTokens returns the reference names ({..path}, including ones inside a
// {a|..path} alternation) used in a format string, scanning braces the way
// expand does. Function tokens carry no references.
func refTokens(format string) []string {
var refs []string
rs := []rune(format)
for i := 0; i < len(rs); i++ {
switch rs[i] {
case '#':
i++
case '{':
end := i + 1
for end < len(rs) && rs[end] != '}' {
end++
}
if end >= len(rs) {
return refs // checkTokens already rejected the unterminated brace
}
if body := string(rs[i+1 : end]); strings.IndexByte(body, '(') < 0 {
for _, name := range strings.Split(body, "|") {
if isRef(name) {
refs = append(refs, name)
}
}
}
i = end
}
}
return refs
}
// repeatOf reads a template's "repeat" (default 1): how many times its format
// is rendered and concatenated. A present one must be a positive integer.
func repeatOf(m map[string]any) (int, error) {