Close the calc and reference spellings of an overlap, and reach the repeat rule behind a choice

This commit is contained in:
M
2026-08-30 21:04:54 +02:00
committed by lilleman-tw
parent 9b5e34332b
commit 3ebaa5958d
5 changed files with 97 additions and 27 deletions
+16 -5
View File
@@ -93,11 +93,22 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
// A reference can name a bound level from the data root, which renders it // A reference can name a bound level from the data root, which renders it
// afresh beside the path that reads its held draw — the same overlap by // afresh beside the path that reads its held draw — the same overlap by
// another spelling. // another spelling.
_, err := New([]string{writeData(t, map[string]string{ rejected := map[string]string{
"cat": `{"format":"{p.first}|{..cat.p}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, "reference names the head": `{"format":"{p.first}|{..cat.p}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`,
})}) "reference names the leaf": `{"format":"{p.addr}|{..cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`,
if err == nil || !strings.Contains(err.Error(), "reads a path into") { }
t.Fatalf("New = %v, want the reference rejected as an overlap", err) for name, file := range rejected {
_, err := New([]string{writeData(t, map[string]string{"cat": file})})
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
t.Errorf("%s: New = %v, want the reference rejected as an overlap", name, err)
}
}
// A reference to anything this format does not bind is untouched.
if _, err := New([]string{writeData(t, map[string]string{
"cat": `{"format":"{p.first} {..surname}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`,
"surname": `["Eriksson","Lindqvist"]`,
})}); err != nil {
t.Errorf("New = %v, want a reference outside the bound level accepted", err)
} }
} }
+16 -7
View File
@@ -49,7 +49,9 @@ type template struct {
// bound is the fields the format addresses by dotted path, each drawn once per // 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 // expansion so two tokens read one row (see compileOps and expand). nil when
// the format takes no path. // the format takes no path.
bound map[string]bool // Each maps the field to one path token reading it, for naming the other half
// of an overlap.
bound map[string]string
} }
func (*template) isNode() {} func (*template) isNode() {}
@@ -161,7 +163,7 @@ func compileTemplate(m map[string]any) (node, error) {
return nil, err return nil, err
} }
t.ops, t.grow, t.bound = compileOps(format) t.ops, t.grow, t.bound = compileOps(format)
if err := checkNoOverlap(t.ops, t.bound); err != nil { if err := checkNoOverlap(t.ops, t.bound, format); err != nil {
return nil, err return nil, err
} }
return t, nil return t, nil
@@ -171,28 +173,35 @@ func compileTemplate(m map[string]any) (node, error) {
// address a node whichever way the draw goes. A multi-variant choice must carry the // 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 // 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. // here resolves on every render, and a typo is a New-time error.
func checkPath(n node, tail []string) error { func checkPath(n node, tail []string, level string) error {
if len(tail) == 0 { if len(tail) == 0 {
return nil return nil
} }
switch n := n.(type) { switch n := n.(type) {
case *template: case *template:
if n.repeat > 1 { if n.repeat > 1 {
return fmt.Errorf("it carries a repeat, which a path reading one draw of it cannot apply") return fmt.Errorf("the level %q carries a repeat, which a path reading one draw of it cannot apply", level)
} }
child, ok := n.fields[tail[0]] child, ok := n.fields[tail[0]]
if !ok { if !ok {
return fmt.Errorf("no field %q", tail[0]) return fmt.Errorf("no field %q", tail[0])
} }
return checkPath(child, tail[1:]) return checkPath(child, tail[1:], level+"."+tail[0])
case *choice: case *choice:
if len(n.items) > 1 { if len(n.items) > 1 {
if want := strings.Join(tail, "."); !n.shared[want] { if want := strings.Join(tail, "."); !n.shared[want] {
return unreachableInChoice(n, want) return unreachableInChoice(n, want)
} }
return nil // every variant carries it, so any draw resolves // Reachability is settled; each variant still answers for itself, so a
// rule about the level (its repeat) holds behind a choice as in front.
for _, item := range n.items {
if err := checkPath(item, tail, level); err != nil {
return err
}
}
return nil
} }
return checkPath(n.items[0], tail) return checkPath(n.items[0], tail, level)
case literal: case literal:
return fmt.Errorf("a plain string has no field %q", tail[0]) return fmt.Errorf("a plain string has no field %q", tail[0])
default: default:
+37
View File
@@ -32,12 +32,49 @@ func linkRefs(root map[string]node) error {
if err != nil { if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err) 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 t.fields[name] = target
} }
return nil 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])
}
}
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 {
return true
}
for _, c := range contained(n) {
if reaches(c.node, want) {
return true
}
}
return false
}
// walkNodes calls fn once per contained node, passing the dot path that reaches it, // walkNodes calls fn once per contained node, passing the dot path that reaches it,
// visiting keys in sorted order so which of several broken nodes gets reported does // visiting keys in sorted order so which of several broken nodes gets reported does
// not depend on map iteration. // not depend on map iteration.
+4 -3
View File
@@ -178,7 +178,7 @@ type draws struct {
// linkRefs prove every step, so this cannot fail. // linkRefs prove every step, so this cannot fail.
func resolve(s *session, arms []arm, t *template, bound *draws) string { func resolve(s *session, arms []arm, t *template, bound *draws) string {
a := arms[s.IntN(len(arms))] a := arms[s.IntN(len(arms))]
if !t.bound[a.key] { if _, isBound := t.bound[a.key]; !isBound {
return render(s, t.fields[a.key]) return render(s, t.fields[a.key])
} }
if v, read := bound.value[a.name]; read { if v, read := bound.value[a.name]; read {
@@ -190,8 +190,9 @@ func resolve(s *session, arms []arm, t *template, bound *draws) string {
bound.variant[a.key] = n bound.variant[a.key] = n
} }
// Hold the draw at every level passed through, so two paths sharing a prefix // 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 // share it. The leaf needs no hold: no other token can name it, since
// can name it. // checkNoOverlap rejects a field or calc spelling and checkNotBound a
// reference one.
for i, seg := range a.tail { for i, seg := range a.tail {
if i < len(a.steps) { if i < len(a.steps) {
held, drew := bound.variant[a.steps[i]] held, drew := bound.variant[a.steps[i]]
+24 -12
View File
@@ -154,7 +154,7 @@ func checkTokens(format string, fields map[string]node) error {
} }
return fmt.Errorf("token {%s}: no field %q", t.body, a.key) return fmt.Errorf("token {%s}: no field %q", t.body, a.key)
} }
if err := checkPath(head, a.tail); err != nil { if err := checkPath(head, a.tail, a.key); err != nil {
return fmt.Errorf("token {%s}: field %q: %w", t.body, a.key, err) return fmt.Errorf("token {%s}: field %q: %w", t.body, a.key, err)
} }
} }
@@ -211,29 +211,39 @@ func splitArm(name string) arm {
// expands it afresh, so their values disagree. One spelling, so there is nothing // 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 // to get wrong. Names are compared in sorted order, so which pair is reported
// does not depend on where the tokens sit. // does not depend on where the tokens sit.
func checkNoOverlap(ops []op, bound map[string]bool) error { func checkNoOverlap(ops []op, bound map[string]string, format string) error {
var names []string var names []reader
for i := range ops { for i := range ops {
if ops[i].kind != 'f' { if ops[i].kind != 'f' {
continue continue
} }
for _, a := range ops[i].arms { for _, a := range ops[i].arms {
if bound[a.key] { if _, isBound := bound[a.key]; isBound {
names = append(names, a.name) names = append(names, reader{a.name, "token {" + a.name + "}"})
} }
} }
} }
sort.Strings(names) // A calc operand renders its field, so it names a level exactly as a token does.
for _, name := range calcOperands(format) {
if _, isBound := bound[name]; isBound {
names = append(names, reader{name, fmt.Sprintf("calc operand %q", name)})
}
}
sort.Slice(names, func(i, j int) bool { return names[i].name < names[j].name })
for i, level := range names { for i, level := range names {
for _, path := range names[i+1:] { for _, path := range names[i+1:] {
if strings.HasPrefix(path, level+".") { if strings.HasPrefix(path.name, level.name+".") {
return fmt.Errorf("token {%s} renders a level that {%s} reads a path into; name the fields you want instead", 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)
} }
} }
} }
return nil 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.
type reader struct{ name, label string }
// checkSegments rejects an unfinished path: "{a.}", "{.b}" and "{a..b}" each have // checkSegments rejects an unfinished path: "{a.}", "{.b}" and "{a..b}" each have
// a segment naming nothing. A field really named "" would otherwise make them // a segment naming nothing. A field really named "" would otherwise make them
// resolve, so a typo would read as a path that worked. // resolve, so a typo would read as a path that worked.
@@ -282,10 +292,10 @@ type op struct {
// so every token reading one sees the same row (see expand). bound is nil when the // so every token reading one sees the same row (see expand). bound is nil when the
// format takes no path, so data that uses none carries no render-time cost. // format takes no path, so data that uses none carries no render-time cost.
// Call checkTokens first: it is what proves the scan and every token are valid. // Call checkTokens first: it is what proves the scan and every token are valid.
func compileOps(format string) ([]op, int, map[string]bool) { func compileOps(format string) ([]op, int, map[string]string) {
var ops []op var ops []op
var lit strings.Builder var lit strings.Builder
var bound map[string]bool var bound map[string]string
grow := 0 grow := 0
flush := func() { flush := func() {
if lit.Len() > 0 { if lit.Len() > 0 {
@@ -311,9 +321,11 @@ func compileOps(format string) ([]op, int, map[string]bool) {
for _, a := range arms { for _, a := range arms {
if len(a.tail) > 0 { if len(a.tail) > 0 {
if bound == nil { if bound == nil {
bound = map[string]bool{} bound = map[string]string{}
}
if _, named := bound[a.key]; !named {
bound[a.key] = a.name // the first path reading it, for error messages
} }
bound[a.key] = true
} }
} }
ops = append(ops, op{kind: 'f', arms: arms}) ops = append(ops, op{kind: 'f', arms: arms})