diff --git a/README.md b/README.md index c38dce6..2c597c9 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,19 @@ renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm postal code beside Tranås. Each row carries its own `weight`, so how often a place appears is data too. The rule holds both ways: two tails of one head come from the same row, and one path read twice reads one value (`{p.first} … {p.first}@…` gives one name). -A bare `{place}` in such a format reads that same draw. + +**One draw, one spelling.** A format may not both *render* a level and *read a +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: + +``` +token {p} renders a level that {p.first} reads a path into; name the fields you want instead +``` + +For the same reason a path may not read into a level carrying a `repeat`: it +reads one draw, so the repeat could never apply. A path is held at **every level it passes through**, not just the first, so the facts can nest as deeply as they belong: diff --git a/bound_test.go b/bound_test.go index 4f48aa1..c64780c 100644 --- a/bound_test.go +++ b/bound_test.go @@ -47,7 +47,7 @@ func TestRenderingALevelAndReadingIntoItIsRejected(t *testing.T) { // other rendered — so the pair is a load error rather than a shape whose two // halves can disagree. rejected := map[string]string{ - "bare head beside a path": `{"format":"{p} + {p.first}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, + "bare head beside a path": `{"format":"{p} + {p.first}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, "prefix path beside a path": `{"format":"{p.addr}|{p.addr.city}","p":[{"format":"{addr}","addr":[` + `{"format":"{city}","city":["Kiruna","Boden"]},{"format":"{city}","city":["Malmö","Lund"]}]}]}`, "either order": `{"format":"{p.first} + {p}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, diff --git a/node.go b/node.go index 85edeba..a4c050f 100644 --- a/node.go +++ b/node.go @@ -47,7 +47,7 @@ type template struct { 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 boundHeads and expand). nil when + // expansion so two tokens read one row (see compileOps and expand). nil when // the format takes no path. bound map[string]bool } @@ -161,6 +161,9 @@ func compileTemplate(m map[string]any) (node, error) { return nil, err } t.ops, t.grow, t.bound = compileOps(format) + if err := checkNoOverlap(t.ops, t.bound); err != nil { + return nil, err + } return t, nil } @@ -174,6 +177,9 @@ func checkPath(n node, tail []string) error { } switch n := n.(type) { case *template: + if n.repeat > 1 { + return fmt.Errorf("it carries a repeat, which a path reading one draw of it cannot apply") + } child, ok := n.fields[tail[0]] if !ok { return fmt.Errorf("no field %q", tail[0]) diff --git a/render.go b/render.go index e47d7dd..3f32ac8 100644 --- a/render.go +++ b/render.go @@ -189,31 +189,41 @@ func resolve(s *session, arms []arm, t *template, bound *draws) string { n = drawn(s, t.fields[a.key]) bound.variant[a.key] = n } - // Walk the tail, holding the draw at every level the path passes through, so - // two paths sharing a prefix share every choice along it, not just the head. - // The last level is held too: another token may name it in full. + // Hold the draw at every level passed through, so two paths sharing a prefix + // share it; the leaf needs no hold, as checkNoOverlap means no other token + // can name it. for i, seg := range a.tail { - held, drew := bound.variant[a.steps[i]] - if !drew { - held = drawn(s, child(n, seg)) - bound.variant[a.steps[i]] = held + if i < len(a.steps) { + held, drew := bound.variant[a.steps[i]] + if !drew { + held = drawn(s, child(n, seg)) + bound.variant[a.steps[i]] = held + } + n = held + continue } - n = held + n = child(n, seg) } v := render(s, n) bound.value[a.name] = v return v } -// child is the node one path segment names below an already-drawn node. checkPath -// proved the segment exists and that drawn leaves a template here, so this cannot -// fail. +// child is the node one path segment names below an already-drawn node. It holds +// while checkPath and the set a choice shares (see sharedPaths) agree with this +// walk: both prove the segment exists and that drawn leaves a template here. Each +// way that can break panics naming the segment, so a slip in that agreement +// reports where it happened rather than surfacing a nil node a level later. func child(n node, seg string) node { t, ok := n.(*template) if !ok { panic(fmt.Sprintf("fakes: %q under %T, which carries no fields", seg, n)) } - return t.fields[seg] + c, ok := t.fields[seg] + if !ok { + panic(fmt.Sprintf("fakes: no field %q under a drawn level", seg)) + } + return c } // drawn resolves a choice to one variant, so a bound head is a concrete node the diff --git a/template.go b/template.go index 3130d64..b6d9922 100644 --- a/template.go +++ b/template.go @@ -2,6 +2,7 @@ package fakes import ( "fmt" + "sort" "strings" ) @@ -178,15 +179,12 @@ func fieldTokens(format string) []string { // arm is one alternative of a {a|b} token, split into the key naming the node in // a template's fields (a sibling field, or the whole "..path" string a reference is // bound under) and the tail of a dotted path into it. A non-empty tail is what makes -// the arm a bound draw: its head is drawn once per expansion (see boundHeads). +// the arm a bound draw: its head is drawn once per expansion (see compileOps). type arm struct { - name string // as written, and the key a bound draw's value is held under - key string - tail []string - // steps holds the key for each segment the walk descends to, so every level a - // path passes through is held — including its last, which another path may - // name in full ({p.addr} beside {p.addr.city}). - steps []string + name string // as written, and the key a bound draw's value is held under + key string + tail []string + steps []string // key per level passed through; the head and leaf hold their own } // splitArm splits one token alternative into key and tail. A reference keeps its @@ -200,13 +198,42 @@ func splitArm(name string) arm { return arm{name: name, key: name} } segs := strings.Split(tail, ".") - steps := make([]string, len(segs)) - for i := range segs { - steps[i] = head + "." + strings.Join(segs[:i+1], ".") + var steps []string + for i := 0; i < len(segs)-1; i++ { // every level except the leaf's own + steps = append(steps, head+"."+strings.Join(segs[:i+1], ".")) } return arm{name: name, key: head, tail: segs, steps: steps} } +// checkNoOverlap rejects a format that both renders a level and reads a path into +// it — {p} beside {p.first}, or {p.addr} beside {p.addr.city}. The two spell one +// draw two ways: the path reads the level's held draw, while rendering the level +// expands it afresh, so their values disagree. One spelling, so there is nothing +// to get wrong. Names are compared in sorted order, so which pair is reported +// does not depend on where the tokens sit. +func checkNoOverlap(ops []op, bound map[string]bool) error { + var names []string + for i := range ops { + if ops[i].kind != 'f' { + continue + } + for _, a := range ops[i].arms { + if bound[a.key] { + names = append(names, a.name) + } + } + } + sort.Strings(names) + for i, level := range names { + for _, path := range names[i+1:] { + if strings.HasPrefix(path, level+".") { + return fmt.Errorf("token {%s} renders a level that {%s} reads a path into; name the fields you want instead", level, path) + } + } + } + return nil +} + // checkSegments rejects an unfinished path: "{a.}", "{.b}" and "{a..b}" each have // a segment naming nothing. A field really named "" would otherwise make them // resolve, so a typo would read as a path that worked.