From 447cfae50acedb33463fb052cd6787cc8d708f0f Mon Sep 17 00:00:00 2001 From: M Date: Sun, 30 Aug 2026 21:25:49 +0200 Subject: [PATCH] Hold a bound level against every route that renders it, and let a matching string be --- README.md | 9 +++--- bound_test.go | 17 +++++++++++ data.go | 3 ++ node.go | 18 +++++------ reference.go | 83 +++++++++++++++++++++++++++++++++++---------------- template.go | 3 +- 6 files changed, 92 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 2c597c9..749039e 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,9 @@ one path read twice reads one value (`{p.first} … {p.first}@…` gives one nam path into* it — `{p}` beside `{p.first}`, or `{place}` beside `{place.locality}`. Rendering a level expands it afresh while a path reads the level's held draw, so the two would disagree; naming the fields you want is the one spelling that -always agrees, and the other is a load error: +always agrees, and the other is a load error. This covers every way a level can +be rendered: a token, a `{calc()}` operand, and a `{..path}` reference — wherever +the reference sits, including in a field the format renders. ``` token {p} renders a level that {p.first} reads a path into; name the fields you want instead @@ -373,9 +375,8 @@ region it sits in all come from one draw. Two paths part company exactly where they diverge: `{p.a.v}` and `{p.b.v}` share the row and nothing below it. The binding lasts for one expansion, so each `repeat` iteration draws again and a -nested template keeps its own. A field no dotted token addresses is unaffected — -`{word} {word}` still draws twice — and so are `{calc()}` operands, which read -their fields directly. +nested template keeps its own. A field no dotted token addresses is unaffected: +`{word} {word}` still draws twice. `New` checks a path the way `Fake` resolves one: every variant of a multi-variant choice must carry the whole path, so a row missing a field is named at load: diff --git a/bound_test.go b/bound_test.go index 0a528a5..f8bda59 100644 --- a/bound_test.go +++ b/bound_test.go @@ -117,6 +117,23 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) { } } +func TestASharedNodeIsWalkedOnce(t *testing.T) { + // Two fields reaching one node make the render graph a diamond, not a tree. + // The search past a bound level must take that in its stride rather than walk + // the shared node once per route. + f, err := New([]string{writeData(t, map[string]string{ + "cat": `{"format":"{p.first}|{q}","p":[{"format":"{first}","first":["Anna","Bo"]}],` + + `"q":{"format":"{a}{b}","a":{"format":"{..shared}"},"b":{"format":"{..shared}"}}}`, + "shared": `["x"]`, + })}, WithSeed(1)) + if err != nil { + t.Fatalf("New = %v, want a shared node accepted", err) + } + if got, err := f.Fake("cat"); err != nil || !strings.HasSuffix(got, "|xx") { + t.Fatalf("Fake(cat) = %q, %v, want it to end in xx", got, err) + } +} + func TestReferenceToAMatchingStringIsAccepted(t *testing.T) { // A literal renders one fixed string, so no draw of it can disagree with a // held one. Two unrelated literals that merely spell the same text must not diff --git a/data.go b/data.go index 5415a6c..457dce2 100644 --- a/data.go +++ b/data.go @@ -33,6 +33,9 @@ func loadData(paths []string) (map[string]node, error) { if err := checkNoCycles(root); err != nil { return nil, err } + if err := checkBoundLevelsHeld(root); err != nil { + return nil, err + } return root, nil } diff --git a/node.go b/node.go index e795062..21041d3 100644 --- a/node.go +++ b/node.go @@ -46,11 +46,9 @@ type template struct { separator string ops []op // format compiled once (see compileOps); what expand walks grow int // minimum output size, to size the render buffer - // bound is the fields the format addresses by dotted path, each drawn once per - // expansion so two tokens read one row (see compileOps and expand). nil when - // the format takes no path. - // Each maps the field to one path token reading it, for naming the other half - // of an overlap. + // bound maps each field the format addresses by dotted path to one path token + // reading it: drawn once per expansion (see compileOps and expand), and the + // token names the other half of an overlap. nil when the format takes no path. bound map[string]string } @@ -169,10 +167,12 @@ func compileTemplate(m map[string]any) (node, error) { return t, nil } -// checkPath is the static twin of descend: it reports whether a dotted tail can -// address a node whichever way the draw goes. A multi-variant choice must carry the -// whole remaining path in the set every variant shares, so a path that validates -// here resolves on every render, and a typo is a New-time error. +// checkPath reports whether a token's dotted tail can address a node whichever way +// the draw goes, by the reachability rule descend applies — a multi-variant choice +// must carry the whole remaining path in the set every variant shares — plus the +// rules a held draw adds, which descend has no need of: a level a path reads may +// not carry a repeat, and each variant answers for that itself. So a path that +// validates here resolves on every render, and a typo is a New-time error. func checkPath(n node, tail []string, level string) error { if len(tail) == 0 { return nil diff --git a/reference.go b/reference.go index 23fb7f5..eaa7e76 100644 --- a/reference.go +++ b/reference.go @@ -32,43 +32,74 @@ func linkRefs(root map[string]node) error { if err != nil { return fmt.Errorf("%s: reference {%s}: %w", path, name, err) } - if err := checkNotBound(t, name, target); err != nil { - return fmt.Errorf("%s: %w", path, err) - } t.fields[name] = target } return nil }) } -// checkNotBound rejects a reference that lands on a level this format binds, or -// anywhere under one. Rendering the target draws it afresh beside the path that -// reads its held draw, which is the overlap checkNoOverlap rejects between tokens -// — a reference is one more spelling of it, and the only one that can reach a -// level from outside the format. Heads are checked in sorted order so which -// overlap is reported does not vary. -func checkNotBound(t *template, ref string, target node) error { - heads := make([]string, 0, len(t.bound)) - for head := range t.bound { - heads = append(heads, head) - } - sort.Strings(heads) - for _, head := range heads { - if reaches(t.fields[head], target) { - return fmt.Errorf("reference {%s} renders a level that {%s} reads a path into; name the fields you want instead", ref, t.bound[head]) +// checkBoundLevelsHeld rejects every route to a bound level except the paths that +// read it. A path holds one draw of the level; anything else that renders it draws +// again, and the two disagree. checkNoOverlap settles the spellings within one +// format (a token, a calc operand); this settles the rest — a reference, whether it +// sits in that format or in anything the format renders, however deep. +// +// It runs after checkNoCycles, whose guarantee is what lets the walk terminate. +func checkBoundLevelsHeld(root map[string]node) error { + return walkNodes(root, func(path string, n node) error { + t, ok := n.(*template) + if !ok || len(t.bound) == 0 { + return nil } - } - return nil + heads := make([]string, 0, len(t.bound)) + for head := range t.bound { + heads = append(heads, head) + } + sort.Strings(heads) // so which overlap is reported does not vary + for _, head := range heads { + held := map[node]bool{} + cover(t.fields[head], held) + for _, e := range renderEdges(t) { + if splitArm(e.label).key == head { + continue // a path token reading this level, which is the one route allowed + } + if renders(e.to, held, map[node]bool{}) { + return fmt.Errorf("%s: {%s} renders a level that {%s} reads a path into; name the fields you want instead", path, e.label, t.bound[head]) + } + } + } + return nil + }) } -// reaches reports whether want is n or a node contained in it. Containment is the -// JSON structure, so the walk is over a tree and always terminates. -func reaches(n, want node) bool { - if n == want { +// cover collects what one held draw of a level answers for: the level and +// everything contained in it, since a path may read any of it. Literals are left +// out — one fixed string cannot disagree with itself, and a literal is a value, so +// two that spell the same text are indistinguishable. +func cover(n node, into map[node]bool) { + if _, fixed := n.(literal); fixed { + return + } + into[n] = true + for _, c := range contained(n) { + cover(c.node, into) + } +} + +// renders reports whether rendering n can reach anything in want, following the +// same edges expand does. seen keeps a node shared by several routes from being +// walked twice; checkNoCycles has already proved the graph is a DAG, so the walk +// ends. +func renders(n node, want, seen map[node]bool) bool { + if want[n] { return true } - for _, c := range contained(n) { - if reaches(c.node, want) { + if seen[n] { + return false + } + seen[n] = true + for _, e := range renderEdges(n) { + if renders(e.to, want, seen) { return true } } diff --git a/template.go b/template.go index 64355cc..a4bc480 100644 --- a/template.go +++ b/template.go @@ -240,8 +240,7 @@ func checkNoOverlap(ops []op, bound map[string]string, format string) error { return nil } -// reader is one way a format reaches a bound field: the name it reads, and how to -// name that spelling in an error. +// reader is one way a format reaches a bound field, and how to name that spelling. type reader struct{ name, label string } // checkSegments rejects an unfinished path: "{a.}", "{.b}" and "{a..b}" each have