From fdbbf94d2cf6c289663678ddfc71662c78a74988 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 18:45:31 +0200 Subject: [PATCH] Compare and cache a read by its one spelling, so two spellings of one reference are one level; reject a slash after a sigil naming the spelling --- AGENTS.md | 1 + hold.go | 81 +++++++++++----------------------------------------- path.go | 3 +- reference.go | 7 ++++- template.go | 56 ++++++++++++++++++++++++++++++++++-- 5 files changed, 79 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index daf3121..75d511e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - One spelling per result: reject the other at `New`, and let the error name the spelling to use. - A standing choice a reader would relitigate goes under Decisions in the README, not in a comment. - A README example is a `json` block that loads and renders as a category; `readme_test.go` runs every one. +- Cyclomatic complexity is gated at 14: the table-shaped dispatches (`eachToken`, `calc.factor`, `walkPath`) sit at 13–14 and stay whole; anything else that reaches 14 is decomposed. # Deferred diff --git a/hold.go b/hold.go index def4091..a900a7c 100644 --- a/hold.go +++ b/hold.go @@ -179,60 +179,20 @@ func renders(n node, want, seen map[node]bool) bool { return false } -// arm is one alternative of a {a|b} token or one operand, split into the key -// naming the node in a template's fields (a sibling field, or the head 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 compileOps). -type arm struct { - 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 name into key and tail. refs maps a reference to what -// linkRefs bound it to; before linking, a reference is whole. -func splitArm(name string, refs map[string]refBinding) arm { - if isRef(name) { - b, bound := refs[name] - if !bound || len(b.tail) == 0 { - key := name - if bound { - key = b.key - } - return arm{name: name, key: key} - } - return pathArm(name, b.key, b.tail) - } - head, tail, dotted := strings.Cut(name, ".") - if !dotted { - return arm{name: name, key: name} - } - return pathArm(name, head, strings.Split(tail, ".")) -} - -func pathArm(name, key string, segs []string) arm { - var steps []string - for i := 0; i < len(segs)-1; i++ { // every level except the leaf's own - steps = append(steps, key+"."+strings.Join(segs[:i+1], ".")) - } - return arm{name: name, key: key, 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 path reads the -// level's held draw while rendering the level expands it afresh, so their values -// would disagree. Names are compared in sorted order, so which pair is reported -// does not depend on where the tokens sit. +// 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. 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 // calc operand both naming "p") are reported as the format writes them. - sort.SliceStable(names, func(i, j int) bool { return names[i].name < names[j].name }) + sort.SliceStable(names, func(i, j int) bool { return names[i].path < names[j].path }) for i, level := range names { for _, path := range names[i+1:] { - if strings.HasPrefix(path.name, level.name+".") { + if strings.HasPrefix(path.path, level.path+".") { return fmt.Errorf("%s renders a level that {%s} reads a path into; name the fields you want instead", level.label, path.name) } } @@ -240,8 +200,9 @@ func checkNoOverlap(format string, bound map[string]string, refs map[string]refB return nil } -// reader is one way a format reaches a bound field, and how to name that spelling. -type reader struct{ name, label string } +// reader is one way a format reaches a bound field: as written, by its one +// 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 // format writes them. An operand renders its field, so it names a level exactly @@ -256,14 +217,14 @@ func boundReaders(format string, bound map[string]string, refs map[string]refBin for _, operand := range tokenOperands(t.body) { a := splitArm(operand, refs) if _, isBound := bound[a.key]; isBound { - names = append(names, reader{a.name, fmt.Sprintf("%s operand %q", fn, operand)}) + 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 { - names = append(names, reader{a.name, "token {" + a.name + "}"}) + names = append(names, reader{a.name, a.path, "token {" + a.name + "}"}) } } return nil @@ -271,16 +232,6 @@ func boundReaders(format string, bound map[string]string, refs map[string]refBin return names } -// splitArms splits a token body's '|' alternatives. -func splitArms(body string, refs map[string]refBinding) []arm { - parts := strings.Split(body, "|") - arms := make([]arm, len(parts)) - for i, p := range parts { - arms[i] = splitArm(p, refs) - } - return arms -} - // checkNoRepeatedRead rejects a bare token repeated on a held name: {w} {w} beside // {uppercase(w)} would read one draw twice, where {w} {w} alone draws twice. The // error names the single-token spelling. @@ -306,8 +257,8 @@ func checkNoRepeatedRead(format string, c formatOps, refs map[string]refBinding) } // draws is what an expansion has already drawn for its held names: the variant each -// was drawn as, so every path under it reads one row, and the value each read, so -// the same name read twice reads one value. +// was drawn as, so every path under it reads one row, and the value each read, by +// its one spelling, so the same read written twice reads one value. type draws struct { variant map[string]node value map[string]string @@ -324,7 +275,7 @@ func readField(s *session, t *template, held *draws, a arm) string { if !t.held[a.key] { return render(s, t.fields[a.key]) } - if v, read := held.value[a.name]; read { + if v, read := held.value[a.path]; read { return v } var v string @@ -345,7 +296,7 @@ func readField(s *session, t *template, held *draws, a arm) string { }, leaf: func(n node) error { v = render(s, n); return nil }, }) - held.value[a.name] = v + held.value[a.path] = v return v } diff --git a/path.go b/path.go index 40e7461..cc3edff 100644 --- a/path.go +++ b/path.go @@ -18,7 +18,8 @@ type pathWalk struct { // walkPath descends tail from n: a group or template by its next segment, a // choice by w.choice, which consumes no segment. A missing segment is an error, -// so no walk reaches past what the data holds. +// so no walk reaches past what the data holds. A table-shaped dispatch, one case +// per node kind, kept whole on purpose. func walkPath(n node, tail []string, w pathWalk) error { if len(tail) == 0 { if w.leaf != nil { diff --git a/reference.go b/reference.go index 6d685e4..664521b 100644 --- a/reference.go +++ b/reference.go @@ -29,6 +29,9 @@ func refShape(name string) (sigil, rest string, err error) { if rest == "" { return "", "", fmt.Errorf("reference has no path") } + if strings.HasPrefix(rest, "/") { + return "", "", fmt.Errorf("the path after %s starts at a name, not a /; write {%s%s}", sigil, sigil, rest[1:]) + } if strings.HasPrefix(rest, ".") { return "", "", fmt.Errorf("a reference starts with / (the root), . (this folder) or .. (the folder above)") } @@ -137,7 +140,9 @@ func eachTemplate(root map[string]node, fn func(folder []string, path string, t } // resolveRef walks a reference path through the folders to the category it names, -// returning that head, the node, and the tail left to read into it. +// 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 where they end, +// not a leaf. func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { var n node = &group{children: root} i := 0 diff --git a/template.go b/template.go index 87f2ff9..32f5f38 100644 --- a/template.go +++ b/template.go @@ -229,8 +229,60 @@ func fieldTokens(format string) []string { return names } -// checkSegments rejects an unfinished path: "{a.}", "{.b}" and "{a..b}" each have -// a segment naming nothing. A field really named "" would otherwise make them +// arm is one alternative of a {a|b} token or one operand, split into the key +// naming the node in a template's fields (a sibling field, or the head 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 compileOps). +type arm struct { + name string // as written, for messages + key string + tail []string + steps []string // key per level passed through; the head and leaf hold their own + path string // key and tail, the one spelling every way of writing this read shares +} + +// splitArm splits one name into key and tail. refs maps a reference to what +// linkRefs bound it to; before linking, a reference is whole. +func splitArm(name string, refs map[string]refBinding) arm { + if isRef(name) { + b, bound := refs[name] + if !bound || len(b.tail) == 0 { + key := name + if bound { + key = b.key + } + return arm{name: name, key: key, path: key} + } + return pathArm(name, b.key, b.tail) + } + head, tail, dotted := strings.Cut(name, ".") + if !dotted { + return arm{name: name, key: name, path: name} + } + return pathArm(name, head, strings.Split(tail, ".")) +} + +func pathArm(name, key string, segs []string) arm { + var steps []string + for i := 0; i < len(segs)-1; i++ { // every level except the leaf's own + steps = append(steps, key+"."+strings.Join(segs[:i+1], ".")) + } + return arm{name: name, key: key, tail: segs, steps: steps, path: key + "." + strings.Join(segs, ".")} +} + +// splitArms splits a token body's '|' alternatives. +func splitArms(body string, refs map[string]refBinding) []arm { + parts := strings.Split(body, "|") + arms := make([]arm, len(parts)) + for i, p := range parts { + arms[i] = splitArm(p, refs) + } + return arms +} + +// checkSegments rejects an unfinished path: "{a.}" 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. func checkSegments(a arm) error { if len(a.tail) == 0 {