diff --git a/AGENTS.md b/AGENTS.md index 439b0ce..1dcbb74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ In [docs/decisions.md](docs/decisions.md): - A change to what exists is a major; a minor only adds - Seeded output is promised within one version - An error is a contract by what it names, not its bytes -- A format holding a reference is fenced at link, and its errors name its path +- Every format is fenced at link, and its errors name its path - Raising the lowest supported Go is a major - The format check runs on the latest Go only - The changelog heading is the one spelling of a release; CI cuts the tag diff --git a/calc.go b/calc.go index a279978..60801fb 100644 --- a/calc.go +++ b/calc.go @@ -120,10 +120,14 @@ func constantValue(n calcNode, fields map[string]node) (float64, bool) { return float64(n), true case calcVar: t, ok := fields[string(n)].(*template) - if !ok || !t.fixed || t.repeat > 1 { + if !ok || t.repeat > 1 { return 0, false } - v, err := strconv.ParseFloat(strings.TrimSpace(t.lit), 64) + lit, fixed := t.fixedText() + if !fixed { + return 0, false + } + v, err := strconv.ParseFloat(strings.TrimSpace(lit), 64) return v, err == nil case calcNeg: v, ok := constantValue(n.x, fields) @@ -161,11 +165,12 @@ func neverNumeric(n node) (text string, never bool) { case *nullItem: return "", true case *template: - if !n.fixed || n.repeat > 1 { + lit, fixed := n.fixedText() + if !fixed || n.repeat > 1 { return "", false } - if _, err := strconv.ParseFloat(strings.TrimSpace(n.lit), 64); err != nil { - return n.lit, true + if _, err := strconv.ParseFloat(strings.TrimSpace(lit), 64); err != nil { + return lit, true } case *choice: for _, it := range n.items { diff --git a/docs/decisions.md b/docs/decisions.md index 1f43aa4..bbb1731 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -121,12 +121,12 @@ this repo's own are. A script branches on the exit code and reads the named path or spelling, so those hold; wording improves in a minor. -## A format holding a reference is fenced at link, and its errors name its path +## Every format is fenced at link, and its errors name its path -2026-09-24, larv-review; approved 2026-09-25 by lilleman. Goal 2: a format compiles once, and one holding a reference -can compile only once bound, so its fences fire at link and name the category path as -every link error does, a cell's with its line; one without a reference still names its -file at compile. Valid while compilation needs the bound references. +2026-09-28, larv-review; approval pending. Goal 2: every format compiles in one phase, +at link, since one holding a reference can compile only once bound, so no check reads +compiled ops a format lacks yet. Its fences name the category path as every link error +does, a cell's with its line. Valid while compilation needs the bound references. ## Raising the lowest supported Go is a major diff --git a/holdfence.go b/holdfence.go index b839621..e3e749f 100644 --- a/holdfence.go +++ b/holdfence.go @@ -144,7 +144,11 @@ func operandDraw(n node, into map[node]bool) { // isFixed is a string that varies nothing: fixed text with no fields to read into. func isFixed(n node) bool { t, ok := n.(*template) - return ok && t.fixed && len(t.fields) == 0 + if !ok { + return false + } + _, fixed := t.fixedText() + return fixed && len(t.fields) == 0 } // renders reports whether rendering n can reach anything in want, following the diff --git a/node.go b/node.go index b6c274a..a0cc34c 100644 --- a/node.go +++ b/node.go @@ -41,7 +41,7 @@ func (*nullItem) isNode() {} // template renders a format string, substituting {tokens} from fields. A bare // JSON string is a template with no fields. repeat (default 1) renders that format // that many times and joins the results with separator (default ""), each render -// an independent pick. A format holding a reference compiles in `linkTemplateRefs`. +// an independent pick. Every format compiles in `linkTemplateRefs`. type template struct { // Filled by `compileString`, `compileTemplate` and `table.compileRowFormat`: format string @@ -60,10 +60,8 @@ type template struct { cellRow int // the row the cell sits in // Filled by `template.compileFormat`, from `template.format` and `template.refs`: - ops []op // what expand walks - grow int // minimum output size, to size the render buffer - fixed bool // no op varies, so every render is lit - lit string // the whole output when fixed + ops []op // what expand walks + grow int // minimum output size, to size the render buffer // bound maps each field the format addresses by dotted path to one path token // reading it, which is the half of an overlap the fences name. nil when the // format takes no path. @@ -208,18 +206,18 @@ func compileString(s string) (node, error) { if err != nil { return nil, err } - t := &template{format: s, tokens: toks, repeat: 1, fromString: true} - if err := t.compileRefFree(); err != nil { - return nil, err - } - return t, nil + return &template{format: s, tokens: toks, repeat: 1, fromString: true}, nil } -func (t *template) compileRefFree() error { - if len(refTokens(t.tokens)) > 0 { - return nil +// fixedText is the whole output of a format holding no token, which every render is. +func (t *template) fixedText() (string, bool) { + switch { + case len(t.tokens) == 0: + return "", true + case len(t.tokens) == 1 && t.tokens[0].kind == 'l': + return t.tokens[0].lit, true } - return t.compileFormat() + return "", false } // compileFormat compiles the format into ops, and applies the fences that need the @@ -227,15 +225,6 @@ func (t *template) compileRefFree() error { func (t *template) compileFormat() error { c := compileOps(t.tokens, t.refs) t.ops, t.grow, t.bound, t.held, t.heldLocal = c.ops, c.grow, c.bound, c.held, c.heldLocal - t.fixed = true - for _, o := range t.ops { - if o.kind != 'l' { - t.fixed = false - } - } - if t.fixed && len(t.ops) == 1 { - t.lit = t.ops[0].lit - } if err := checkNoOverlap(t.ops, t.bound); err != nil { return err } @@ -340,11 +329,7 @@ func compileTemplate(m map[string]any, pos position) (node, error) { if err := checkNestedDrawGroup(fields, o.group); err != nil { return nil, err } - t := &template{format: o.format, tokens: toks, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, drawGroup: o.group, isRecord: fieldPos == inColumn} - if err := t.compileRefFree(); err != nil { - return nil, err - } - return t, nil + return &template{format: o.format, tokens: toks, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, drawGroup: o.group, isRecord: fieldPos == inColumn}, nil } // templateOptions is what a template object's option keys say. diff --git a/reference.go b/reference.go index 9b4c041..c8e6882 100644 --- a/reference.go +++ b/reference.go @@ -67,9 +67,9 @@ func refSegments(name string, folder []string) ([]string, error) { return append(append([]string{}, base...), segs...), nil } -// linkRefs resolves every reference in the assembled tree. The head of the path — -// up to the category it names — is bound into the referring template's refHeads -// under its root path, and the rest reads into it the way a sibling path does, so +// linkRefs resolves every reference in the assembled tree and compiles every format. +// The head of the path — up to the category it names — is bound into the referring +// template's refHeads under its root path, and the rest reads into it the way a sibling path does, so // a reference is held like a sibling and two spellings of one target are one // draw. 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 reads a @@ -83,17 +83,26 @@ func linkRefs(root map[string]node) error { }) } -// linkTemplateRefs binds one template's references against root, refusing one that names the -// category it sits in. -// docs/decisions.md#a-category-never-references-itself-and-a-records-fences-run-at-load +// linkTemplateRefs binds one template's references against root, then compiles its format. func linkTemplateRefs(folder []string, path, category string, t *template, root map[string]node) error { - names := refTokens(t.tokens) - if len(names) == 0 { - return nil - } if t.cellOf != nil { path = fmt.Sprintf("%s, line %d", path, t.cellRow+2) } + if names := refTokens(t.tokens); len(names) > 0 { + if err := t.bindRefs(folder, path, category, names, root); err != nil { + return err + } + } + if err := t.compileFormat(); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + t.readsColumn = columnReadOf(t) + return nil +} + +// bindRefs refuses a reference naming the category t sits in. +// docs/decisions.md#a-category-never-references-itself-and-a-records-fences-run-at-load +func (t *template) bindRefs(folder []string, path, category string, names []string, root map[string]node) error { t.refs = make(map[string]refBinding, len(names)) t.refHeads = make(map[string]node, len(names)) for _, name := range names { @@ -115,10 +124,6 @@ func linkTemplateRefs(folder []string, path, category string, t *template, root t.refHeads[key] = target t.refs[name] = refBinding{key, tail} } - if err := t.compileFormat(); err != nil { - return fmt.Errorf("%s: %w", path, err) - } - t.readsColumn = columnReadOf(t) return nil } diff --git a/render.go b/render.go index c7b100b..1275386 100644 --- a/render.go +++ b/render.go @@ -71,8 +71,8 @@ func render(s *session, n node, sc renderScope) string { case *template: sc = sc.in(n) if n.repeat == 1 { - if n.fixed { - return n.lit + if lit, fixed := n.fixedText(); fixed { + return lit } return expand(s, n, sc) } diff --git a/table.go b/table.go index f11c4d9..9c407d9 100644 --- a/table.go +++ b/table.go @@ -344,7 +344,7 @@ func (t *table) compileRowFormat(format string) error { } t.formatTemplate = &template{format: format, tokens: toks, fields: t.fields, repeat: 1, isRecord: true, table: t} t.pinnedRow = &tableRow{t} - return t.formatTemplate.compileRefFree() + return nil } // setTablePaths gives every table the path a selector on it is written at, before a diff --git a/todo.md b/todo.md index 3b58261..8232804 100644 --- a/todo.md +++ b/todo.md @@ -21,8 +21,6 @@ Self-sufficiency 5.6), every seat capped by Locality, and all nine naming This round, in order: -- Compile every format in one phase: a template holding a reference has nil `ops` - until `linkTemplateRefs`, so a check reading `ops` before link skips it silently. - Run `New`, `NewTemplate` and `FakeStruct` through one bind pipeline that names where they differ: each picks its own fences today. - Make `disagreement` and `bothTyped` a table of cases: each swaps its operands and diff --git a/value.go b/value.go index 1dd8b23..32718e4 100644 --- a/value.go +++ b/value.go @@ -143,11 +143,12 @@ func (v proven) or(w proven) proven { // proveTemplate proves a template that renders one value: fixed text, or a format that is // one token alone. func (p *valueProof) proveTemplate(t *template) proven { + lit, fixed := t.fixedText() switch { case t.repeat != 1: return unproven(fmt.Sprintf("%q carries a repeat, which composes text rather than one value", t.format)) - case t.fixed: - return literalValue(t.lit) + case fixed: + return literalValue(lit) case len(t.ops) != 1: v := unproven(notOneValue(t.format, typedCalls)) v.notOperand = notOneValue(t.format, operandCalls)